From f637a92e3e6486b1f1ccfef256e53a711f1ec6c7 Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Sat, 16 May 2026 09:40:29 +0200 Subject: [PATCH 1/7] refactor: split large rust files --- README.md | 285 ++- src-tauri/src/app_setup.rs | 213 ++ src-tauri/src/drivers/common.rs | 2137 ----------------- src-tauri/src/drivers/mod.rs | 1 - src-tauri/src/drivers/pgsql.rs | 1484 ------------ .../drivers/pgsql/commands/admin_commands.rs | 175 ++ .../pgsql/commands/metadata_commands.rs | 219 ++ src-tauri/src/drivers/pgsql/commands/mod.rs | 19 + .../pgsql/commands/object_info_commands.rs | 61 + .../drivers/pgsql/commands/pool_connection.rs | 293 +++ .../drivers/pgsql/commands/pubsub_commands.rs | 178 ++ .../drivers/pgsql/commands/query_commands.rs | 223 ++ .../pgsql/commands/snapshot_persistence.rs | 283 +++ .../pgsql/commands/statistics_commands.rs | 117 + src-tauri/src/drivers/pgsql/ddl_generation.rs | 298 +++ src-tauri/src/drivers/pgsql/extensions.rs | 105 + .../src/drivers/pgsql/metadata_schema.rs | 280 +++ .../drivers/pgsql/metadata_views_functions.rs | 219 ++ src-tauri/src/drivers/pgsql/mod.rs | 92 + .../drivers/pgsql/query_execution/helpers.rs | 109 + .../src/drivers/pgsql/query_execution/mod.rs | 8 + .../drivers/pgsql/query_execution/simple.rs | 57 + .../pgsql/query_execution/streaming.rs | 176 ++ src-tauri/src/main.rs | 212 +- yarn.lock | 464 +++- 25 files changed, 3768 insertions(+), 3940 deletions(-) create mode 100644 src-tauri/src/app_setup.rs delete mode 100644 src-tauri/src/drivers/common.rs delete mode 100644 src-tauri/src/drivers/pgsql.rs create mode 100644 src-tauri/src/drivers/pgsql/commands/admin_commands.rs create mode 100644 src-tauri/src/drivers/pgsql/commands/metadata_commands.rs create mode 100644 src-tauri/src/drivers/pgsql/commands/mod.rs create mode 100644 src-tauri/src/drivers/pgsql/commands/object_info_commands.rs create mode 100644 src-tauri/src/drivers/pgsql/commands/pool_connection.rs create mode 100644 src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs create mode 100644 src-tauri/src/drivers/pgsql/commands/query_commands.rs create mode 100644 src-tauri/src/drivers/pgsql/commands/snapshot_persistence.rs create mode 100644 src-tauri/src/drivers/pgsql/commands/statistics_commands.rs create mode 100644 src-tauri/src/drivers/pgsql/ddl_generation.rs create mode 100644 src-tauri/src/drivers/pgsql/extensions.rs create mode 100644 src-tauri/src/drivers/pgsql/metadata_schema.rs create mode 100644 src-tauri/src/drivers/pgsql/metadata_views_functions.rs create mode 100644 src-tauri/src/drivers/pgsql/mod.rs create mode 100644 src-tauri/src/drivers/pgsql/query_execution/helpers.rs create mode 100644 src-tauri/src/drivers/pgsql/query_execution/mod.rs create mode 100644 src-tauri/src/drivers/pgsql/query_execution/simple.rs create mode 100644 src-tauri/src/drivers/pgsql/query_execution/streaming.rs diff --git a/README.md b/README.md index 9d101c5..49ad2e4 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,217 @@ # RSQL -A high-performance PostgreSQL client built with Tauri v2, React, and Rust. Designed from the ground up to be fast — even with millions of rows. +A high-performance, open-source PostgreSQL client built with Tauri v2, React, and Rust. Designed from the ground up to be fast — even with millions of rows. -![Website screenshot](docs/rsql.png) +Free forever. No account, no telemetry, no feature gating. -## Important -App signing is in progress. To allow on macOS, use the following command: +![RSQL screenshot](docs/rsql.png) -```bash -xattr -dr com.apple.quarantine /Applications/RSQL.app -``` +> **macOS note:** App signing is in progress. To allow on macOS: +> ```bash +> xattr -dr com.apple.quarantine /Applications/RSQL.app +> ``` + +--- +## Features + +### Query Editor +- **Monaco SQL editor** — syntax highlighting, context-aware autocomplete (schemas, tables, columns, aliases), SQL snippets, SQL formatter +- **Multi-tab interface** — open multiple queries side by side, split editor mode +- **Query history** — searchable execution history with timing, row counts, and timestamps (last 500 queries) +- **Workspaces** — save and restore groups of tabs across sessions +- **Query timeout** — configurable per-query timeout (5s to 10min) +- **Multi-statement execution** — `SELECT 1; INSERT ...; SELECT * FROM users;` handled natively + +### Results +- **WebGL canvas grid** — `@glideapps/glide-data-grid` renders directly to canvas. Zero DOM nodes per cell. Smooth 60fps scrolling through millions of rows +- **Virtual pagination** — server-side cursors with 2,000-row pages. Only ~24 pages kept in memory at any time. 5M+ rows, same frontend memory as 1K rows +- **Inline editing** — click to edit cells. Generates `UPDATE`/`DELETE` with proper quoting and transactions +- **Record view** — form-style single-row viewer for wide tables +- **Column sorting & filtering** — full-text search across results (debounced at 200ms) +- **Result pinning & diff** — pin a result, run another query, see added/removed/changed rows (diff computed in Rust) +- **Export** — CSV, JSON, SQL INSERT, Markdown, XML. Copy to clipboard or save to file +- **CSV import** — import CSV files with column mapping preview + +### Schema Explorer +- **Tree sidebar** — schemas, tables, views, materialized views, functions, trigger functions, indexes, constraints, triggers, rules, RLS policies +- **Object properties** — detailed modal with columns, indexes, foreign keys, generated DDL, and a visual structure editor (ALTER TABLE builder) +- **ERD diagrams** — auto-generated entity-relationship diagrams with FK lines, drag-and-drop layout, SVG export +- **FK navigation** — click any foreign key value in the grid to jump directly to the referenced row +- **Schema diff** — compare two schemas side by side, see modified/added/removed objects +- **Command palette** — `Cmd+K` fuzzy search across tables, views, functions, connections, actions, and workspaces + +### PostGIS & Spatial +- **Map view** — automatic detection of geometry/geography columns (WKT, GeoJSON, EWKB). Rendered on OpenStreetMap tiles via Leaflet with Point, LineString, and Polygon support + +### Performance & Monitoring +- **EXPLAIN visualizer** — `EXPLAIN (ANALYZE, FORMAT JSON)` rendered as an interactive plan tree with cost breakdown, row estimates vs actuals, and timing per node +- **Performance monitor** — dedicated dashboard with tabs: + - **Overview** — database-level statistics + - **Activity** — live `pg_stat_activity` (active sessions, running queries) + - **Tables** — seq scans, index scans, inserts, updates, deletes, dead tuples, last vacuum/analyze + - **Indexes** — index usage statistics + - **Locks** — active lock monitoring + - **Bloat** — table bloat detection + - **History** — query execution timeline + +### Administration +- **Roles panel** — view roles with permission grants +- **Extensions panel** — installed and available PostgreSQL extensions +- **Enums panel** — browse ENUM types and their values +- **PG settings** — view all PostgreSQL configuration parameters +- **LISTEN/NOTIFY** — subscribe to channels, send notifications, discover channels from triggers + +### Developer Tools +- **Inline terminal** — built-in PTY terminal via `portable-pty` + `xterm.js`. Run psql, migrations, or any shell command without leaving the app +- **DDL generation** — generate `CREATE` statements for any database object +- **OS notifications** — notify on long-running queries (>5s) when the app is unfocused + +### Connection Management +- **Multiple connections** — manage and switch between databases +- **SSH tunnels** — connect to remote databases through SSH (password and key file auth) via native Rust `russh` +- **SSL/TLS** — secure connections with `postgres-native-tls` +- **Connection pooling** — dual pool: 16 connections for queries, 8 for metadata. Query and metadata traffic never block each other +- **Test connection** — verify connectivity before saving + +--- ## Why It's Fast -### Zero-Copy Wire Protocol -Queries use PostgreSQL's **simple_query protocol** — the server returns all values as pre-formatted text. No type conversion, no ORM mapping, no intermediate representations. Raw text goes straight from the TCP socket to the frontend. +RSQL is not just another Electron wrapper around a web UI. Every layer of the stack is optimized for throughput and responsiveness. ### Packed Binary IPC -Results are encoded as flat strings with ASCII unit/record separators (`\x1F` / `\x1E`), not nested JSON arrays. This eliminates JSON serialization overhead entirely for result data. A 100K-row result serializes in microseconds, not milliseconds. +Results are encoded as flat strings with ASCII unit/record separators (`\x1F` / `\x1E`), not nested JSON arrays. A 100K-row result serializes in microseconds. No per-cell quoting, no array nesting, no JSON overhead. -### Pre-Allocated String Packing -Row packing uses a single pre-allocated `String` buffer with capacity estimation. No intermediate `Vec` per row, no `.join()` chains, no `.replace()` allocations. Separator sanitization is done inline, character by character. - -### Virtual Pagination with Server-Side Cursors -Large results (>2K rows) use PostgreSQL cursors with `FETCH` batching. Pages are pre-packed into cache-friendly strings on the Rust side. Page serving is O(1) — zero packing at read time. Only pages near the viewport are kept in memory; distant pages are evicted automatically. +### Zero-Copy Wire Protocol +Queries use PostgreSQL's `simple_query` protocol — the server returns all values as pre-formatted text. No type conversion, no ORM mapping, no intermediate representations. -### Dual Connection Pool -Each database connection maintains two TCP sockets: -- **Query connection** — user queries, EXPLAIN, virtual pagination -- **Metadata connection** — schema loading, table info, activity monitoring +### SIMD JSON Serialization +All IPC command responses use `sonic-rs` (SIMD-accelerated, AVX2/SSE4/NEON) instead of `serde_json`. Results are returned as raw `tauri::ipc::Response` — zero re-serialization by the framework. ~2-3x faster than serde_json for typical payloads. -This means metadata loads never block while a long query runs, and vice versa. +### Virtual Pagination with Server-Side Cursors +Large results use PostgreSQL cursors (`DECLARE CURSOR` + `FETCH FORWARD 10,000`). Pages of 2,000 rows are pre-packed into cache-friendly strings on the Rust side. Page serving is O(1) — zero processing at read time. The frontend keeps ~24 pages around the viewport; distant pages are LRU-evicted. No row limit on virtual pagination. ### WebGL Canvas Rendering -The results grid renders directly to a WebGL canvas via `@glideapps/glide-data-grid`. No DOM nodes per cell. Scrolling through 500K rows is as smooth as scrolling through 50. - -Virtual scroll invalidation uses `requestAnimationFrame` batching — multiple page fetches within one frame cause only one re-render. Theme override objects are pre-computed once, not re-created per cell. +The results grid renders to a single `` element via `@glideapps/glide-data-grid`. O(1) DOM complexity regardless of dataset size. No layout thrashing, GPU-accelerated paint. ### Parallel Processing -Results over 50K rows use Rayon for parallel page packing across CPU cores. Below that threshold, sequential processing is faster due to cache locality. +Results over 50K rows use `rayon` for parallel page packing across CPU cores. Below that threshold, sequential processing wins due to cache locality. -### Debounced Search -Full-text search across results is debounced at 200ms to avoid filtering 50K+ rows on every keystroke. +### Dual Connection Pool +Each database maintains two `deadpool-postgres` pools: +- **16 connections** for queries — user SQL, EXPLAIN, virtual pagination +- **8 connections** for metadata — schema loading, autocomplete, activity monitoring -### SIMD JSON Serialization -All IPC command responses bypass Tauri's default `serde_json` serializer. Instead, results are pre-serialized with `sonic-rs` (SIMD-accelerated) and returned as raw `tauri::ipc::Response` — zero re-serialization by the framework. ~3.5x faster than serde_json for typical payloads. +Metadata loads never block while a long query runs, and vice versa. -### Multi-Statement Execution -`simple_query` handles `SELECT 1; INSERT ...; SELECT * FROM users;` natively. Returns the last result set that had rows — no splitting or reparsing on the client side. +### Pre-Allocated String Packing +Row packing uses a single pre-allocated `String` buffer with capacity estimation. No intermediate `Vec` per row, no `.join()` chains. Separator sanitization is done inline, character by character. -## Features +--- -- **Monaco SQL editor** — syntax highlighting, context-aware autocomplete (schemas, tables, columns, aliases), SQL snippets, formatter -- **Results grid** — WebGL canvas, column sorting, inline editing (UPDATE/DELETE with transactions), export (CSV, JSON, SQL, Markdown, XML) -- **Database explorer** — tree sidebar with schemas, tables, views, materialized views, functions, triggers, indexes, constraints, policies -- **ERD diagrams** — interactive entity-relationship diagrams with FK lines, drag-and-drop, SVG export -- **FK navigation** — click foreign key values to jump to referenced rows -- **Map view** — automatic detection of PostGIS geometry/geography columns (WKT, GeoJSON, EWKB), rendered on OpenStreetMap tiles via Leaflet with Point, LineString, and Polygon support -- **EXPLAIN visualizer** — `EXPLAIN (ANALYZE, FORMAT JSON)` with plan tree rendering -- **Performance monitor** — live `pg_stat_activity`, database stats, table stats -- **Diff tool** — pin a result, run another query, see added/removed rows (diff computed in Rust) -- **Inline terminal** — built-in PTY terminal via `portable-pty` + `xterm.js` -- **Command palette** — Cmd+K/Cmd+P fuzzy search across all database objects, actions, and saved workspaces -- **Workspaces** — save and restore tab groups across sessions -- **Query history** — searchable execution history with timing and row counts -- **Notifications** — OS-level notifications for long-running queries (>5s) when app is unfocused +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Frontend (React 19 + TypeScript) │ +│ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │ +│ │ Monaco │ │ Leaflet │ │ Glide Data Grid │ │ +│ │ Editor │ │ Maps │ │ (WebGL Canvas) │ │ +│ └──────────┘ └──────────┘ └────────────────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │ +│ │ xterm.js│ │ cmdk │ │ Zustand Stores │ │ +│ │ Terminal │ │ Palette │ │ (State Mgmt) │ │ +│ └──────────┘ └──────────┘ └────────────────────┘ │ +├─────────────────────────────────────────────────────┤ +│ Tauri v2 IPC (packed binary \x1F/\x1E format) │ +├─────────────────────────────────────────────────────┤ +│ Backend (Rust) │ +│ ┌──────────────┐ ┌────────────┐ ┌─────────────┐ │ +│ │ tokio-postgres│ │ sonic-rs │ │ rayon │ │ +│ │ (zero-copy) │ │ (SIMD) │ │ (parallel) │ │ +│ └──────────────┘ └────────────┘ └─────────────┘ │ +│ ┌──────────────┐ ┌────────────┐ ┌─────────────┐ │ +│ │ deadpool │ │ russh │ │ libsql │ │ +│ │ (pooling) │ │ (SSH) │ │ (local db) │ │ +│ └──────────────┘ └────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` ## Tech Stack | Layer | Technology | |-------|-----------| -| Frontend | React 19, TypeScript, Zustand, Monaco Editor, Leaflet | -| UI | Tailwind CSS v4, shadcn/ui, oklch color system | -| Results Grid | @glideapps/glide-data-grid (WebGL canvas) | +| Frontend | React 19, TypeScript, Zustand, Tailwind CSS v4, shadcn/ui | +| Editor | Monaco Editor with monaco-sql-languages | +| Results Grid | @glideapps/glide-data-grid v6 (WebGL canvas) | +| Maps | Leaflet + OpenStreetMap | | Terminal | xterm.js + portable-pty | | Backend | Rust, Tauri v2, tokio-postgres (simple_query protocol) | -| Performance | sonic-rs (SIMD JSON), rayon (parallel packing), packed binary IPC, dual connection pool | +| Serialization | sonic-rs v0.5 (SIMD JSON) | +| Parallelism | rayon v1.11 | +| Connection Pool | deadpool-postgres v0.14 (16 query + 8 metadata) | +| SSH | russh v0.57 (native async Rust SSH) | +| Local Storage | libsql (SQLite — connections, queries, workspaces, page snapshots) | + +--- + +## Performance Numbers + +All numbers are verified from source code — not marketing estimates. + +| Metric | Value | Source | +|--------|-------|--------| +| Cursor fetch size | 10,000 rows/round-trip | `CURSOR_FETCH_SIZE` in common.rs | +| Page size | 2,000 rows/page | `VITE_PAGE_SIZE` default | +| Frontend cache window | ~24 pages in memory | results-panel.tsx | +| Concurrent page fetches | 6 parallel requests | results-panel.tsx | +| Query connection pool | 16 connections | deadpool config | +| Metadata connection pool | 8 connections | deadpool config | +| Parallel packing threshold | 50,000+ rows | rayon in common.rs | +| IPC format | `\x1F` cell / `\x1E` row separators | common.rs | +| Search debounce | 200ms | results-panel.tsx | +| Grid row height | 32px | results-grid.tsx | +| Column width | 80–400px (auto-calculated from first 100 rows) | results-grid.tsx | + +### vs. Competitors + +| | RSQL | pgAdmin | DBeaver | DataGrip | TablePlus | +|---|---|---|---|---|---| +| **Price** | **Free** | Free | $0/$250/yr | $229/yr | $99 | +| **Runtime** | System WebView | Python + browser | JVM (Java 21) | JVM | Native | +| **Binary size** | **~20 MB** | ~180 MB | ~200 MB | ~600 MB | ~40 MB | +| **Grid tech** | **Canvas (WebGL)** | DOM table | SWT native | Swing | Native | +| **Memory** | **~80–150 MB** | ~200–400 MB | ~500 MB–1 GB | ~700 MB–2 GB | ~100–200 MB | +| **EXPLAIN visualizer** | Yes | Yes | Yes | Partial | No | +| **PostGIS map** | Yes | No | Yes | No | No | +| **Built-in terminal** | Yes | No | No | Yes | No | +| **Command palette** | Yes | No | No | Yes | Yes | +| **Schema diff** | Yes | No | Pro only | Yes | No | +| **FK navigation** | Yes | No | Partial | Yes | No | +| **Canvas grid** | **Yes** | No | No | No | No | +| **Open source** | **Yes** | Yes | Community | No | No | + +--- + +## Roadmap + +Planned features, roughly in priority order: + +- [ ] **Safe mode / production guard** — color-coded connections (red=production, yellow=staging, green=dev), read-only mode for production, explicit confirm for DML/DDL +- [ ] **AI-powered text-to-SQL** — natural language → SQL with schema context, support for OpenAI/Claude/Ollama local models (bring your own key) +- [ ] **Inline charts** — bar, line, pie charts directly in the results panel for aggregate queries +- [ ] **Query parameterization** — detect `$1`/`:param` placeholders, show input panel, execute with native PG parameterized queries +- [ ] **Visual query builder** — drag tables from the schema browser, auto-generate JOINs, build WHERE clauses visually +- [ ] **Multi-format import** — Excel (.xlsx), Parquet, JSON array import via Rust crates (calamine, arrow/parquet) +- [ ] **Schema migration scripts** — generate runnable ALTER/CREATE migration scripts from schema diff results +- [ ] **Backup & restore GUI** — wrapper around pg_dump/pg_restore with format selection, schema/data-only options +- [ ] **RLS policy editor** — visual editor for Row-Level Security policies (USING/WITH CHECK expressions) +- [ ] **Local DuckDB execution** — run SQL against CSV/Parquet files without a PostgreSQL server +- [ ] **Vim-style navigation** — Monaco vim mode + keyboard-only grid/sidebar navigation +- [ ] **Multi-database support** — MySQL, SQLite, Redis via the existing `drivers/` architecture + +--- ## Development @@ -93,38 +228,38 @@ yarn tauri build ## Release Workflow -The release workflow (`.github/workflows/release.yml`) builds release artifacts, signs updater metadata, and currently signs Windows and Linux artifacts. macOS signing/notarization is intentionally still pending. +The release workflow (`.github/workflows/release.yml`) builds release artifacts, signs updater metadata, and currently signs Windows and Linux artifacts. macOS signing/notarization is in progress. + +The app checks GitHub Releases for updates via `https://github.com/rust-dd/rust-sql/releases/latest/download/latest.json`, with a manual "Check for Updates" action plus a silent startup check. -Updater support is now wired into the app runtime as well. The app checks GitHub Releases via `https://github.com/rust-dd/rust-sql/releases/latest/download/latest.json`, and the packaged build enables a manual "Check for Updates" action plus a silent startup check. +### Required Secrets -Required updater secrets: +**Updater:** +- `TAURI_UPDATER_PUBLIC_KEY` — public key from `yarn tauri signer generate` +- `TAURI_SIGNING_PRIVATE_KEY` — private updater signing key +- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` (optional) -- `TAURI_UPDATER_PUBLIC_KEY` (public key content generated by `yarn tauri signer generate`) -- `TAURI_SIGNING_PRIVATE_KEY` (path or content of the private updater signing key) -- Optional: `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` +**Windows:** +- `WINDOWS_CERTIFICATE` — base64-encoded `.pfx` +- `WINDOWS_CERTIFICATE_PASSWORD` +- `WINDOWS_TIMESTAMP_URL` (optional, defaults to `http://timestamp.digicert.com`) -Platform code signing / notarization secrets: +**Linux:** +- `TAURI_SIGNING_RPM_KEY` — ASCII-armored private GPG key +- `TAURI_SIGNING_RPM_KEY_PASSPHRASE` (optional) +- `APPIMAGETOOL_SIGN_PASSPHRASE` +- `SIGN_KEY` (optional — GPG key id for AppImage signing) -- Windows: `WINDOWS_CERTIFICATE` (base64-encoded `.pfx`) -- Windows: `WINDOWS_CERTIFICATE_PASSWORD` -- Windows optional: `WINDOWS_TIMESTAMP_URL` (defaults to `http://timestamp.digicert.com`) -- Linux: `TAURI_SIGNING_RPM_KEY` (ASCII-armored private GPG key) -- Linux optional: `TAURI_SIGNING_RPM_KEY_PASSPHRASE` -- Linux/AppImage: `APPIMAGETOOL_SIGN_PASSPHRASE` -- Linux optional: `SIGN_KEY` (specific GPG key id or fingerprint for AppImage signing) -- macOS later: `APPLE_CERTIFICATE` (base64-encoded `.p12` Developer ID Application certificate) -- macOS later: `APPLE_CERTIFICATE_PASSWORD` -- macOS later: `APPLE_SIGNING_IDENTITY` (for example: `Developer ID Application: Your Name (TEAMID)`) -- macOS later notarization option A: `APPLE_ID`, `APPLE_PASSWORD` (app-specific password), `APPLE_TEAM_ID` -- macOS later notarization option B: `APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_P8` +**macOS (pending):** +- `APPLE_CERTIFICATE` — base64-encoded `.p12` Developer ID Application certificate +- `APPLE_CERTIFICATE_PASSWORD` +- `APPLE_SIGNING_IDENTITY` +- Notarization: `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID` or `APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_P8` -Notes: +Push a tag like `v1.x.x` to trigger a release build. -- `bundle.createUpdaterArtifacts` is enabled, so release builds will generate signed updater artifacts and `latest.json`. -- The updater uses the latest published GitHub release. Draft releases are not visible to clients until you publish them. -- If you build locally without `TAURI_UPDATER_PUBLIC_KEY`, the app still builds, but the updater plugin stays disabled for that build. -- The current release workflow requires the updater secrets above, plus the Windows and Linux signing secrets listed here. macOS signing secrets are documented for the later notarized rollout. +--- -For manual runs (`workflow_dispatch`), provide the release tag explicitly, for example `v1.x.x`. +## License -After the updater secrets are configured, pushing a tag like `v1.x.x` builds release artifacts and publishes signed updater metadata. +Open source. See [LICENSE](LICENSE) for details. diff --git a/src-tauri/src/app_setup.rs b/src-tauri/src/app_setup.rs new file mode 100644 index 0000000..11fd94f --- /dev/null +++ b/src-tauri/src/app_setup.rs @@ -0,0 +1,213 @@ +use std::{collections::BTreeMap, sync::Arc}; +use tauri::Manager; +use tauri::menu::{AboutMetadata, MenuBuilder, SubmenuBuilder}; +use tokio::sync::Mutex; + +use crate::{AppState, LOCAL_DB_NAME, terminal, utils}; + +pub fn setup_app(app: &mut tauri::App) -> Result<(), Box> { + #[cfg(desktop)] + if let Some(pubkey) = option_env!("TAURI_UPDATER_PUBLIC_KEY") { + app.handle() + .plugin(tauri_plugin_updater::Builder::new().pubkey(pubkey).build())?; + } else { + tracing::info!( + "Updater disabled because TAURI_UPDATER_PUBLIC_KEY was not set at build time" + ); + } + + let app_handle = app.handle().clone(); + + tauri::async_runtime::block_on(async move { + let db_path = if cfg!(debug_assertions) { + LOCAL_DB_NAME.to_string() + } else { + let app_dir = app_handle + .path() + .app_data_dir() + .expect("Failed to resolve app data directory"); + std::fs::create_dir_all(&app_dir).ok(); + app_dir.join(LOCAL_DB_NAME).to_string_lossy().to_string() + }; + + let db = libsql::Builder::new_local(&db_path) + .build() + .await + .expect("Failed to open local database"); + + // Create tables + let conn = db.connect().expect("Failed to create connection"); + conn.execute( + "CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + driver TEXT NOT NULL DEFAULT 'PGSQL', + username TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', + database TEXT NOT NULL DEFAULT '', + host TEXT NOT NULL DEFAULT '', + port TEXT NOT NULL DEFAULT '', + ssl TEXT NOT NULL DEFAULT 'false' + )", + (), + ) + .await + .expect("Failed to create projects table"); + + conn.execute( + "CREATE TABLE IF NOT EXISTS queries ( + id TEXT PRIMARY KEY, + sql TEXT NOT NULL DEFAULT '' + )", + (), + ) + .await + .expect("Failed to create queries table"); + + conn.execute( + "CREATE TABLE IF NOT EXISTS workspaces ( + name TEXT PRIMARY KEY, + tabs TEXT NOT NULL DEFAULT '[]' + )", + (), + ) + .await + .expect("Failed to create workspaces table"); + + conn.execute( + "CREATE TABLE IF NOT EXISTS virtual_query_snapshots ( + query_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + sql TEXT NOT NULL, + columns_packed TEXT NOT NULL DEFAULT '', + total_rows INTEGER NOT NULL DEFAULT 0, + page_size INTEGER NOT NULL DEFAULT 0, + col_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + )", + (), + ) + .await + .expect("Failed to create virtual_query_snapshots table"); + + conn.execute( + "CREATE TABLE IF NOT EXISTS virtual_query_pages ( + query_id TEXT NOT NULL, + page_index INTEGER NOT NULL, + packed_page TEXT NOT NULL DEFAULT '', + PRIMARY KEY (query_id, page_index) + )", + (), + ) + .await + .expect("Failed to create virtual_query_pages table"); + + // Best-effort orphan cleanup in case app exited before tab-close cleanup. + conn.execute( + "DELETE FROM virtual_query_pages + WHERE query_id NOT IN (SELECT query_id FROM virtual_query_snapshots)", + (), + ) + .await + .ok(); + + // SSH tunnel columns migration + for col in [ + "ssh_enabled", + "ssh_host", + "ssh_port", + "ssh_user", + "ssh_password", + "ssh_key_path", + ] { + conn.execute( + &format!( + "ALTER TABLE projects ADD COLUMN {} TEXT NOT NULL DEFAULT ''", + col + ), + (), + ) + .await + .ok(); // Ignore "column already exists" errors + } + + let state = AppState { + clients: Arc::new(Mutex::new(BTreeMap::new())), + meta_clients: Arc::new(Mutex::new(BTreeMap::new())), + cancel_tokens: Arc::new(Mutex::new(BTreeMap::new())), + client_ssl: Arc::new(Mutex::new(BTreeMap::new())), + local_db: db, + resource_monitor: Arc::new(Mutex::new(utils::ResourceMonitor::new())), + virtual_cache: Arc::new(Mutex::new(BTreeMap::new())), + notify_handles: Arc::new(Mutex::new(BTreeMap::new())), + ssh_tunnels: Arc::new(Mutex::new(BTreeMap::new())), + }; + app_handle.manage(state); + + let terminal_state = terminal::TerminalState { + sessions: Arc::new(Mutex::new(std::collections::HashMap::new())), + }; + app_handle.manage(terminal_state); + }); + + // Native menu + let handle = app.handle(); + + let app_menu = SubmenuBuilder::new(handle, "RSQL") + .about(Some(AboutMetadata { + name: Some("RSQL".into()), + version: Some(env!("CARGO_PKG_VERSION").into()), + copyright: Some("\u{00a9} 2025 rust-dd".into()), + comments: Some( + "Modern SQL client for PostgreSQL.\nBuilt with Tauri, React, and Rust." + .into(), + ), + website: Some("https://github.com/rust-dd/rust-sql".into()), + website_label: Some("GitHub".into()), + ..Default::default() + })) + .separator() + .services() + .separator() + .hide() + .hide_others() + .show_all() + .separator() + .quit() + .build()?; + + let edit_menu = SubmenuBuilder::new(handle, "Edit") + .undo() + .redo() + .separator() + .cut() + .copy() + .paste() + .select_all() + .build()?; + + let view_menu = SubmenuBuilder::new(handle, "View").fullscreen().build()?; + + let window_menu = SubmenuBuilder::new(handle, "Window") + .minimize() + .maximize() + .separator() + .close_window() + .build()?; + + let menu = MenuBuilder::new(handle) + .items(&[&app_menu, &edit_menu, &view_menu, &window_menu]) + .build()?; + + handle.set_menu(menu)?; + + #[cfg(debug_assertions)] + { + let window = app + .get_webview_window("main") + .expect("main window not found"); + window.open_devtools(); + window.close_devtools(); + } + + Ok(()) +} diff --git a/src-tauri/src/drivers/common.rs b/src-tauri/src/drivers/common.rs deleted file mode 100644 index fb672e7..0000000 --- a/src-tauri/src/drivers/common.rs +++ /dev/null @@ -1,2137 +0,0 @@ -use deadpool_postgres::Pool; -use rayon::prelude::*; -use std::sync::Arc; -use std::time::Instant; -use tokio::time as tokio_time; -use tokio_postgres::{Client, SimpleQueryMessage}; - -use crate::common::enums::AppError; -use crate::common::pgsql::{PgsqlLoadColumns, PgsqlLoadSchemas, PgsqlLoadTables}; - -/// Safely get a pool Arc from the AppState client map. -/// Returns a cloned Arc so the caller can drop the MutexGuard immediately. -pub fn get_pool( - clients_guard: &std::collections::BTreeMap>, - project_id: &str, -) -> Result, AppError> { - clients_guard - .get(project_id) - .cloned() - .ok_or_else(|| AppError::ClientNotConnected(project_id.to_string())) -} - -/// Process simple_query messages, returning the last result set that had rows. -/// If no result set had rows but commands ran, returns synthetic "N rows affected". -/// If nothing at all, returns empty vecs. -fn process_simple_messages(messages: Vec) -> (Vec, Vec>) { - let mut cur_columns: Vec = Vec::new(); - let mut cur_rows: Vec> = Vec::new(); - let mut last_columns: Vec = Vec::new(); - let mut last_rows: Vec> = Vec::new(); - let mut has_row_result = false; - let mut total_affected: u64 = 0; - - for msg in messages { - match msg { - SimpleQueryMessage::Row(row) => { - let col_count = row.columns().len(); - if cur_columns.is_empty() { - cur_columns = Vec::with_capacity(col_count); - for c in row.columns() { - cur_columns.push(c.name().to_owned()); - } - } - let mut cells = Vec::with_capacity(col_count); - for i in 0..col_count { - cells.push(row.get(i).unwrap_or("null").to_owned()); - } - cur_rows.push(cells); - } - SimpleQueryMessage::CommandComplete(n) => { - if !cur_rows.is_empty() { - last_columns = std::mem::take(&mut cur_columns); - last_rows = std::mem::take(&mut cur_rows); - has_row_result = true; - } else { - cur_columns.clear(); - cur_rows.clear(); - } - total_affected += n; - } - _ => {} - } - } - - // Handle trailing rows (shouldn't happen but be safe) - if !cur_rows.is_empty() { - return (cur_columns, cur_rows); - } - - if has_row_result { - (last_columns, last_rows) - } else if total_affected > 0 { - ( - vec!["Result".into()], - vec![vec![format!("{} rows affected", total_affected)]], - ) - } else { - (Vec::new(), Vec::new()) - } -} - -/// Execute a timed query and return (columns, rows_as_strings, elapsed_ms). -/// Uses simple_query protocol — PG returns all values as text, no type conversion needed. -/// Supports multi-statement: returns the last result set that had rows. -pub async fn execute_query( - client: &Client, - sql: &str, -) -> Result<(Vec, Vec>, f32), AppError> { - let start = Instant::now(); - let messages = client - .simple_query(sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let (columns, rows) = process_simple_messages(messages); - let elapsed = start.elapsed().as_millis() as f32; - Ok((columns, rows, elapsed)) -} - -/// Cell separator for packed format (Unit Separator, ASCII 0x1F) -const CELL_SEP: char = '\x1F'; -/// Row separator for packed format (Record Separator, ASCII 0x1E) -const ROW_SEP: char = '\x1E'; - -/// Join string slices with a char separator — avoids .to_string() on the separator. -#[inline] -fn join_sep(items: &[String], sep: char) -> String { - let total: usize = items.iter().map(|s| s.len()).sum::() + items.len(); - let mut out = String::with_capacity(total); - for (i, item) in items.iter().enumerate() { - if i > 0 { - out.push(sep); - } - out.push_str(item); - } - out -} - -/// Events emitted during streamed query execution. -#[derive(serde::Serialize, Clone)] -#[serde(tag = "type")] -pub enum QueryStreamEvent { - #[serde(rename = "columns")] - Columns { columns: String, total_rows: usize }, - #[serde(rename = "chunk")] - Chunk { data: String }, - #[serde(rename = "done")] - Done { elapsed: f32, capped: bool }, -} - -/// Maximum rows to send to the frontend to prevent OOM in the webview. -const MAX_STREAM_ROWS: usize = 500_000; -/// Rows fetched per cursor FETCH round-trip. -const CURSOR_FETCH_SIZE: usize = 10_000; - -/// Execute a timed query and return results in compact packed string format. -/// Format: "col1\x1Fcol2\x1E row1val1\x1Frow1val2\x1E row2val1\x1Frow2val2" -/// Uses simple_query protocol with multi-statement support. -pub async fn execute_query_packed(client: &Client, sql: &str) -> Result<(String, f32), AppError> { - let start = Instant::now(); - let messages = client - .simple_query(sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let (columns, rows) = process_simple_messages(messages); - - if columns.is_empty() { - return Ok((String::new(), start.elapsed().as_millis() as f32)); - } - - let header = join_sep(&columns, CELL_SEP); - let body = pack_rows_vec(&rows); - - let packed = if body.is_empty() { - header - } else { - let mut s = String::with_capacity(header.len() + 1 + body.len()); - s.push_str(&header); - s.push(ROW_SEP); - s.push_str(&body); - s - }; - let elapsed = start.elapsed().as_millis() as f32; - Ok((packed, elapsed)) -} - -/// Stream query results using a PostgreSQL cursor. -/// Fetches rows in batches from the server — never loads the full result into Rust memory. -/// Caps at MAX_STREAM_ROWS to protect the webview from OOM. -pub async fn execute_query_streamed( - client: &Client, - sql: &str, - stream_id: &str, - app: &tauri::AppHandle, -) -> Result<(), AppError> { - use tauri::Emitter; - - let start = Instant::now(); - let event_name = format!("query-stream-{}", stream_id); - - // Begin transaction + declare cursor for memory-efficient streaming - client - .batch_execute("BEGIN") - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let cursor_sql = format!("DECLARE _rsql_cur NO SCROLL CURSOR FOR {}", sql); - match client.batch_execute(&cursor_sql).await { - Ok(_) => { - // Cursor-based fetch loop using simple_query for zero type conversion - let fetch_sql = format!("FETCH {} FROM _rsql_cur", CURSOR_FETCH_SIZE); - let mut total_sent: usize = 0; - let mut columns_sent = false; - let mut capped = false; - - loop { - let messages = match client.simple_query(&fetch_sql).await { - Ok(msgs) => msgs, - Err(e) => { - let _ = client.batch_execute("CLOSE _rsql_cur; ROLLBACK").await; - return Err(AppError::QueryFailed(e.to_string())); - } - }; - - let mut batch_rows: Vec> = Vec::new(); - let mut batch_columns: Option> = None; - - for msg in messages { - if let SimpleQueryMessage::Row(row) = msg { - let col_count = row.columns().len(); - if batch_columns.is_none() { - let mut cols = Vec::with_capacity(col_count); - for c in row.columns() { - cols.push(c.name().to_owned()); - } - batch_columns = Some(cols); - } - let mut cells = Vec::with_capacity(col_count); - for i in 0..col_count { - cells.push(row.get(i).unwrap_or("null").to_owned()); - } - batch_rows.push(cells); - } - } - - if batch_rows.is_empty() { - break; - } - - // Emit columns on first batch - if !columns_sent && let Some(cols) = batch_columns { - let header = join_sep(&cols, CELL_SEP); - let _ = app.emit( - &event_name, - QueryStreamEvent::Columns { - columns: header, - total_rows: 0, - }, - ); - columns_sent = true; - } - - let packed = pack_rows_vec(&batch_rows); - let _ = app.emit(&event_name, QueryStreamEvent::Chunk { data: packed }); - - total_sent += batch_rows.len(); - if total_sent >= MAX_STREAM_ROWS { - capped = true; - break; - } - } - - // No rows at all - if !columns_sent { - let _ = app.emit( - &event_name, - QueryStreamEvent::Columns { - columns: String::new(), - total_rows: 0, - }, - ); - } - - // Clean up cursor + transaction - client.batch_execute("CLOSE _rsql_cur").await.ok(); - client.batch_execute("COMMIT").await.ok(); - - let elapsed = start.elapsed().as_millis() as f32; - let _ = app.emit(&event_name, QueryStreamEvent::Done { elapsed, capped }); - } - Err(_cursor_err) => { - // DECLARE CURSOR failed (non-SELECT query like INSERT/UPDATE/DDL) - client.batch_execute("ROLLBACK").await.ok(); - - // Re-execute with simple_query for multi-statement support - let messages = client - .simple_query(sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let (columns, rows) = process_simple_messages(messages); - - if columns.is_empty() { - let _ = app.emit( - &event_name, - QueryStreamEvent::Columns { - columns: String::new(), - total_rows: 0, - }, - ); - } else { - let header = join_sep(&columns, CELL_SEP); - let _ = app.emit( - &event_name, - QueryStreamEvent::Columns { - columns: header, - total_rows: rows.len(), - }, - ); - - let packed = pack_rows_vec(&rows); - let _ = app.emit(&event_name, QueryStreamEvent::Chunk { data: packed }); - } - - let elapsed = start.elapsed().as_millis() as f32; - let _ = app.emit( - &event_name, - QueryStreamEvent::Done { - elapsed, - capped: false, - }, - ); - } - } - - Ok(()) -} - -/// A cached query: pre-packed page strings for zero-copy serving. -/// Each page is a single large String (~1-2 MB) so the OS reclaims RSS on drop. -pub struct CachedQuery { - pages: Vec, - page_size: usize, -} - -/// In-memory virtual cache: query_id → pre-packed pages. -pub type VirtualCache = std::collections::BTreeMap; - -/// Pack a slice of rows (each row = Vec) into wire format. -/// Pre-allocates capacity and writes directly — zero intermediate allocations. -fn pack_rows_vec(rows: &[Vec]) -> String { - if rows.is_empty() { - return String::new(); - } - // Estimate capacity: avg ~20 chars per cell - let est = rows.len() * rows.first().map_or(10, |r| r.len()) * 20; - let mut out = String::with_capacity(est); - - for (ri, row) in rows.iter().enumerate() { - if ri > 0 { - out.push(ROW_SEP); - } - for (ci, cell) in row.iter().enumerate() { - if ci > 0 { - out.push(CELL_SEP); - } - // Inline separator sanitization — avoids .replace() allocations - for ch in cell.chars() { - if ch == CELL_SEP || ch == ROW_SEP { - out.push(' '); - } else { - out.push(ch); - } - } - } - } - out -} - -/// Execute a query in one shot using simple_query protocol. -/// Pre-packs results into page-sized strings cached in-memory. -/// Returns (columns_packed, total_rows, first_page_packed, elapsed_ms). -/// If the SQL is non-SELECT / returns 0 rows, returns empty columns_packed signal -/// with a synthetic affected-rows message in first_page_packed when applicable. -pub async fn execute_virtual( - client: &Client, - cache: &tokio::sync::Mutex, - sql: &str, - query_id: &str, - page_size: usize, -) -> Result<(String, usize, String, f32), AppError> { - let start = Instant::now(); - - let messages = client - .simple_query(sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let (columns, all_rows) = process_simple_messages(messages); - - // Non-SELECT or empty result - if columns.is_empty() { - let elapsed = start.elapsed().as_millis() as f32; - return Ok((String::new(), 0, String::new(), elapsed)); - } - - // Synthetic "N rows affected" result — pass through as fallback format - if columns.len() == 1 && columns[0] == "Result" { - let mut fallback = String::with_capacity(64); - fallback.push_str(&columns[0]); - fallback.push(ROW_SEP); - if let Some(r) = all_rows.first() { - fallback.push_str(&join_sep(r, CELL_SEP)); - } - let elapsed = start.elapsed().as_millis() as f32; - return Ok((String::new(), 0, fallback, elapsed)); - } - - let total_rows = all_rows.len(); - - // Pre-pack into pages — use rayon only for large results (>50K rows) - let chunks: Vec<&[Vec]> = all_rows.chunks(page_size).collect(); - let pages: Vec = if total_rows > 50_000 { - chunks - .par_iter() - .map(|chunk| pack_rows_vec(chunk)) - .collect() - } else { - chunks.iter().map(|chunk| pack_rows_vec(chunk)).collect() - }; - - let columns_packed = join_sep(&columns, CELL_SEP); - let first_page_packed = pages.first().cloned().unwrap_or_default(); - - // Store pre-packed pages in cache - { - let mut c = cache.lock().await; - c.insert(query_id.to_string(), CachedQuery { pages, page_size }); - } - - let elapsed = start.elapsed().as_millis() as f32; - Ok((columns_packed, total_rows, first_page_packed, elapsed)) -} - -/// Fetch a pre-packed page from the in-memory cache. O(1) — no packing at serve time. -pub async fn fetch_virtual_page( - cache: &tokio::sync::Mutex, - query_id: &str, - _col_count: usize, - offset: usize, - _limit: usize, -) -> Result { - let c = cache.lock().await; - let entry = c - .get(query_id) - .ok_or_else(|| AppError::QueryFailed(format!("Virtual query {} not found", query_id)))?; - - let page_index = offset / entry.page_size; - Ok(entry.pages.get(page_index).cloned().unwrap_or_default()) -} - -/// Remove a query from the in-memory cache. Large page strings are freed → OS reclaims RSS. -pub async fn close_virtual( - cache: &tokio::sync::Mutex, - query_id: &str, -) -> Result<(), AppError> { - let mut c = cache.lock().await; - c.remove(query_id); - Ok(()) -} - -/// Load schemas with a timeout. The query string is driver-specific. -pub async fn load_schemas(client: &Client, query_sql: &str) -> Result { - let rows = tokio_time::timeout( - tokio_time::Duration::from_secs(10), - client.query(query_sql, &[]), - ) - .await - .map_err(|_| AppError::QueryTimeout)? - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows.iter().map(|r| r.get(0)).collect()) -} - -/// Load all user databases from pg_database. -pub async fn load_databases(pool: &Pool) -> Result, AppError> { - let client = pool.get().await.map_err(|e| AppError::DatabaseError(e.to_string()))?; - let rows = client - .query( - "SELECT datname FROM pg_database WHERE datallowconn = true AND datistemplate = false ORDER BY datname", - &[], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) -} - -/// Load tablespaces from the server. -pub async fn load_tablespaces(pool: &Pool) -> Result, AppError> { - let client = pool.get().await.map_err(|e| AppError::DatabaseError(e.to_string()))?; - let rows = client - .query( - "SELECT spcname, pg_catalog.pg_get_userbyid(spcowner) AS owner, \ - COALESCE(pg_catalog.pg_tablespace_location(oid), '') AS location \ - FROM pg_catalog.pg_tablespace ORDER BY spcname", - &[], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - Ok(rows.iter().map(|r| { - (r.get::<_, String>(0), r.get::<_, String>(1), r.get::<_, String>(2)) - }).collect()) -} - -/// Load tables for a given schema. -pub async fn load_tables( - client: &Client, - query_sql: &str, - schema: &str, -) -> Result { - let rows = client - .query(query_sql, &[&schema]) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows.iter().map(|r| (r.get(0), r.get(1))).collect()) -} - -/// Load columns for a given schema and table. -pub async fn load_columns( - client: &Client, - schema: &str, - table: &str, -) -> Result { - let rows = client - .query( - r#"SELECT column_name - FROM information_schema.columns - WHERE table_schema = $1 AND table_name = $2 - ORDER BY ordinal_position"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) -} - -/// Column detail info: (name, data_type, nullable, default_value) -pub type ColumnDetail = (String, String, bool, Option); - -/// Load detailed column info for a given schema and table. -pub async fn load_column_details( - client: &Client, - schema: &str, - table: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT column_name, data_type, is_nullable, column_default - FROM information_schema.columns - WHERE table_schema = $1 AND table_name = $2 - ORDER BY ordinal_position"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let data_type: String = r.get(1); - let nullable_str: String = r.get(2); - let default_val: Option = r.get(3); - (name, data_type, nullable_str == "YES", default_val) - }) - .collect()) -} - -/// Index info: (index_name, column_name, is_unique, is_primary) -pub type IndexDetail = (String, String, bool, bool); - -/// Load indexes for a given schema and table. -pub async fn load_indexes( - client: &Client, - schema: &str, - table: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT - i.relname AS index_name, - a.attname AS column_name, - ix.indisunique AS is_unique, - ix.indisprimary AS is_primary - FROM pg_index ix - JOIN pg_class t ON t.oid = ix.indrelid - JOIN pg_class i ON i.oid = ix.indexrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) - WHERE n.nspname = $1 AND t.relname = $2 - ORDER BY i.relname, a.attnum"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let index_name: String = r.get(0); - let column_name: String = r.get(1); - let is_unique: bool = r.get(2); - let is_primary: bool = r.get(3); - (index_name, column_name, is_unique, is_primary) - }) - .collect()) -} - -/// Trigger info: (trigger_name, event, timing) -pub type TriggerDetail = (String, String, String); - -/// Load triggers for a given schema and table. -pub async fn load_triggers( - client: &Client, - schema: &str, - table: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT DISTINCT trigger_name, event_manipulation, action_timing - FROM information_schema.triggers - WHERE trigger_schema = $1 AND event_object_table = $2 - ORDER BY trigger_name"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let event: String = r.get(1); - let timing: String = r.get(2); - (name, event, timing) - }) - .collect()) -} - -/// Rule info: (rule_name, event) -pub type RuleDetail = (String, String); - -/// Load rules for a given schema and table. -pub async fn load_rules( - client: &Client, - schema: &str, - table: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT rulename, ev_type - FROM pg_rules - WHERE schemaname = $1 AND tablename = $2 - ORDER BY rulename"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let event: String = r.get(1); - (name, event) - }) - .collect()) -} - -/// Policy info: (policy_name, permissive, command) -pub type PolicyDetail = (String, String, String); - -/// Load RLS policies for a given schema and table. -pub async fn load_policies( - client: &Client, - schema: &str, - table: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT pol.polname, - CASE WHEN pol.polpermissive THEN 'PERMISSIVE' ELSE 'RESTRICTIVE' END, - CASE pol.polcmd - WHEN 'r' THEN 'SELECT' - WHEN 'a' THEN 'INSERT' - WHEN 'w' THEN 'UPDATE' - WHEN 'd' THEN 'DELETE' - WHEN '*' THEN 'ALL' - ELSE pol.polcmd::text - END - FROM pg_policy pol - JOIN pg_class c ON c.oid = pol.polrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = $1 AND c.relname = $2 - ORDER BY pol.polname"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let perm: String = r.get(1); - let cmd: String = r.get(2); - (name, perm, cmd) - }) - .collect()) -} - -/// View info: (view_name) -pub async fn load_views(client: &Client, schema: &str) -> Result, AppError> { - let rows = client - .query( - r#"SELECT table_name - FROM information_schema.views - WHERE table_schema = $1 - ORDER BY table_name"#, - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) -} - -/// Load materialized views for a schema. -pub async fn load_materialized_views( - client: &Client, - schema: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT matviewname - FROM pg_matviews - WHERE schemaname = $1 - ORDER BY matviewname"#, - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) -} - -/// Function info: (name, return_type, arguments) -pub type FunctionInfo = (String, String, String); - -/// Load functions for a schema (excluding trigger functions and aggregates). -pub async fn load_functions(client: &Client, schema: &str) -> Result, AppError> { - let rows = client - .query( - r#"SELECT p.proname, - pg_get_function_result(p.oid), - pg_get_function_arguments(p.oid) - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = $1 - AND p.prokind IN ('f', 'p') - AND pg_get_function_result(p.oid) != 'trigger' - ORDER BY p.proname"#, - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let ret: String = r.get(1); - let args: String = r.get(2); - (name, ret, args) - }) - .collect()) -} - -/// Load trigger functions for a schema (functions that return trigger). -pub async fn load_trigger_functions( - client: &Client, - schema: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT p.proname, - pg_get_function_arguments(p.oid) - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = $1 - AND pg_get_function_result(p.oid) = 'trigger' - ORDER BY p.proname"#, - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let args: String = r.get(1); - (name, args) - }) - .collect()) -} - -/// Database stats: (stat_name, stat_value) -pub type DbStat = (String, String); - -/// Load pg_stat_activity - active connections and queries. -pub async fn load_activity(client: &Client) -> Result>, AppError> { - let rows = client - .query( - r#"SELECT - pid::text, - COALESCE(usename, '') AS usename, - COALESCE(datname, '') AS datname, - COALESCE(state, 'unknown') AS state, - COALESCE(wait_event_type, '') AS wait_event_type, - COALESCE(wait_event, '') AS wait_event, - COALESCE(LEFT(query, 500), '') AS query, - COALESCE(EXTRACT(EPOCH FROM (now() - query_start))::text, '0') AS duration_sec, - COALESCE(backend_type, '') AS backend_type, - COALESCE(client_addr::text, 'local') AS client_addr - FROM pg_stat_activity - WHERE datname = current_database() - ORDER BY state, query_start NULLS LAST"#, - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..10).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -/// Load pg_stat_database - database-level stats. -pub async fn load_database_stats(client: &Client) -> Result, AppError> { - let rows = client - .query( - r#"SELECT - 'Active Connections' AS stat, numbackends::text AS val FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Transactions Committed', xact_commit::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Transactions Rolled Back', xact_rollback::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Blocks Read (disk)', blks_read::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Blocks Hit (cache)', blks_hit::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Cache Hit Ratio', - CASE WHEN (blks_hit + blks_read) > 0 - THEN ROUND(blks_hit::numeric / (blks_hit + blks_read) * 100, 2)::text || '%' - ELSE 'N/A' - END - FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Rows Returned', tup_returned::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Rows Fetched', tup_fetched::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Rows Inserted', tup_inserted::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Rows Updated', tup_updated::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Rows Deleted', tup_deleted::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Temp Files', temp_files::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Temp Bytes', pg_size_pretty(temp_bytes) FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Deadlocks', deadlocks::text FROM pg_stat_database WHERE datname = current_database() - UNION ALL - SELECT 'Database Size', pg_size_pretty(pg_database_size(current_database()))"#, - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let val: String = r.get(1); - (name, val) - }) - .collect()) -} - -/// Load pg_stat_user_tables - table-level stats. -pub async fn load_table_stats(client: &Client) -> Result>, AppError> { - let rows = client - .query( - r#"SELECT - schemaname, - relname, - COALESCE(seq_scan, 0)::text AS seq_scan, - COALESCE(seq_tup_read, 0)::text AS seq_tup_read, - COALESCE(idx_scan, 0)::text AS idx_scan, - COALESCE(idx_tup_fetch, 0)::text AS idx_tup_fetch, - COALESCE(n_tup_ins, 0)::text AS inserts, - COALESCE(n_tup_upd, 0)::text AS updates, - COALESCE(n_tup_del, 0)::text AS deletes, - COALESCE(n_live_tup, 0)::text AS live_tuples, - COALESCE(n_dead_tup, 0)::text AS dead_tuples, - COALESCE(last_vacuum::text, 'never') AS last_vacuum, - COALESCE(last_autovacuum::text, 'never') AS last_autovacuum, - COALESCE(last_analyze::text, 'never') AS last_analyze - FROM pg_stat_user_tables - ORDER BY seq_scan + COALESCE(idx_scan, 0) DESC - LIMIT 100"#, - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..14).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -/// Constraint info: (constraint_name, constraint_type, column_name) -pub type ConstraintDetail = (String, String, String); - -/// Load constraints for a given schema and table. -pub async fn load_constraints( - client: &Client, - schema: &str, - table: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT - tc.constraint_name, - tc.constraint_type, - COALESCE(kcu.column_name, '') - FROM information_schema.table_constraints tc - LEFT JOIN information_schema.key_column_usage kcu - ON kcu.constraint_name = tc.constraint_name - AND kcu.table_schema = tc.table_schema - AND kcu.table_name = tc.table_name - WHERE tc.table_schema = $1 AND tc.table_name = $2 - ORDER BY tc.constraint_name, kcu.ordinal_position"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let name: String = r.get(0); - let ctype: String = r.get(1); - let col: String = r.get(2); - (name, ctype, col) - }) - .collect()) -} - -/// FK relation: (source_table, source_column, target_table, target_column) -pub type ForeignKeyInfo = (String, String, String, String); - -/// Table statistics: Vec of (key, value) pairs -pub type ObjectStats = Vec<(String, String)>; - -/// FK detail: (constraint_name, source_schema, source_table, source_column, target_schema, target_table, target_column, on_update, on_delete) -pub type FKDetail = ( - String, - String, - String, - String, - String, - String, - String, - String, - String, -); - -/// Load live statistics for a table. -pub async fn load_table_statistics( - client: &Client, - schema: &str, - table: &str, -) -> Result { - let rows = client - .query( - r#"SELECT - c.reltuples::bigint::text, - pg_size_pretty(pg_table_size(c.oid)), - pg_size_pretty(pg_indexes_size(c.oid)), - pg_size_pretty(pg_total_relation_size(c.oid)), - COALESCE(s.last_vacuum::text, 'never'), - COALESCE(s.last_analyze::text, 'never'), - COALESCE(s.last_autovacuum::text, 'never'), - COALESCE(s.last_autoanalyze::text, 'never'), - COALESCE(s.n_dead_tup, 0)::text, - COALESCE(s.n_live_tup, 0)::text, - COALESCE(s.seq_scan, 0)::text, - COALESCE(s.idx_scan, 0)::text - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid - WHERE n.nspname = $1 AND c.relname = $2 - LIMIT 1"#, - &[&schema, &table], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let keys = [ - "row_estimate", - "table_size", - "index_size", - "total_size", - "last_vacuum", - "last_analyze", - "last_autovacuum", - "last_autoanalyze", - "dead_tuples", - "live_tuples", - "seq_scan", - "idx_scan", - ]; - - if let Some(row) = rows.first() { - Ok(keys - .iter() - .enumerate() - .map(|(i, k)| { - let val: Option = row.try_get(i).ok(); - (k.to_string(), val.unwrap_or_else(|| "-".into())) - }) - .collect()) - } else { - Ok(Vec::new()) - } -} - -/// Load outgoing or incoming FK details for a table. -pub async fn load_fk_details( - client: &Client, - schema: &str, - table: &str, - direction: &str, // "outgoing" or "incoming" -) -> Result, AppError> { - let where_clause = if direction == "incoming" { - "nsp_tgt.nspname = $1 AND tgt.relname = $2" - } else { - "nsp.nspname = $1 AND src.relname = $2" - }; - - let sql = format!( - r#"SELECT - con.conname, - nsp.nspname, - src.relname, - a_src.attname, - nsp_tgt.nspname, - tgt.relname, - a_tgt.attname, - CASE con.confupdtype - WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' - WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' - WHEN 'd' THEN 'SET DEFAULT' ELSE '' END, - CASE con.confdeltype - WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' - WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' - WHEN 'd' THEN 'SET DEFAULT' ELSE '' END - FROM pg_constraint con - JOIN pg_class src ON src.oid = con.conrelid - JOIN pg_namespace nsp ON nsp.oid = src.relnamespace - JOIN pg_class tgt ON tgt.oid = con.confrelid - JOIN pg_namespace nsp_tgt ON nsp_tgt.oid = tgt.relnamespace - JOIN pg_attribute a_src ON a_src.attrelid = con.conrelid AND a_src.attnum = ANY(con.conkey) - JOIN pg_attribute a_tgt ON a_tgt.attrelid = con.confrelid AND a_tgt.attnum = ANY(con.confkey) - WHERE con.contype = 'f' AND {where_clause} - ORDER BY con.conname"# - ); - - let rows = client - .query(&sql, &[&schema, &table]) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - ( - r.get(0), - r.get(1), - r.get(2), - r.get(3), - r.get(4), - r.get(5), - r.get(6), - r.get(7), - r.get(8), - ) - }) - .collect()) -} - -/// Load view metadata. -pub async fn load_view_info( - client: &Client, - schema: &str, - view: &str, -) -> Result { - let rows = client - .query( - r#"SELECT - COALESCE(v.is_updatable, 'NO'), - COALESCE(v.check_option, 'NONE'), - pg_get_viewdef(c.oid, true) - FROM information_schema.views v - JOIN pg_class c ON c.relname = v.table_name - JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = v.table_schema - WHERE v.table_schema = $1 AND v.table_name = $2 - LIMIT 1"#, - &[&schema, &view], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - if let Some(row) = rows.first() { - Ok(vec![ - ("is_updatable".into(), row.get::<_, String>(0)), - ("check_option".into(), row.get::<_, String>(1)), - ("definition".into(), row.get::<_, String>(2)), - ]) - } else { - Ok(Vec::new()) - } -} - -/// Load materialized view metadata. -pub async fn load_matview_info( - client: &Client, - schema: &str, - matview: &str, -) -> Result { - let sql = r#"SELECT - c.reltuples::bigint::text, - pg_size_pretty(pg_total_relation_size(c.oid)), - CASE WHEN m.ispopulated THEN 'YES' ELSE 'NO' END, - m.definition - FROM pg_matviews m - JOIN pg_class c ON c.relname = m.matviewname - JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = m.schemaname - WHERE m.schemaname = $1 AND m.matviewname = $2 - LIMIT 1"#; - - let rows = client - .query(sql, &[&schema, &matview]) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - if let Some(row) = rows.first() { - Ok(vec![ - ("row_estimate".into(), row.get::<_, String>(0)), - ("total_size".into(), row.get::<_, String>(1)), - ("is_populated".into(), row.get::<_, String>(2)), - ("definition".into(), row.get::<_, String>(3)), - ]) - } else { - Ok(Vec::new()) - } -} - -/// Load function metadata. -pub async fn load_function_info( - client: &Client, - schema: &str, - func_name: &str, -) -> Result { - let rows = client - .query( - r#"SELECT - l.lanname, - CASE p.provolatile WHEN 'i' THEN 'IMMUTABLE' WHEN 's' THEN 'STABLE' WHEN 'v' THEN 'VOLATILE' ELSE '' END, - p.proisstrict::text, - p.prosecdef::text, - p.procost::text, - p.prorows::text, - pg_get_function_result(p.oid), - pg_get_function_arguments(p.oid), - p.prosrc - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - JOIN pg_language l ON l.oid = p.prolang - WHERE n.nspname = $1 AND p.proname = $2 - LIMIT 1"#, - &[&schema, &func_name], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let keys = [ - "language", - "volatility", - "is_strict", - "security_definer", - "estimated_cost", - "estimated_rows", - "return_type", - "arguments", - "source", - ]; - - if let Some(row) = rows.first() { - Ok(keys - .iter() - .enumerate() - .map(|(i, k)| { - let val: Option = row.try_get(i).ok(); - (k.to_string(), val.unwrap_or_default()) - }) - .collect()) - } else { - Ok(Vec::new()) - } -} - -/// Generate full DDL for an object. Returns lines of DDL as a single String. -pub async fn generate_full_ddl( - client: &Client, - schema: &str, - name: &str, - object_type: &str, // "table", "view", "matview", "function" -) -> Result { - match object_type { - "table" => generate_table_ddl(client, schema, name).await, - "view" => generate_view_ddl(client, schema, name).await, - "matview" => generate_matview_ddl(client, schema, name).await, - "function" | "trigger-function" => generate_function_ddl(client, schema, name).await, - _ => Err(AppError::QueryFailed(format!( - "Unknown object type: {}", - object_type - ))), - } -} - -async fn generate_table_ddl( - client: &Client, - schema: &str, - table: &str, -) -> Result { - // Use simple_query so we can handle the complex CTE in one shot - let sql = format!( - r#"WITH col_ddl AS ( - SELECT ordinal_position, - ' "' || column_name || '" ' || - CASE - WHEN udt_name = 'varchar' THEN 'character varying' || COALESCE('(' || character_maximum_length || ')', '') - WHEN udt_name = 'bpchar' THEN 'character' || COALESCE('(' || character_maximum_length || ')', '') - WHEN udt_name = 'numeric' AND numeric_precision IS NOT NULL THEN 'numeric(' || numeric_precision || COALESCE(',' || numeric_scale, '') || ')' - ELSE data_type - END || - CASE WHEN is_nullable = 'NO' THEN ' NOT NULL' ELSE '' END || - CASE WHEN column_default IS NOT NULL THEN ' DEFAULT ' || column_default ELSE '' END AS col_def - FROM information_schema.columns - WHERE table_schema = '{schema}' AND table_name = '{table}' -) -SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# - ); - - let col_result = client - .simple_query(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - let mut col_defs = String::new(); - for msg in &col_result { - if let SimpleQueryMessage::Row(row) = msg { - col_defs = row.get(0).unwrap_or("").to_string(); - } - } - - let mut ddl = format!("CREATE TABLE \"{schema}\".\"{table}\" (\n{col_defs}\n);\n"); - - // Helper: extract single-column text rows from simple_query results - fn collect_lines(messages: &[SimpleQueryMessage]) -> Vec { - let mut out = Vec::new(); - for msg in messages { - if let SimpleQueryMessage::Row(row) = msg { - if let Some(line) = row.get(0) { - if !line.is_empty() { - out.push(line.to_string()); - } - } - } - } - out - } - - // Constraints (PK, FK, UNIQUE, CHECK) - let con_sql = format!( - r#"SELECT 'ALTER TABLE "{schema}"."{table}" ADD CONSTRAINT "' || con.conname || '" ' || pg_get_constraintdef(con.oid) || ';' - FROM pg_constraint con - JOIN pg_class c ON c.oid = con.conrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '{schema}' AND c.relname = '{table}' - ORDER BY CASE con.contype WHEN 'p' THEN 0 WHEN 'u' THEN 1 WHEN 'f' THEN 2 ELSE 3 END"# - ); - let con_result = client - .simple_query(&con_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&con_result) { - ddl.push('\n'); - ddl.push_str(&line); - ddl.push('\n'); - } - - // Indexes (non-constraint) - let idx_sql = format!( - r#"SELECT pg_get_indexdef(i.indexrelid) || ';' - FROM pg_index i - JOIN pg_class tbl ON tbl.oid = i.indrelid - JOIN pg_namespace n ON n.oid = tbl.relnamespace - WHERE n.nspname = '{schema}' AND tbl.relname = '{table}' - AND NOT i.indisprimary - AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = i.indexrelid)"# - ); - let idx_result = client - .simple_query(&idx_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&idx_result) { - ddl.push('\n'); - ddl.push_str(&line); - ddl.push('\n'); - } - - // Triggers - let trig_sql = format!( - r#"SELECT pg_get_triggerdef(t.oid) || ';' - FROM pg_trigger t - JOIN pg_class c ON c.oid = t.tgrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '{schema}' AND c.relname = '{table}' - AND NOT t.tgisinternal"# - ); - let trig_result = client - .simple_query(&trig_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&trig_result) { - ddl.push('\n'); - ddl.push_str(&line); - ddl.push('\n'); - } - - // RLS - let rls_sql = format!( - r#"SELECT CASE WHEN c.relrowsecurity THEN 'ALTER TABLE "{schema}"."{table}" ENABLE ROW LEVEL SECURITY;' ELSE '' END - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '{schema}' AND c.relname = '{table}'"# - ); - let rls_result = client - .simple_query(&rls_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&rls_result) { - ddl.push('\n'); - ddl.push_str(&line); - ddl.push('\n'); - } - - // Policies - let pol_sql = format!( - r#"SELECT 'CREATE POLICY "' || pol.polname || '" ON "{schema}"."{table}"' || - CASE pol.polcmd WHEN 'r' THEN ' FOR SELECT' WHEN 'a' THEN ' FOR INSERT' WHEN 'w' THEN ' FOR UPDATE' WHEN 'd' THEN ' FOR DELETE' WHEN '*' THEN '' END || - CASE WHEN pol.polpermissive THEN ' AS PERMISSIVE' ELSE ' AS RESTRICTIVE' END || - COALESCE(E'\n USING (' || pg_get_expr(pol.polqual, pol.polrelid) || ')', '') || - COALESCE(E'\n WITH CHECK (' || pg_get_expr(pol.polwithcheck, pol.polrelid) || ')', '') || - ';' - FROM pg_policy pol - JOIN pg_class c ON c.oid = pol.polrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '{schema}' AND c.relname = '{table}'"# - ); - let pol_result = client - .simple_query(&pol_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&pol_result) { - ddl.push('\n'); - ddl.push_str(&line); - ddl.push('\n'); - } - - // Table comment - let cmt_sql = format!( - r#"SELECT 'COMMENT ON TABLE "{schema}"."{table}" IS ' || quote_literal(d.description) || ';' - FROM pg_description d - JOIN pg_class c ON c.oid = d.objoid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '{schema}' AND c.relname = '{table}' AND d.objsubid = 0"# - ); - let cmt_result = client - .simple_query(&cmt_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&cmt_result) { - ddl.push('\n'); - ddl.push_str(&line); - ddl.push('\n'); - } - - // Column comments - let col_cmt_sql = format!( - r#"SELECT 'COMMENT ON COLUMN "{schema}"."{table}"."' || a.attname || '" IS ' || quote_literal(d.description) || ';' - FROM pg_description d - JOIN pg_class c ON c.oid = d.objoid - JOIN pg_namespace n ON n.oid = c.relnamespace - JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid - WHERE n.nspname = '{schema}' AND c.relname = '{table}' AND d.objsubid > 0 - ORDER BY d.objsubid"# - ); - let col_cmt_result = client - .simple_query(&col_cmt_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for line in collect_lines(&col_cmt_result) { - ddl.push_str(&line); - ddl.push('\n'); - } - - Ok(ddl.trim_end().to_string()) -} - -async fn generate_view_ddl(client: &Client, schema: &str, view: &str) -> Result { - let sql = format!( - r#"SELECT 'CREATE OR REPLACE VIEW "{schema}"."{view}" AS' || E'\n' || pg_get_viewdef('"{schema}"."{view}"'::regclass, true) || ';'"# - ); - let result = client - .simple_query(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for msg in &result { - if let SimpleQueryMessage::Row(row) = msg { - return Ok(row.get(0).unwrap_or("").to_string()); - } - } - Ok(String::new()) -} - -async fn generate_matview_ddl( - client: &Client, - schema: &str, - matview: &str, -) -> Result { - let sql = format!( - r#"SELECT 'CREATE MATERIALIZED VIEW "{schema}"."{matview}" AS' || E'\n' || definition - FROM pg_matviews - WHERE schemaname = '{schema}' AND matviewname = '{matview}'"# - ); - let result = client - .simple_query(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let mut ddl = String::new(); - for msg in &result { - if let SimpleQueryMessage::Row(row) = msg { - ddl = row.get(0).unwrap_or("").to_string(); - } - } - - // Indexes on matview - let idx_sql = format!( - r#"SELECT pg_get_indexdef(i.indexrelid) || ';' - FROM pg_index i - JOIN pg_class tbl ON tbl.oid = i.indrelid - JOIN pg_namespace n ON n.oid = tbl.relnamespace - WHERE n.nspname = '{schema}' AND tbl.relname = '{matview}'"# - ); - let idx_result = client - .simple_query(&idx_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for msg in &idx_result { - if let SimpleQueryMessage::Row(row) = msg { - if let Some(line) = row.get(0) { - ddl.push('\n'); - ddl.push_str(line); - } - } - } - - Ok(ddl.trim_end().to_string()) -} - -async fn generate_function_ddl( - client: &Client, - schema: &str, - func_name: &str, -) -> Result { - let sql = format!( - r#"SELECT pg_get_functiondef(p.oid) - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = '{schema}' AND p.proname = '{func_name}' - LIMIT 1"# - ); - let result = client - .simple_query(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - for msg in &result { - if let SimpleQueryMessage::Row(row) = msg { - return Ok(row.get(0).unwrap_or("").to_string()); - } - } - Ok(String::new()) -} - -/// Load all foreign key relationships for a given schema. -pub async fn load_foreign_keys( - client: &Client, - schema: &str, -) -> Result, AppError> { - let rows = client - .query( - r#"SELECT - kcu.table_name AS source_table, - kcu.column_name AS source_column, - ccu.table_name AS target_table, - ccu.column_name AS target_column - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name - AND ccu.table_schema = tc.table_schema - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = $1 - ORDER BY kcu.table_name, kcu.column_name"#, - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| { - let src_table: String = r.get(0); - let src_col: String = r.get(1); - let tgt_table: String = r.get(2); - let tgt_col: String = r.get(3); - (src_table, src_col, tgt_table, tgt_col) - }) - .collect()) -} - -pub async fn parse_csv_preview( - file_path: &str, - max_rows: usize, -) -> Result<(Vec, Vec>), AppError> { - let mut rdr = csv::ReaderBuilder::new() - .has_headers(true) - .from_path(file_path) - .map_err(|e| AppError::QueryFailed(format!("Failed to read CSV: {}", e)))?; - - let headers: Vec = rdr - .headers() - .map_err(|e| AppError::QueryFailed(format!("Failed to parse CSV headers: {}", e)))? - .iter() - .map(|h| h.to_string()) - .collect(); - - let mut rows = Vec::new(); - for result in rdr.records().take(max_rows) { - let record = - result.map_err(|e| AppError::QueryFailed(format!("CSV parse error: {}", e)))?; - rows.push(record.iter().map(|f| f.to_string()).collect()); - } - - Ok((headers, rows)) -} - -pub async fn import_csv_to_table( - client: &deadpool_postgres::Client, - file_path: &str, - schema: &str, - table: &str, - column_mapping: &[(usize, String)], -) -> Result { - let mut rdr = csv::ReaderBuilder::new() - .has_headers(true) - .from_path(file_path) - .map_err(|e| AppError::QueryFailed(format!("Failed to read CSV: {}", e)))?; - - if column_mapping.is_empty() { - return Err(AppError::QueryFailed( - "No column mapping provided".to_string(), - )); - } - - let col_names: Vec = column_mapping - .iter() - .map(|(_, name)| format!("\"{}\"", name)) - .collect(); - let placeholders: Vec = (1..=column_mapping.len()) - .map(|i| format!("${}", i)) - .collect(); - - let insert_sql = format!( - "INSERT INTO \"{}\".\"{}\" ({}) VALUES ({})", - schema, - table, - col_names.join(", "), - placeholders.join(", "), - ); - - let statement = client - .prepare(&insert_sql) - .await - .map_err(|e| AppError::QueryFailed(format!("Failed to prepare statement: {}", e)))?; - - client - .execute("BEGIN", &[]) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let mut imported = 0usize; - for result in rdr.records() { - let record = result.map_err(|e| { - AppError::QueryFailed(format!("CSV parse error at row {}: {}", imported + 1, e)) - })?; - - let values: Vec = column_mapping - .iter() - .map(|(idx, _)| record.get(*idx).unwrap_or("").to_string()) - .collect(); - - let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = values - .iter() - .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync)) - .collect(); - - match client.execute(&statement, ¶ms).await { - Ok(_) => imported += 1, - Err(e) => { - client.execute("ROLLBACK", &[]).await.ok(); - return Err(AppError::QueryFailed(format!( - "Import failed at row {}: {}", - imported + 1, - e - ))); - } - } - } - - client - .execute("COMMIT", &[]) - .await - .map_err(|e| AppError::QueryFailed(format!("Failed to commit: {}", e)))?; - - Ok(imported) -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct PgRole { - pub name: String, - pub superuser: bool, - pub create_db: bool, - pub create_role: bool, - pub login: bool, - pub replication: bool, - pub bypass_rls: bool, - pub conn_limit: i32, - pub valid_until: String, - pub member_of: Vec, -} - -pub async fn load_roles(client: &deadpool_postgres::Client) -> Result, AppError> { - let rows = client - .query( - "SELECT r.rolname, r.rolsuper, r.rolcreatedb, r.rolcreaterole, - r.rolcanlogin, r.rolreplication, r.rolbypassrls, r.rolconnlimit, - COALESCE(r.rolvaliduntil::text, ''), - COALESCE(array_agg(m.rolname) FILTER (WHERE m.rolname IS NOT NULL), '{}')::text[] - FROM pg_roles r - LEFT JOIN pg_auth_members am ON am.member = r.oid - LEFT JOIN pg_roles m ON m.oid = am.roleid - GROUP BY r.oid, r.rolname, r.rolsuper, r.rolcreatedb, r.rolcreaterole, - r.rolcanlogin, r.rolreplication, r.rolbypassrls, r.rolconnlimit, r.rolvaliduntil - ORDER BY r.rolname", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let mut roles = Vec::new(); - for row in rows { - roles.push(PgRole { - name: row.get::<_, String>(0), - superuser: row.get::<_, bool>(1), - create_db: row.get::<_, bool>(2), - create_role: row.get::<_, bool>(3), - login: row.get::<_, bool>(4), - replication: row.get::<_, bool>(5), - bypass_rls: row.get::<_, bool>(6), - conn_limit: row.get::<_, i32>(7), - valid_until: row.get::<_, String>(8), - member_of: row.get::<_, Vec>(9), - }); - } - - Ok(roles) -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct TableGrant { - pub schema: String, - pub table: String, - pub grantee: String, - pub privileges: Vec, -} - -pub async fn load_table_grants( - client: &deadpool_postgres::Client, - role_name: &str, -) -> Result, AppError> { - let rows = client - .query( - "SELECT table_schema, table_name, grantee, - array_agg(privilege_type ORDER BY privilege_type)::text[] - FROM information_schema.table_privileges - WHERE grantee = $1 - GROUP BY table_schema, table_name, grantee - ORDER BY table_schema, table_name", - &[&role_name], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let mut grants = Vec::new(); - for row in rows { - grants.push(TableGrant { - schema: row.get(0), - table: row.get(1), - grantee: row.get(2), - privileges: row.get::<_, Vec>(3), - }); - } - - Ok(grants) -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct DbGrant { - pub database: String, - pub privilege: String, -} - -pub async fn load_database_grants( - client: &deadpool_postgres::Client, - role_name: &str, -) -> Result, AppError> { - let rows = client - .query( - "SELECT datname, privilege_type - FROM pg_database - CROSS JOIN LATERAL ( - SELECT privilege_type - FROM (VALUES ('CONNECT'), ('CREATE'), ('TEMPORARY')) AS privs(privilege_type) - WHERE has_database_privilege($1, datname, privilege_type) - ) t - WHERE NOT datistemplate - ORDER BY datname, privilege_type", - &[&role_name], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - let mut grants = Vec::new(); - for row in rows { - grants.push(DbGrant { - database: row.get(0), - privilege: row.get(1), - }); - } - - Ok(grants) -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct SchemaObject { - pub object_type: String, - pub name: String, - pub definition: String, -} - -pub async fn extract_schema_objects( - client: &deadpool_postgres::Client, - schema: &str, -) -> Result, AppError> { - let mut objects = Vec::new(); - - // Tables with columns - let rows = client - .query( - "SELECT c.relname, - string_agg( - a.attname || ' ' || pg_catalog.format_type(a.atttypid, a.atttypmod) || - CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END || - COALESCE(' DEFAULT ' || pg_get_expr(d.adbin, d.adrelid), ''), - ', ' ORDER BY a.attnum - ) - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped - LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum - WHERE n.nspname = $1 AND c.relkind = 'r' - GROUP BY c.relname ORDER BY c.relname", - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - for row in &rows { - objects.push(SchemaObject { - object_type: "table".to_string(), - name: row.get(0), - definition: row.get::<_, Option>(1).unwrap_or_default(), - }); - } - - // Views - let rows = client - .query( - "SELECT viewname, definition FROM pg_views WHERE schemaname = $1 ORDER BY viewname", - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - for row in &rows { - objects.push(SchemaObject { - object_type: "view".to_string(), - name: row.get(0), - definition: row.get::<_, Option>(1).unwrap_or_default(), - }); - } - - // Materialized views - let rows = client - .query( - "SELECT matviewname, definition FROM pg_matviews WHERE schemaname = $1 ORDER BY matviewname", - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - for row in &rows { - objects.push(SchemaObject { - object_type: "matview".to_string(), - name: row.get(0), - definition: row.get::<_, Option>(1).unwrap_or_default(), - }); - } - - // Functions - let rows = client - .query( - "SELECT p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', - COALESCE(pg_get_functiondef(p.oid), '') - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = $1 ORDER BY p.proname", - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - for row in &rows { - objects.push(SchemaObject { - object_type: "function".to_string(), - name: row.get(0), - definition: row.get::<_, Option>(1).unwrap_or_default(), - }); - } - - // Indexes - let rows = client - .query( - "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 ORDER BY indexname", - &[&schema], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - for row in &rows { - objects.push(SchemaObject { - object_type: "index".to_string(), - name: row.get(0), - definition: row.get::<_, Option>(1).unwrap_or_default(), - }); - } - - Ok(objects) -} - -pub async fn discover_notify_channels( - client: &deadpool_postgres::Client, -) -> Result, AppError> { - // Extract channel names from: - // 1. pg_notify() calls in trigger function bodies - // 2. NOTIFY statements in trigger function bodies - // 3. Currently active listeners from pg_stat_activity - let rows = client - .query( - r#"SELECT DISTINCT channel FROM ( - SELECT (regexp_matches(prosrc, 'pg_notify\s*\(\s*''([^'']+)''', 'gi'))[1] AS channel - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - UNION - SELECT (regexp_matches(prosrc, '\mNOTIFY\s+([a-zA-Z_][a-zA-Z0-9_]*)', 'gi'))[1] AS channel - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - ) sub - WHERE channel IS NOT NULL - ORDER BY channel"#, - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) -} - -pub async fn load_active_locks( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - l.pid::text, - COALESCE(a.usename, '') AS user, - COALESCE(l.mode, '') AS mode, - COALESCE(l.locktype, '') AS locktype, - CASE WHEN l.granted THEN 'granted' ELSE 'waiting' END AS status, - COALESCE(c.relname, '') AS relation, - COALESCE(n.nspname, '') AS schema, - COALESCE(left(a.query, 200), '') AS query, - COALESCE(extract(epoch from now() - a.query_start)::text, '0') AS duration, - COALESCE(a.wait_event_type || ':' || a.wait_event, '') AS wait_event - FROM pg_locks l - JOIN pg_stat_activity a ON a.pid = l.pid - LEFT JOIN pg_class c ON c.oid = l.relation - LEFT JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE a.pid != pg_backend_pid() - ORDER BY NOT l.granted, l.pid", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..10).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -pub async fn load_index_usage( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - s.schemaname, - s.relname AS table, - s.indexrelname AS index, - pg_size_pretty(pg_relation_size(i.indexrelid)) AS size, - COALESCE(s.idx_scan, 0)::text AS scans, - COALESCE(s.idx_tup_read, 0)::text AS tuples_read, - COALESCE(s.idx_tup_fetch, 0)::text AS tuples_fetched, - CASE - WHEN s.idx_scan = 0 THEN 'unused' - WHEN s.idx_scan < 10 THEN 'rarely_used' - ELSE 'active' - END AS status, - COALESCE(pg_get_indexdef(i.indexrelid), '') AS definition - FROM pg_stat_user_indexes s - JOIN pg_index i ON i.indexrelid = s.indexrelid - WHERE NOT i.indisprimary - ORDER BY s.idx_scan ASC, pg_relation_size(i.indexrelid) DESC", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..9).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -pub async fn load_table_bloat( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - schemaname, - relname AS table, - n_live_tup::text AS live_tuples, - n_dead_tup::text AS dead_tuples, - CASE WHEN n_live_tup > 0 - THEN round(100.0 * n_dead_tup / (n_live_tup + n_dead_tup), 1)::text - ELSE '0' - END AS bloat_pct, - pg_size_pretty(pg_total_relation_size(relid)) AS total_size, - COALESCE(last_vacuum::text, 'never') AS last_vacuum, - COALESCE(last_autovacuum::text, 'never') AS last_autovacuum, - COALESCE(last_analyze::text, 'never') AS last_analyze, - COALESCE(last_autoanalyze::text, 'never') AS last_autoanalyze - FROM pg_stat_user_tables - ORDER BY n_dead_tup DESC", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..10).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -pub async fn load_extensions( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - e.extname AS name, - e.extversion AS installed_version, - COALESCE(a.default_version, '') AS default_version, - COALESCE(a.comment, '') AS comment, - n.nspname AS schema - FROM pg_extension e - JOIN pg_namespace n ON n.oid = e.extnamespace - LEFT JOIN pg_available_extensions a ON a.name = e.extname - ORDER BY e.extname", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..5).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -pub async fn load_available_extensions( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - a.name, - COALESCE(a.default_version, '') AS version, - COALESCE(a.comment, '') AS comment - FROM pg_available_extensions a - LEFT JOIN pg_extension e ON e.extname = a.name - WHERE e.oid IS NULL - ORDER BY a.name", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..3).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -pub async fn load_enum_types( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - n.nspname AS schema, - t.typname AS name, - string_agg(e.enumlabel, ', ' ORDER BY e.enumsortorder) AS labels - FROM pg_type t - JOIN pg_namespace n ON n.oid = t.typnamespace - JOIN pg_enum e ON e.enumtypid = t.oid - WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - GROUP BY n.nspname, t.typname - ORDER BY n.nspname, t.typname", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..3).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} - -pub async fn load_pg_settings( - client: &deadpool_postgres::Client, -) -> Result>, AppError> { - let rows = client - .query( - "SELECT - name, - COALESCE(setting, '') AS setting, - COALESCE(unit, '') AS unit, - category, - COALESCE(short_desc, '') AS description, - context, - COALESCE(source, '') AS source, - COALESCE(boot_val, '') AS boot_val, - COALESCE(reset_val, '') AS reset_val - FROM pg_settings - ORDER BY category, name", - &[], - ) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - - Ok(rows - .iter() - .map(|r| (0..9).map(|i| r.get::<_, String>(i)).collect()) - .collect()) -} diff --git a/src-tauri/src/drivers/mod.rs b/src-tauri/src/drivers/mod.rs index 7878916..c23eeb7 100644 --- a/src-tauri/src/drivers/mod.rs +++ b/src-tauri/src/drivers/mod.rs @@ -1,2 +1 @@ -pub mod common; pub mod pgsql; diff --git a/src-tauri/src/drivers/pgsql.rs b/src-tauri/src/drivers/pgsql.rs deleted file mode 100644 index e85733d..0000000 --- a/src-tauri/src/drivers/pgsql.rs +++ /dev/null @@ -1,1484 +0,0 @@ -use std::{ - collections::BTreeMap, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use deadpool_postgres::{Manager as PgManager, ManagerConfig, Pool, RecyclingMethod}; - -use crate::AppState; -use crate::common::enums::{AppError, ProjectConnectionStatus}; -use crate::common::pgsql::{PgsqlLoadColumns, PgsqlLoadSchemas, PgsqlLoadTables}; -use crate::drivers::common::{ - ColumnDetail, ConstraintDetail, DbGrant, DbStat, FKDetail, ForeignKeyInfo, FunctionInfo, - IndexDetail, ObjectStats, PgRole, PolicyDetail, RuleDetail, SchemaObject, TableGrant, - TriggerDetail, close_virtual, discover_notify_channels, execute_query, execute_query_packed, - execute_query_streamed, execute_virtual, extract_schema_objects, fetch_virtual_page, - generate_full_ddl, get_pool, import_csv_to_table, load_active_locks, load_activity, - load_available_extensions, load_column_details, load_columns, load_constraints, - load_database_grants, load_database_stats, load_databases, load_enum_types, load_extensions, - load_fk_details, load_foreign_keys, load_function_info, load_functions, load_index_usage, - load_indexes, load_materialized_views, load_matview_info, load_pg_settings, load_policies, - load_roles, load_rules, load_schemas, load_table_bloat, load_table_grants, - load_table_statistics, load_table_stats, load_tables, load_tablespaces, load_trigger_functions, - load_triggers, load_view_info, load_views, parse_csv_preview, -}; - -use futures_util::StreamExt; -use native_tls::TlsConnector; -use postgres_native_tls::MakeTlsConnector; -use tauri::ipc::Response; -use tauri::{AppHandle, Emitter, Manager, Result, State}; -use tokio::time::{Duration, sleep}; -use tokio_postgres::{AsyncMessage, CancelToken, Config, NoTls}; - -const CELL_SEP: char = '\x1F'; -const SNAPSHOT_PAGE_WRITE_RETRIES: usize = 3; - -fn is_sqlite_lock_error(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - lower.contains("database is locked") || lower.contains("database busy") -} - -/// Walk the full std::error::Error source chain into a single string. -fn full_error_chain(e: &dyn std::error::Error) -> String { - let mut msg = e.to_string(); - let mut src = e.source(); - while let Some(cause) = src { - msg.push_str(": "); - msg.push_str(&cause.to_string()); - src = cause.source(); - } - msg -} - -fn create_pg_pool( - cfg: &Config, - use_ssl: bool, - max_size: usize, -) -> std::result::Result { - let manager_config = ManagerConfig { - recycling_method: RecyclingMethod::Custom("ROLLBACK".into()), - }; - - if use_ssl { - let tls_connector = TlsConnector::builder() - .build() - .map_err(|e| AppError::ConnectionFailed(e.to_string()))?; - let tls = MakeTlsConnector::new(tls_connector); - let manager = PgManager::from_config(cfg.clone(), tls, manager_config); - Pool::builder(manager) - .max_size(max_size) - .build() - .map_err(|e| AppError::ConnectionFailed(e.to_string())) - } else { - let manager = PgManager::from_config(cfg.clone(), NoTls, manager_config); - Pool::builder(manager) - .max_size(max_size) - .build() - .map_err(|e| AppError::ConnectionFailed(e.to_string())) - } -} - -async fn acquire_client( - pools_mutex: &tokio::sync::Mutex>>, - project_id: &str, -) -> std::result::Result { - let pool = { - let pools = pools_mutex.lock().await; - get_pool(&pools, project_id)? - }; - - pool.get() - .await - .map_err(|e| AppError::ConnectionFailed(e.to_string())) -} - -async fn apply_statement_timeout( - client: &deadpool_postgres::Client, - timeout_ms: u32, -) { - if timeout_ms > 0 { - client - .simple_query(&format!("SET statement_timeout = {}", timeout_ms)) - .await - .ok(); - } -} - -async fn reset_statement_timeout( - client: &deadpool_postgres::Client, - timeout_ms: u32, -) { - if timeout_ms > 0 { - client.simple_query("RESET statement_timeout").await.ok(); - } -} - -async fn set_cancel_token( - app_state: &AppState, - project_id: &str, - token: CancelToken, -) -> std::result::Result<(), AppError> { - let mut cancel_tokens = app_state.cancel_tokens.lock().await; - cancel_tokens.insert(project_id.to_string(), token); - Ok(()) -} - -#[derive(Clone)] -struct VirtualSnapshotMeta { - project_id: String, - sql: String, - page_size: usize, - col_count: usize, -} - -fn now_unix_secs() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or_default() -} - -async fn snapshot_upsert_metadata( - app_state: &AppState, - project_id: &str, - query_id: &str, - sql: &str, - columns_packed: &str, - total_rows: usize, - page_size: usize, - col_count: usize, -) -> std::result::Result<(), AppError> { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - conn.execute( - "INSERT OR REPLACE INTO virtual_query_snapshots ( - query_id, project_id, sql, columns_packed, total_rows, page_size, col_count, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - libsql::params![ - query_id, - project_id, - sql, - columns_packed, - total_rows as i64, - page_size as i64, - col_count as i64, - now_unix_secs(), - ], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - Ok(()) -} - -async fn snapshot_store_page( - app_state: &AppState, - query_id: &str, - page_index: usize, - packed_page: &str, -) -> std::result::Result<(), AppError> { - if packed_page.is_empty() { - return Ok(()); - } - - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - for attempt in 0..SNAPSHOT_PAGE_WRITE_RETRIES { - match conn - .execute( - "INSERT OR IGNORE INTO virtual_query_pages (query_id, page_index, packed_page) - VALUES (?1, ?2, ?3)", - libsql::params![query_id, page_index as i64, packed_page], - ) - .await - { - Ok(_) => return Ok(()), - Err(e) => { - let msg = e.to_string(); - if is_sqlite_lock_error(&msg) { - if attempt + 1 < SNAPSHOT_PAGE_WRITE_RETRIES { - sleep(Duration::from_millis((attempt as u64 + 1) * 8)).await; - continue; - } - // Snapshot persistence is best-effort; skip noisy lock errors. - tracing::debug!( - "Skipping snapshot page persist for {} page {} due to SQLite lock", - query_id, - page_index - ); - return Ok(()); - } - return Err(AppError::DatabaseError(msg)); - } - } - } - - Ok(()) -} - -async fn snapshot_load_page( - app_state: &AppState, - query_id: &str, - page_index: usize, -) -> std::result::Result, AppError> { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let mut rows = conn - .query( - "SELECT packed_page - FROM virtual_query_pages - WHERE query_id = ?1 AND page_index = ?2 - LIMIT 1", - libsql::params![query_id, page_index as i64], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let maybe_row = rows - .next() - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - if let Some(row) = maybe_row { - let packed: String = row - .get(0) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - Ok(Some(packed)) - } else { - Ok(None) - } -} - -async fn snapshot_load_metadata( - app_state: &AppState, - query_id: &str, -) -> std::result::Result, AppError> { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let mut rows = conn - .query( - "SELECT project_id, sql, page_size, col_count - FROM virtual_query_snapshots - WHERE query_id = ?1 - LIMIT 1", - libsql::params![query_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let maybe_row = rows - .next() - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let Some(row) = maybe_row else { - return Ok(None); - }; - - let project_id: String = row - .get(0) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let sql: String = row - .get(1) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let page_size_i64: i64 = row - .get(2) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let col_count_i64: i64 = row - .get(3) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - if page_size_i64 <= 0 { - return Ok(None); - } - - Ok(Some(VirtualSnapshotMeta { - project_id, - sql, - page_size: page_size_i64 as usize, - col_count: col_count_i64.max(0) as usize, - })) -} - -async fn snapshot_cleanup_query( - app_state: &AppState, - query_id: &str, -) -> std::result::Result<(), AppError> { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - conn.execute( - "DELETE FROM virtual_query_pages WHERE query_id = ?1", - libsql::params![query_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - conn.execute( - "DELETE FROM virtual_query_snapshots WHERE query_id = ?1", - libsql::params![query_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - Ok(()) -} - -async fn restore_virtual_from_snapshot( - app_state: &AppState, - query_id: &str, -) -> std::result::Result { - let Some(meta) = snapshot_load_metadata(app_state, query_id).await? else { - return Ok(false); - }; - - let client = acquire_client(&app_state.clients, &meta.project_id).await?; - set_cancel_token(app_state, &meta.project_id, client.cancel_token()).await?; - - let (columns_packed, total_rows, first_page_packed, _) = execute_virtual( - &client, - &app_state.virtual_cache, - &meta.sql, - query_id, - meta.page_size, - ) - .await?; - - if columns_packed.is_empty() { - return Ok(false); - } - - let col_count = if meta.col_count > 0 { - meta.col_count - } else { - columns_packed.split(CELL_SEP).count() - }; - - if let Err(e) = snapshot_upsert_metadata( - app_state, - &meta.project_id, - query_id, - &meta.sql, - &columns_packed, - total_rows, - meta.page_size, - col_count, - ) - .await - { - tracing::warn!( - "Failed to refresh snapshot metadata for {}: {:?}", - query_id, - e - ); - } - if let Err(e) = snapshot_store_page(app_state, query_id, 0, &first_page_packed).await { - tracing::warn!( - "Failed to refresh snapshot first page for {}: {:?}", - query_id, - e - ); - } - - Ok(true) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_test_connection( - key: [&str; 6], -) -> Result { - let user = key[0]; - let password = key[1]; - let database = key[2]; - let host = key[3]; - let port: u16 = key[4].parse().unwrap_or(5432); - let use_ssl = key[5] == "true"; - - let mut cfg = Config::new(); - cfg.user(user) - .password(password) - .dbname(database) - .host(host) - .port(port); - - let pool = create_pg_pool(&cfg, use_ssl, 1)?; - let client = pool - .get() - .await - .map_err(|e| AppError::ConnectionFailed(full_error_chain(&e)))?; - - let row = client - .query_one("SELECT version()", &[]) - .await - .map_err(|e| AppError::ConnectionFailed(e.to_string()))?; - - let version: String = row.get(0); - Ok(version) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_connector( - project_id: &str, - key: Option<[&str; 6]>, - ssh: Option>, - app: AppHandle, -) -> Result { - let app_state = app.state::(); - { - let clients = app_state.clients.lock().await; - if clients.contains_key(project_id) { - return Ok(ProjectConnectionStatus::Connected); - } - } - - let (user, password, database, host, port_str, use_ssl) = match key { - Some(key) => ( - key[0].to_string(), - key[1].to_string(), - key[2].to_string(), - key[3].to_string(), - key[4].to_string(), - key[5] == "true", - ), - None => { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let mut rows = conn - .query( - "SELECT username, password, database, host, port, ssl FROM projects WHERE id = ?1", - libsql::params![project_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let row = rows - .next() - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))? - .ok_or_else(|| AppError::ProjectNotFound(project_id.to_string()))?; - ( - row.get::(0).unwrap_or_default(), - row.get::(1).unwrap_or_default(), - row.get::(2).unwrap_or_default(), - row.get::(3).unwrap_or_default(), - row.get::(4).unwrap_or_default(), - row.get::(5).map(|s| s == "true").unwrap_or(false), - ) - } - }; - - // Determine effective host/port, potentially through an SSH tunnel - let (effective_host, effective_port_str) = if let Some(ref ssh_params) = ssh { - // ssh_params: [ssh_host, ssh_port, ssh_user, ssh_password, ssh_key_path] - if ssh_params.len() >= 3 && !ssh_params[0].is_empty() { - let ssh_host = &ssh_params[0]; - let ssh_port: u16 = ssh_params[1].parse().unwrap_or(22); - let ssh_user = &ssh_params[2]; - let ssh_password = ssh_params - .get(3) - .filter(|s| !s.is_empty()) - .map(|s| s.as_str()); - let ssh_key_path = ssh_params - .get(4) - .filter(|s| !s.is_empty()) - .map(|s| s.as_str()); - - // Stop any existing tunnel for this project - app_state.ssh_tunnels.lock().await.remove(project_id); - - let tunnel = crate::ssh::start_tunnel( - ssh_host, - ssh_port, - ssh_user, - ssh_password, - ssh_key_path, - &host, - port_str.parse().unwrap_or(5432), - ) - .await - .map_err(|e| AppError::ConnectionFailed(e))?; - - let local_port = tunnel.local_port; - app_state - .ssh_tunnels - .lock() - .await - .insert(project_id.to_string(), tunnel); - - ("127.0.0.1".to_string(), local_port.to_string()) - } else { - (host.clone(), port_str.clone()) - } - } else { - (host.clone(), port_str.clone()) - }; - - let port: u16 = effective_port_str.parse().unwrap_or(5432); - let mut cfg = Config::new(); - cfg.user(&user) - .password(&password) - .dbname(&database) - .host(&effective_host) - .port(port); - - // Create two pools: one for user queries, one for metadata. - let query_pool = match create_pg_pool(&cfg, use_ssl, 16) { - Ok(p) => Arc::new(p), - Err(e) => { - tracing::error!("Query pool creation failed: {:?}", e); - return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); - } - }; - let meta_pool = match create_pg_pool(&cfg, use_ssl, 8) { - Ok(p) => Arc::new(p), - Err(e) => { - tracing::error!("Meta pool creation failed: {:?}", e); - return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); - } - }; - - // Validate connectivity eagerly so connector keeps previous fail/connected behavior. - let query_client = match query_pool.get().await { - Ok(c) => c, - Err(e) => { - tracing::error!("Query pool initial connection failed: {:?}", e); - return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); - } - }; - if let Err(e) = meta_pool.get().await { - tracing::error!("Meta pool initial connection failed: {:?}", e); - return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); - } - - { - let mut clients = app_state.clients.lock().await; - clients.insert(project_id.to_string(), Arc::clone(&query_pool)); - } - { - let mut meta_clients = app_state.meta_clients.lock().await; - meta_clients.insert(project_id.to_string(), Arc::clone(&meta_pool)); - } - { - let mut cancel_tokens = app_state.cancel_tokens.lock().await; - cancel_tokens.insert(project_id.to_string(), query_client.cancel_token()); - } - { - let mut client_ssl = app_state.client_ssl.lock().await; - client_ssl.insert(project_id.to_string(), use_ssl); - } - - Ok(ProjectConnectionStatus::Connected) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_databases(project_id: &str, app: AppHandle) -> Result> { - let app_state = app.state::(); - let pool = { - let pools = app_state.meta_clients.lock().await; - get_pool(&pools, project_id)? - }; - - load_databases(&pool).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_tablespaces( - project_id: &str, - app: AppHandle, -) -> Result> { - let app_state = app.state::(); - let pool = { - let pools = app_state.meta_clients.lock().await; - get_pool(&pools, project_id)? - }; - - load_tablespaces(&pool).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_schemas( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_schemas( - &client, - r#"SELECT schema_name FROM information_schema.schemata - WHERE schema_name NOT IN ('pg_catalog', 'information_schema') - ORDER BY schema_name"#, - ) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_tables( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_tables( - &client, - r#"SELECT table_name, - pg_size_pretty(pg_total_relation_size('"' || table_schema || '"."' || table_name || '"')) AS size - FROM information_schema.tables - WHERE table_schema = $1 - ORDER BY table_name"#, - schema, - ) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_columns( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_columns(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_column_details( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_column_details(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_indexes( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_indexes(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_constraints( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_constraints(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_triggers( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_triggers(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_rules( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_rules(&client, schema, table).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_policies( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_policies(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_views( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_views(&client, schema).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_materialized_views( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_materialized_views(&client, schema) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_functions( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_functions(&client, schema).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_trigger_functions( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_trigger_functions(&client, schema) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_activity( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - let result = load_activity(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_database_stats( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_database_stats(&client).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_table_stats( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - let result = load_table_stats(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_foreign_keys( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - - load_foreign_keys(&client, schema).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_run_query( - project_id: &str, - sql: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - set_cancel_token(&app_state, project_id, client.cancel_token()).await?; - - let result = execute_query(&client, sql).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_cancel_query(project_id: &str, app_state: State<'_, AppState>) -> Result { - let cancel_token = { - let cancel_tokens = app_state.cancel_tokens.lock().await; - cancel_tokens - .get(project_id) - .cloned() - .ok_or_else(|| AppError::ClientNotConnected(project_id.to_string()))? - }; - - let use_ssl = { - let client_ssl = app_state.client_ssl.lock().await; - *client_ssl.get(project_id).unwrap_or(&false) - }; - - if use_ssl { - let tls_connector = TlsConnector::builder() - .build() - .map_err(|e| AppError::ConnectionFailed(e.to_string()))?; - let tls = MakeTlsConnector::new(tls_connector); - cancel_token - .cancel_query(tls) - .await - .map_err(|e| AppError::QueryFailed(format!("Failed to cancel query: {e}")))?; - } else { - cancel_token - .cancel_query(NoTls) - .await - .map_err(|e| AppError::QueryFailed(format!("Failed to cancel query: {e}")))?; - } - - Ok(true) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_run_query_packed( - project_id: &str, - sql: &str, - timeout_ms: Option, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - set_cancel_token(&app_state, project_id, client.cancel_token()).await?; - - let timeout = timeout_ms.unwrap_or(0); - apply_statement_timeout(&client, timeout).await; - let result = execute_query_packed(&client, sql).await; - reset_statement_timeout(&client, timeout).await; - - let result = result?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_run_query_streamed( - project_id: &str, - sql: &str, - stream_id: &str, - app: AppHandle, -) -> Result<()> { - let app_state = app.state::(); - let client = acquire_client(&app_state.clients, project_id).await?; - set_cancel_token(&app_state, project_id, client.cancel_token()).await?; - - execute_query_streamed(&client, sql, stream_id, &app) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_execute_virtual( - project_id: &str, - sql: &str, - query_id: &str, - page_size: usize, - timeout_ms: Option, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - set_cancel_token(&app_state, project_id, client.cancel_token()).await?; - - let timeout = timeout_ms.unwrap_or(0); - apply_statement_timeout(&client, timeout).await; - let result = - execute_virtual(&client, &app_state.virtual_cache, sql, query_id, page_size).await; - reset_statement_timeout(&client, timeout).await; - let result = result?; - - let col_count = if result.0.is_empty() { - 0 - } else { - result.0.split(CELL_SEP).count() - }; - if let Err(e) = snapshot_upsert_metadata( - &app_state, project_id, query_id, sql, &result.0, result.1, page_size, col_count, - ) - .await - { - tracing::warn!( - "Failed to persist virtual snapshot metadata for {}: {:?}", - query_id, - e - ); - } - if let Err(e) = snapshot_store_page(&app_state, query_id, 0, &result.2).await { - tracing::warn!( - "Failed to persist virtual snapshot first page for {}: {:?}", - query_id, - e - ); - } - - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_fetch_page( - query_id: &str, - col_count: usize, - offset: usize, - limit: usize, - app_state: State<'_, AppState>, -) -> Result { - let page_index = if limit == 0 { 0 } else { offset / limit }; - - match fetch_virtual_page(&app_state.virtual_cache, query_id, col_count, offset, limit).await { - Ok(packed) => { - if let Err(e) = snapshot_store_page(&app_state, query_id, page_index, &packed).await { - tracing::warn!("Failed to persist fetched page for {}: {:?}", query_id, e); - } - let json = - sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; - return Ok(Response::new(json)); - } - Err(err) => { - tracing::debug!( - "Virtual cache miss for query {}, trying snapshot fallback: {:?}", - query_id, - err - ); - } - } - - if let Some(packed) = snapshot_load_page(&app_state, query_id, page_index).await? { - let json = - sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; - return Ok(Response::new(json)); - } - - if restore_virtual_from_snapshot(&app_state, query_id).await? { - let packed = - fetch_virtual_page(&app_state.virtual_cache, query_id, col_count, offset, limit) - .await?; - if let Err(e) = snapshot_store_page(&app_state, query_id, page_index, &packed).await { - tracing::warn!( - "Failed to persist restored page for {} (page {}): {:?}", - query_id, - page_index, - e - ); - } - let json = - sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; - return Ok(Response::new(json)); - } - - Err(AppError::QueryFailed(format!( - "Virtual query {} not found in memory and no snapshot available", - query_id - )) - .into()) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_close_virtual(query_id: &str, app_state: State<'_, AppState>) -> Result<()> { - close_virtual(&app_state.virtual_cache, query_id).await?; - if let Err(e) = snapshot_cleanup_query(&app_state, query_id).await { - tracing::warn!( - "Failed to cleanup virtual snapshot for {}: {:?}", - query_id, - e - ); - } - Ok(()) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_table_statistics( - project_id: &str, - schema: &str, - table: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_table_statistics(&client, schema, table) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_fk_details( - project_id: &str, - schema: &str, - table: &str, - direction: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_fk_details(&client, schema, table, direction) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_view_info( - project_id: &str, - schema: &str, - view: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_view_info(&client, schema, view) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_matview_info( - project_id: &str, - schema: &str, - matview: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_matview_info(&client, schema, matview) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_function_info( - project_id: &str, - schema: &str, - func_name: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_function_info(&client, schema, func_name) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_generate_ddl( - project_id: &str, - schema: &str, - name: &str, - object_type: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - generate_full_ddl(&client, schema, name, object_type) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_csv_preview(file_path: &str) -> Result<(Vec, Vec>)> { - parse_csv_preview(file_path, 5).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_csv_import( - project_id: &str, - file_path: &str, - schema: &str, - table: &str, - column_mapping: Vec<(usize, String)>, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - import_csv_to_table(&client, file_path, schema, table, &column_mapping) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_listen_start(project_id: &str, channel: &str, app: AppHandle) -> Result { - let app_handle = app.clone(); - let app_state = app_handle.state::(); - let listen_key = format!("{}:{}", project_id, channel); - - { - let handles = app_state.notify_handles.lock().await; - if handles.contains_key(&listen_key) { - return Ok(true); // Already listening - } - } - - // Get connection config from local db - let (cfg, use_ssl) = { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let mut rows = conn - .query( - "SELECT username, password, database, host, port, ssl FROM projects WHERE id = ?1", - libsql::params![project_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let row = rows - .next() - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))? - .ok_or_else(|| AppError::ProjectNotFound(project_id.to_string()))?; - - let mut cfg = Config::new(); - cfg.user(&row.get::(0).unwrap_or_default()) - .password(&row.get::(1).unwrap_or_default()) - .dbname(&row.get::(2).unwrap_or_default()) - .host(&row.get::(3).unwrap_or_default()) - .port( - row.get::(4) - .unwrap_or_default() - .parse() - .unwrap_or(5432), - ); - let ssl = row.get::(5).map(|s| s == "true").unwrap_or(false); - (cfg, ssl) - }; - - let channel_owned = channel.to_string(); - let event_name = format!("pg-notify-{}", project_id); - - let handle = tokio::spawn(async move { - // Helper: drive a connection, forwarding notifications as Tauri events - async fn listen_loop( - client: tokio_postgres::Client, - mut connection: tokio_postgres::Connection, - channel: &str, - event_name: &str, - app: &AppHandle, - ) where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, - T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, - { - let listen_sql = format!("LISTEN \"{}\"", channel.replace('"', "\"\"")); - if let Err(e) = client.batch_execute(&listen_sql).await { - tracing::error!("LISTEN command failed: {:?}", e); - return; - } - tracing::info!("LISTEN started on channel: {}", channel); - - let mut stream = futures_util::stream::poll_fn(move |cx| connection.poll_message(cx)); - - while let Some(msg) = stream.next().await { - match msg { - Ok(AsyncMessage::Notification(n)) => { - let payload = serde_json::json!({ - "channel": n.channel(), - "payload": n.payload(), - }); - let _ = app.emit(event_name, payload); - } - Ok(_) => {} - Err(e) => { - tracing::error!("LISTEN stream error: {:?}", e); - break; - } - } - } - tracing::info!("LISTEN ended on channel: {}", channel); - drop(client); - } - - if use_ssl { - let tls_connector = match TlsConnector::builder().build() { - Ok(c) => c, - Err(e) => { - tracing::error!("LISTEN TLS error: {:?}", e); - return; - } - }; - let tls = MakeTlsConnector::new(tls_connector); - match cfg.connect(tls).await { - Ok((client, connection)) => { - listen_loop(client, connection, &channel_owned, &event_name, &app).await; - } - Err(e) => tracing::error!("LISTEN connect error: {:?}", e), - } - } else { - match cfg.connect(NoTls).await { - Ok((client, connection)) => { - listen_loop(client, connection, &channel_owned, &event_name, &app).await; - } - Err(e) => tracing::error!("LISTEN connect error: {:?}", e), - } - } - }); - - { - let mut handles = app_state.notify_handles.lock().await; - handles.insert(listen_key, handle); - } - - Ok(true) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_listen_stop(project_id: &str, channel: &str, app: AppHandle) -> Result { - let app_state = app.state::(); - let listen_key = format!("{}:{}", project_id, channel); - - let mut handles = app_state.notify_handles.lock().await; - if let Some(handle) = handles.remove(&listen_key) { - handle.abort(); - } - - Ok(true) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_notify_send( - project_id: &str, - channel: &str, - payload: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - let sql = format!( - "SELECT pg_notify('{}', '{}')", - channel.replace('\'', "''"), - payload.replace('\'', "''"), - ); - client - .batch_execute(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(true) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_discover_channels( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - discover_notify_channels(&client).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_roles( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_roles(&client).await.map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_table_grants( - project_id: &str, - role_name: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_table_grants(&client, role_name) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_database_grants( - project_id: &str, - role_name: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - load_database_grants(&client, role_name) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_extract_schema_objects( - project_id: &str, - schema: &str, - app_state: State<'_, AppState>, -) -> Result> { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - extract_schema_objects(&client, schema) - .await - .map_err(Into::into) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_locks( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_active_locks(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_index_usage( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_index_usage(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_table_bloat( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_table_bloat(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_extensions( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_extensions(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_available_extensions( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_available_extensions(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_enum_types( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_enum_types(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_table_action( - project_id: &str, - action: &str, - schema: &str, - table: &str, - object_type: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - - // Quote identifiers safely - fn qi(name: &str) -> String { - format!("\"{}\"", name.replace('"', "\"\"")) - } - - let qualified = format!("{}.{}", qi(schema), qi(table)); - - let sql = match (object_type, action) { - // Table actions - ("table", "ANALYZE") => format!("ANALYZE {qualified}"), - ("table", "VACUUM") => format!("VACUUM {qualified}"), - ("table", "VACUUM FULL") => format!("VACUUM FULL {qualified}"), - ("table", "REINDEX") => format!("REINDEX TABLE {qualified}"), - ("table", "TRUNCATE") => format!("TRUNCATE TABLE {qualified}"), - ("table", "DROP TABLE") => format!("DROP TABLE {qualified}"), - // View actions - ("view", "DROP VIEW") => format!("DROP VIEW {qualified}"), - ("view", "DROP VIEW CASCADE") => format!("DROP VIEW {qualified} CASCADE"), - // Materialized view actions - ("matview", "REFRESH") => format!("REFRESH MATERIALIZED VIEW {qualified}"), - ("matview", "REFRESH CONCURRENTLY") => { - format!("REFRESH MATERIALIZED VIEW CONCURRENTLY {qualified}") - } - ("matview", "DROP MATERIALIZED VIEW") => format!("DROP MATERIALIZED VIEW {qualified}"), - // Function actions - ("function" | "trigger-function", "DROP FUNCTION") => format!("DROP FUNCTION {qualified}"), - ("function" | "trigger-function", "DROP FUNCTION CASCADE") => { - format!("DROP FUNCTION {qualified} CASCADE") - } - _ => { - return Err(AppError::QueryFailed(format!( - "Unknown action '{}' for object type '{}'", - action, object_type - )) - .into()); - } - }; - - execute_query(&client, &sql).await.map_err(|e| e)?; - - Ok(format!("{action} completed successfully.")) -} - -#[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_load_pg_settings( - project_id: &str, - app_state: State<'_, AppState>, -) -> Result { - let client = acquire_client(&app_state.meta_clients, project_id).await?; - let result = load_pg_settings(&client).await?; - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; - Ok(Response::new(json)) -} diff --git a/src-tauri/src/drivers/pgsql/commands/admin_commands.rs b/src-tauri/src/drivers/pgsql/commands/admin_commands.rs new file mode 100644 index 0000000..ec61700 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/admin_commands.rs @@ -0,0 +1,175 @@ +use crate::AppState; +use crate::common::enums::AppError; +use crate::drivers::pgsql::{ + DbGrant, PgRole, SchemaObject, TableGrant, execute_query, extract_schema_objects, + import_csv_to_table, load_available_extensions, load_database_grants, load_enum_types, + load_extensions, load_pg_settings, load_roles, load_table_grants, parse_csv_preview, +}; + +use tauri::ipc::Response; +use tauri::{Result, State}; + +use super::pool_connection::acquire_client; + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_csv_preview(file_path: &str) -> Result<(Vec, Vec>)> { + parse_csv_preview(file_path, 5).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_csv_import( + project_id: &str, + file_path: &str, + schema: &str, + table: &str, + column_mapping: Vec<(usize, String)>, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.clients, project_id).await?; + import_csv_to_table(&client, file_path, schema, table, &column_mapping) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_roles( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_roles(&client).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_table_grants( + project_id: &str, + role_name: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_table_grants(&client, role_name) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_database_grants( + project_id: &str, + role_name: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_database_grants(&client, role_name) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_extract_schema_objects( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + extract_schema_objects(&client, schema) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_extensions( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_extensions(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_available_extensions( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_available_extensions(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_enum_types( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_enum_types(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_table_action( + project_id: &str, + action: &str, + schema: &str, + table: &str, + object_type: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.clients, project_id).await?; + + // Quote identifiers safely + fn qi(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) + } + + let qualified = format!("{}.{}", qi(schema), qi(table)); + + let sql = match (object_type, action) { + // Table actions + ("table", "ANALYZE") => format!("ANALYZE {qualified}"), + ("table", "VACUUM") => format!("VACUUM {qualified}"), + ("table", "VACUUM FULL") => format!("VACUUM FULL {qualified}"), + ("table", "REINDEX") => format!("REINDEX TABLE {qualified}"), + ("table", "TRUNCATE") => format!("TRUNCATE TABLE {qualified}"), + ("table", "DROP TABLE") => format!("DROP TABLE {qualified}"), + // View actions + ("view", "DROP VIEW") => format!("DROP VIEW {qualified}"), + ("view", "DROP VIEW CASCADE") => format!("DROP VIEW {qualified} CASCADE"), + // Materialized view actions + ("matview", "REFRESH") => format!("REFRESH MATERIALIZED VIEW {qualified}"), + ("matview", "REFRESH CONCURRENTLY") => { + format!("REFRESH MATERIALIZED VIEW CONCURRENTLY {qualified}") + } + ("matview", "DROP MATERIALIZED VIEW") => format!("DROP MATERIALIZED VIEW {qualified}"), + // Function actions + ("function" | "trigger-function", "DROP FUNCTION") => format!("DROP FUNCTION {qualified}"), + ("function" | "trigger-function", "DROP FUNCTION CASCADE") => { + format!("DROP FUNCTION {qualified} CASCADE") + } + _ => { + return Err(AppError::QueryFailed(format!( + "Unknown action '{}' for object type '{}'", + action, object_type + )) + .into()); + } + }; + + execute_query(&client, &sql).await.map_err(|e| e)?; + + Ok(format!("{action} completed successfully.")) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_pg_settings( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_pg_settings(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} diff --git a/src-tauri/src/drivers/pgsql/commands/metadata_commands.rs b/src-tauri/src/drivers/pgsql/commands/metadata_commands.rs new file mode 100644 index 0000000..343861e --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/metadata_commands.rs @@ -0,0 +1,219 @@ +use crate::AppState; +use crate::common::pgsql::{PgsqlLoadColumns, PgsqlLoadSchemas, PgsqlLoadTables}; +use crate::drivers::pgsql::{ + ColumnDetail, ConstraintDetail, FunctionInfo, IndexDetail, PolicyDetail, RuleDetail, + TriggerDetail, get_pool, load_column_details, load_columns, load_constraints, load_databases, + load_functions, load_indexes, load_materialized_views, load_policies, load_rules, load_schemas, + load_tables, load_tablespaces, load_trigger_functions, load_triggers, load_views, +}; + +use tauri::{AppHandle, Manager, Result, State}; + +use super::pool_connection::acquire_client; + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_databases(project_id: &str, app: AppHandle) -> Result> { + let app_state = app.state::(); + let pool = { + let pools = app_state.meta_clients.lock().await; + get_pool(&pools, project_id)? + }; + + load_databases(&pool).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_tablespaces( + project_id: &str, + app: AppHandle, +) -> Result> { + let app_state = app.state::(); + let pool = { + let pools = app_state.meta_clients.lock().await; + get_pool(&pools, project_id)? + }; + + load_tablespaces(&pool).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_schemas( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_schemas( + &client, + r#"SELECT schema_name FROM information_schema.schemata + WHERE schema_name NOT IN ('pg_catalog', 'information_schema') + ORDER BY schema_name"#, + ) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_tables( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_tables( + &client, + r#"SELECT table_name, + pg_size_pretty(pg_total_relation_size('"' || table_schema || '"."' || table_name || '"')) AS size + FROM information_schema.tables + WHERE table_schema = $1 + ORDER BY table_name"#, + schema, + ) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_columns( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_columns(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_column_details( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_column_details(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_indexes( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_indexes(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_constraints( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_constraints(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_triggers( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_triggers(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_rules( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_rules(&client, schema, table).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_policies( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_policies(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_views( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_views(&client, schema).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_materialized_views( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_materialized_views(&client, schema) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_functions( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_functions(&client, schema).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_trigger_functions( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_trigger_functions(&client, schema) + .await + .map_err(Into::into) +} diff --git a/src-tauri/src/drivers/pgsql/commands/mod.rs b/src-tauri/src/drivers/pgsql/commands/mod.rs new file mode 100644 index 0000000..ed2d386 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/mod.rs @@ -0,0 +1,19 @@ +pub(crate) const CELL_SEP: char = '\x1F'; +pub(crate) const SNAPSHOT_PAGE_WRITE_RETRIES: usize = 3; + +pub mod pool_connection; +pub mod snapshot_persistence; +pub mod metadata_commands; +pub mod object_info_commands; +pub mod statistics_commands; +pub mod query_commands; +pub mod pubsub_commands; +pub mod admin_commands; + +pub use pool_connection::*; +pub use metadata_commands::*; +pub use object_info_commands::*; +pub use statistics_commands::*; +pub use query_commands::*; +pub use pubsub_commands::*; +pub use admin_commands::*; diff --git a/src-tauri/src/drivers/pgsql/commands/object_info_commands.rs b/src-tauri/src/drivers/pgsql/commands/object_info_commands.rs new file mode 100644 index 0000000..97b2a08 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/object_info_commands.rs @@ -0,0 +1,61 @@ +use crate::AppState; +use crate::drivers::pgsql::{ + ObjectStats, generate_full_ddl, load_function_info, load_matview_info, load_view_info, +}; + +use tauri::{Result, State}; + +use super::pool_connection::acquire_client; + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_view_info( + project_id: &str, + schema: &str, + view: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_view_info(&client, schema, view) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_matview_info( + project_id: &str, + schema: &str, + matview: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_matview_info(&client, schema, matview) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_function_info( + project_id: &str, + schema: &str, + func_name: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_function_info(&client, schema, func_name) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_generate_ddl( + project_id: &str, + schema: &str, + name: &str, + object_type: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + generate_full_ddl(&client, schema, name, object_type) + .await + .map_err(Into::into) +} diff --git a/src-tauri/src/drivers/pgsql/commands/pool_connection.rs b/src-tauri/src/drivers/pgsql/commands/pool_connection.rs new file mode 100644 index 0000000..b3a4632 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/pool_connection.rs @@ -0,0 +1,293 @@ +use std::{ + collections::BTreeMap, + sync::Arc, +}; + +use deadpool_postgres::{Manager as PgManager, ManagerConfig, Pool, RecyclingMethod}; + +use crate::AppState; +use crate::common::enums::{AppError, ProjectConnectionStatus}; +use crate::drivers::pgsql::get_pool; + +use native_tls::TlsConnector; +use postgres_native_tls::MakeTlsConnector; +use tauri::{AppHandle, Manager, Result}; +use tokio_postgres::{CancelToken, Config, NoTls}; + +pub(crate) fn is_sqlite_lock_error(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("database is locked") || lower.contains("database busy") +} + +/// Walk the full std::error::Error source chain into a single string. +pub(crate) fn full_error_chain(e: &dyn std::error::Error) -> String { + let mut msg = e.to_string(); + let mut src = e.source(); + while let Some(cause) = src { + msg.push_str(": "); + msg.push_str(&cause.to_string()); + src = cause.source(); + } + msg +} + +pub(crate) fn create_pg_pool( + cfg: &Config, + use_ssl: bool, + max_size: usize, +) -> std::result::Result { + let manager_config = ManagerConfig { + recycling_method: RecyclingMethod::Custom("ROLLBACK".into()), + }; + + if use_ssl { + let tls_connector = TlsConnector::builder() + .build() + .map_err(|e| AppError::ConnectionFailed(e.to_string()))?; + let tls = MakeTlsConnector::new(tls_connector); + let manager = PgManager::from_config(cfg.clone(), tls, manager_config); + Pool::builder(manager) + .max_size(max_size) + .build() + .map_err(|e| AppError::ConnectionFailed(e.to_string())) + } else { + let manager = PgManager::from_config(cfg.clone(), NoTls, manager_config); + Pool::builder(manager) + .max_size(max_size) + .build() + .map_err(|e| AppError::ConnectionFailed(e.to_string())) + } +} + +pub(crate) async fn acquire_client( + pools_mutex: &tokio::sync::Mutex>>, + project_id: &str, +) -> std::result::Result { + let pool = { + let pools = pools_mutex.lock().await; + get_pool(&pools, project_id)? + }; + + pool.get() + .await + .map_err(|e| AppError::ConnectionFailed(e.to_string())) +} + +pub(crate) async fn apply_statement_timeout( + client: &deadpool_postgres::Client, + timeout_ms: u32, +) { + if timeout_ms > 0 { + client + .simple_query(&format!("SET statement_timeout = {}", timeout_ms)) + .await + .ok(); + } +} + +pub(crate) async fn reset_statement_timeout( + client: &deadpool_postgres::Client, + timeout_ms: u32, +) { + if timeout_ms > 0 { + client.simple_query("RESET statement_timeout").await.ok(); + } +} + +pub(crate) async fn set_cancel_token( + app_state: &AppState, + project_id: &str, + token: CancelToken, +) -> std::result::Result<(), AppError> { + let mut cancel_tokens = app_state.cancel_tokens.lock().await; + cancel_tokens.insert(project_id.to_string(), token); + Ok(()) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_test_connection( + key: [&str; 6], +) -> Result { + let user = key[0]; + let password = key[1]; + let database = key[2]; + let host = key[3]; + let port: u16 = key[4].parse().unwrap_or(5432); + let use_ssl = key[5] == "true"; + + let mut cfg = Config::new(); + cfg.user(user) + .password(password) + .dbname(database) + .host(host) + .port(port); + + let pool = create_pg_pool(&cfg, use_ssl, 1)?; + let client = pool + .get() + .await + .map_err(|e| AppError::ConnectionFailed(full_error_chain(&e)))?; + + let row = client + .query_one("SELECT version()", &[]) + .await + .map_err(|e| AppError::ConnectionFailed(e.to_string()))?; + + let version: String = row.get(0); + Ok(version) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_connector( + project_id: &str, + key: Option<[&str; 6]>, + ssh: Option>, + app: AppHandle, +) -> Result { + let app_state = app.state::(); + { + let clients = app_state.clients.lock().await; + if clients.contains_key(project_id) { + return Ok(ProjectConnectionStatus::Connected); + } + } + + let (user, password, database, host, port_str, use_ssl) = match key { + Some(key) => ( + key[0].to_string(), + key[1].to_string(), + key[2].to_string(), + key[3].to_string(), + key[4].to_string(), + key[5] == "true", + ), + None => { + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let mut rows = conn + .query( + "SELECT username, password, database, host, port, ssl FROM projects WHERE id = ?1", + libsql::params![project_id], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let row = rows + .next() + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))? + .ok_or_else(|| AppError::ProjectNotFound(project_id.to_string()))?; + ( + row.get::(0).unwrap_or_default(), + row.get::(1).unwrap_or_default(), + row.get::(2).unwrap_or_default(), + row.get::(3).unwrap_or_default(), + row.get::(4).unwrap_or_default(), + row.get::(5).map(|s| s == "true").unwrap_or(false), + ) + } + }; + + // Determine effective host/port, potentially through an SSH tunnel + let (effective_host, effective_port_str) = if let Some(ref ssh_params) = ssh { + // ssh_params: [ssh_host, ssh_port, ssh_user, ssh_password, ssh_key_path] + if ssh_params.len() >= 3 && !ssh_params[0].is_empty() { + let ssh_host = &ssh_params[0]; + let ssh_port: u16 = ssh_params[1].parse().unwrap_or(22); + let ssh_user = &ssh_params[2]; + let ssh_password = ssh_params + .get(3) + .filter(|s| !s.is_empty()) + .map(|s| s.as_str()); + let ssh_key_path = ssh_params + .get(4) + .filter(|s| !s.is_empty()) + .map(|s| s.as_str()); + + // Stop any existing tunnel for this project + app_state.ssh_tunnels.lock().await.remove(project_id); + + let tunnel = crate::ssh::start_tunnel( + ssh_host, + ssh_port, + ssh_user, + ssh_password, + ssh_key_path, + &host, + port_str.parse().unwrap_or(5432), + ) + .await + .map_err(|e| AppError::ConnectionFailed(e))?; + + let local_port = tunnel.local_port; + app_state + .ssh_tunnels + .lock() + .await + .insert(project_id.to_string(), tunnel); + + ("127.0.0.1".to_string(), local_port.to_string()) + } else { + (host.clone(), port_str.clone()) + } + } else { + (host.clone(), port_str.clone()) + }; + + let port: u16 = effective_port_str.parse().unwrap_or(5432); + let mut cfg = Config::new(); + cfg.user(&user) + .password(&password) + .dbname(&database) + .host(&effective_host) + .port(port); + + // Create two pools: one for user queries, one for metadata. + let query_pool = match create_pg_pool(&cfg, use_ssl, 16) { + Ok(p) => Arc::new(p), + Err(e) => { + tracing::error!("Query pool creation failed: {:?}", e); + return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); + } + }; + let meta_pool = match create_pg_pool(&cfg, use_ssl, 8) { + Ok(p) => Arc::new(p), + Err(e) => { + tracing::error!("Meta pool creation failed: {:?}", e); + return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); + } + }; + + // Validate connectivity eagerly so connector keeps previous fail/connected behavior. + let query_client = match query_pool.get().await { + Ok(c) => c, + Err(e) => { + tracing::error!("Query pool initial connection failed: {:?}", e); + return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); + } + }; + if let Err(e) = meta_pool.get().await { + tracing::error!("Meta pool initial connection failed: {:?}", e); + return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); + } + + { + let mut clients = app_state.clients.lock().await; + clients.insert(project_id.to_string(), Arc::clone(&query_pool)); + } + { + let mut meta_clients = app_state.meta_clients.lock().await; + meta_clients.insert(project_id.to_string(), Arc::clone(&meta_pool)); + } + { + let mut cancel_tokens = app_state.cancel_tokens.lock().await; + cancel_tokens.insert(project_id.to_string(), query_client.cancel_token()); + } + { + let mut client_ssl = app_state.client_ssl.lock().await; + client_ssl.insert(project_id.to_string(), use_ssl); + } + + Ok(ProjectConnectionStatus::Connected) +} diff --git a/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs b/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs new file mode 100644 index 0000000..263ceea --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs @@ -0,0 +1,178 @@ +use crate::AppState; +use crate::common::enums::AppError; +use crate::drivers::pgsql::discover_notify_channels; + +use futures_util::StreamExt; +use native_tls::TlsConnector; +use postgres_native_tls::MakeTlsConnector; +use tauri::{AppHandle, Emitter, Manager, Result, State}; +use tokio_postgres::{AsyncMessage, Config, NoTls}; + +use super::pool_connection::acquire_client; + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_listen_start(project_id: &str, channel: &str, app: AppHandle) -> Result { + let app_handle = app.clone(); + let app_state = app_handle.state::(); + let listen_key = format!("{}:{}", project_id, channel); + + { + let handles = app_state.notify_handles.lock().await; + if handles.contains_key(&listen_key) { + return Ok(true); // Already listening + } + } + + // Get connection config from local db + let (cfg, use_ssl) = { + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let mut rows = conn + .query( + "SELECT username, password, database, host, port, ssl FROM projects WHERE id = ?1", + libsql::params![project_id], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + let row = rows + .next() + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))? + .ok_or_else(|| AppError::ProjectNotFound(project_id.to_string()))?; + + let mut cfg = Config::new(); + cfg.user(&row.get::(0).unwrap_or_default()) + .password(&row.get::(1).unwrap_or_default()) + .dbname(&row.get::(2).unwrap_or_default()) + .host(&row.get::(3).unwrap_or_default()) + .port( + row.get::(4) + .unwrap_or_default() + .parse() + .unwrap_or(5432), + ); + let ssl = row.get::(5).map(|s| s == "true").unwrap_or(false); + (cfg, ssl) + }; + + let channel_owned = channel.to_string(); + let event_name = format!("pg-notify-{}", project_id); + + let handle = tokio::spawn(async move { + // Helper: drive a connection, forwarding notifications as Tauri events + async fn listen_loop( + client: tokio_postgres::Client, + mut connection: tokio_postgres::Connection, + channel: &str, + event_name: &str, + app: &AppHandle, + ) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + let listen_sql = format!("LISTEN \"{}\"", channel.replace('"', "\"\"")); + if let Err(e) = client.batch_execute(&listen_sql).await { + tracing::error!("LISTEN command failed: {:?}", e); + return; + } + tracing::info!("LISTEN started on channel: {}", channel); + + let mut stream = futures_util::stream::poll_fn(move |cx| connection.poll_message(cx)); + + while let Some(msg) = stream.next().await { + match msg { + Ok(AsyncMessage::Notification(n)) => { + let payload = serde_json::json!({ + "channel": n.channel(), + "payload": n.payload(), + }); + let _ = app.emit(event_name, payload); + } + Ok(_) => {} + Err(e) => { + tracing::error!("LISTEN stream error: {:?}", e); + break; + } + } + } + tracing::info!("LISTEN ended on channel: {}", channel); + drop(client); + } + + if use_ssl { + let tls_connector = match TlsConnector::builder().build() { + Ok(c) => c, + Err(e) => { + tracing::error!("LISTEN TLS error: {:?}", e); + return; + } + }; + let tls = MakeTlsConnector::new(tls_connector); + match cfg.connect(tls).await { + Ok((client, connection)) => { + listen_loop(client, connection, &channel_owned, &event_name, &app).await; + } + Err(e) => tracing::error!("LISTEN connect error: {:?}", e), + } + } else { + match cfg.connect(NoTls).await { + Ok((client, connection)) => { + listen_loop(client, connection, &channel_owned, &event_name, &app).await; + } + Err(e) => tracing::error!("LISTEN connect error: {:?}", e), + } + } + }); + + { + let mut handles = app_state.notify_handles.lock().await; + handles.insert(listen_key, handle); + } + + Ok(true) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_listen_stop(project_id: &str, channel: &str, app: AppHandle) -> Result { + let app_state = app.state::(); + let listen_key = format!("{}:{}", project_id, channel); + + let mut handles = app_state.notify_handles.lock().await; + if let Some(handle) = handles.remove(&listen_key) { + handle.abort(); + } + + Ok(true) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_notify_send( + project_id: &str, + channel: &str, + payload: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.clients, project_id).await?; + let sql = format!( + "SELECT pg_notify('{}', '{}')", + channel.replace('\'', "''"), + payload.replace('\'', "''"), + ); + client + .batch_execute(&sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(true) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_discover_channels( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + discover_notify_channels(&client).await.map_err(Into::into) +} diff --git a/src-tauri/src/drivers/pgsql/commands/query_commands.rs b/src-tauri/src/drivers/pgsql/commands/query_commands.rs new file mode 100644 index 0000000..224fc69 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/query_commands.rs @@ -0,0 +1,223 @@ +use crate::AppState; +use crate::common::enums::AppError; +use crate::drivers::pgsql::{ + close_virtual, execute_query, execute_query_packed, execute_query_streamed, execute_virtual, + fetch_virtual_page, +}; + +use native_tls::TlsConnector; +use postgres_native_tls::MakeTlsConnector; +use tauri::ipc::Response; +use tauri::{AppHandle, Manager, Result, State}; +use tokio_postgres::NoTls; + +use super::CELL_SEP; +use super::pool_connection::{ + acquire_client, apply_statement_timeout, reset_statement_timeout, set_cancel_token, +}; +use super::snapshot_persistence::{ + restore_virtual_from_snapshot, snapshot_cleanup_query, snapshot_load_page, snapshot_store_page, + snapshot_upsert_metadata, +}; + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_run_query( + project_id: &str, + sql: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.clients, project_id).await?; + set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + + let result = execute_query(&client, sql).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_cancel_query(project_id: &str, app_state: State<'_, AppState>) -> Result { + let cancel_token = { + let cancel_tokens = app_state.cancel_tokens.lock().await; + cancel_tokens + .get(project_id) + .cloned() + .ok_or_else(|| AppError::ClientNotConnected(project_id.to_string()))? + }; + + let use_ssl = { + let client_ssl = app_state.client_ssl.lock().await; + *client_ssl.get(project_id).unwrap_or(&false) + }; + + if use_ssl { + let tls_connector = TlsConnector::builder() + .build() + .map_err(|e| AppError::ConnectionFailed(e.to_string()))?; + let tls = MakeTlsConnector::new(tls_connector); + cancel_token + .cancel_query(tls) + .await + .map_err(|e| AppError::QueryFailed(format!("Failed to cancel query: {e}")))?; + } else { + cancel_token + .cancel_query(NoTls) + .await + .map_err(|e| AppError::QueryFailed(format!("Failed to cancel query: {e}")))?; + } + + Ok(true) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_run_query_packed( + project_id: &str, + sql: &str, + timeout_ms: Option, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.clients, project_id).await?; + set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + + let timeout = timeout_ms.unwrap_or(0); + apply_statement_timeout(&client, timeout).await; + let result = execute_query_packed(&client, sql).await; + reset_statement_timeout(&client, timeout).await; + + let result = result?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_run_query_streamed( + project_id: &str, + sql: &str, + stream_id: &str, + app: AppHandle, +) -> Result<()> { + let app_state = app.state::(); + let client = acquire_client(&app_state.clients, project_id).await?; + set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + + execute_query_streamed(&client, sql, stream_id, &app) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_execute_virtual( + project_id: &str, + sql: &str, + query_id: &str, + page_size: usize, + timeout_ms: Option, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.clients, project_id).await?; + set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + + let timeout = timeout_ms.unwrap_or(0); + apply_statement_timeout(&client, timeout).await; + let result = + execute_virtual(&client, &app_state.virtual_cache, sql, query_id, page_size).await; + reset_statement_timeout(&client, timeout).await; + let result = result?; + + let col_count = if result.0.is_empty() { + 0 + } else { + result.0.split(CELL_SEP).count() + }; + if let Err(e) = snapshot_upsert_metadata( + &app_state, project_id, query_id, sql, &result.0, result.1, page_size, col_count, + ) + .await + { + tracing::warn!( + "Failed to persist virtual snapshot metadata for {}: {:?}", + query_id, + e + ); + } + if let Err(e) = snapshot_store_page(&app_state, query_id, 0, &result.2).await { + tracing::warn!( + "Failed to persist virtual snapshot first page for {}: {:?}", + query_id, + e + ); + } + + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_fetch_page( + query_id: &str, + col_count: usize, + offset: usize, + limit: usize, + app_state: State<'_, AppState>, +) -> Result { + let page_index = if limit == 0 { 0 } else { offset / limit }; + + match fetch_virtual_page(&app_state.virtual_cache, query_id, col_count, offset, limit).await { + Ok(packed) => { + if let Err(e) = snapshot_store_page(&app_state, query_id, page_index, &packed).await { + tracing::warn!("Failed to persist fetched page for {}: {:?}", query_id, e); + } + let json = + sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; + return Ok(Response::new(json)); + } + Err(err) => { + tracing::debug!( + "Virtual cache miss for query {}, trying snapshot fallback: {:?}", + query_id, + err + ); + } + } + + if let Some(packed) = snapshot_load_page(&app_state, query_id, page_index).await? { + let json = + sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; + return Ok(Response::new(json)); + } + + if restore_virtual_from_snapshot(&app_state, query_id).await? { + let packed = + fetch_virtual_page(&app_state.virtual_cache, query_id, col_count, offset, limit) + .await?; + if let Err(e) = snapshot_store_page(&app_state, query_id, page_index, &packed).await { + tracing::warn!( + "Failed to persist restored page for {} (page {}): {:?}", + query_id, + page_index, + e + ); + } + let json = + sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; + return Ok(Response::new(json)); + } + + Err(AppError::QueryFailed(format!( + "Virtual query {} not found in memory and no snapshot available", + query_id + )) + .into()) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_close_virtual(query_id: &str, app_state: State<'_, AppState>) -> Result<()> { + close_virtual(&app_state.virtual_cache, query_id).await?; + if let Err(e) = snapshot_cleanup_query(&app_state, query_id).await { + tracing::warn!( + "Failed to cleanup virtual snapshot for {}: {:?}", + query_id, + e + ); + } + Ok(()) +} diff --git a/src-tauri/src/drivers/pgsql/commands/snapshot_persistence.rs b/src-tauri/src/drivers/pgsql/commands/snapshot_persistence.rs new file mode 100644 index 0000000..3edccf5 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/snapshot_persistence.rs @@ -0,0 +1,283 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::AppState; +use crate::common::enums::AppError; +use crate::drivers::pgsql::execute_virtual; + +use tokio::time::{Duration, sleep}; + +use super::pool_connection::{acquire_client, is_sqlite_lock_error, set_cancel_token}; +use super::{CELL_SEP, SNAPSHOT_PAGE_WRITE_RETRIES}; + +#[derive(Clone)] +pub(crate) struct VirtualSnapshotMeta { + pub(crate) project_id: String, + pub(crate) sql: String, + pub(crate) page_size: usize, + pub(crate) col_count: usize, +} + +pub(crate) fn now_unix_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or_default() +} + +pub(crate) async fn snapshot_upsert_metadata( + app_state: &AppState, + project_id: &str, + query_id: &str, + sql: &str, + columns_packed: &str, + total_rows: usize, + page_size: usize, + col_count: usize, +) -> std::result::Result<(), AppError> { + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + conn.execute( + "INSERT OR REPLACE INTO virtual_query_snapshots ( + query_id, project_id, sql, columns_packed, total_rows, page_size, col_count, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + libsql::params![ + query_id, + project_id, + sql, + columns_packed, + total_rows as i64, + page_size as i64, + col_count as i64, + now_unix_secs(), + ], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + Ok(()) +} + +pub(crate) async fn snapshot_store_page( + app_state: &AppState, + query_id: &str, + page_index: usize, + packed_page: &str, +) -> std::result::Result<(), AppError> { + if packed_page.is_empty() { + return Ok(()); + } + + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + for attempt in 0..SNAPSHOT_PAGE_WRITE_RETRIES { + match conn + .execute( + "INSERT OR IGNORE INTO virtual_query_pages (query_id, page_index, packed_page) + VALUES (?1, ?2, ?3)", + libsql::params![query_id, page_index as i64, packed_page], + ) + .await + { + Ok(_) => return Ok(()), + Err(e) => { + let msg = e.to_string(); + if is_sqlite_lock_error(&msg) { + if attempt + 1 < SNAPSHOT_PAGE_WRITE_RETRIES { + sleep(Duration::from_millis((attempt as u64 + 1) * 8)).await; + continue; + } + // Snapshot persistence is best-effort; skip noisy lock errors. + tracing::debug!( + "Skipping snapshot page persist for {} page {} due to SQLite lock", + query_id, + page_index + ); + return Ok(()); + } + return Err(AppError::DatabaseError(msg)); + } + } + } + + Ok(()) +} + +pub(crate) async fn snapshot_load_page( + app_state: &AppState, + query_id: &str, + page_index: usize, +) -> std::result::Result, AppError> { + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + let mut rows = conn + .query( + "SELECT packed_page + FROM virtual_query_pages + WHERE query_id = ?1 AND page_index = ?2 + LIMIT 1", + libsql::params![query_id, page_index as i64], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + let maybe_row = rows + .next() + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + if let Some(row) = maybe_row { + let packed: String = row + .get(0) + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + Ok(Some(packed)) + } else { + Ok(None) + } +} + +pub(crate) async fn snapshot_load_metadata( + app_state: &AppState, + query_id: &str, +) -> std::result::Result, AppError> { + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + let mut rows = conn + .query( + "SELECT project_id, sql, page_size, col_count + FROM virtual_query_snapshots + WHERE query_id = ?1 + LIMIT 1", + libsql::params![query_id], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + let maybe_row = rows + .next() + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + let Some(row) = maybe_row else { + return Ok(None); + }; + + let project_id: String = row + .get(0) + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let sql: String = row + .get(1) + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let page_size_i64: i64 = row + .get(2) + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let col_count_i64: i64 = row + .get(3) + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + if page_size_i64 <= 0 { + return Ok(None); + } + + Ok(Some(VirtualSnapshotMeta { + project_id, + sql, + page_size: page_size_i64 as usize, + col_count: col_count_i64.max(0) as usize, + })) +} + +pub(crate) async fn snapshot_cleanup_query( + app_state: &AppState, + query_id: &str, +) -> std::result::Result<(), AppError> { + let conn = app_state + .local_db + .connect() + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + conn.execute( + "DELETE FROM virtual_query_pages WHERE query_id = ?1", + libsql::params![query_id], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + conn.execute( + "DELETE FROM virtual_query_snapshots WHERE query_id = ?1", + libsql::params![query_id], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + Ok(()) +} + +pub(crate) async fn restore_virtual_from_snapshot( + app_state: &AppState, + query_id: &str, +) -> std::result::Result { + let Some(meta) = snapshot_load_metadata(app_state, query_id).await? else { + return Ok(false); + }; + + let client = acquire_client(&app_state.clients, &meta.project_id).await?; + set_cancel_token(app_state, &meta.project_id, client.cancel_token()).await?; + + let (columns_packed, total_rows, first_page_packed, _) = execute_virtual( + &client, + &app_state.virtual_cache, + &meta.sql, + query_id, + meta.page_size, + ) + .await?; + + if columns_packed.is_empty() { + return Ok(false); + } + + let col_count = if meta.col_count > 0 { + meta.col_count + } else { + columns_packed.split(CELL_SEP).count() + }; + + if let Err(e) = snapshot_upsert_metadata( + app_state, + &meta.project_id, + query_id, + &meta.sql, + &columns_packed, + total_rows, + meta.page_size, + col_count, + ) + .await + { + tracing::warn!( + "Failed to refresh snapshot metadata for {}: {:?}", + query_id, + e + ); + } + if let Err(e) = snapshot_store_page(app_state, query_id, 0, &first_page_packed).await { + tracing::warn!( + "Failed to refresh snapshot first page for {}: {:?}", + query_id, + e + ); + } + + Ok(true) +} diff --git a/src-tauri/src/drivers/pgsql/commands/statistics_commands.rs b/src-tauri/src/drivers/pgsql/commands/statistics_commands.rs new file mode 100644 index 0000000..3c9485f --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/statistics_commands.rs @@ -0,0 +1,117 @@ +use crate::AppState; +use crate::common::enums::AppError; +use crate::drivers::pgsql::{ + DbStat, FKDetail, ForeignKeyInfo, ObjectStats, load_active_locks, load_activity, + load_database_stats, load_fk_details, load_foreign_keys, load_index_usage, load_table_bloat, + load_table_statistics, load_table_stats, +}; + +use tauri::ipc::Response; +use tauri::{Result, State}; + +use super::pool_connection::acquire_client; + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_activity( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + let result = load_activity(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_database_stats( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_database_stats(&client).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_table_stats( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + let result = load_table_stats(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_foreign_keys( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + + load_foreign_keys(&client, schema).await.map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_table_statistics( + project_id: &str, + schema: &str, + table: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_table_statistics(&client, schema, table) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_fk_details( + project_id: &str, + schema: &str, + table: &str, + direction: &str, + app_state: State<'_, AppState>, +) -> Result> { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + load_fk_details(&client, schema, table, direction) + .await + .map_err(Into::into) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_locks( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_active_locks(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_index_usage( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_index_usage(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_table_bloat( + project_id: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = load_table_bloat(&client).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} diff --git a/src-tauri/src/drivers/pgsql/ddl_generation.rs b/src-tauri/src/drivers/pgsql/ddl_generation.rs new file mode 100644 index 0000000..810a2dd --- /dev/null +++ b/src-tauri/src/drivers/pgsql/ddl_generation.rs @@ -0,0 +1,298 @@ +use tokio_postgres::{Client, SimpleQueryMessage}; + +use crate::common::enums::AppError; + +/// Generate full DDL for an object. Returns lines of DDL as a single String. +pub async fn generate_full_ddl( + client: &Client, + schema: &str, + name: &str, + object_type: &str, // "table", "view", "matview", "function" +) -> Result { + match object_type { + "table" => generate_table_ddl(client, schema, name).await, + "view" => generate_view_ddl(client, schema, name).await, + "matview" => generate_matview_ddl(client, schema, name).await, + "function" | "trigger-function" => generate_function_ddl(client, schema, name).await, + _ => Err(AppError::QueryFailed(format!( + "Unknown object type: {}", + object_type + ))), + } +} + +async fn generate_table_ddl( + client: &Client, + schema: &str, + table: &str, +) -> Result { + // Use simple_query so we can handle the complex CTE in one shot + let sql = format!( + r#"WITH col_ddl AS ( + SELECT ordinal_position, + ' "' || column_name || '" ' || + CASE + WHEN udt_name = 'varchar' THEN 'character varying' || COALESCE('(' || character_maximum_length || ')', '') + WHEN udt_name = 'bpchar' THEN 'character' || COALESCE('(' || character_maximum_length || ')', '') + WHEN udt_name = 'numeric' AND numeric_precision IS NOT NULL THEN 'numeric(' || numeric_precision || COALESCE(',' || numeric_scale, '') || ')' + ELSE data_type + END || + CASE WHEN is_nullable = 'NO' THEN ' NOT NULL' ELSE '' END || + CASE WHEN column_default IS NOT NULL THEN ' DEFAULT ' || column_default ELSE '' END AS col_def + FROM information_schema.columns + WHERE table_schema = '{schema}' AND table_name = '{table}' +) +SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# + ); + + let col_result = client + .simple_query(&sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let mut col_defs = String::new(); + for msg in &col_result { + if let SimpleQueryMessage::Row(row) = msg { + col_defs = row.get(0).unwrap_or("").to_string(); + } + } + + let mut ddl = format!("CREATE TABLE \"{schema}\".\"{table}\" (\n{col_defs}\n);\n"); + + // Helper: extract single-column text rows from simple_query results + fn collect_lines(messages: &[SimpleQueryMessage]) -> Vec { + let mut out = Vec::new(); + for msg in messages { + if let SimpleQueryMessage::Row(row) = msg { + if let Some(line) = row.get(0) { + if !line.is_empty() { + out.push(line.to_string()); + } + } + } + } + out + } + + // Constraints (PK, FK, UNIQUE, CHECK) + let con_sql = format!( + r#"SELECT 'ALTER TABLE "{schema}"."{table}" ADD CONSTRAINT "' || con.conname || '" ' || pg_get_constraintdef(con.oid) || ';' + FROM pg_constraint con + JOIN pg_class c ON c.oid = con.conrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '{schema}' AND c.relname = '{table}' + ORDER BY CASE con.contype WHEN 'p' THEN 0 WHEN 'u' THEN 1 WHEN 'f' THEN 2 ELSE 3 END"# + ); + let con_result = client + .simple_query(&con_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&con_result) { + ddl.push('\n'); + ddl.push_str(&line); + ddl.push('\n'); + } + + // Indexes (non-constraint) + let idx_sql = format!( + r#"SELECT pg_get_indexdef(i.indexrelid) || ';' + FROM pg_index i + JOIN pg_class tbl ON tbl.oid = i.indrelid + JOIN pg_namespace n ON n.oid = tbl.relnamespace + WHERE n.nspname = '{schema}' AND tbl.relname = '{table}' + AND NOT i.indisprimary + AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = i.indexrelid)"# + ); + let idx_result = client + .simple_query(&idx_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&idx_result) { + ddl.push('\n'); + ddl.push_str(&line); + ddl.push('\n'); + } + + // Triggers + let trig_sql = format!( + r#"SELECT pg_get_triggerdef(t.oid) || ';' + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '{schema}' AND c.relname = '{table}' + AND NOT t.tgisinternal"# + ); + let trig_result = client + .simple_query(&trig_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&trig_result) { + ddl.push('\n'); + ddl.push_str(&line); + ddl.push('\n'); + } + + // RLS + let rls_sql = format!( + r#"SELECT CASE WHEN c.relrowsecurity THEN 'ALTER TABLE "{schema}"."{table}" ENABLE ROW LEVEL SECURITY;' ELSE '' END + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '{schema}' AND c.relname = '{table}'"# + ); + let rls_result = client + .simple_query(&rls_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&rls_result) { + ddl.push('\n'); + ddl.push_str(&line); + ddl.push('\n'); + } + + // Policies + let pol_sql = format!( + r#"SELECT 'CREATE POLICY "' || pol.polname || '" ON "{schema}"."{table}"' || + CASE pol.polcmd WHEN 'r' THEN ' FOR SELECT' WHEN 'a' THEN ' FOR INSERT' WHEN 'w' THEN ' FOR UPDATE' WHEN 'd' THEN ' FOR DELETE' WHEN '*' THEN '' END || + CASE WHEN pol.polpermissive THEN ' AS PERMISSIVE' ELSE ' AS RESTRICTIVE' END || + COALESCE(E'\n USING (' || pg_get_expr(pol.polqual, pol.polrelid) || ')', '') || + COALESCE(E'\n WITH CHECK (' || pg_get_expr(pol.polwithcheck, pol.polrelid) || ')', '') || + ';' + FROM pg_policy pol + JOIN pg_class c ON c.oid = pol.polrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '{schema}' AND c.relname = '{table}'"# + ); + let pol_result = client + .simple_query(&pol_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&pol_result) { + ddl.push('\n'); + ddl.push_str(&line); + ddl.push('\n'); + } + + // Table comment + let cmt_sql = format!( + r#"SELECT 'COMMENT ON TABLE "{schema}"."{table}" IS ' || quote_literal(d.description) || ';' + FROM pg_description d + JOIN pg_class c ON c.oid = d.objoid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '{schema}' AND c.relname = '{table}' AND d.objsubid = 0"# + ); + let cmt_result = client + .simple_query(&cmt_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&cmt_result) { + ddl.push('\n'); + ddl.push_str(&line); + ddl.push('\n'); + } + + // Column comments + let col_cmt_sql = format!( + r#"SELECT 'COMMENT ON COLUMN "{schema}"."{table}"."' || a.attname || '" IS ' || quote_literal(d.description) || ';' + FROM pg_description d + JOIN pg_class c ON c.oid = d.objoid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid + WHERE n.nspname = '{schema}' AND c.relname = '{table}' AND d.objsubid > 0 + ORDER BY d.objsubid"# + ); + let col_cmt_result = client + .simple_query(&col_cmt_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for line in collect_lines(&col_cmt_result) { + ddl.push_str(&line); + ddl.push('\n'); + } + + Ok(ddl.trim_end().to_string()) +} + +async fn generate_view_ddl(client: &Client, schema: &str, view: &str) -> Result { + let sql = format!( + r#"SELECT 'CREATE OR REPLACE VIEW "{schema}"."{view}" AS' || E'\n' || pg_get_viewdef('"{schema}"."{view}"'::regclass, true) || ';'"# + ); + let result = client + .simple_query(&sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for msg in &result { + if let SimpleQueryMessage::Row(row) = msg { + return Ok(row.get(0).unwrap_or("").to_string()); + } + } + Ok(String::new()) +} + +async fn generate_matview_ddl( + client: &Client, + schema: &str, + matview: &str, +) -> Result { + let sql = format!( + r#"SELECT 'CREATE MATERIALIZED VIEW "{schema}"."{matview}" AS' || E'\n' || definition + FROM pg_matviews + WHERE schemaname = '{schema}' AND matviewname = '{matview}'"# + ); + let result = client + .simple_query(&sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let mut ddl = String::new(); + for msg in &result { + if let SimpleQueryMessage::Row(row) = msg { + ddl = row.get(0).unwrap_or("").to_string(); + } + } + + // Indexes on matview + let idx_sql = format!( + r#"SELECT pg_get_indexdef(i.indexrelid) || ';' + FROM pg_index i + JOIN pg_class tbl ON tbl.oid = i.indrelid + JOIN pg_namespace n ON n.oid = tbl.relnamespace + WHERE n.nspname = '{schema}' AND tbl.relname = '{matview}'"# + ); + let idx_result = client + .simple_query(&idx_sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for msg in &idx_result { + if let SimpleQueryMessage::Row(row) = msg { + if let Some(line) = row.get(0) { + ddl.push('\n'); + ddl.push_str(line); + } + } + } + + Ok(ddl.trim_end().to_string()) +} + +async fn generate_function_ddl( + client: &Client, + schema: &str, + func_name: &str, +) -> Result { + let sql = format!( + r#"SELECT pg_get_functiondef(p.oid) + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = '{schema}' AND p.proname = '{func_name}' + LIMIT 1"# + ); + let result = client + .simple_query(&sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + for msg in &result { + if let SimpleQueryMessage::Row(row) = msg { + return Ok(row.get(0).unwrap_or("").to_string()); + } + } + Ok(String::new()) +} diff --git a/src-tauri/src/drivers/pgsql/extensions.rs b/src-tauri/src/drivers/pgsql/extensions.rs new file mode 100644 index 0000000..e720263 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/extensions.rs @@ -0,0 +1,105 @@ +use crate::common::enums::AppError; + +pub async fn load_extensions( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + e.extname AS name, + e.extversion AS installed_version, + COALESCE(a.default_version, '') AS default_version, + COALESCE(a.comment, '') AS comment, + n.nspname AS schema + FROM pg_extension e + JOIN pg_namespace n ON n.oid = e.extnamespace + LEFT JOIN pg_available_extensions a ON a.name = e.extname + ORDER BY e.extname", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..5).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} + +pub async fn load_available_extensions( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + a.name, + COALESCE(a.default_version, '') AS version, + COALESCE(a.comment, '') AS comment + FROM pg_available_extensions a + LEFT JOIN pg_extension e ON e.extname = a.name + WHERE e.oid IS NULL + ORDER BY a.name", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..3).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} + +pub async fn load_enum_types( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + n.nspname AS schema, + t.typname AS name, + string_agg(e.enumlabel, ', ' ORDER BY e.enumsortorder) AS labels + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + JOIN pg_enum e ON e.enumtypid = t.oid + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + GROUP BY n.nspname, t.typname + ORDER BY n.nspname, t.typname", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..3).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} + +pub async fn load_pg_settings( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + name, + COALESCE(setting, '') AS setting, + COALESCE(unit, '') AS unit, + category, + COALESCE(short_desc, '') AS description, + context, + COALESCE(source, '') AS source, + COALESCE(boot_val, '') AS boot_val, + COALESCE(reset_val, '') AS reset_val + FROM pg_settings + ORDER BY category, name", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..9).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} diff --git a/src-tauri/src/drivers/pgsql/metadata_schema.rs b/src-tauri/src/drivers/pgsql/metadata_schema.rs new file mode 100644 index 0000000..f928036 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/metadata_schema.rs @@ -0,0 +1,280 @@ +use deadpool_postgres::Pool; +use tokio::time as tokio_time; +use tokio_postgres::Client; + +use crate::common::enums::AppError; +use crate::common::pgsql::{PgsqlLoadColumns, PgsqlLoadSchemas, PgsqlLoadTables}; + +use super::{ColumnDetail, ConstraintDetail, IndexDetail, PolicyDetail, RuleDetail, TriggerDetail}; + +/// Load schemas with a timeout. The query string is driver-specific. +pub async fn load_schemas(client: &Client, query_sql: &str) -> Result { + let rows = tokio_time::timeout( + tokio_time::Duration::from_secs(10), + client.query(query_sql, &[]), + ) + .await + .map_err(|_| AppError::QueryTimeout)? + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows.iter().map(|r| r.get(0)).collect()) +} + +/// Load all user databases from pg_database. +pub async fn load_databases(pool: &Pool) -> Result, AppError> { + let client = pool.get().await.map_err(|e| AppError::DatabaseError(e.to_string()))?; + let rows = client + .query( + "SELECT datname FROM pg_database WHERE datallowconn = true AND datistemplate = false ORDER BY datname", + &[], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) +} + +/// Load tablespaces from the server. +pub async fn load_tablespaces(pool: &Pool) -> Result, AppError> { + let client = pool.get().await.map_err(|e| AppError::DatabaseError(e.to_string()))?; + let rows = client + .query( + "SELECT spcname, pg_catalog.pg_get_userbyid(spcowner) AS owner, \ + COALESCE(pg_catalog.pg_tablespace_location(oid), '') AS location \ + FROM pg_catalog.pg_tablespace ORDER BY spcname", + &[], + ) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + Ok(rows.iter().map(|r| { + (r.get::<_, String>(0), r.get::<_, String>(1), r.get::<_, String>(2)) + }).collect()) +} + +/// Load tables for a given schema. +pub async fn load_tables( + client: &Client, + query_sql: &str, + schema: &str, +) -> Result { + let rows = client + .query(query_sql, &[&schema]) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows.iter().map(|r| (r.get(0), r.get(1))).collect()) +} + +/// Load columns for a given schema and table. +pub async fn load_columns( + client: &Client, + schema: &str, + table: &str, +) -> Result { + let rows = client + .query( + r#"SELECT column_name + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + ORDER BY ordinal_position"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) +} + +/// Load detailed column info for a given schema and table. +pub async fn load_column_details( + client: &Client, + schema: &str, + table: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + ORDER BY ordinal_position"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let data_type: String = r.get(1); + let nullable_str: String = r.get(2); + let default_val: Option = r.get(3); + (name, data_type, nullable_str == "YES", default_val) + }) + .collect()) +} + +/// Load indexes for a given schema and table. +pub async fn load_indexes( + client: &Client, + schema: &str, + table: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT + i.relname AS index_name, + a.attname AS column_name, + ix.indisunique AS is_unique, + ix.indisprimary AS is_primary + FROM pg_index ix + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) + WHERE n.nspname = $1 AND t.relname = $2 + ORDER BY i.relname, a.attnum"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let index_name: String = r.get(0); + let column_name: String = r.get(1); + let is_unique: bool = r.get(2); + let is_primary: bool = r.get(3); + (index_name, column_name, is_unique, is_primary) + }) + .collect()) +} + +/// Load triggers for a given schema and table. +pub async fn load_triggers( + client: &Client, + schema: &str, + table: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT DISTINCT trigger_name, event_manipulation, action_timing + FROM information_schema.triggers + WHERE trigger_schema = $1 AND event_object_table = $2 + ORDER BY trigger_name"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let event: String = r.get(1); + let timing: String = r.get(2); + (name, event, timing) + }) + .collect()) +} + +/// Load rules for a given schema and table. +pub async fn load_rules( + client: &Client, + schema: &str, + table: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT rulename, ev_type + FROM pg_rules + WHERE schemaname = $1 AND tablename = $2 + ORDER BY rulename"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let event: String = r.get(1); + (name, event) + }) + .collect()) +} + +/// Load RLS policies for a given schema and table. +pub async fn load_policies( + client: &Client, + schema: &str, + table: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT pol.polname, + CASE WHEN pol.polpermissive THEN 'PERMISSIVE' ELSE 'RESTRICTIVE' END, + CASE pol.polcmd + WHEN 'r' THEN 'SELECT' + WHEN 'a' THEN 'INSERT' + WHEN 'w' THEN 'UPDATE' + WHEN 'd' THEN 'DELETE' + WHEN '*' THEN 'ALL' + ELSE pol.polcmd::text + END + FROM pg_policy pol + JOIN pg_class c ON c.oid = pol.polrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 + ORDER BY pol.polname"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let perm: String = r.get(1); + let cmd: String = r.get(2); + (name, perm, cmd) + }) + .collect()) +} + +/// Load constraints for a given schema and table. +pub async fn load_constraints( + client: &Client, + schema: &str, + table: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT + tc.constraint_name, + tc.constraint_type, + COALESCE(kcu.column_name, '') + FROM information_schema.table_constraints tc + LEFT JOIN information_schema.key_column_usage kcu + ON kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.table_name = tc.table_name + WHERE tc.table_schema = $1 AND tc.table_name = $2 + ORDER BY tc.constraint_name, kcu.ordinal_position"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let ctype: String = r.get(1); + let col: String = r.get(2); + (name, ctype, col) + }) + .collect()) +} diff --git a/src-tauri/src/drivers/pgsql/metadata_views_functions.rs b/src-tauri/src/drivers/pgsql/metadata_views_functions.rs new file mode 100644 index 0000000..f9f53a6 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/metadata_views_functions.rs @@ -0,0 +1,219 @@ +use tokio_postgres::Client; + +use crate::common::enums::AppError; + +use super::{FunctionInfo, ObjectStats}; + +/// View info: (view_name) +pub async fn load_views(client: &Client, schema: &str) -> Result, AppError> { + let rows = client + .query( + r#"SELECT table_name + FROM information_schema.views + WHERE table_schema = $1 + ORDER BY table_name"#, + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) +} + +/// Load materialized views for a schema. +pub async fn load_materialized_views( + client: &Client, + schema: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT matviewname + FROM pg_matviews + WHERE schemaname = $1 + ORDER BY matviewname"#, + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) +} + +/// Load functions for a schema (excluding trigger functions and aggregates). +pub async fn load_functions(client: &Client, schema: &str) -> Result, AppError> { + let rows = client + .query( + r#"SELECT p.proname, + pg_get_function_result(p.oid), + pg_get_function_arguments(p.oid) + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 + AND p.prokind IN ('f', 'p') + AND pg_get_function_result(p.oid) != 'trigger' + ORDER BY p.proname"#, + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let ret: String = r.get(1); + let args: String = r.get(2); + (name, ret, args) + }) + .collect()) +} + +/// Load trigger functions for a schema (functions that return trigger). +pub async fn load_trigger_functions( + client: &Client, + schema: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT p.proname, + pg_get_function_arguments(p.oid) + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 + AND pg_get_function_result(p.oid) = 'trigger' + ORDER BY p.proname"#, + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let args: String = r.get(1); + (name, args) + }) + .collect()) +} + +/// Load view metadata. +pub async fn load_view_info( + client: &Client, + schema: &str, + view: &str, +) -> Result { + let rows = client + .query( + r#"SELECT + COALESCE(v.is_updatable, 'NO'), + COALESCE(v.check_option, 'NONE'), + pg_get_viewdef(c.oid, true) + FROM information_schema.views v + JOIN pg_class c ON c.relname = v.table_name + JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = v.table_schema + WHERE v.table_schema = $1 AND v.table_name = $2 + LIMIT 1"#, + &[&schema, &view], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + if let Some(row) = rows.first() { + Ok(vec![ + ("is_updatable".into(), row.get::<_, String>(0)), + ("check_option".into(), row.get::<_, String>(1)), + ("definition".into(), row.get::<_, String>(2)), + ]) + } else { + Ok(Vec::new()) + } +} + +/// Load materialized view metadata. +pub async fn load_matview_info( + client: &Client, + schema: &str, + matview: &str, +) -> Result { + let sql = r#"SELECT + c.reltuples::bigint::text, + pg_size_pretty(pg_total_relation_size(c.oid)), + CASE WHEN m.ispopulated THEN 'YES' ELSE 'NO' END, + m.definition + FROM pg_matviews m + JOIN pg_class c ON c.relname = m.matviewname + JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = m.schemaname + WHERE m.schemaname = $1 AND m.matviewname = $2 + LIMIT 1"#; + + let rows = client + .query(sql, &[&schema, &matview]) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + if let Some(row) = rows.first() { + Ok(vec![ + ("row_estimate".into(), row.get::<_, String>(0)), + ("total_size".into(), row.get::<_, String>(1)), + ("is_populated".into(), row.get::<_, String>(2)), + ("definition".into(), row.get::<_, String>(3)), + ]) + } else { + Ok(Vec::new()) + } +} + +/// Load function metadata. +pub async fn load_function_info( + client: &Client, + schema: &str, + func_name: &str, +) -> Result { + let rows = client + .query( + r#"SELECT + l.lanname, + CASE p.provolatile WHEN 'i' THEN 'IMMUTABLE' WHEN 's' THEN 'STABLE' WHEN 'v' THEN 'VOLATILE' ELSE '' END, + p.proisstrict::text, + p.prosecdef::text, + p.procost::text, + p.prorows::text, + pg_get_function_result(p.oid), + pg_get_function_arguments(p.oid), + p.prosrc + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + JOIN pg_language l ON l.oid = p.prolang + WHERE n.nspname = $1 AND p.proname = $2 + LIMIT 1"#, + &[&schema, &func_name], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let keys = [ + "language", + "volatility", + "is_strict", + "security_definer", + "estimated_cost", + "estimated_rows", + "return_type", + "arguments", + "source", + ]; + + if let Some(row) = rows.first() { + Ok(keys + .iter() + .enumerate() + .map(|(i, k)| { + let val: Option = row.try_get(i).ok(); + (k.to_string(), val.unwrap_or_default()) + }) + .collect()) + } else { + Ok(Vec::new()) + } +} diff --git a/src-tauri/src/drivers/pgsql/mod.rs b/src-tauri/src/drivers/pgsql/mod.rs new file mode 100644 index 0000000..d96e3f1 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/mod.rs @@ -0,0 +1,92 @@ +use deadpool_postgres::Pool; +use std::sync::Arc; + +use crate::common::enums::AppError; + +pub mod query_execution; +pub mod metadata_schema; +pub mod metadata_views_functions; +pub mod statistics_activity; +pub mod ddl_generation; +pub mod roles_schema_objects; +pub mod extensions; +pub mod commands; + +pub use query_execution::*; +pub use metadata_schema::*; +pub use metadata_views_functions::*; +pub use statistics_activity::*; +pub use ddl_generation::*; +pub use roles_schema_objects::*; +pub use extensions::*; +pub use commands::*; + +/// Safely get a pool Arc from the AppState client map. +/// Returns a cloned Arc so the caller can drop the MutexGuard immediately. +pub fn get_pool( + clients_guard: &std::collections::BTreeMap>, + project_id: &str, +) -> Result, AppError> { + clients_guard + .get(project_id) + .cloned() + .ok_or_else(|| AppError::ClientNotConnected(project_id.to_string())) +} + +/// Cell separator for packed format (Unit Separator, ASCII 0x1F) +pub(crate) const CELL_SEP: char = '\x1F'; +/// Row separator for packed format (Record Separator, ASCII 0x1E) +pub(crate) const ROW_SEP: char = '\x1E'; + +/// A cached query: pre-packed page strings for zero-copy serving. +/// Each page is a single large String (~1-2 MB) so the OS reclaims RSS on drop. +pub struct CachedQuery { + pub(crate) pages: Vec, + pub(crate) page_size: usize, +} + +/// In-memory virtual cache: query_id → pre-packed pages. +pub type VirtualCache = std::collections::BTreeMap; + +/// Column detail info: (name, data_type, nullable, default_value) +pub type ColumnDetail = (String, String, bool, Option); + +/// Index info: (index_name, column_name, is_unique, is_primary) +pub type IndexDetail = (String, String, bool, bool); + +/// Trigger info: (trigger_name, event, timing) +pub type TriggerDetail = (String, String, String); + +/// Rule info: (rule_name, event) +pub type RuleDetail = (String, String); + +/// Policy info: (policy_name, permissive, command) +pub type PolicyDetail = (String, String, String); + +/// Function info: (name, return_type, arguments) +pub type FunctionInfo = (String, String, String); + +/// Database stats: (stat_name, stat_value) +pub type DbStat = (String, String); + +/// Constraint info: (constraint_name, constraint_type, column_name) +pub type ConstraintDetail = (String, String, String); + +/// FK relation: (source_table, source_column, target_table, target_column) +pub type ForeignKeyInfo = (String, String, String, String); + +/// Table statistics: Vec of (key, value) pairs +pub type ObjectStats = Vec<(String, String)>; + +/// FK detail: (constraint_name, source_schema, source_table, source_column, target_schema, target_table, target_column, on_update, on_delete) +pub type FKDetail = ( + String, + String, + String, + String, + String, + String, + String, + String, + String, +); diff --git a/src-tauri/src/drivers/pgsql/query_execution/helpers.rs b/src-tauri/src/drivers/pgsql/query_execution/helpers.rs new file mode 100644 index 0000000..35898f6 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/query_execution/helpers.rs @@ -0,0 +1,109 @@ +use tokio_postgres::SimpleQueryMessage; + +use super::super::{CELL_SEP, ROW_SEP}; + +/// Process simple_query messages, returning the last result set that had rows. +/// If no result set had rows but commands ran, returns synthetic "N rows affected". +/// If nothing at all, returns empty vecs. +pub(crate) fn process_simple_messages( + messages: Vec, +) -> (Vec, Vec>) { + let mut cur_columns: Vec = Vec::new(); + let mut cur_rows: Vec> = Vec::new(); + let mut last_columns: Vec = Vec::new(); + let mut last_rows: Vec> = Vec::new(); + let mut has_row_result = false; + let mut total_affected: u64 = 0; + + for msg in messages { + match msg { + SimpleQueryMessage::Row(row) => { + let col_count = row.columns().len(); + if cur_columns.is_empty() { + cur_columns = Vec::with_capacity(col_count); + for c in row.columns() { + cur_columns.push(c.name().to_owned()); + } + } + let mut cells = Vec::with_capacity(col_count); + for i in 0..col_count { + cells.push(row.get(i).unwrap_or("null").to_owned()); + } + cur_rows.push(cells); + } + SimpleQueryMessage::CommandComplete(n) => { + if !cur_rows.is_empty() { + last_columns = std::mem::take(&mut cur_columns); + last_rows = std::mem::take(&mut cur_rows); + has_row_result = true; + } else { + cur_columns.clear(); + cur_rows.clear(); + } + total_affected += n; + } + _ => {} + } + } + + // Handle trailing rows (shouldn't happen but be safe) + if !cur_rows.is_empty() { + return (cur_columns, cur_rows); + } + + if has_row_result { + (last_columns, last_rows) + } else if total_affected > 0 { + ( + vec!["Result".into()], + vec![vec![format!("{} rows affected", total_affected)]], + ) + } else { + (Vec::new(), Vec::new()) + } +} + +/// Join string slices with a char separator — avoids .to_string() on the separator. +#[inline] +pub(crate) fn join_sep(items: &[String], sep: char) -> String { + let total: usize = items.iter().map(|s| s.len()).sum::() + items.len(); + let mut out = String::with_capacity(total); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(sep); + } + out.push_str(item); + } + out +} + +/// Pack a slice of rows (each row = Vec) into wire format. +/// Pre-allocates capacity and writes directly — zero intermediate allocations. +pub(crate) fn pack_rows_vec(rows: &[Vec]) -> String { + if rows.is_empty() { + return String::new(); + } + // Estimate capacity: avg ~20 chars per cell + let est = rows.len() * rows.first().map_or(10, |r| r.len()) * 20; + let mut out = String::with_capacity(est); + + for (ri, row) in rows.iter().enumerate() { + if ri > 0 { + out.push(ROW_SEP); + } + for (ci, cell) in row.iter().enumerate() { + if ci > 0 { + out.push(CELL_SEP); + } + // Inline separator sanitization — avoids .replace() allocations + for ch in cell.chars() { + if ch == CELL_SEP || ch == ROW_SEP { + out.push(' '); + } else { + out.push(ch); + } + } + } + } + out +} diff --git a/src-tauri/src/drivers/pgsql/query_execution/mod.rs b/src-tauri/src/drivers/pgsql/query_execution/mod.rs new file mode 100644 index 0000000..94f3a0c --- /dev/null +++ b/src-tauri/src/drivers/pgsql/query_execution/mod.rs @@ -0,0 +1,8 @@ +mod helpers; +mod simple; +mod streaming; +mod virtual_cache; + +pub use simple::*; +pub use streaming::*; +pub use virtual_cache::*; diff --git a/src-tauri/src/drivers/pgsql/query_execution/simple.rs b/src-tauri/src/drivers/pgsql/query_execution/simple.rs new file mode 100644 index 0000000..28cac88 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/query_execution/simple.rs @@ -0,0 +1,57 @@ +use std::time::Instant; +use tokio_postgres::Client; + +use crate::common::enums::AppError; + +use super::super::{CELL_SEP, ROW_SEP}; +use super::helpers::{join_sep, pack_rows_vec, process_simple_messages}; + +/// Execute a timed query and return (columns, rows_as_strings, elapsed_ms). +/// Uses simple_query protocol — PG returns all values as text, no type conversion needed. +/// Supports multi-statement: returns the last result set that had rows. +pub async fn execute_query( + client: &Client, + sql: &str, +) -> Result<(Vec, Vec>, f32), AppError> { + let start = Instant::now(); + let messages = client + .simple_query(sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let (columns, rows) = process_simple_messages(messages); + let elapsed = start.elapsed().as_millis() as f32; + Ok((columns, rows, elapsed)) +} + +/// Execute a timed query and return results in compact packed string format. +/// Format: "col1\x1Fcol2\x1E row1val1\x1Frow1val2\x1E row2val1\x1Frow2val2" +/// Uses simple_query protocol with multi-statement support. +pub async fn execute_query_packed(client: &Client, sql: &str) -> Result<(String, f32), AppError> { + let start = Instant::now(); + let messages = client + .simple_query(sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let (columns, rows) = process_simple_messages(messages); + + if columns.is_empty() { + return Ok((String::new(), start.elapsed().as_millis() as f32)); + } + + let header = join_sep(&columns, CELL_SEP); + let body = pack_rows_vec(&rows); + + let packed = if body.is_empty() { + header + } else { + let mut s = String::with_capacity(header.len() + 1 + body.len()); + s.push_str(&header); + s.push(ROW_SEP); + s.push_str(&body); + s + }; + let elapsed = start.elapsed().as_millis() as f32; + Ok((packed, elapsed)) +} diff --git a/src-tauri/src/drivers/pgsql/query_execution/streaming.rs b/src-tauri/src/drivers/pgsql/query_execution/streaming.rs new file mode 100644 index 0000000..1b0880d --- /dev/null +++ b/src-tauri/src/drivers/pgsql/query_execution/streaming.rs @@ -0,0 +1,176 @@ +use std::time::Instant; +use tokio_postgres::{Client, SimpleQueryMessage}; + +use crate::common::enums::AppError; + +use super::super::CELL_SEP; +use super::helpers::{join_sep, pack_rows_vec, process_simple_messages}; + +/// Events emitted during streamed query execution. +#[derive(serde::Serialize, Clone)] +#[serde(tag = "type")] +pub enum QueryStreamEvent { + #[serde(rename = "columns")] + Columns { columns: String, total_rows: usize }, + #[serde(rename = "chunk")] + Chunk { data: String }, + #[serde(rename = "done")] + Done { elapsed: f32, capped: bool }, +} + +/// Maximum rows to send to the frontend to prevent OOM in the webview. +const MAX_STREAM_ROWS: usize = 500_000; +/// Rows fetched per cursor FETCH round-trip. +const CURSOR_FETCH_SIZE: usize = 10_000; + +/// Stream query results using a PostgreSQL cursor. +/// Fetches rows in batches from the server — never loads the full result into Rust memory. +/// Caps at MAX_STREAM_ROWS to protect the webview from OOM. +pub async fn execute_query_streamed( + client: &Client, + sql: &str, + stream_id: &str, + app: &tauri::AppHandle, +) -> Result<(), AppError> { + use tauri::Emitter; + + let start = Instant::now(); + let event_name = format!("query-stream-{}", stream_id); + + // Begin transaction + declare cursor for memory-efficient streaming + client + .batch_execute("BEGIN") + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let cursor_sql = format!("DECLARE _rsql_cur NO SCROLL CURSOR FOR {}", sql); + match client.batch_execute(&cursor_sql).await { + Ok(_) => { + // Cursor-based fetch loop using simple_query for zero type conversion + let fetch_sql = format!("FETCH {} FROM _rsql_cur", CURSOR_FETCH_SIZE); + let mut total_sent: usize = 0; + let mut columns_sent = false; + let mut capped = false; + + loop { + let messages = match client.simple_query(&fetch_sql).await { + Ok(msgs) => msgs, + Err(e) => { + let _ = client.batch_execute("CLOSE _rsql_cur; ROLLBACK").await; + return Err(AppError::QueryFailed(e.to_string())); + } + }; + + let mut batch_rows: Vec> = Vec::new(); + let mut batch_columns: Option> = None; + + for msg in messages { + if let SimpleQueryMessage::Row(row) = msg { + let col_count = row.columns().len(); + if batch_columns.is_none() { + let mut cols = Vec::with_capacity(col_count); + for c in row.columns() { + cols.push(c.name().to_owned()); + } + batch_columns = Some(cols); + } + let mut cells = Vec::with_capacity(col_count); + for i in 0..col_count { + cells.push(row.get(i).unwrap_or("null").to_owned()); + } + batch_rows.push(cells); + } + } + + if batch_rows.is_empty() { + break; + } + + // Emit columns on first batch + if !columns_sent && let Some(cols) = batch_columns { + let header = join_sep(&cols, CELL_SEP); + let _ = app.emit( + &event_name, + QueryStreamEvent::Columns { + columns: header, + total_rows: 0, + }, + ); + columns_sent = true; + } + + let packed = pack_rows_vec(&batch_rows); + let _ = app.emit(&event_name, QueryStreamEvent::Chunk { data: packed }); + + total_sent += batch_rows.len(); + if total_sent >= MAX_STREAM_ROWS { + capped = true; + break; + } + } + + // No rows at all + if !columns_sent { + let _ = app.emit( + &event_name, + QueryStreamEvent::Columns { + columns: String::new(), + total_rows: 0, + }, + ); + } + + // Clean up cursor + transaction + client.batch_execute("CLOSE _rsql_cur").await.ok(); + client.batch_execute("COMMIT").await.ok(); + + let elapsed = start.elapsed().as_millis() as f32; + let _ = app.emit(&event_name, QueryStreamEvent::Done { elapsed, capped }); + } + Err(_cursor_err) => { + // DECLARE CURSOR failed (non-SELECT query like INSERT/UPDATE/DDL) + client.batch_execute("ROLLBACK").await.ok(); + + // Re-execute with simple_query for multi-statement support + let messages = client + .simple_query(sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let (columns, rows) = process_simple_messages(messages); + + if columns.is_empty() { + let _ = app.emit( + &event_name, + QueryStreamEvent::Columns { + columns: String::new(), + total_rows: 0, + }, + ); + } else { + let header = join_sep(&columns, CELL_SEP); + let _ = app.emit( + &event_name, + QueryStreamEvent::Columns { + columns: header, + total_rows: rows.len(), + }, + ); + + let packed = pack_rows_vec(&rows); + let _ = app.emit(&event_name, QueryStreamEvent::Chunk { data: packed }); + } + + let elapsed = start.elapsed().as_millis() as f32; + let _ = app.emit( + &event_name, + QueryStreamEvent::Done { + elapsed, + capped: false, + }, + ); + } + } + + Ok(()) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a64155a..0e6e1a3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,7 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod app_setup; mod common; mod dbs; mod drivers; @@ -12,8 +13,6 @@ const LOCAL_DB_NAME: &str = "rsql.db"; use deadpool_postgres::Pool; use std::{collections::BTreeMap, sync::Arc}; -use tauri::Manager; -use tauri::menu::{AboutMetadata, MenuBuilder, SubmenuBuilder}; use tokio::sync::Mutex; use tokio_postgres::CancelToken; use tracing::Level; @@ -25,7 +24,7 @@ pub struct AppState { pub client_ssl: Arc>>, pub local_db: libsql::Database, pub resource_monitor: Arc>, - pub virtual_cache: Arc>, + pub virtual_cache: Arc>, pub notify_handles: Arc>>>, pub ssh_tunnels: Arc>>, } @@ -43,212 +42,7 @@ fn main() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) - .setup(|app| { - #[cfg(desktop)] - if let Some(pubkey) = option_env!("TAURI_UPDATER_PUBLIC_KEY") { - app.handle() - .plugin(tauri_plugin_updater::Builder::new().pubkey(pubkey).build())?; - } else { - tracing::info!( - "Updater disabled because TAURI_UPDATER_PUBLIC_KEY was not set at build time" - ); - } - - let app_handle = app.handle().clone(); - - tauri::async_runtime::block_on(async move { - let db_path = if cfg!(debug_assertions) { - LOCAL_DB_NAME.to_string() - } else { - let app_dir = app_handle - .path() - .app_data_dir() - .expect("Failed to resolve app data directory"); - std::fs::create_dir_all(&app_dir).ok(); - app_dir.join(LOCAL_DB_NAME).to_string_lossy().to_string() - }; - - let db = libsql::Builder::new_local(&db_path) - .build() - .await - .expect("Failed to open local database"); - - // Create tables - let conn = db.connect().expect("Failed to create connection"); - conn.execute( - "CREATE TABLE IF NOT EXISTS projects ( - id TEXT PRIMARY KEY, - driver TEXT NOT NULL DEFAULT 'PGSQL', - username TEXT NOT NULL DEFAULT '', - password TEXT NOT NULL DEFAULT '', - database TEXT NOT NULL DEFAULT '', - host TEXT NOT NULL DEFAULT '', - port TEXT NOT NULL DEFAULT '', - ssl TEXT NOT NULL DEFAULT 'false' - )", - (), - ) - .await - .expect("Failed to create projects table"); - - conn.execute( - "CREATE TABLE IF NOT EXISTS queries ( - id TEXT PRIMARY KEY, - sql TEXT NOT NULL DEFAULT '' - )", - (), - ) - .await - .expect("Failed to create queries table"); - - conn.execute( - "CREATE TABLE IF NOT EXISTS workspaces ( - name TEXT PRIMARY KEY, - tabs TEXT NOT NULL DEFAULT '[]' - )", - (), - ) - .await - .expect("Failed to create workspaces table"); - - conn.execute( - "CREATE TABLE IF NOT EXISTS virtual_query_snapshots ( - query_id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - sql TEXT NOT NULL, - columns_packed TEXT NOT NULL DEFAULT '', - total_rows INTEGER NOT NULL DEFAULT 0, - page_size INTEGER NOT NULL DEFAULT 0, - col_count INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL - )", - (), - ) - .await - .expect("Failed to create virtual_query_snapshots table"); - - conn.execute( - "CREATE TABLE IF NOT EXISTS virtual_query_pages ( - query_id TEXT NOT NULL, - page_index INTEGER NOT NULL, - packed_page TEXT NOT NULL DEFAULT '', - PRIMARY KEY (query_id, page_index) - )", - (), - ) - .await - .expect("Failed to create virtual_query_pages table"); - - // Best-effort orphan cleanup in case app exited before tab-close cleanup. - conn.execute( - "DELETE FROM virtual_query_pages - WHERE query_id NOT IN (SELECT query_id FROM virtual_query_snapshots)", - (), - ) - .await - .ok(); - - // SSH tunnel columns migration - for col in [ - "ssh_enabled", - "ssh_host", - "ssh_port", - "ssh_user", - "ssh_password", - "ssh_key_path", - ] { - conn.execute( - &format!( - "ALTER TABLE projects ADD COLUMN {} TEXT NOT NULL DEFAULT ''", - col - ), - (), - ) - .await - .ok(); // Ignore "column already exists" errors - } - - let state = AppState { - clients: Arc::new(Mutex::new(BTreeMap::new())), - meta_clients: Arc::new(Mutex::new(BTreeMap::new())), - cancel_tokens: Arc::new(Mutex::new(BTreeMap::new())), - client_ssl: Arc::new(Mutex::new(BTreeMap::new())), - local_db: db, - resource_monitor: Arc::new(Mutex::new(utils::ResourceMonitor::new())), - virtual_cache: Arc::new(Mutex::new(BTreeMap::new())), - notify_handles: Arc::new(Mutex::new(BTreeMap::new())), - ssh_tunnels: Arc::new(Mutex::new(BTreeMap::new())), - }; - app_handle.manage(state); - - let terminal_state = terminal::TerminalState { - sessions: Arc::new(Mutex::new(std::collections::HashMap::new())), - }; - app_handle.manage(terminal_state); - }); - - // Native menu - let handle = app.handle(); - - let app_menu = SubmenuBuilder::new(handle, "RSQL") - .about(Some(AboutMetadata { - name: Some("RSQL".into()), - version: Some(env!("CARGO_PKG_VERSION").into()), - copyright: Some("\u{00a9} 2025 rust-dd".into()), - comments: Some( - "Modern SQL client for PostgreSQL.\nBuilt with Tauri, React, and Rust." - .into(), - ), - website: Some("https://github.com/rust-dd/rust-sql".into()), - website_label: Some("GitHub".into()), - ..Default::default() - })) - .separator() - .services() - .separator() - .hide() - .hide_others() - .show_all() - .separator() - .quit() - .build()?; - - let edit_menu = SubmenuBuilder::new(handle, "Edit") - .undo() - .redo() - .separator() - .cut() - .copy() - .paste() - .select_all() - .build()?; - - let view_menu = SubmenuBuilder::new(handle, "View").fullscreen().build()?; - - let window_menu = SubmenuBuilder::new(handle, "Window") - .minimize() - .maximize() - .separator() - .close_window() - .build()?; - - let menu = MenuBuilder::new(handle) - .items(&[&app_menu, &edit_menu, &view_menu, &window_menu]) - .build()?; - - handle.set_menu(menu)?; - - #[cfg(debug_assertions)] - { - let window = app - .get_webview_window("main") - .expect("main window not found"); - window.open_devtools(); - window.close_devtools(); - } - - Ok(()) - }) + .setup(app_setup::setup_app) .invoke_handler(tauri::generate_handler![ dbs::project::project_db_select, dbs::project::project_db_insert, diff --git a/yarn.lock b/yarn.lock index 12798c7..88f21d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11,16 +11,7 @@ js-tokens "^4.0.0" picocolors "^1.1.1" -"@babel/code-frame@^7.28.6": - version "7.29.0" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz" - integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== - dependencies: - "@babel/helper-validator-identifier" "^7.28.5" - js-tokens "^4.0.0" - picocolors "^1.1.1" - -"@babel/code-frame@^7.29.0": +"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": version "7.29.0" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz" integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== @@ -217,14 +208,7 @@ dependencies: "@babel/types" "^7.28.4" -"@babel/parser@^7.28.6": - version "7.29.0" - resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz" - integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww== - dependencies: - "@babel/types" "^7.29.0" - -"@babel/parser@^7.29.0": +"@babel/parser@^7.28.6", "@babel/parser@^7.29.0": version "7.29.0" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz" integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww== @@ -327,7 +311,7 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" -"@babel/types@^7.22.5": +"@babel/types@^7.22.5", "@babel/types@^7.28.6", "@babel/types@^7.29.0": version "7.29.0" resolved "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz" integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== @@ -335,13 +319,27 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" -"@babel/types@^7.28.6", "@babel/types@^7.29.0": - version "7.29.0" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz" - integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== +"@emnapi/core@^1.4.3", "@emnapi/core@^1.4.5": + version "1.10.0" + resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467" + integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw== dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.28.5" + "@emnapi/wasi-threads" "1.2.1" + tslib "^2.4.0" + +"@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.4.5": + version "1.10.0" + resolved "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c" + integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.2.1", "@emnapi/wasi-threads@^1.0.4": + version "1.2.1" + resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548" + integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== + dependencies: + tslib "^2.4.0" "@emotion/is-prop-valid@^1.2.0": version "1.4.0" @@ -355,11 +353,136 @@ resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz" integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== +"@esbuild/aix-ppc64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz#bef96351f16520055c947aba28802eede3c9e9a9" + integrity sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA== + +"@esbuild/android-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz#d2e70be7d51a529425422091e0dcb90374c1546c" + integrity sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg== + +"@esbuild/android-arm@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz#d2a753fe2a4c73b79437d0ba1480e2d760097419" + integrity sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ== + +"@esbuild/android-x64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz#5278836e3c7ae75761626962f902a0d55352e683" + integrity sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw== + "@esbuild/darwin-arm64@0.25.9": version "0.25.9" resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz" integrity sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg== +"@esbuild/darwin-x64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz#e27dbc3b507b3a1cea3b9280a04b8b6b725f82be" + integrity sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ== + +"@esbuild/freebsd-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz#364e3e5b7a1fd45d92be08c6cc5d890ca75908ca" + integrity sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q== + +"@esbuild/freebsd-x64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz#7c869b45faeb3df668e19ace07335a0711ec56ab" + integrity sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg== + +"@esbuild/linux-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz#48d42861758c940b61abea43ba9a29b186d6cb8b" + integrity sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw== + +"@esbuild/linux-arm@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz#6ce4b9cabf148274101701d112b89dc67cc52f37" + integrity sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw== + +"@esbuild/linux-ia32@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz#207e54899b79cac9c26c323fc1caa32e3143f1c4" + integrity sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A== + +"@esbuild/linux-loong64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz#0ba48a127159a8f6abb5827f21198b999ffd1fc0" + integrity sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ== + +"@esbuild/linux-mips64el@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz#a4d4cc693d185f66a6afde94f772b38ce5d64eb5" + integrity sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA== + +"@esbuild/linux-ppc64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz#0f5805c1c6d6435a1dafdc043cb07a19050357db" + integrity sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w== + +"@esbuild/linux-riscv64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz#6776edece0f8fca79f3386398b5183ff2a827547" + integrity sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg== + +"@esbuild/linux-s390x@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz#3f6f29ef036938447c2218d309dc875225861830" + integrity sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA== + +"@esbuild/linux-x64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz#831fe0b0e1a80a8b8391224ea2377d5520e1527f" + integrity sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg== + +"@esbuild/netbsd-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz#06f99d7eebe035fbbe43de01c9d7e98d2a0aa548" + integrity sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q== + +"@esbuild/netbsd-x64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz#db99858e6bed6e73911f92a88e4edd3a8c429a52" + integrity sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g== + +"@esbuild/openbsd-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz#afb886c867e36f9d86bb21e878e1185f5d5a0935" + integrity sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ== + +"@esbuild/openbsd-x64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz#30855c9f8381fac6a0ef5b5f31ac6e7108a66ecf" + integrity sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA== + +"@esbuild/openharmony-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz#2f2144af31e67adc2a8e3705c20c2bd97bd88314" + integrity sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg== + +"@esbuild/sunos-x64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz#69b99a9b5bd226c9eb9c6a73f990fddd497d732e" + integrity sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw== + +"@esbuild/win32-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz#d789330a712af916c88325f4ffe465f885719c6b" + integrity sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ== + +"@esbuild/win32-ia32@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz#52fc735406bd49688253e74e4e837ac2ba0789e3" + integrity sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww== + +"@esbuild/win32-x64@0.25.9": + version "0.25.9" + resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f" + integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ== + "@glideapps/glide-data-grid@^6.0.3": version "6.0.3" resolved "https://registry.npmjs.org/@glideapps/glide-data-grid/-/glide-data-grid-6.0.3.tgz" @@ -481,12 +604,21 @@ dependencies: "@monaco-editor/loader" "^1.5.0" +"@napi-rs/wasm-runtime@^0.2.12": + version "0.2.12" + resolved "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" + integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ== + dependencies: + "@emnapi/core" "^1.4.3" + "@emnapi/runtime" "^1.4.3" + "@tybys/wasm-util" "^0.10.0" + "@radix-ui/primitive@1.1.3": version "1.1.3" resolved "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz" integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg== -"@radix-ui/react-compose-refs@^1.1.1", "@radix-ui/react-compose-refs@1.1.2": +"@radix-ui/react-compose-refs@1.1.2", "@radix-ui/react-compose-refs@^1.1.1": version "1.1.2" resolved "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz" integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg== @@ -541,7 +673,7 @@ "@radix-ui/react-primitive" "2.1.3" "@radix-ui/react-use-callback-ref" "1.1.1" -"@radix-ui/react-id@^1.1.0", "@radix-ui/react-id@1.1.1": +"@radix-ui/react-id@1.1.1", "@radix-ui/react-id@^1.1.0": version "1.1.1" resolved "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz" integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg== @@ -571,7 +703,7 @@ "@radix-ui/react-compose-refs" "1.1.2" "@radix-ui/react-use-layout-effect" "1.1.1" -"@radix-ui/react-primitive@^2.0.2", "@radix-ui/react-primitive@2.1.3": +"@radix-ui/react-primitive@2.1.3", "@radix-ui/react-primitive@^2.0.2": version "2.1.3" resolved "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz" integrity sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ== @@ -622,11 +754,111 @@ resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz" integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA== +"@rollup/rollup-android-arm-eabi@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz#7d41dc45adcfcb272504ebcea9c8a5b2c659e963" + integrity sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag== + +"@rollup/rollup-android-arm64@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.1.tgz#6c708fae2c9755e994c42d56c34a94cb77020650" + integrity sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw== + "@rollup/rollup-darwin-arm64@4.50.1": version "4.50.1" resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.1.tgz" integrity sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw== +"@rollup/rollup-darwin-x64@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.1.tgz#0af089f3d658d05573208dabb3a392b44d7f4630" + integrity sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw== + +"@rollup/rollup-freebsd-arm64@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.1.tgz#46c22a16d18180e99686647543335567221caa9c" + integrity sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA== + +"@rollup/rollup-freebsd-x64@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.1.tgz#819ffef2f81891c266456952962a13110c8e28b5" + integrity sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ== + +"@rollup/rollup-linux-arm-gnueabihf@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.1.tgz#7fe283c14793e607e653a3214b09f8973f08262a" + integrity sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg== + +"@rollup/rollup-linux-arm-musleabihf@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.1.tgz#066e92eb22ea30560414ec800a6d119ba0b435ac" + integrity sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw== + +"@rollup/rollup-linux-arm64-gnu@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.1.tgz#480d518ea99a8d97b2a174c46cd55164f138cc37" + integrity sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw== + +"@rollup/rollup-linux-arm64-musl@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.1.tgz#ed7db3b8999b60dd20009ddf71c95f3af49423c8" + integrity sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w== + +"@rollup/rollup-linux-loongarch64-gnu@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.1.tgz#16a6927a35f5dbc505ff874a4e1459610c0c6f46" + integrity sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q== + +"@rollup/rollup-linux-ppc64-gnu@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.1.tgz#a006700469be0041846c45b494c35754e6a04eea" + integrity sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q== + +"@rollup/rollup-linux-riscv64-gnu@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.1.tgz#0fcc45b2ec8a0e54218ca48849ea6d596f53649c" + integrity sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ== + +"@rollup/rollup-linux-riscv64-musl@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.1.tgz#d6e617eec9fe6f5859ee13fad435a16c42b469f2" + integrity sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg== + +"@rollup/rollup-linux-s390x-gnu@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.1.tgz#b147760d63c6f35b4b18e6a25a2a760dd3ea0c05" + integrity sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg== + +"@rollup/rollup-linux-x64-gnu@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.1.tgz#fc0be1da374f85e7e85dccaf1ff12d7cfc9fbe3d" + integrity sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA== + +"@rollup/rollup-linux-x64-musl@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.1.tgz#54c79932e0f9a3c992b034c82325be3bcde0d067" + integrity sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg== + +"@rollup/rollup-openharmony-arm64@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.1.tgz#fc48e74d413623ac02c1d521bec3e5e784488fdc" + integrity sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA== + +"@rollup/rollup-win32-arm64-msvc@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.1.tgz#8ce3d1181644406362cf1e62c90e88ab083e02bb" + integrity sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ== + +"@rollup/rollup-win32-ia32-msvc@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.1.tgz#dd2dfc896eac4b2689d55f01c6d51c249263f805" + integrity sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A== + +"@rollup/rollup-win32-x64-msvc@4.50.1": + version "4.50.1" + resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz#13f758c97b9fbbac56b6928547a3ff384e7cfb3e" + integrity sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA== + "@tailwindcss/node@4.1.13": version "4.1.13" resolved "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz" @@ -640,11 +872,73 @@ source-map-js "^1.2.1" tailwindcss "4.1.13" +"@tailwindcss/oxide-android-arm64@4.1.13": + version "4.1.13" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz#34e02dc9bbb3902c36800c75edad3f033cd33ce3" + integrity sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew== + "@tailwindcss/oxide-darwin-arm64@4.1.13": version "4.1.13" resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz" integrity sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ== +"@tailwindcss/oxide-darwin-x64@4.1.13": + version "4.1.13" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz#259aa6d8c58c6d4fd01e856ea731924ba2afcab9" + integrity sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw== + +"@tailwindcss/oxide-freebsd-x64@4.1.13": + version "4.1.13" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz#b9987fb460ed24d4227392970e6af8e90784d434" + integrity sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ== + +"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13": + version "4.1.13" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz#ed157b7fa2ea79cc97f196383f461c9be1acc309" + integrity sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw== + +"@tailwindcss/oxide-linux-arm64-gnu@4.1.13": + version "4.1.13" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz#5732ad1e5679d7d93999563e63728a813f3d121c" + integrity sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ== + +"@tailwindcss/oxide-linux-arm64-musl@4.1.13": + version "4.1.13" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz#987837bc5bf88ef84e2aef38c6cbebed0cf40d81" + integrity sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg== + +"@tailwindcss/oxide-linux-x64-gnu@4.1.13": + version "4.1.13" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz#a673731e1c8ae6e97bdacd6140ec08cdc23121fb" + integrity sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ== + +"@tailwindcss/oxide-linux-x64-musl@4.1.13": + version "4.1.13" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz#5201013bff73ab309ad5fe0ff0abe1ad51b2bd63" + integrity sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ== + +"@tailwindcss/oxide-wasm32-wasi@4.1.13": + version "4.1.13" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz#6af873b3417468670b88c70bcb3f6d5fa76fbaae" + integrity sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA== + dependencies: + "@emnapi/core" "^1.4.5" + "@emnapi/runtime" "^1.4.5" + "@emnapi/wasi-threads" "^1.0.4" + "@napi-rs/wasm-runtime" "^0.2.12" + "@tybys/wasm-util" "^0.10.0" + tslib "^2.8.0" + +"@tailwindcss/oxide-win32-arm64-msvc@4.1.13": + version "4.1.13" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz#feca2e628d6eac3fb156613e53c2a3d8006b7d16" + integrity sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg== + +"@tailwindcss/oxide-win32-x64-msvc@4.1.13": + version "4.1.13" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz#20db1f2dabbc6b89bda9f4af5e1ab848079ea3dc" + integrity sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw== + "@tailwindcss/oxide@4.1.13": version "4.1.13" resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz" @@ -690,6 +984,56 @@ resolved "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.8.4.tgz" integrity sha512-BKu8HRkYV01SMTa7r4fLx+wjgtRK8Vep7lmBdHDioP6b8XH3q2KgsAyPWfEZaZIkZ2LY4SqqGARaE9oilNe0oA== +"@tauri-apps/cli-darwin-x64@2.8.4": + version "2.8.4" + resolved "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.8.4.tgz#b9c274fedce570da1910559add68657d264019db" + integrity sha512-imb9PfSd/7G6VAO7v1bQ2A3ZH4NOCbhGJFLchxzepGcXf9NKkfun157JH9mko29K6sqAwuJ88qtzbKCbWJTH9g== + +"@tauri-apps/cli-linux-arm-gnueabihf@2.8.4": + version "2.8.4" + resolved "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.8.4.tgz#657131a05f422b9141277f0668d370e8d671bdc0" + integrity sha512-Ml215UnDdl7/fpOrF1CNovym/KjtUbCuPgrcZ4IhqUCnhZdXuphud/JT3E8X97Y03TZ40Sjz8raXYI2ET0exzw== + +"@tauri-apps/cli-linux-arm64-gnu@2.8.4": + version "2.8.4" + resolved "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.8.4.tgz#35a14541e09b6548b811626d1a5d2574932116ef" + integrity sha512-pbcgBpMyI90C83CxE5REZ9ODyIlmmAPkkJXtV398X3SgZEIYy5TACYqlyyv2z5yKgD8F8WH4/2fek7+jH+ZXAw== + +"@tauri-apps/cli-linux-arm64-musl@2.8.4": + version "2.8.4" + resolved "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.8.4.tgz#bdd9deea17e0c2e4edf511a071c87670616af8a3" + integrity sha512-zumFeaU1Ws5Ay872FTyIm7z8kfzEHu8NcIn8M6TxbJs0a7GRV21KBdpW1zNj2qy7HynnpQCqjAYXTUUmm9JAOw== + +"@tauri-apps/cli-linux-riscv64-gnu@2.8.4": + version "2.8.4" + resolved "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.8.4.tgz#ac3c751ce5727fbd1da280f0aa2fb444fcd706b5" + integrity sha512-qiqbB3Zz6IyO201f+1ojxLj65WYj8mixL5cOMo63nlg8CIzsP23cPYUrx1YaDPsCLszKZo7tVs14pc7BWf+/aQ== + +"@tauri-apps/cli-linux-x64-gnu@2.8.4": + version "2.8.4" + resolved "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.8.4.tgz#7b2000b5e6597dc62f48cb67ee98f61b54493a19" + integrity sha512-TaqaDd9Oy6k45Hotx3pOf+pkbsxLaApv4rGd9mLuRM1k6YS/aw81YrsMryYPThrxrScEIUcmNIHaHsLiU4GMkw== + +"@tauri-apps/cli-linux-x64-musl@2.8.4": + version "2.8.4" + resolved "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.8.4.tgz#5dd9f6e666e004e00313d86a5d71480f7ac1269a" + integrity sha512-ot9STAwyezN8w+bBHZ+bqSQIJ0qPZFlz/AyscpGqB/JnJQVDFQcRDmUPFEaAtt2UUHSWzN3GoTJ5ypqLBp2WQA== + +"@tauri-apps/cli-win32-arm64-msvc@2.8.4": + version "2.8.4" + resolved "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.8.4.tgz#75eadbad9ae5726cc53139bbeae4c4a8fc4a92be" + integrity sha512-+2aJ/g90dhLiOLFSD1PbElXX3SoMdpO7HFPAZB+xot3CWlAZD1tReUFy7xe0L5GAR16ZmrxpIDM9v9gn5xRy/w== + +"@tauri-apps/cli-win32-ia32-msvc@2.8.4": + version "2.8.4" + resolved "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.8.4.tgz#60e6cdad4fb59d91a194652581742d08c952f0a7" + integrity sha512-yj7WDxkL1t9Uzr2gufQ1Hl7hrHuFKTNEOyascbc109EoiAqCp0tgZ2IykQqOZmZOHU884UAWI1pVMqBhS/BfhA== + +"@tauri-apps/cli-win32-x64-msvc@2.8.4": + version "2.8.4" + resolved "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.8.4.tgz#44461625197c531537ccba84b64c804a0d7228ae" + integrity sha512-XuvGB4ehBdd7QhMZ9qbj/8icGEatDuBNxyYHbLKsTYh90ggUlPa/AtaqcC1Fo69lGkTmq9BOKrs1aWSi7xDonA== + "@tauri-apps/cli@^2": version "2.8.4" resolved "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.8.4.tgz" @@ -735,6 +1079,13 @@ dependencies: "@tauri-apps/api" "^2.10.1" +"@tybys/wasm-util@^0.10.0": + version "0.10.2" + resolved "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e" + integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== + dependencies: + tslib "^2.4.0" + "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz" @@ -1119,6 +1470,51 @@ lightningcss-darwin-arm64@1.30.1: resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz" integrity sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ== +lightningcss-darwin-x64@1.30.1: + version "1.30.1" + resolved "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz#e81105d3fd6330860c15fe860f64d39cff5fbd22" + integrity sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA== + +lightningcss-freebsd-x64@1.30.1: + version "1.30.1" + resolved "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz#a0e732031083ff9d625c5db021d09eb085af8be4" + integrity sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig== + +lightningcss-linux-arm-gnueabihf@1.30.1: + version "1.30.1" + resolved "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz#1f5ecca6095528ddb649f9304ba2560c72474908" + integrity sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q== + +lightningcss-linux-arm64-gnu@1.30.1: + version "1.30.1" + resolved "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz#eee7799726103bffff1e88993df726f6911ec009" + integrity sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw== + +lightningcss-linux-arm64-musl@1.30.1: + version "1.30.1" + resolved "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz#f2e4b53f42892feeef8f620cbb889f7c064a7dfe" + integrity sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ== + +lightningcss-linux-x64-gnu@1.30.1: + version "1.30.1" + resolved "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz#2fc7096224bc000ebb97eea94aea248c5b0eb157" + integrity sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw== + +lightningcss-linux-x64-musl@1.30.1: + version "1.30.1" + resolved "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz#66dca2b159fd819ea832c44895d07e5b31d75f26" + integrity sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ== + +lightningcss-win32-arm64-msvc@1.30.1: + version "1.30.1" + resolved "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz#7d8110a19d7c2d22bfdf2f2bb8be68e7d1b69039" + integrity sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA== + +lightningcss-win32-x64-msvc@1.30.1: + version "1.30.1" + resolved "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz#fd7dd008ea98494b85d24b4bea016793f2e0e352" + integrity sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg== + lightningcss@1.30.1: version "1.30.1" resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz" @@ -1470,16 +1866,16 @@ tailwind-merge@^3.3.1: resolved "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz" integrity sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g== -tailwindcss@^4.1.13: - version "4.1.14" - resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz" - integrity sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA== - tailwindcss@4.1.13: version "4.1.13" resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz" integrity sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w== +tailwindcss@^4.1.13: + version "4.1.14" + resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz" + integrity sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA== + tapable@^2.2.0: version "2.2.3" resolved "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz" @@ -1512,7 +1908,7 @@ ts-invariant@^0.10.3: dependencies: tslib "^2.1.0" -tslib@^2.0.0, tslib@^2.1.0: +tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== From 90f9a5c319d96a24cfe773f35f24dfc660b3b6aa Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Sat, 16 May 2026 09:40:47 +0200 Subject: [PATCH 2/7] chore: add missing splitted files --- .../pgsql/query_execution/virtual_cache.rs | 100 +++++++++ .../pgsql/roles_schema_objects/csv_import.rs | 108 ++++++++++ .../drivers/pgsql/roles_schema_objects/mod.rs | 7 + .../roles_schema_objects/roles_grants.rs | 127 +++++++++++ .../roles_schema_objects/schema_objects.rs | 148 +++++++++++++ .../pgsql/statistics_activity/database.rs | 188 ++++++++++++++++ .../drivers/pgsql/statistics_activity/mod.rs | 5 + .../pgsql/statistics_activity/objects.rs | 200 ++++++++++++++++++ 8 files changed, 883 insertions(+) create mode 100644 src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs create mode 100644 src-tauri/src/drivers/pgsql/roles_schema_objects/csv_import.rs create mode 100644 src-tauri/src/drivers/pgsql/roles_schema_objects/mod.rs create mode 100644 src-tauri/src/drivers/pgsql/roles_schema_objects/roles_grants.rs create mode 100644 src-tauri/src/drivers/pgsql/roles_schema_objects/schema_objects.rs create mode 100644 src-tauri/src/drivers/pgsql/statistics_activity/database.rs create mode 100644 src-tauri/src/drivers/pgsql/statistics_activity/mod.rs create mode 100644 src-tauri/src/drivers/pgsql/statistics_activity/objects.rs diff --git a/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs b/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs new file mode 100644 index 0000000..ccc006c --- /dev/null +++ b/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs @@ -0,0 +1,100 @@ +use rayon::prelude::*; +use std::time::Instant; +use tokio_postgres::Client; + +use crate::common::enums::AppError; + +use super::super::{CELL_SEP, CachedQuery, ROW_SEP, VirtualCache}; +use super::helpers::{join_sep, pack_rows_vec, process_simple_messages}; + +/// Execute a query in one shot using simple_query protocol. +/// Pre-packs results into page-sized strings cached in-memory. +/// Returns (columns_packed, total_rows, first_page_packed, elapsed_ms). +/// If the SQL is non-SELECT / returns 0 rows, returns empty columns_packed signal +/// with a synthetic affected-rows message in first_page_packed when applicable. +pub async fn execute_virtual( + client: &Client, + cache: &tokio::sync::Mutex, + sql: &str, + query_id: &str, + page_size: usize, +) -> Result<(String, usize, String, f32), AppError> { + let start = Instant::now(); + + let messages = client + .simple_query(sql) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let (columns, all_rows) = process_simple_messages(messages); + + // Non-SELECT or empty result + if columns.is_empty() { + let elapsed = start.elapsed().as_millis() as f32; + return Ok((String::new(), 0, String::new(), elapsed)); + } + + // Synthetic "N rows affected" result — pass through as fallback format + if columns.len() == 1 && columns[0] == "Result" { + let mut fallback = String::with_capacity(64); + fallback.push_str(&columns[0]); + fallback.push(ROW_SEP); + if let Some(r) = all_rows.first() { + fallback.push_str(&join_sep(r, CELL_SEP)); + } + let elapsed = start.elapsed().as_millis() as f32; + return Ok((String::new(), 0, fallback, elapsed)); + } + + let total_rows = all_rows.len(); + + // Pre-pack into pages — use rayon only for large results (>50K rows) + let chunks: Vec<&[Vec]> = all_rows.chunks(page_size).collect(); + let pages: Vec = if total_rows > 50_000 { + chunks + .par_iter() + .map(|chunk| pack_rows_vec(chunk)) + .collect() + } else { + chunks.iter().map(|chunk| pack_rows_vec(chunk)).collect() + }; + + let columns_packed = join_sep(&columns, CELL_SEP); + let first_page_packed = pages.first().cloned().unwrap_or_default(); + + // Store pre-packed pages in cache + { + let mut c = cache.lock().await; + c.insert(query_id.to_string(), CachedQuery { pages, page_size }); + } + + let elapsed = start.elapsed().as_millis() as f32; + Ok((columns_packed, total_rows, first_page_packed, elapsed)) +} + +/// Fetch a pre-packed page from the in-memory cache. O(1) — no packing at serve time. +pub async fn fetch_virtual_page( + cache: &tokio::sync::Mutex, + query_id: &str, + _col_count: usize, + offset: usize, + _limit: usize, +) -> Result { + let c = cache.lock().await; + let entry = c + .get(query_id) + .ok_or_else(|| AppError::QueryFailed(format!("Virtual query {} not found", query_id)))?; + + let page_index = offset / entry.page_size; + Ok(entry.pages.get(page_index).cloned().unwrap_or_default()) +} + +/// Remove a query from the in-memory cache. Large page strings are freed → OS reclaims RSS. +pub async fn close_virtual( + cache: &tokio::sync::Mutex, + query_id: &str, +) -> Result<(), AppError> { + let mut c = cache.lock().await; + c.remove(query_id); + Ok(()) +} diff --git a/src-tauri/src/drivers/pgsql/roles_schema_objects/csv_import.rs b/src-tauri/src/drivers/pgsql/roles_schema_objects/csv_import.rs new file mode 100644 index 0000000..deac3d5 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/roles_schema_objects/csv_import.rs @@ -0,0 +1,108 @@ +use crate::common::enums::AppError; + +pub async fn parse_csv_preview( + file_path: &str, + max_rows: usize, +) -> Result<(Vec, Vec>), AppError> { + let mut rdr = csv::ReaderBuilder::new() + .has_headers(true) + .from_path(file_path) + .map_err(|e| AppError::QueryFailed(format!("Failed to read CSV: {}", e)))?; + + let headers: Vec = rdr + .headers() + .map_err(|e| AppError::QueryFailed(format!("Failed to parse CSV headers: {}", e)))? + .iter() + .map(|h| h.to_string()) + .collect(); + + let mut rows = Vec::new(); + for result in rdr.records().take(max_rows) { + let record = + result.map_err(|e| AppError::QueryFailed(format!("CSV parse error: {}", e)))?; + rows.push(record.iter().map(|f| f.to_string()).collect()); + } + + Ok((headers, rows)) +} + +pub async fn import_csv_to_table( + client: &deadpool_postgres::Client, + file_path: &str, + schema: &str, + table: &str, + column_mapping: &[(usize, String)], +) -> Result { + let mut rdr = csv::ReaderBuilder::new() + .has_headers(true) + .from_path(file_path) + .map_err(|e| AppError::QueryFailed(format!("Failed to read CSV: {}", e)))?; + + if column_mapping.is_empty() { + return Err(AppError::QueryFailed( + "No column mapping provided".to_string(), + )); + } + + let col_names: Vec = column_mapping + .iter() + .map(|(_, name)| format!("\"{}\"", name)) + .collect(); + let placeholders: Vec = (1..=column_mapping.len()) + .map(|i| format!("${}", i)) + .collect(); + + let insert_sql = format!( + "INSERT INTO \"{}\".\"{}\" ({}) VALUES ({})", + schema, + table, + col_names.join(", "), + placeholders.join(", "), + ); + + let statement = client + .prepare(&insert_sql) + .await + .map_err(|e| AppError::QueryFailed(format!("Failed to prepare statement: {}", e)))?; + + client + .execute("BEGIN", &[]) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let mut imported = 0usize; + for result in rdr.records() { + let record = result.map_err(|e| { + AppError::QueryFailed(format!("CSV parse error at row {}: {}", imported + 1, e)) + })?; + + let values: Vec = column_mapping + .iter() + .map(|(idx, _)| record.get(*idx).unwrap_or("").to_string()) + .collect(); + + let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = values + .iter() + .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync)) + .collect(); + + match client.execute(&statement, ¶ms).await { + Ok(_) => imported += 1, + Err(e) => { + client.execute("ROLLBACK", &[]).await.ok(); + return Err(AppError::QueryFailed(format!( + "Import failed at row {}: {}", + imported + 1, + e + ))); + } + } + } + + client + .execute("COMMIT", &[]) + .await + .map_err(|e| AppError::QueryFailed(format!("Failed to commit: {}", e)))?; + + Ok(imported) +} diff --git a/src-tauri/src/drivers/pgsql/roles_schema_objects/mod.rs b/src-tauri/src/drivers/pgsql/roles_schema_objects/mod.rs new file mode 100644 index 0000000..f154bfe --- /dev/null +++ b/src-tauri/src/drivers/pgsql/roles_schema_objects/mod.rs @@ -0,0 +1,7 @@ +mod csv_import; +mod roles_grants; +mod schema_objects; + +pub use csv_import::*; +pub use roles_grants::*; +pub use schema_objects::*; diff --git a/src-tauri/src/drivers/pgsql/roles_schema_objects/roles_grants.rs b/src-tauri/src/drivers/pgsql/roles_schema_objects/roles_grants.rs new file mode 100644 index 0000000..b58265d --- /dev/null +++ b/src-tauri/src/drivers/pgsql/roles_schema_objects/roles_grants.rs @@ -0,0 +1,127 @@ +use crate::common::enums::AppError; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PgRole { + pub name: String, + pub superuser: bool, + pub create_db: bool, + pub create_role: bool, + pub login: bool, + pub replication: bool, + pub bypass_rls: bool, + pub conn_limit: i32, + pub valid_until: String, + pub member_of: Vec, +} + +pub async fn load_roles(client: &deadpool_postgres::Client) -> Result, AppError> { + let rows = client + .query( + "SELECT r.rolname, r.rolsuper, r.rolcreatedb, r.rolcreaterole, + r.rolcanlogin, r.rolreplication, r.rolbypassrls, r.rolconnlimit, + COALESCE(r.rolvaliduntil::text, ''), + COALESCE(array_agg(m.rolname) FILTER (WHERE m.rolname IS NOT NULL), '{}')::text[] + FROM pg_roles r + LEFT JOIN pg_auth_members am ON am.member = r.oid + LEFT JOIN pg_roles m ON m.oid = am.roleid + GROUP BY r.oid, r.rolname, r.rolsuper, r.rolcreatedb, r.rolcreaterole, + r.rolcanlogin, r.rolreplication, r.rolbypassrls, r.rolconnlimit, r.rolvaliduntil + ORDER BY r.rolname", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let mut roles = Vec::new(); + for row in rows { + roles.push(PgRole { + name: row.get::<_, String>(0), + superuser: row.get::<_, bool>(1), + create_db: row.get::<_, bool>(2), + create_role: row.get::<_, bool>(3), + login: row.get::<_, bool>(4), + replication: row.get::<_, bool>(5), + bypass_rls: row.get::<_, bool>(6), + conn_limit: row.get::<_, i32>(7), + valid_until: row.get::<_, String>(8), + member_of: row.get::<_, Vec>(9), + }); + } + + Ok(roles) +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct TableGrant { + pub schema: String, + pub table: String, + pub grantee: String, + pub privileges: Vec, +} + +pub async fn load_table_grants( + client: &deadpool_postgres::Client, + role_name: &str, +) -> Result, AppError> { + let rows = client + .query( + "SELECT table_schema, table_name, grantee, + array_agg(privilege_type ORDER BY privilege_type)::text[] + FROM information_schema.table_privileges + WHERE grantee = $1 + GROUP BY table_schema, table_name, grantee + ORDER BY table_schema, table_name", + &[&role_name], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let mut grants = Vec::new(); + for row in rows { + grants.push(TableGrant { + schema: row.get(0), + table: row.get(1), + grantee: row.get(2), + privileges: row.get::<_, Vec>(3), + }); + } + + Ok(grants) +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct DbGrant { + pub database: String, + pub privilege: String, +} + +pub async fn load_database_grants( + client: &deadpool_postgres::Client, + role_name: &str, +) -> Result, AppError> { + let rows = client + .query( + "SELECT datname, privilege_type + FROM pg_database + CROSS JOIN LATERAL ( + SELECT privilege_type + FROM (VALUES ('CONNECT'), ('CREATE'), ('TEMPORARY')) AS privs(privilege_type) + WHERE has_database_privilege($1, datname, privilege_type) + ) t + WHERE NOT datistemplate + ORDER BY datname, privilege_type", + &[&role_name], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let mut grants = Vec::new(); + for row in rows { + grants.push(DbGrant { + database: row.get(0), + privilege: row.get(1), + }); + } + + Ok(grants) +} diff --git a/src-tauri/src/drivers/pgsql/roles_schema_objects/schema_objects.rs b/src-tauri/src/drivers/pgsql/roles_schema_objects/schema_objects.rs new file mode 100644 index 0000000..01332f8 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/roles_schema_objects/schema_objects.rs @@ -0,0 +1,148 @@ +use crate::common::enums::AppError; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SchemaObject { + pub object_type: String, + pub name: String, + pub definition: String, +} + +pub async fn extract_schema_objects( + client: &deadpool_postgres::Client, + schema: &str, +) -> Result, AppError> { + let mut objects = Vec::new(); + + // Tables with columns + let rows = client + .query( + "SELECT c.relname, + string_agg( + a.attname || ' ' || pg_catalog.format_type(a.atttypid, a.atttypmod) || + CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END || + COALESCE(' DEFAULT ' || pg_get_expr(d.adbin, d.adrelid), ''), + ', ' ORDER BY a.attnum + ) + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped + LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum + WHERE n.nspname = $1 AND c.relkind = 'r' + GROUP BY c.relname ORDER BY c.relname", + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + for row in &rows { + objects.push(SchemaObject { + object_type: "table".to_string(), + name: row.get(0), + definition: row.get::<_, Option>(1).unwrap_or_default(), + }); + } + + // Views + let rows = client + .query( + "SELECT viewname, definition FROM pg_views WHERE schemaname = $1 ORDER BY viewname", + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + for row in &rows { + objects.push(SchemaObject { + object_type: "view".to_string(), + name: row.get(0), + definition: row.get::<_, Option>(1).unwrap_or_default(), + }); + } + + // Materialized views + let rows = client + .query( + "SELECT matviewname, definition FROM pg_matviews WHERE schemaname = $1 ORDER BY matviewname", + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + for row in &rows { + objects.push(SchemaObject { + object_type: "matview".to_string(), + name: row.get(0), + definition: row.get::<_, Option>(1).unwrap_or_default(), + }); + } + + // Functions + let rows = client + .query( + "SELECT p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', + COALESCE(pg_get_functiondef(p.oid), '') + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 ORDER BY p.proname", + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + for row in &rows { + objects.push(SchemaObject { + object_type: "function".to_string(), + name: row.get(0), + definition: row.get::<_, Option>(1).unwrap_or_default(), + }); + } + + // Indexes + let rows = client + .query( + "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 ORDER BY indexname", + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + for row in &rows { + objects.push(SchemaObject { + object_type: "index".to_string(), + name: row.get(0), + definition: row.get::<_, Option>(1).unwrap_or_default(), + }); + } + + Ok(objects) +} + +pub async fn discover_notify_channels( + client: &deadpool_postgres::Client, +) -> Result, AppError> { + // Extract channel names from: + // 1. pg_notify() calls in trigger function bodies + // 2. NOTIFY statements in trigger function bodies + // 3. Currently active listeners from pg_stat_activity + let rows = client + .query( + r#"SELECT DISTINCT channel FROM ( + SELECT (regexp_matches(prosrc, 'pg_notify\s*\(\s*''([^'']+)''', 'gi'))[1] AS channel + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + UNION + SELECT (regexp_matches(prosrc, '\mNOTIFY\s+([a-zA-Z_][a-zA-Z0-9_]*)', 'gi'))[1] AS channel + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + ) sub + WHERE channel IS NOT NULL + ORDER BY channel"#, + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) +} diff --git a/src-tauri/src/drivers/pgsql/statistics_activity/database.rs b/src-tauri/src/drivers/pgsql/statistics_activity/database.rs new file mode 100644 index 0000000..a592fd2 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/statistics_activity/database.rs @@ -0,0 +1,188 @@ +use tokio_postgres::Client; + +use crate::common::enums::AppError; + +use super::super::DbStat; + +/// Load pg_stat_activity - active connections and queries. +pub async fn load_activity(client: &Client) -> Result>, AppError> { + let rows = client + .query( + r#"SELECT + pid::text, + COALESCE(usename, '') AS usename, + COALESCE(datname, '') AS datname, + COALESCE(state, 'unknown') AS state, + COALESCE(wait_event_type, '') AS wait_event_type, + COALESCE(wait_event, '') AS wait_event, + COALESCE(LEFT(query, 500), '') AS query, + COALESCE(EXTRACT(EPOCH FROM (now() - query_start))::text, '0') AS duration_sec, + COALESCE(backend_type, '') AS backend_type, + COALESCE(client_addr::text, 'local') AS client_addr + FROM pg_stat_activity + WHERE datname = current_database() + ORDER BY state, query_start NULLS LAST"#, + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..10).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} + +/// Load pg_stat_database - database-level stats. +pub async fn load_database_stats(client: &Client) -> Result, AppError> { + let rows = client + .query( + r#"SELECT + 'Active Connections' AS stat, numbackends::text AS val FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Transactions Committed', xact_commit::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Transactions Rolled Back', xact_rollback::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Blocks Read (disk)', blks_read::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Blocks Hit (cache)', blks_hit::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Cache Hit Ratio', + CASE WHEN (blks_hit + blks_read) > 0 + THEN ROUND(blks_hit::numeric / (blks_hit + blks_read) * 100, 2)::text || '%' + ELSE 'N/A' + END + FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Rows Returned', tup_returned::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Rows Fetched', tup_fetched::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Rows Inserted', tup_inserted::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Rows Updated', tup_updated::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Rows Deleted', tup_deleted::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Temp Files', temp_files::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Temp Bytes', pg_size_pretty(temp_bytes) FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Deadlocks', deadlocks::text FROM pg_stat_database WHERE datname = current_database() + UNION ALL + SELECT 'Database Size', pg_size_pretty(pg_database_size(current_database()))"#, + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let name: String = r.get(0); + let val: String = r.get(1); + (name, val) + }) + .collect()) +} + +/// Load pg_stat_user_tables - table-level stats. +pub async fn load_table_stats(client: &Client) -> Result>, AppError> { + let rows = client + .query( + r#"SELECT + schemaname, + relname, + COALESCE(seq_scan, 0)::text AS seq_scan, + COALESCE(seq_tup_read, 0)::text AS seq_tup_read, + COALESCE(idx_scan, 0)::text AS idx_scan, + COALESCE(idx_tup_fetch, 0)::text AS idx_tup_fetch, + COALESCE(n_tup_ins, 0)::text AS inserts, + COALESCE(n_tup_upd, 0)::text AS updates, + COALESCE(n_tup_del, 0)::text AS deletes, + COALESCE(n_live_tup, 0)::text AS live_tuples, + COALESCE(n_dead_tup, 0)::text AS dead_tuples, + COALESCE(last_vacuum::text, 'never') AS last_vacuum, + COALESCE(last_autovacuum::text, 'never') AS last_autovacuum, + COALESCE(last_analyze::text, 'never') AS last_analyze + FROM pg_stat_user_tables + ORDER BY seq_scan + COALESCE(idx_scan, 0) DESC + LIMIT 100"#, + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..14).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} + +pub async fn load_active_locks( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + l.pid::text, + COALESCE(a.usename, '') AS user, + COALESCE(l.mode, '') AS mode, + COALESCE(l.locktype, '') AS locktype, + CASE WHEN l.granted THEN 'granted' ELSE 'waiting' END AS status, + COALESCE(c.relname, '') AS relation, + COALESCE(n.nspname, '') AS schema, + COALESCE(left(a.query, 200), '') AS query, + COALESCE(extract(epoch from now() - a.query_start)::text, '0') AS duration, + COALESCE(a.wait_event_type || ':' || a.wait_event, '') AS wait_event + FROM pg_locks l + JOIN pg_stat_activity a ON a.pid = l.pid + LEFT JOIN pg_class c ON c.oid = l.relation + LEFT JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE a.pid != pg_backend_pid() + ORDER BY NOT l.granted, l.pid", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..10).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} + +pub async fn load_index_usage( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + s.schemaname, + s.relname AS table, + s.indexrelname AS index, + pg_size_pretty(pg_relation_size(i.indexrelid)) AS size, + COALESCE(s.idx_scan, 0)::text AS scans, + COALESCE(s.idx_tup_read, 0)::text AS tuples_read, + COALESCE(s.idx_tup_fetch, 0)::text AS tuples_fetched, + CASE + WHEN s.idx_scan = 0 THEN 'unused' + WHEN s.idx_scan < 10 THEN 'rarely_used' + ELSE 'active' + END AS status, + COALESCE(pg_get_indexdef(i.indexrelid), '') AS definition + FROM pg_stat_user_indexes s + JOIN pg_index i ON i.indexrelid = s.indexrelid + WHERE NOT i.indisprimary + ORDER BY s.idx_scan ASC, pg_relation_size(i.indexrelid) DESC", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..9).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} diff --git a/src-tauri/src/drivers/pgsql/statistics_activity/mod.rs b/src-tauri/src/drivers/pgsql/statistics_activity/mod.rs new file mode 100644 index 0000000..0e2208f --- /dev/null +++ b/src-tauri/src/drivers/pgsql/statistics_activity/mod.rs @@ -0,0 +1,5 @@ +mod database; +mod objects; + +pub use database::*; +pub use objects::*; diff --git a/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs b/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs new file mode 100644 index 0000000..ce227fe --- /dev/null +++ b/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs @@ -0,0 +1,200 @@ +use tokio_postgres::Client; + +use crate::common::enums::AppError; + +use super::super::{FKDetail, ForeignKeyInfo, ObjectStats}; + +/// Load live statistics for a table. +pub async fn load_table_statistics( + client: &Client, + schema: &str, + table: &str, +) -> Result { + let rows = client + .query( + r#"SELECT + c.reltuples::bigint::text, + pg_size_pretty(pg_table_size(c.oid)), + pg_size_pretty(pg_indexes_size(c.oid)), + pg_size_pretty(pg_total_relation_size(c.oid)), + COALESCE(s.last_vacuum::text, 'never'), + COALESCE(s.last_analyze::text, 'never'), + COALESCE(s.last_autovacuum::text, 'never'), + COALESCE(s.last_autoanalyze::text, 'never'), + COALESCE(s.n_dead_tup, 0)::text, + COALESCE(s.n_live_tup, 0)::text, + COALESCE(s.seq_scan, 0)::text, + COALESCE(s.idx_scan, 0)::text + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid + WHERE n.nspname = $1 AND c.relname = $2 + LIMIT 1"#, + &[&schema, &table], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + let keys = [ + "row_estimate", + "table_size", + "index_size", + "total_size", + "last_vacuum", + "last_analyze", + "last_autovacuum", + "last_autoanalyze", + "dead_tuples", + "live_tuples", + "seq_scan", + "idx_scan", + ]; + + if let Some(row) = rows.first() { + Ok(keys + .iter() + .enumerate() + .map(|(i, k)| { + let val: Option = row.try_get(i).ok(); + (k.to_string(), val.unwrap_or_else(|| "-".into())) + }) + .collect()) + } else { + Ok(Vec::new()) + } +} + +/// Load outgoing or incoming FK details for a table. +pub async fn load_fk_details( + client: &Client, + schema: &str, + table: &str, + direction: &str, // "outgoing" or "incoming" +) -> Result, AppError> { + let where_clause = if direction == "incoming" { + "nsp_tgt.nspname = $1 AND tgt.relname = $2" + } else { + "nsp.nspname = $1 AND src.relname = $2" + }; + + let sql = format!( + r#"SELECT + con.conname, + nsp.nspname, + src.relname, + a_src.attname, + nsp_tgt.nspname, + tgt.relname, + a_tgt.attname, + CASE con.confupdtype + WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' + WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' ELSE '' END, + CASE con.confdeltype + WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' + WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' ELSE '' END + FROM pg_constraint con + JOIN pg_class src ON src.oid = con.conrelid + JOIN pg_namespace nsp ON nsp.oid = src.relnamespace + JOIN pg_class tgt ON tgt.oid = con.confrelid + JOIN pg_namespace nsp_tgt ON nsp_tgt.oid = tgt.relnamespace + JOIN pg_attribute a_src ON a_src.attrelid = con.conrelid AND a_src.attnum = ANY(con.conkey) + JOIN pg_attribute a_tgt ON a_tgt.attrelid = con.confrelid AND a_tgt.attnum = ANY(con.confkey) + WHERE con.contype = 'f' AND {where_clause} + ORDER BY con.conname"# + ); + + let rows = client + .query(&sql, &[&schema, &table]) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + ( + r.get(0), + r.get(1), + r.get(2), + r.get(3), + r.get(4), + r.get(5), + r.get(6), + r.get(7), + r.get(8), + ) + }) + .collect()) +} + +/// Load all foreign key relationships for a given schema. +pub async fn load_foreign_keys( + client: &Client, + schema: &str, +) -> Result, AppError> { + let rows = client + .query( + r#"SELECT + kcu.table_name AS source_table, + kcu.column_name AS source_column, + ccu.table_name AS target_table, + ccu.column_name AS target_column + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = $1 + ORDER BY kcu.table_name, kcu.column_name"#, + &[&schema], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + let src_table: String = r.get(0); + let src_col: String = r.get(1); + let tgt_table: String = r.get(2); + let tgt_col: String = r.get(3); + (src_table, src_col, tgt_table, tgt_col) + }) + .collect()) +} + +pub async fn load_table_bloat( + client: &deadpool_postgres::Client, +) -> Result>, AppError> { + let rows = client + .query( + "SELECT + schemaname, + relname AS table, + n_live_tup::text AS live_tuples, + n_dead_tup::text AS dead_tuples, + CASE WHEN n_live_tup > 0 + THEN round(100.0 * n_dead_tup / (n_live_tup + n_dead_tup), 1)::text + ELSE '0' + END AS bloat_pct, + pg_size_pretty(pg_total_relation_size(relid)) AS total_size, + COALESCE(last_vacuum::text, 'never') AS last_vacuum, + COALESCE(last_autovacuum::text, 'never') AS last_autovacuum, + COALESCE(last_analyze::text, 'never') AS last_analyze, + COALESCE(last_autoanalyze::text, 'never') AS last_autoanalyze + FROM pg_stat_user_tables + ORDER BY n_dead_tup DESC", + &[], + ) + .await + .map_err(|e| AppError::QueryFailed(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| (0..10).map(|i| r.get::<_, String>(i)).collect()) + .collect()) +} From 1a6682ba2baafbf582ff1f67be4b911935e388d6 Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Sat, 16 May 2026 13:23:57 +0200 Subject: [PATCH 3/7] refactor: split frontend --- src/App.tsx | 320 +- src/components/command-palette.tsx | 523 --- src/components/command-palette/index.tsx | 260 ++ src/components/command-palette/queries.tsx | 37 + src/components/command-palette/search.tsx | 197 ++ src/components/command-palette/tools.tsx | 118 + src/components/command-palette/types.ts | 1 + src/components/command-palette/workspaces.tsx | 120 + src/components/connection-modal.tsx | 413 --- .../connection-modal/form-fields.tsx | 207 ++ src/components/connection-modal/index.tsx | 277 ++ .../connection-modal/ssh-config.tsx | 106 + src/components/erd-diagram.tsx | 636 ---- src/components/erd-diagram/index.tsx | 277 ++ src/components/erd-diagram/interactions.tsx | 128 + src/components/erd-diagram/layout.ts | 79 + src/components/erd-diagram/rendering.tsx | 262 ++ src/components/erd-diagram/table-details.tsx | 33 + src/components/erd-diagram/types.ts | 25 + src/components/object-properties-modal.tsx | 2964 ----------------- .../object-properties-modal/actions-tab.tsx | 261 ++ .../object-properties-modal/columns-tab.tsx | 87 + .../object-properties-modal/ddl-tab.tsx | 110 + .../foreign-keys-tab.tsx | 155 + .../object-properties-modal/index.tsx | 256 ++ .../object-properties-modal/indexes-tab.tsx | 92 + .../object-properties-modal/modal-header.tsx | 144 + .../overview-function.tsx | 97 + .../object-properties-modal/overview-tab.tsx | 269 ++ .../object-properties-modal/overview-view.tsx | 85 + .../object-properties-modal/shared.tsx | 121 + .../structure-editor/columns-section.tsx | 177 + .../structure-editor/fk-card.tsx | 232 ++ .../structure-editor/fkeys-section.tsx | 133 + .../structure-editor/index.tsx | 287 ++ .../structure-editor/indexes-section.tsx | 170 + .../structure-editor/initialization.ts | 115 + .../structure-editor/pk-section.tsx | 115 + .../structure-editor/unique-section.tsx | 156 + .../object-properties-modal/types.ts | 76 + .../use-object-data.ts | 238 ++ src/components/performance-monitor.tsx | 690 ---- .../performance-monitor/activity-tab.tsx | 52 + .../performance-monitor/bloat-tab.tsx | 74 + .../performance-monitor/history-tab.tsx | 44 + src/components/performance-monitor/index.tsx | 283 ++ .../performance-monitor/indexes-tab.tsx | 61 + .../performance-monitor/locks-tab.tsx | 67 + .../performance-monitor/overview-tab.tsx | 79 + .../performance-monitor/table-stats-tab.tsx | 59 + src/components/performance-monitor/types.ts | 69 + .../index.tsx} | 170 +- src/components/results-grid/rendering.tsx | 181 + src/components/results-panel.tsx | 1112 ------- src/components/results-panel/constants.ts | 5 + src/components/results-panel/diff-view.tsx | 145 + src/components/results-panel/index.tsx | 225 ++ src/components/results-panel/toolbar-edit.tsx | 89 + .../results-panel/toolbar-export.tsx | 81 + src/components/results-panel/toolbar.tsx | 277 ++ src/components/results-panel/types.ts | 38 + src/components/results-panel/use-edit-mode.ts | 269 ++ .../results-panel/use-virtual-paging.ts | 144 + src/components/server-sidebar.tsx | 950 ------ .../server-sidebar/add-database-dialog.tsx | 71 + src/components/server-sidebar/constants.ts | 2 + src/components/server-sidebar/ddl-queries.ts | 28 + .../server-sidebar/indent-guides.tsx | 17 + src/components/server-sidebar/index.tsx | 219 ++ .../server-sidebar/render-saved-queries.tsx | 51 + .../server-sidebar/render-schema-objects.tsx | 220 ++ .../server-sidebar/render-server-group.tsx | 235 ++ .../server-sidebar/render-table-details.tsx | 146 + .../server-sidebar/section-header.tsx | 26 + src/components/server-sidebar/tree-row.tsx | 47 + src/components/server-sidebar/types.ts | 93 + src/hooks/use-app-startup.ts | 15 + src/hooks/use-query-lifecycle.ts | 294 ++ src/lib/database-driver/factory.ts | 32 + src/lib/database-driver/index.ts | 154 + .../pgsql.ts} | 206 +- src/lib/query-helpers.ts | 28 + src/monaco/completion-provider.ts | 519 --- .../completion-provider/alias-parser.ts | 27 + src/monaco/completion-provider/index.ts | 175 + src/monaco/completion-provider/keywords.ts | 125 + src/monaco/completion-provider/resolver.ts | 85 + src/monaco/completion-provider/snippets.ts | 110 + src/stores/project-store.ts | 526 --- src/stores/project-store/connection.ts | 174 + src/stores/project-store/core.ts | 144 + src/stores/project-store/index.ts | 23 + src/stores/project-store/indexes.ts | 75 + src/stores/project-store/schema.ts | 122 + src/stores/project-store/views.ts | 68 + 95 files changed, 10615 insertions(+), 8965 deletions(-) delete mode 100644 src/components/command-palette.tsx create mode 100644 src/components/command-palette/index.tsx create mode 100644 src/components/command-palette/queries.tsx create mode 100644 src/components/command-palette/search.tsx create mode 100644 src/components/command-palette/tools.tsx create mode 100644 src/components/command-palette/types.ts create mode 100644 src/components/command-palette/workspaces.tsx delete mode 100644 src/components/connection-modal.tsx create mode 100644 src/components/connection-modal/form-fields.tsx create mode 100644 src/components/connection-modal/index.tsx create mode 100644 src/components/connection-modal/ssh-config.tsx delete mode 100644 src/components/erd-diagram.tsx create mode 100644 src/components/erd-diagram/index.tsx create mode 100644 src/components/erd-diagram/interactions.tsx create mode 100644 src/components/erd-diagram/layout.ts create mode 100644 src/components/erd-diagram/rendering.tsx create mode 100644 src/components/erd-diagram/table-details.tsx create mode 100644 src/components/erd-diagram/types.ts delete mode 100644 src/components/object-properties-modal.tsx create mode 100644 src/components/object-properties-modal/actions-tab.tsx create mode 100644 src/components/object-properties-modal/columns-tab.tsx create mode 100644 src/components/object-properties-modal/ddl-tab.tsx create mode 100644 src/components/object-properties-modal/foreign-keys-tab.tsx create mode 100644 src/components/object-properties-modal/index.tsx create mode 100644 src/components/object-properties-modal/indexes-tab.tsx create mode 100644 src/components/object-properties-modal/modal-header.tsx create mode 100644 src/components/object-properties-modal/overview-function.tsx create mode 100644 src/components/object-properties-modal/overview-tab.tsx create mode 100644 src/components/object-properties-modal/overview-view.tsx create mode 100644 src/components/object-properties-modal/shared.tsx create mode 100644 src/components/object-properties-modal/structure-editor/columns-section.tsx create mode 100644 src/components/object-properties-modal/structure-editor/fk-card.tsx create mode 100644 src/components/object-properties-modal/structure-editor/fkeys-section.tsx create mode 100644 src/components/object-properties-modal/structure-editor/index.tsx create mode 100644 src/components/object-properties-modal/structure-editor/indexes-section.tsx create mode 100644 src/components/object-properties-modal/structure-editor/initialization.ts create mode 100644 src/components/object-properties-modal/structure-editor/pk-section.tsx create mode 100644 src/components/object-properties-modal/structure-editor/unique-section.tsx create mode 100644 src/components/object-properties-modal/types.ts create mode 100644 src/components/object-properties-modal/use-object-data.ts delete mode 100644 src/components/performance-monitor.tsx create mode 100644 src/components/performance-monitor/activity-tab.tsx create mode 100644 src/components/performance-monitor/bloat-tab.tsx create mode 100644 src/components/performance-monitor/history-tab.tsx create mode 100644 src/components/performance-monitor/index.tsx create mode 100644 src/components/performance-monitor/indexes-tab.tsx create mode 100644 src/components/performance-monitor/locks-tab.tsx create mode 100644 src/components/performance-monitor/overview-tab.tsx create mode 100644 src/components/performance-monitor/table-stats-tab.tsx create mode 100644 src/components/performance-monitor/types.ts rename src/components/{results-grid.tsx => results-grid/index.tsx} (65%) create mode 100644 src/components/results-grid/rendering.tsx delete mode 100644 src/components/results-panel.tsx create mode 100644 src/components/results-panel/constants.ts create mode 100644 src/components/results-panel/diff-view.tsx create mode 100644 src/components/results-panel/index.tsx create mode 100644 src/components/results-panel/toolbar-edit.tsx create mode 100644 src/components/results-panel/toolbar-export.tsx create mode 100644 src/components/results-panel/toolbar.tsx create mode 100644 src/components/results-panel/types.ts create mode 100644 src/components/results-panel/use-edit-mode.ts create mode 100644 src/components/results-panel/use-virtual-paging.ts delete mode 100644 src/components/server-sidebar.tsx create mode 100644 src/components/server-sidebar/add-database-dialog.tsx create mode 100644 src/components/server-sidebar/constants.ts create mode 100644 src/components/server-sidebar/ddl-queries.ts create mode 100644 src/components/server-sidebar/indent-guides.tsx create mode 100644 src/components/server-sidebar/index.tsx create mode 100644 src/components/server-sidebar/render-saved-queries.tsx create mode 100644 src/components/server-sidebar/render-schema-objects.tsx create mode 100644 src/components/server-sidebar/render-server-group.tsx create mode 100644 src/components/server-sidebar/render-table-details.tsx create mode 100644 src/components/server-sidebar/section-header.tsx create mode 100644 src/components/server-sidebar/tree-row.tsx create mode 100644 src/components/server-sidebar/types.ts create mode 100644 src/hooks/use-app-startup.ts create mode 100644 src/hooks/use-query-lifecycle.ts create mode 100644 src/lib/database-driver/factory.ts create mode 100644 src/lib/database-driver/index.ts rename src/lib/{database-driver.ts => database-driver/pgsql.ts} (56%) create mode 100644 src/lib/query-helpers.ts delete mode 100644 src/monaco/completion-provider.ts create mode 100644 src/monaco/completion-provider/alias-parser.ts create mode 100644 src/monaco/completion-provider/index.ts create mode 100644 src/monaco/completion-provider/keywords.ts create mode 100644 src/monaco/completion-provider/resolver.ts create mode 100644 src/monaco/completion-provider/snippets.ts delete mode 100644 src/stores/project-store.ts create mode 100644 src/stores/project-store/connection.ts create mode 100644 src/stores/project-store/core.ts create mode 100644 src/stores/project-store/index.ts create mode 100644 src/stores/project-store/indexes.ts create mode 100644 src/stores/project-store/schema.ts create mode 100644 src/stores/project-store/views.ts diff --git a/src/App.tsx b/src/App.tsx index 757c9db..25b05ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useCallback } from "react"; import { Toaster } from "sonner"; import { ConnectionModal } from "@/components/connection-modal"; import { ResizeHandle } from "@/components/resize-handle"; @@ -19,46 +19,16 @@ import { TopBar } from "@/components/top-bar"; import { EditorToolbar } from "@/components/editor-toolbar"; import { StatusBar } from "@/components/status-bar"; import { CommandPalette } from "@/components/command-palette"; -import { DriverFactory } from "@/lib/database-driver"; -import { checkForUpdates, startBackgroundUpdateCheck } from "@/lib/updater"; -import * as virtualCache from "@/lib/virtual-cache"; +import { checkForUpdates } from "@/lib/updater"; import { useProjectStore } from "@/stores/project-store"; import { useTabStore, useActiveTab } from "@/stores/tab-store"; import { useUIStore } from "@/stores/ui-store"; -import { useHistoryStore } from "@/stores/history-store"; import { ResultsGrid } from "@/components/results-grid"; +import { useAppStartup } from "@/hooks/use-app-startup"; +import { useQueryLifecycle } from "@/hooks/use-query-lifecycle"; import type { ProjectDetails } from "@/types"; import "@/monaco/setup"; -const NOTIFY_THRESHOLD_MS = 5000; -const DEFAULT_PAGE_SIZE = 2_000; -const PAGE_SIZE_RAW = Number(import.meta.env.VITE_PAGE_SIZE ?? DEFAULT_PAGE_SIZE); -const PAGE_SIZE = Number.isFinite(PAGE_SIZE_RAW) && PAGE_SIZE_RAW >= 100 - ? Math.floor(PAGE_SIZE_RAW) - : DEFAULT_PAGE_SIZE; -const CELL_SEP = "\x1F"; -const ROW_SEP = "\x1E"; - -function isQueryCancelledError(message: string): boolean { - const lower = message.toLowerCase(); - return lower.includes("canceling statement due to user request") - || lower.includes("cancelling statement due to user request") - || lower.includes("query canceled") - || lower.includes("query cancelled") - || lower.includes("statement timeout"); -} - -function notifyQueryComplete(sql: string, time: number, success: boolean, rowCount?: number) { - if (document.hasFocus() || time < NOTIFY_THRESHOLD_MS) return; - if (!("Notification" in window)) return; - if (Notification.permission !== "granted") return; - const preview = sql.slice(0, 60).replace(/\n/g, " "); - const body = success - ? `${rowCount?.toLocaleString() ?? 0} rows in ${(time / 1000).toFixed(1)}s` - : `Query failed after ${(time / 1000).toFixed(1)}s`; - new Notification(success ? "Query Complete" : "Query Failed", { body: `${preview}\n${body}` }); -} - export default function App() { const sidebarWidth = useUIStore((s) => s.sidebarWidth); const editorHeight = useUIStore((s) => s.editorHeight); @@ -67,7 +37,6 @@ export default function App() { const setSidebarWidth = useUIStore((s) => s.setSidebarWidth); const setEditorHeight = useUIStore((s) => s.setEditorHeight); - const loadProjects = useProjectStore((s) => s.loadProjects); const projects = useProjectStore((s) => s.projects); const saveConnection = useProjectStore((s) => s.saveConnection); const updateConnection = useProjectStore((s) => s.updateConnection); @@ -75,290 +44,13 @@ export default function App() { const selectedTabIndex = useTabStore((s) => s.selectedTabIndex); const activeTab = useActiveTab(); const updateContent = useTabStore((s) => s.updateContent); - const updateResult = useTabStore((s) => s.updateResult); - const setExecuting = useTabStore((s) => s.setExecuting); - const closeTab = useTabStore((s) => s.closeTab); - const setExplainResult = useTabStore((s) => s.setExplainResult); - const setVirtualQuery = useTabStore((s) => s.setVirtualQuery); - const setSplitResult = useTabStore((s) => s.setSplitResult); - const setSplitExecuting = useTabStore((s) => s.setSplitExecuting); - const addHistoryEntry = useHistoryStore((s) => s.addEntry); // Edit connection state const [editingConnection, setEditingConnection] = useState<{ name: string; details: ProjectDetails } | null>(null); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); - useEffect(() => { - void loadProjects(); - }, [loadProjects]); - - useEffect(() => { - startBackgroundUpdateCheck(); - }, []); - - const connectProject = useProjectStore((s) => s.connect); - - const runQuery = useCallback(async () => { - const { tabs, selectedTabIndex: idx } = useTabStore.getState(); - const tab = tabs[idx]; - if (!tab?.projectId || !tab.editorValue.trim()) return; - - const d = useProjectStore.getState().projects[tab.projectId]; - if (!d) return; - - // Auto-connect if not connected - const connStatus = useProjectStore.getState().status[tab.projectId]; - if (connStatus !== "Connected") { - await connectProject(tab.projectId); - const newStatus = useProjectStore.getState().status[tab.projectId]; - if (newStatus !== "Connected") return; - } - - setExecuting(idx, true); - const startTime = Date.now(); - try { - const driver = DriverFactory.getDriver(d.driver); - - // Clean up previous virtual query - const prevVQ = tab.virtualQuery; - if (prevVQ?.queryId) { - await driver.closeVirtual?.(tab.projectId, prevVQ.queryId).catch(() => {}); - virtualCache.clearQuery(prevVQ.queryId); - setVirtualQuery(idx, undefined); - } - - const timeoutMs = tab.queryTimeout || undefined; - - if (driver.executeVirtual) { - const sql = tab.editorValue; - const queryId = crypto.randomUUID().replace(/-/g, "").slice(0, 12); - const [colsPacked, totalRows, pagePacked, elapsed] = - await driver.executeVirtual(tab.projectId, sql, queryId, PAGE_SIZE, timeoutMs); - - if (!colsPacked) { - // Fallback format from backend: header + rows in one packed string. - const parts = pagePacked ? pagePacked.split(ROW_SEP) : []; - const columns = parts[0] ? parts[0].split(CELL_SEP) : []; - const rows = parts.slice(1).map((r) => r.split(CELL_SEP)); - - await driver.closeVirtual?.(tab.projectId, queryId).catch(() => {}); - updateResult(idx, { columns, rows, time: elapsed }); - notifyQueryComplete(tab.editorValue, elapsed, true, rows.length); - - addHistoryEntry({ - projectId: tab.projectId, - database: d.database, - sql: tab.editorValue.trim(), - executionTime: elapsed, - rowCount: rows.length, - success: true, - timestamp: startTime, - }); - } else { - const columns = colsPacked.split(CELL_SEP); - const firstPage = pagePacked - ? pagePacked.split(ROW_SEP).map((r) => r.split(CELL_SEP)) - : []; - - if (totalRows <= PAGE_SIZE) { - await driver.closeVirtual?.(tab.projectId, queryId).catch(() => {}); - updateResult(idx, { columns, rows: firstPage, time: elapsed }); - notifyQueryComplete(tab.editorValue, elapsed, true, firstPage.length); - } else { - virtualCache.setPage(queryId, 0, firstPage); - setVirtualQuery(idx, { queryId, columns, totalRows, pageSize: PAGE_SIZE, colCount: columns.length, time: elapsed }); - updateResult(idx, { columns, rows: firstPage, time: elapsed }); - notifyQueryComplete(tab.editorValue, elapsed, true, totalRows); - } - - addHistoryEntry({ - projectId: tab.projectId, - database: d.database, - sql: tab.editorValue.trim(), - executionTime: elapsed, - rowCount: totalRows > PAGE_SIZE ? totalRows : firstPage.length, - success: true, - timestamp: startTime, - }); - } - } else { - // One-shot fallback - const [cols, rows, time] = await driver.runQuery(tab.projectId, tab.editorValue, timeoutMs); - updateResult(idx, { columns: cols, rows, time }); - notifyQueryComplete(tab.editorValue, time, true, rows.length); - addHistoryEntry({ - projectId: tab.projectId, - database: d.database, - sql: tab.editorValue.trim(), - executionTime: time, - rowCount: rows.length, - success: true, - timestamp: startTime, - }); - } - } catch (err: any) { - const elapsed = Date.now() - startTime; - const errorMsg = err?.message ?? String(err); - const cancelled = isQueryCancelledError(errorMsg); - updateResult(idx, { - columns: [cancelled ? "Info" : "Error"], - rows: [[cancelled ? "Query cancelled" : errorMsg]], - time: 0, - }); - if (!cancelled) { - notifyQueryComplete(tab.editorValue, elapsed, false); - } - addHistoryEntry({ - projectId: tab.projectId, - database: d.database, - sql: tab.editorValue.trim(), - executionTime: elapsed, - rowCount: 0, - success: false, - error: cancelled ? "Query cancelled" : errorMsg, - timestamp: startTime, - }); - } - useUIStore.getState().setSelectedRow(0); - }, [setExecuting, updateResult, setVirtualQuery, addHistoryEntry, connectProject]); - - const runExplain = useCallback(async () => { - const { tabs, selectedTabIndex: idx } = useTabStore.getState(); - const tab = tabs[idx]; - if (!tab?.projectId || !tab.editorValue.trim()) return; - - const d = useProjectStore.getState().projects[tab.projectId]; - if (!d) return; - - // Auto-connect if not connected - const connStatus = useProjectStore.getState().status[tab.projectId]; - if (connStatus !== "Connected") { - await connectProject(tab.projectId); - const newStatus = useProjectStore.getState().status[tab.projectId]; - if (newStatus !== "Connected") return; - } - - setExecuting(idx, true); - try { - const driver = DriverFactory.getDriver(d.driver); - // Strip trailing semicolons from user's query to avoid syntax errors - const userSql = tab.editorValue.replace(/;\s*$/, ""); - const sql = `EXPLAIN (ANALYZE, FORMAT JSON) ${userSql}`; - const [, rows] = await driver.runQuery(tab.projectId, sql); - // PG returns the JSON plan as a single text cell; join all rows - const jsonText = rows.map((r) => r[0]).join("\n"); - let plans: unknown; - try { - plans = JSON.parse(jsonText); - } catch { - // Some drivers return each row separately or wrap in brackets - // Try finding valid JSON within the text - const match = jsonText.match(/\[[\s\S]*\]/); - if (match) { - plans = JSON.parse(match[0]); - } else { - throw new Error(`Could not parse EXPLAIN output:\n${jsonText.slice(0, 500)}`); - } - } - if (Array.isArray(plans) && plans.length > 0) { - setExplainResult(idx, plans[0]); - } - } catch (err: any) { - const errorMsg = err?.message ?? String(err); - const cancelled = isQueryCancelledError(errorMsg); - updateResult(idx, { - columns: [cancelled ? "Info" : "Explain Error"], - rows: [[cancelled ? "Explain cancelled" : errorMsg]], - time: 0, - }); - setExplainResult(idx, undefined); - } - setExecuting(idx, false); - }, [setExecuting, updateResult, setExplainResult, connectProject]); - - const cancelQuery = useCallback(async () => { - const { tabs, selectedTabIndex: idx } = useTabStore.getState(); - const tab = tabs[idx]; - if (!tab?.projectId || !tab.isExecuting) return; - - const d = useProjectStore.getState().projects[tab.projectId]; - if (!d) return; - - try { - const driver = DriverFactory.getDriver(d.driver); - await driver.cancelQuery?.(tab.projectId); - } catch (err) { - console.error("Failed to cancel query:", err); - } - }, []); - - const runSplitQuery = useCallback(async () => { - const { tabs, selectedTabIndex: idx } = useTabStore.getState(); - const tab = tabs[idx]; - if (!tab?.projectId || !tab.splitEditorValue?.trim()) return; - - const d = useProjectStore.getState().projects[tab.projectId]; - if (!d) return; - - const connStatus = useProjectStore.getState().status[tab.projectId]; - if (connStatus !== "Connected") { - await connectProject(tab.projectId); - const newStatus = useProjectStore.getState().status[tab.projectId]; - if (newStatus !== "Connected") return; - } - - setSplitExecuting(idx, true); - try { - const driver = DriverFactory.getDriver(d.driver); - const [cols, rows, time] = await driver.runQuery(tab.projectId, tab.splitEditorValue); - setSplitResult(idx, { columns: cols, rows, time }); - } catch (err: any) { - const errorMsg = err?.message ?? String(err); - const cancelled = isQueryCancelledError(errorMsg); - setSplitResult(idx, { - columns: [cancelled ? "Info" : "Error"], - rows: [[cancelled ? "Query cancelled" : errorMsg]], - time: 0, - }); - } - }, [setSplitExecuting, setSplitResult, connectProject]); - - // Keyboard shortcuts - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "w") { - e.preventDefault(); - const { tabs: t, selectedTabIndex: idx } = useTabStore.getState(); - if (t.length > 0) { - const closingTab = t[idx]; - if (closingTab?.virtualQuery?.queryId && closingTab.projectId) { - const dd = useProjectStore.getState().projects[closingTab.projectId]; - if (dd) DriverFactory.getDriver(dd.driver).closeVirtual?.(closingTab.projectId, closingTab.virtualQuery.queryId).catch(() => {}); - virtualCache.clearQuery(closingTab.virtualQuery.queryId); - } - closeTab(idx); - } - } - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "Enter") { - e.preventDefault(); - void runExplain(); - } - if ((e.metaKey || e.ctrlKey) && (e.key === "p" || e.key === "k")) { - e.preventDefault(); - setCommandPaletteOpen((v) => !v); - } - if ((e.metaKey || e.ctrlKey) && e.key === "`") { - e.preventDefault(); - useTabStore.getState().openTerminalTab(); - } - if ((e.metaKey || e.ctrlKey) && e.key === ".") { - e.preventDefault(); - void cancelQuery(); - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [cancelQuery, closeTab, runExplain]); + useAppStartup(); + const { runQuery, runExplain, cancelQuery, runSplitQuery } = useQueryLifecycle({ setCommandPaletteOpen }); const handleSaveConnection = useCallback( async (connection: { name: string; driver: string; username: string; password: string; database: string; host: string; port: string; ssl: boolean; sshEnabled?: boolean; sshHost?: string; sshPort?: string; sshUser?: string; sshPassword?: string; sshKeyPath?: string }) => { diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx deleted file mode 100644 index 38efc76..0000000 --- a/src/components/command-palette.tsx +++ /dev/null @@ -1,523 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { createPortal } from "react-dom"; -import { Command } from "cmdk"; -import { useProjectStore } from "@/stores/project-store"; -import { useTabStore, useActiveTab } from "@/stores/tab-store"; -import { useUIStore } from "@/stores/ui-store"; -import { useWorkspaceStore } from "@/stores/workspace-store"; -import { useQueryStore } from "@/stores/query-store"; -import { format as formatSQL } from "sql-formatter"; -import { - Table, Eye, FileCode, Layers, Database, Save, FolderOpen, Trash2, - Plus, Play, GitBranch, AlignLeft, Moon, Sun, Terminal, Activity, - Network, XCircle, Pin, PinOff, Download, -} from "lucide-react"; - -type Page = "root" | "save-workspace" | "load-workspace" | "delete-workspace" | "save-query"; - -export function CommandPalette({ - open, onClose, onExecute, onExplain, - onCheckUpdates, -}: { - open: boolean; - onClose: () => void; - onExecute: () => void; - onExplain: () => void; - onCheckUpdates: () => void; -}) { - const [page, setPage] = useState("root"); - const [workspaceName, setWorkspaceName] = useState(""); - const [queryName, setQueryName] = useState(""); - const containerRef = useRef(null); - - const tables = useProjectStore((s) => s.tables); - const views = useProjectStore((s) => s.views); - const materializedViews = useProjectStore((s) => s.materializedViews); - const functions = useProjectStore((s) => s.functions); - const schemas = useProjectStore((s) => s.schemas); - const projects = useProjectStore((s) => s.projects); - const status = useProjectStore((s) => s.status); - const connectProject = useProjectStore((s) => s.connect); - - const openTab = useTabStore((s) => s.openTab); - const activeTab = useActiveTab(); - - const theme = useUIStore((s) => s.theme); - const toggleTheme = useUIStore((s) => s.toggleTheme); - const setConnectionModalOpen = useUIStore((s) => s.setConnectionModalOpen); - const pinnedResult = useUIStore((s) => s.pinnedResult); - const clearPinnedResult = useUIStore((s) => s.clearPinnedResult); - - const workspaces = useWorkspaceStore((s) => s.workspaces); - const loadWorkspaces = useWorkspaceStore((s) => s.load); - const saveWorkspace = useWorkspaceStore((s) => s.save); - const removeWorkspace = useWorkspaceStore((s) => s.remove); - const workspacesLoaded = useWorkspaceStore((s) => s.loaded); - - const saveQueryAction = useQueryStore((s) => s.saveQuery); - - useEffect(() => { - if (open) { - setPage("root"); - setWorkspaceName(""); - setQueryName(""); - if (!workspacesLoaded) void loadWorkspaces(); - } - }, [open, workspacesLoaded, loadWorkspaces]); - - // Close on Escape at root page - useEffect(() => { - if (!open) return; - const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") { - if (page !== "root") { - e.preventDefault(); - e.stopPropagation(); - setPage("root"); - setWorkspaceName(""); - setQueryName(""); - } else { - onClose(); - } - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [open, page, onClose]); - - // Click outside to close - useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - onClose(); - } - }; - window.addEventListener("mousedown", handler); - return () => window.removeEventListener("mousedown", handler); - }, [open, onClose]); - - const handleSaveWorkspace = useCallback(async () => { - if (!workspaceName.trim()) return; - const tabs = useTabStore.getState().tabs - .filter((t) => t.type === "query") - .map((t) => ({ title: t.title, editorValue: t.editorValue, projectId: t.projectId, type: t.type })); - await saveWorkspace(workspaceName.trim(), JSON.stringify(tabs)); - onClose(); - }, [workspaceName, saveWorkspace, onClose]); - - const handleSaveQuery = useCallback(async () => { - if (!queryName.trim()) return; - const tab = useTabStore.getState().tabs[useTabStore.getState().selectedTabIndex]; - if (!tab?.projectId || !tab.editorValue?.trim()) return; - const d = useProjectStore.getState().projects[tab.projectId]; - if (!d) return; - await saveQueryAction(tab.projectId, d.database, d.driver, queryName.trim(), tab.editorValue); - onClose(); - }, [queryName, saveQueryAction, onClose]); - - const handleLoadWorkspace = useCallback((tabsJson: string) => { - try { - const tabs = JSON.parse(tabsJson) as { title: string; editorValue: string; projectId?: string; type: string }[]; - const store = useTabStore.getState(); - for (const tab of tabs) { - store.openTab(tab.projectId, tab.editorValue); - } - } catch { /* ignore parse errors */ } - onClose(); - }, [onClose]); - - const handleDeleteWorkspace = useCallback(async (name: string) => { - await removeWorkspace(name); - if (workspaces.length <= 1) setPage("root"); - }, [removeWorkspace, workspaces.length]); - - const selectItem = useCallback((type: string, projectId: string, schema: string, name: string) => { - onClose(); - if (type === "table" || type === "view" || type === "matview") { - openTab(projectId, `SELECT * FROM "${schema}"."${name}" LIMIT 100;`); - } else if (type === "function") { - openTab(projectId, `-- Function: ${schema}.${name}\nSELECT pg_get_functiondef(p.oid)\nFROM pg_proc p\nJOIN pg_namespace n ON n.oid = p.pronamespace\nWHERE n.nspname = '${schema}' AND p.proname = '${name}'\nLIMIT 1;`); - } else if (type === "schema") { - openTab(projectId, `-- Schema: ${name}\n`); - } - }, [openTab, onClose]); - - const formatQuery = useCallback(() => { - const { tabs, selectedTabIndex: idx } = useTabStore.getState(); - const tab = tabs[idx]; - if (!tab?.editorValue?.trim()) return; - try { - const formatted = formatSQL(tab.editorValue, { language: "postgresql", tabWidth: 2, keywordCase: "upper" }); - useTabStore.getState().updateContent(idx, formatted); - } catch { /* ignore */ } - onClose(); - }, [onClose]); - - if (!open) return null; - - const activeProject = activeTab?.projectId; - const hasQuery = !!activeTab?.editorValue?.trim(); - - return createPortal( - <> -
-
- { - if (e.key === "Backspace" && page !== "root") { - const input = e.currentTarget.querySelector("[cmdk-input]") as HTMLInputElement | null; - if (input && input.value === "") { - e.preventDefault(); - setPage("root"); - setWorkspaceName(""); - setQueryName(""); - } - } - }} - loop - > - {page === "save-workspace" ? ( - <> -
- - setWorkspaceName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") void handleSaveWorkspace(); - else if (e.key === "Escape") { e.stopPropagation(); setPage("root"); setWorkspaceName(""); } - }} - className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none font-mono" - /> -
-
- Press Enter to save current query tabs as a workspace -
- - ) : page === "save-query" ? ( - <> -
- - setQueryName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") void handleSaveQuery(); - else if (e.key === "Escape") { e.stopPropagation(); setPage("root"); setQueryName(""); } - }} - className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none font-mono" - /> -
-
- Press Enter to save the current query -
- - ) : page === "load-workspace" || page === "delete-workspace" ? ( - <> -
- {page === "delete-workspace" - ? - : } - - {page === "delete-workspace" ? "Delete Workspace" : "Load Workspace"} - -
- - {workspaces.length === 0 ? ( - No saved workspaces - ) : ( - workspaces.map((ws) => ( - { - if (page === "delete-workspace") void handleDeleteWorkspace(ws.name); - else handleLoadWorkspace(ws.tabs); - }} - > - -
- {ws.name} -
- {(() => { try { return `${JSON.parse(ws.tabs).length} tabs`; } catch { return ""; } })()} -
-
- {page === "delete-workspace" && } -
- )) - )} -
- - ) : ( - <> - - - No results found - - {/* Actions */} - - { onClose(); setConnectionModalOpen(true); }}> - - New Connection - Connection - - { onClose(); useTabStore.getState().openTab(activeProject); }}> - - New Query Tab - - { onClose(); useTabStore.getState().openTerminalTab(); }}> - - Open Terminal - {navigator.platform.includes("Mac") ? "\u2318" : "Ctrl"}+` - - {activeProject && hasQuery && ( - <> - { onClose(); onExecute(); }}> - - Execute Query - {navigator.platform.includes("Mac") ? "\u2318" : "Ctrl"}+Enter - - { onClose(); onExplain(); }}> - - Explain Query - {navigator.platform.includes("Mac") ? "\u2318" : "Ctrl"}+Shift+Enter - - { onClose(); }}> - - Cancel Query - {navigator.platform.includes("Mac") ? "\u2318" : "Ctrl"}+. - - - )} - {hasQuery && ( - <> - - - Format SQL - - setPage("save-query")}> - - Save Query - - - )} - { onClose(); toggleTheme(); }}> - {theme === "light" - ? - : } - {theme === "light" ? "Dark Mode" : "Light Mode"} - - { onClose(); onCheckUpdates(); }}> - - Check for Updates - App - - {pinnedResult ? ( - { onClose(); clearPinnedResult(); }}> - - Clear Pinned Result - - ) : activeTab?.result && ( - { - onClose(); - const tab = useTabStore.getState().tabs[useTabStore.getState().selectedTabIndex]; - if (tab?.result) useUIStore.getState().pinResult(tab.result, tab.editorValue.slice(0, 60)); - }}> - - Pin Current Result - - )} - - - {/* Connections */} - {Object.keys(projects).length > 0 && ( - - {Object.entries(projects).map(([id, details]) => { - const connected = status[id] === "Connected"; - return ( - { - onClose(); - if (!connected) void connectProject(id); - }} - > - - {id} - {details.host}:{details.port}/{details.database} - - {connected ? "Connected" : "Connect"} - - - ); - })} - {Object.entries(projects).map(([id]) => { - const connected = status[id] === "Connected"; - if (!connected) return null; - return ( - { onClose(); useTabStore.getState().openMonitorTab(id); }} - > - - Monitor {id} - Performance - - ); - })} - {Object.entries(schemas).map(([projectId, projectSchemas]) => - projectSchemas.map((s) => ( - { onClose(); useTabStore.getState().openERDTab(projectId, s); }} - > - - ERD {projectId}/{s} - Diagram - - )) - )} - - )} - - {/* Workspaces */} - - setPage("save-workspace")}> - - Save Workspace - - {workspaces.length > 0 && ( - <> - setPage("load-workspace")}> - - Load Workspace - - setPage("delete-workspace")}> - - Delete Workspace - - - )} - - - {/* Database objects */} - {Object.entries(tables).map(([key, schemaTables]) => { - const [projectId, schema] = key.split("::"); - if (!schemaTables.length) return null; - return ( - - {schemaTables.map((t) => ( - selectItem("table", projectId, schema, t.name)} - > - - {t.name} - Table - {t.size && {t.size}} - - ))} - - ); - })} - - {Object.entries(views).map(([key, schemaViews]) => { - const [projectId, schema] = key.split("::"); - if (!schemaViews.length) return null; - return ( - - {schemaViews.map((v) => ( - selectItem("view", projectId, schema, v)} - > - - {v} - View - - ))} - - ); - })} - - {Object.entries(materializedViews).map(([key, matViews]) => { - const [projectId, schema] = key.split("::"); - if (!matViews.length) return null; - return ( - - {matViews.map((mv) => ( - selectItem("matview", projectId, schema, mv)} - > - - {mv} - Mat. View - - ))} - - ); - })} - - {Object.entries(functions).map(([key, fns]) => { - const [projectId, schema] = key.split("::"); - if (!fns.length) return null; - return ( - - {fns.map((fn) => ( - selectItem("function", projectId, schema, fn.name)} - > - - {fn.name} - Function - ({fn.arguments || ""}) → {fn.returnType} - - ))} - - ); - })} - - {Object.entries(schemas).map(([projectId, projectSchemas]) => { - if (!projectSchemas.length) return null; - return ( - - {projectSchemas.map((s) => ( - selectItem("schema", projectId, s, s)} - > - - {s} - Schema - - ))} - - ); - })} - - - )} - - - , - document.body - ); -} diff --git a/src/components/command-palette/index.tsx b/src/components/command-palette/index.tsx new file mode 100644 index 0000000..2cdc6e4 --- /dev/null +++ b/src/components/command-palette/index.tsx @@ -0,0 +1,260 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { createPortal } from "react-dom"; +import { Command } from "cmdk"; +import { useProjectStore } from "@/stores/project-store"; +import { useTabStore, useActiveTab } from "@/stores/tab-store"; +import { useUIStore } from "@/stores/ui-store"; +import { useWorkspaceStore } from "@/stores/workspace-store"; +import { useQueryStore } from "@/stores/query-store"; +import { format as formatSQL } from "sql-formatter"; +import type { Page } from "./types"; +import { ActionsGroup } from "./tools"; +import { + SaveWorkspacePage, + LoadOrDeleteWorkspacePage, + WorkspacesGroup, +} from "./workspaces"; +import { SaveQueryPage } from "./queries"; +import { ConnectionsGroup, DatabaseObjectsGroups } from "./search"; + +export function CommandPalette({ + open, onClose, onExecute, onExplain, + onCheckUpdates, +}: { + open: boolean; + onClose: () => void; + onExecute: () => void; + onExplain: () => void; + onCheckUpdates: () => void; +}) { + const [page, setPage] = useState("root"); + const [workspaceName, setWorkspaceName] = useState(""); + const [queryName, setQueryName] = useState(""); + const containerRef = useRef(null); + + const tables = useProjectStore((s) => s.tables); + const views = useProjectStore((s) => s.views); + const materializedViews = useProjectStore((s) => s.materializedViews); + const functions = useProjectStore((s) => s.functions); + const schemas = useProjectStore((s) => s.schemas); + const projects = useProjectStore((s) => s.projects); + const status = useProjectStore((s) => s.status); + const connectProject = useProjectStore((s) => s.connect); + + const openTab = useTabStore((s) => s.openTab); + const activeTab = useActiveTab(); + + const theme = useUIStore((s) => s.theme); + const toggleTheme = useUIStore((s) => s.toggleTheme); + const setConnectionModalOpen = useUIStore((s) => s.setConnectionModalOpen); + const pinnedResult = useUIStore((s) => s.pinnedResult); + const clearPinnedResult = useUIStore((s) => s.clearPinnedResult); + + const workspaces = useWorkspaceStore((s) => s.workspaces); + const loadWorkspaces = useWorkspaceStore((s) => s.load); + const saveWorkspace = useWorkspaceStore((s) => s.save); + const removeWorkspace = useWorkspaceStore((s) => s.remove); + const workspacesLoaded = useWorkspaceStore((s) => s.loaded); + + const saveQueryAction = useQueryStore((s) => s.saveQuery); + + useEffect(() => { + if (open) { + setPage("root"); + setWorkspaceName(""); + setQueryName(""); + if (!workspacesLoaded) void loadWorkspaces(); + } + }, [open, workspacesLoaded, loadWorkspaces]); + + // Close on Escape at root page + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + if (page !== "root") { + e.preventDefault(); + e.stopPropagation(); + setPage("root"); + setWorkspaceName(""); + setQueryName(""); + } else { + onClose(); + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, page, onClose]); + + // Click outside to close + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + onClose(); + } + }; + window.addEventListener("mousedown", handler); + return () => window.removeEventListener("mousedown", handler); + }, [open, onClose]); + + const handleSaveWorkspace = useCallback(async () => { + if (!workspaceName.trim()) return; + const tabs = useTabStore.getState().tabs + .filter((t) => t.type === "query") + .map((t) => ({ title: t.title, editorValue: t.editorValue, projectId: t.projectId, type: t.type })); + await saveWorkspace(workspaceName.trim(), JSON.stringify(tabs)); + onClose(); + }, [workspaceName, saveWorkspace, onClose]); + + const handleSaveQuery = useCallback(async () => { + if (!queryName.trim()) return; + const tab = useTabStore.getState().tabs[useTabStore.getState().selectedTabIndex]; + if (!tab?.projectId || !tab.editorValue?.trim()) return; + const d = useProjectStore.getState().projects[tab.projectId]; + if (!d) return; + await saveQueryAction(tab.projectId, d.database, d.driver, queryName.trim(), tab.editorValue); + onClose(); + }, [queryName, saveQueryAction, onClose]); + + const handleLoadWorkspace = useCallback((tabsJson: string) => { + try { + const tabs = JSON.parse(tabsJson) as { title: string; editorValue: string; projectId?: string; type: string }[]; + const store = useTabStore.getState(); + for (const tab of tabs) { + store.openTab(tab.projectId, tab.editorValue); + } + } catch { /* ignore parse errors */ } + onClose(); + }, [onClose]); + + const handleDeleteWorkspace = useCallback(async (name: string) => { + await removeWorkspace(name); + if (workspaces.length <= 1) setPage("root"); + }, [removeWorkspace, workspaces.length]); + + const selectItem = useCallback((type: string, projectId: string, schema: string, name: string) => { + onClose(); + if (type === "table" || type === "view" || type === "matview") { + openTab(projectId, `SELECT * FROM "${schema}"."${name}" LIMIT 100;`); + } else if (type === "function") { + openTab(projectId, `-- Function: ${schema}.${name}\nSELECT pg_get_functiondef(p.oid)\nFROM pg_proc p\nJOIN pg_namespace n ON n.oid = p.pronamespace\nWHERE n.nspname = '${schema}' AND p.proname = '${name}'\nLIMIT 1;`); + } else if (type === "schema") { + openTab(projectId, `-- Schema: ${name}\n`); + } + }, [openTab, onClose]); + + const formatQuery = useCallback(() => { + const { tabs, selectedTabIndex: idx } = useTabStore.getState(); + const tab = tabs[idx]; + if (!tab?.editorValue?.trim()) return; + try { + const formatted = formatSQL(tab.editorValue, { language: "postgresql", tabWidth: 2, keywordCase: "upper" }); + useTabStore.getState().updateContent(idx, formatted); + } catch { /* ignore */ } + onClose(); + }, [onClose]); + + if (!open) return null; + + const activeProject = activeTab?.projectId; + const hasQuery = !!activeTab?.editorValue?.trim(); + + return createPortal( + <> +
+
+ { + if (e.key === "Backspace" && page !== "root") { + const input = e.currentTarget.querySelector("[cmdk-input]") as HTMLInputElement | null; + if (input && input.value === "") { + e.preventDefault(); + setPage("root"); + setWorkspaceName(""); + setQueryName(""); + } + } + }} + loop + > + {page === "save-workspace" ? ( + + ) : page === "save-query" ? ( + + ) : page === "load-workspace" || page === "delete-workspace" ? ( + + ) : ( + <> + + + No results found + + {/* Actions */} + + + {/* Connections */} + + + {/* Workspaces */} + + + {/* Database objects */} + + + + )} + +
+ , + document.body + ); +} diff --git a/src/components/command-palette/queries.tsx b/src/components/command-palette/queries.tsx new file mode 100644 index 0000000..1ee50ab --- /dev/null +++ b/src/components/command-palette/queries.tsx @@ -0,0 +1,37 @@ +import { Save } from "lucide-react"; +import type { Page } from "./types"; + +export function SaveQueryPage({ + queryName, + setQueryName, + setPage, + handleSaveQuery, +}: { + queryName: string; + setQueryName: (v: string) => void; + setPage: (p: Page) => void; + handleSaveQuery: () => Promise; +}) { + return ( + <> +
+ + setQueryName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSaveQuery(); + else if (e.key === "Escape") { e.stopPropagation(); setPage("root"); setQueryName(""); } + }} + className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none font-mono" + /> +
+
+ Press Enter to save the current query +
+ + ); +} diff --git a/src/components/command-palette/search.tsx b/src/components/command-palette/search.tsx new file mode 100644 index 0000000..668f17f --- /dev/null +++ b/src/components/command-palette/search.tsx @@ -0,0 +1,197 @@ +import { Command } from "cmdk"; +import { useTabStore } from "@/stores/tab-store"; +import { useProjectStore } from "@/stores/project-store"; +import { + Table, Eye, FileCode, Layers, Database, Activity, Network, +} from "lucide-react"; + +type ProjectState = ReturnType; + +export function ConnectionsGroup({ + projects, + status, + schemas, + onClose, + connectProject, +}: { + projects: ProjectState["projects"]; + status: ProjectState["status"]; + schemas: ProjectState["schemas"]; + onClose: () => void; + connectProject: (id: string) => Promise | void; +}) { + if (Object.keys(projects).length === 0) return null; + return ( + + {Object.entries(projects).map(([id, details]) => { + const connected = status[id] === "Connected"; + return ( + { + onClose(); + if (!connected) void connectProject(id); + }} + > + + {id} + {details.host}:{details.port}/{details.database} + + {connected ? "Connected" : "Connect"} + + + ); + })} + {Object.entries(projects).map(([id]) => { + const connected = status[id] === "Connected"; + if (!connected) return null; + return ( + { onClose(); useTabStore.getState().openMonitorTab(id); }} + > + + Monitor {id} + Performance + + ); + })} + {Object.entries(schemas).map(([projectId, projectSchemas]) => + projectSchemas.map((s) => ( + { onClose(); useTabStore.getState().openERDTab(projectId, s); }} + > + + ERD {projectId}/{s} + Diagram + + )) + )} + + ); +} + +export function DatabaseObjectsGroups({ + tables, + views, + materializedViews, + functions, + schemas, + selectItem, +}: { + tables: ProjectState["tables"]; + views: ProjectState["views"]; + materializedViews: ProjectState["materializedViews"]; + functions: ProjectState["functions"]; + schemas: ProjectState["schemas"]; + selectItem: (type: string, projectId: string, schema: string, name: string) => void; +}) { + return ( + <> + {Object.entries(tables).map(([key, schemaTables]) => { + const [projectId, schema] = key.split("::"); + if (!schemaTables.length) return null; + return ( + + {schemaTables.map((t) => ( + selectItem("table", projectId, schema, t.name)} + > +
+ {t.name} + Table + {t.size && {t.size}} + + ))} + + ); + })} + + {Object.entries(views).map(([key, schemaViews]) => { + const [projectId, schema] = key.split("::"); + if (!schemaViews.length) return null; + return ( + + {schemaViews.map((v) => ( + selectItem("view", projectId, schema, v)} + > + + {v} + View + + ))} + + ); + })} + + {Object.entries(materializedViews).map(([key, matViews]) => { + const [projectId, schema] = key.split("::"); + if (!matViews.length) return null; + return ( + + {matViews.map((mv) => ( + selectItem("matview", projectId, schema, mv)} + > + + {mv} + Mat. View + + ))} + + ); + })} + + {Object.entries(functions).map(([key, fns]) => { + const [projectId, schema] = key.split("::"); + if (!fns.length) return null; + return ( + + {fns.map((fn) => ( + selectItem("function", projectId, schema, fn.name)} + > + + {fn.name} + Function + ({fn.arguments || ""}) → {fn.returnType} + + ))} + + ); + })} + + {Object.entries(schemas).map(([projectId, projectSchemas]) => { + if (!projectSchemas.length) return null; + return ( + + {projectSchemas.map((s) => ( + selectItem("schema", projectId, s, s)} + > + + {s} + Schema + + ))} + + ); + })} + + ); +} diff --git a/src/components/command-palette/tools.tsx b/src/components/command-palette/tools.tsx new file mode 100644 index 0000000..7248ce0 --- /dev/null +++ b/src/components/command-palette/tools.tsx @@ -0,0 +1,118 @@ +import { Command } from "cmdk"; +import { useTabStore } from "@/stores/tab-store"; +import { useUIStore } from "@/stores/ui-store"; +import { + Save, Plus, Play, GitBranch, AlignLeft, Moon, Sun, Terminal, + XCircle, Pin, PinOff, Download, +} from "lucide-react"; +import type { Page } from "./types"; + +type UIState = ReturnType; + +export function ActionsGroup({ + onClose, + setConnectionModalOpen, + activeProject, + hasQuery, + onExecute, + onExplain, + formatQuery, + setPage, + theme, + toggleTheme, + onCheckUpdates, + pinnedResult, + clearPinnedResult, + activeTabResult, +}: { + onClose: () => void; + setConnectionModalOpen: (open: boolean) => void; + activeProject: string | undefined; + hasQuery: boolean; + onExecute: () => void; + onExplain: () => void; + formatQuery: () => void; + setPage: (p: Page) => void; + theme: UIState["theme"]; + toggleTheme: () => void; + onCheckUpdates: () => void; + pinnedResult: UIState["pinnedResult"]; + clearPinnedResult: () => void; + activeTabResult: boolean; +}) { + return ( + + { onClose(); setConnectionModalOpen(true); }}> + + New Connection + Connection + + { onClose(); useTabStore.getState().openTab(activeProject); }}> + + New Query Tab + + { onClose(); useTabStore.getState().openTerminalTab(); }}> + + Open Terminal + {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+` + + {activeProject && hasQuery && ( + <> + { onClose(); onExecute(); }}> + + Execute Query + {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter + + { onClose(); onExplain(); }}> + + Explain Query + {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Shift+Enter + + { onClose(); }}> + + Cancel Query + {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+. + + + )} + {hasQuery && ( + <> + + + Format SQL + + setPage("save-query")}> + + Save Query + + + )} + { onClose(); toggleTheme(); }}> + {theme === "light" + ? + : } + {theme === "light" ? "Dark Mode" : "Light Mode"} + + { onClose(); onCheckUpdates(); }}> + + Check for Updates + App + + {pinnedResult ? ( + { onClose(); clearPinnedResult(); }}> + + Clear Pinned Result + + ) : activeTabResult && ( + { + onClose(); + const tab = useTabStore.getState().tabs[useTabStore.getState().selectedTabIndex]; + if (tab?.result) useUIStore.getState().pinResult(tab.result, tab.editorValue.slice(0, 60)); + }}> + + Pin Current Result + + )} + + ); +} diff --git a/src/components/command-palette/types.ts b/src/components/command-palette/types.ts new file mode 100644 index 0000000..1844dfd --- /dev/null +++ b/src/components/command-palette/types.ts @@ -0,0 +1 @@ +export type Page = "root" | "save-workspace" | "load-workspace" | "delete-workspace" | "save-query"; diff --git a/src/components/command-palette/workspaces.tsx b/src/components/command-palette/workspaces.tsx new file mode 100644 index 0000000..384d6fe --- /dev/null +++ b/src/components/command-palette/workspaces.tsx @@ -0,0 +1,120 @@ +import { Command } from "cmdk"; +import { useWorkspaceStore } from "@/stores/workspace-store"; +import { Save, FolderOpen, Trash2 } from "lucide-react"; +import type { Page } from "./types"; + +type WorkspaceStateWorkspaces = ReturnType["workspaces"]; + +export function SaveWorkspacePage({ + workspaceName, + setWorkspaceName, + setPage, + handleSaveWorkspace, +}: { + workspaceName: string; + setWorkspaceName: (v: string) => void; + setPage: (p: Page) => void; + handleSaveWorkspace: () => Promise; +}) { + return ( + <> +
+ + setWorkspaceName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSaveWorkspace(); + else if (e.key === "Escape") { e.stopPropagation(); setPage("root"); setWorkspaceName(""); } + }} + className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none font-mono" + /> +
+
+ Press Enter to save current query tabs as a workspace +
+ + ); +} + +export function LoadOrDeleteWorkspacePage({ + page, + workspaces, + handleDeleteWorkspace, + handleLoadWorkspace, +}: { + page: Page; + workspaces: WorkspaceStateWorkspaces; + handleDeleteWorkspace: (name: string) => Promise; + handleLoadWorkspace: (tabsJson: string) => void; +}) { + return ( + <> +
+ {page === "delete-workspace" + ? + : } + + {page === "delete-workspace" ? "Delete Workspace" : "Load Workspace"} + +
+ + {workspaces.length === 0 ? ( + No saved workspaces + ) : ( + workspaces.map((ws) => ( + { + if (page === "delete-workspace") void handleDeleteWorkspace(ws.name); + else handleLoadWorkspace(ws.tabs); + }} + > + +
+ {ws.name} +
+ {(() => { try { return `${JSON.parse(ws.tabs).length} tabs`; } catch { return ""; } })()} +
+
+ {page === "delete-workspace" && } +
+ )) + )} +
+ + ); +} + +export function WorkspacesGroup({ + setPage, + workspaces, +}: { + setPage: (p: Page) => void; + workspaces: WorkspaceStateWorkspaces; +}) { + return ( + + setPage("save-workspace")}> + + Save Workspace + + {workspaces.length > 0 && ( + <> + setPage("load-workspace")}> + + Load Workspace + + setPage("delete-workspace")}> + + Delete Workspace + + + )} + + ); +} diff --git a/src/components/connection-modal.tsx b/src/components/connection-modal.tsx deleted file mode 100644 index 4b2c1e8..0000000 --- a/src/components/connection-modal.tsx +++ /dev/null @@ -1,413 +0,0 @@ -import type React from "react" -import { useState, useEffect } from "react" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog" -import { Button } from "./ui/button" -import { Input } from "./ui/input" -import { Label } from "./ui/label" -import { Loader2, CheckCircle2, XCircle } from "lucide-react" -import { DRIVER_CONFIGS } from "@/lib/database-driver" -import { pgsqlTestConnection } from "@/tauri" -import type { DriverType, ProjectDetails } from "@/types" - -interface ConnectionModalProps { - open: boolean - onOpenChange: (open: boolean) => void - onSave: (connection: ConnectionConfig) => void - editData?: { name: string; details: ProjectDetails } | null -} - -export interface ConnectionConfig { - id: string - name: string - driver: DriverType - host: string - port: string - database: string - username: string - password: string - ssl: boolean - sshEnabled: boolean - sshHost: string - sshPort: string - sshUser: string - sshPassword: string - sshKeyPath: string -} - -const defaultForm: Omit = { - name: "", - driver: "PGSQL", - host: "localhost", - port: "5432", - database: "", - username: "", - password: "", - ssl: false, - sshEnabled: false, - sshHost: "", - sshPort: "22", - sshUser: "", - sshPassword: "", - sshKeyPath: "", -} - -function parseConnectionString(url: string): Partial> | null { - try { - // Handle postgresql:// and postgres:// schemes - const normalized = url.trim().replace(/^postgres:\/\//, "postgresql://"); - if (!normalized.startsWith("postgresql://")) return null; - const parsed = new URL(normalized); - const params = parsed.searchParams; - const ssl = params.get("sslmode") === "require" || params.get("sslmode") === "verify-full" || params.get("ssl") === "true"; - return { - driver: "PGSQL", - host: parsed.hostname || "localhost", - port: parsed.port || "5432", - database: parsed.pathname.replace(/^\//, "") || "", - username: decodeURIComponent(parsed.username || ""), - password: decodeURIComponent(parsed.password || ""), - ssl, - }; - } catch { - return null; - } -} - -export function ConnectionModal({ open, onOpenChange, onSave, editData }: ConnectionModalProps) { - const [formData, setFormData] = useState>(defaultForm) - const [connString, setConnString] = useState("") - const [connStringError, setConnStringError] = useState(false) - const [testing, setTesting] = useState(false) - const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null) - - useEffect(() => { - if (open && editData) { - setFormData({ - name: editData.name, - driver: editData.details.driver, - host: editData.details.host, - port: editData.details.port, - database: editData.details.database, - username: editData.details.username, - password: editData.details.password, - ssl: editData.details.ssl === "true", - sshEnabled: editData.details.sshEnabled === "true", - sshHost: editData.details.sshHost || "", - sshPort: editData.details.sshPort || "22", - sshUser: editData.details.sshUser || "", - sshPassword: editData.details.sshPassword || "", - sshKeyPath: editData.details.sshKeyPath || "", - }) - setConnString("") - setConnStringError(false) - setTestResult(null) - } else if (open && !editData) { - setFormData(defaultForm) - setConnString("") - setConnStringError(false) - setTestResult(null) - } - }, [open, editData]) - - const handleConnStringPaste = (value: string) => { - setConnString(value) - setConnStringError(false) - if (!value.trim()) return - const parsed = parseConnectionString(value) - if (parsed) { - setFormData((prev) => ({ ...prev, ...parsed, name: prev.name || parsed.database || "" })) - } else { - setConnStringError(true) - } - } - - const isEditing = !!editData - - const handleTestConnection = async () => { - setTesting(true) - setTestResult(null) - try { - const key: [string, string, string, string, string, string] = [ - formData.username, - formData.password, - formData.database, - formData.host, - formData.port, - formData.ssl ? "true" : "false", - ] - const version = await pgsqlTestConnection(key) - setTestResult({ ok: true, message: version }) - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err) - setTestResult({ ok: false, message: msg }) - } finally { - setTesting(false) - } - } - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - const connection: ConnectionConfig = { - ...formData, - id: editData ? editData.name : `conn-${Date.now()}`, - } - onSave(connection) - onOpenChange(false) - } - - return ( - - - - - {isEditing ? "Edit Connection" : "New Connection"} - - - {isEditing ? "Update connection details" : "Add a new database connection"} - - -
- {!isEditing && ( -
- - handleConnStringPaste(e.target.value)} - placeholder="postgresql://user:password@host:5432/database" - className={`bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg ${connStringError ? "border-destructive" : ""}`} - /> - {connStringError && ( -

Invalid connection URL format

- )} -
-
-
or fill in manually
-
-
- )} - -
- -
- {DRIVER_CONFIGS[formData.driver].name} -
-
- -
- - setFormData({ ...formData, name: e.target.value })} - placeholder="production-db" - required - disabled={isEditing} - className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg" - /> -
- -
-
- - setFormData({ ...formData, host: e.target.value })} - placeholder="localhost" - required - className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg" - /> -
-
- - setFormData({ ...formData, port: e.target.value })} - placeholder="5432" - required - className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg" - /> -
-
- -
- - setFormData({ ...formData, database: e.target.value })} - placeholder="mydb" - required - className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg" - /> -
- -
- - setFormData({ ...formData, username: e.target.value })} - placeholder="postgres" - required - className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg" - /> -
- -
- - setFormData({ ...formData, password: e.target.value })} - placeholder="••••••••" - className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg" - /> -
- -
- setFormData({ ...formData, ssl: e.target.checked })} - className="h-4 w-4 rounded border-border bg-input" - /> - -
- -
-
- setFormData({ ...formData, sshEnabled: e.target.checked })} - className="h-4 w-4 rounded border-border bg-input" - /> - -
- {formData.sshEnabled && ( -
-
-
- - setFormData({ ...formData, sshHost: e.target.value })} - placeholder="bastion.example.com" - className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg h-8" - /> -
-
- - setFormData({ ...formData, sshPort: e.target.value })} - placeholder="22" - className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg h-8" - /> -
-
-
- - setFormData({ ...formData, sshUser: e.target.value })} - placeholder="ubuntu" - className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg h-8" - /> -
-
- - setFormData({ ...formData, sshPassword: e.target.value })} - placeholder="••••••••" - className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg h-8" - /> -
-
- - setFormData({ ...formData, sshKeyPath: e.target.value })} - placeholder="~/.ssh/id_rsa" - className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg h-8" - /> -
-
- )} -
- - {testResult && ( -
- {testResult.ok ? ( - - ) : ( - - )} - {testResult.message} -
- )} - -
- -
- - -
-
- -
-
- ) -} diff --git a/src/components/connection-modal/form-fields.tsx b/src/components/connection-modal/form-fields.tsx new file mode 100644 index 0000000..6a5fd2a --- /dev/null +++ b/src/components/connection-modal/form-fields.tsx @@ -0,0 +1,207 @@ +import { Input } from "../ui/input" +import { Label } from "../ui/label" +import { DRIVER_CONFIGS } from "@/lib/database-driver" +import type { DriverType } from "@/types" + +interface ConnStringFieldProps { + value: string + onChange: (value: string) => void + error: boolean +} + +export function ConnStringField({ value, onChange, error }: ConnStringFieldProps) { + return ( +
+ + onChange(e.target.value)} + placeholder="postgresql://user:password@host:5432/database" + className={`bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg ${error ? "border-destructive" : ""}`} + /> + {error && ( +

Invalid connection URL format

+ )} +
+
+
or fill in manually
+
+
+ ) +} + +interface DriverDisplayProps { + driver: DriverType +} + +export function DriverDisplay({ driver }: DriverDisplayProps) { + return ( +
+ +
+ {DRIVER_CONFIGS[driver].name} +
+
+ ) +} + +interface NameFieldProps { + value: string + onChange: (value: string) => void + disabled: boolean +} + +export function NameField({ value, onChange, disabled }: NameFieldProps) { + return ( +
+ + onChange(e.target.value)} + placeholder="production-db" + required + disabled={disabled} + className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg" + /> +
+ ) +} + +interface HostPortFieldsProps { + host: string + port: string + onHostChange: (value: string) => void + onPortChange: (value: string) => void +} + +export function HostPortFields({ host, port, onHostChange, onPortChange }: HostPortFieldsProps) { + return ( +
+
+ + onHostChange(e.target.value)} + placeholder="localhost" + required + className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg" + /> +
+
+ + onPortChange(e.target.value)} + placeholder="5432" + required + className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg" + /> +
+
+ ) +} + +interface DatabaseFieldProps { + value: string + onChange: (value: string) => void +} + +export function DatabaseField({ value, onChange }: DatabaseFieldProps) { + return ( +
+ + onChange(e.target.value)} + placeholder="mydb" + required + className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg" + /> +
+ ) +} + +interface UsernameFieldProps { + value: string + onChange: (value: string) => void +} + +export function UsernameField({ value, onChange }: UsernameFieldProps) { + return ( +
+ + onChange(e.target.value)} + placeholder="postgres" + required + className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg" + /> +
+ ) +} + +interface PasswordFieldProps { + value: string + onChange: (value: string) => void +} + +export function PasswordField({ value, onChange }: PasswordFieldProps) { + return ( +
+ + onChange(e.target.value)} + placeholder="••••••••" + className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg" + /> +
+ ) +} + +interface SslCheckboxProps { + checked: boolean + onChange: (checked: boolean) => void +} + +export function SslCheckbox({ checked, onChange }: SslCheckboxProps) { + return ( +
+ onChange(e.target.checked)} + className="h-4 w-4 rounded border-border bg-input" + /> + +
+ ) +} diff --git a/src/components/connection-modal/index.tsx b/src/components/connection-modal/index.tsx new file mode 100644 index 0000000..861282b --- /dev/null +++ b/src/components/connection-modal/index.tsx @@ -0,0 +1,277 @@ +import type React from "react" +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "../ui/dialog" +import { Button } from "../ui/button" +import { Loader2, CheckCircle2, XCircle } from "lucide-react" +import { pgsqlTestConnection } from "@/tauri" +import type { DriverType, ProjectDetails } from "@/types" +import { + ConnStringField, + DriverDisplay, + NameField, + HostPortFields, + DatabaseField, + UsernameField, + PasswordField, + SslCheckbox, +} from "./form-fields" +import { SshConfig } from "./ssh-config" + +interface ConnectionModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onSave: (connection: ConnectionConfig) => void + editData?: { name: string; details: ProjectDetails } | null +} + +export interface ConnectionConfig { + id: string + name: string + driver: DriverType + host: string + port: string + database: string + username: string + password: string + ssl: boolean + sshEnabled: boolean + sshHost: string + sshPort: string + sshUser: string + sshPassword: string + sshKeyPath: string +} + +const defaultForm: Omit = { + name: "", + driver: "PGSQL", + host: "localhost", + port: "5432", + database: "", + username: "", + password: "", + ssl: false, + sshEnabled: false, + sshHost: "", + sshPort: "22", + sshUser: "", + sshPassword: "", + sshKeyPath: "", +} + +function parseConnectionString(url: string): Partial> | null { + try { + // Handle postgresql:// and postgres:// schemes + const normalized = url.trim().replace(/^postgres:\/\//, "postgresql://"); + if (!normalized.startsWith("postgresql://")) return null; + const parsed = new URL(normalized); + const params = parsed.searchParams; + const ssl = params.get("sslmode") === "require" || params.get("sslmode") === "verify-full" || params.get("ssl") === "true"; + return { + driver: "PGSQL", + host: parsed.hostname || "localhost", + port: parsed.port || "5432", + database: parsed.pathname.replace(/^\//, "") || "", + username: decodeURIComponent(parsed.username || ""), + password: decodeURIComponent(parsed.password || ""), + ssl, + }; + } catch { + return null; + } +} + +export function ConnectionModal({ open, onOpenChange, onSave, editData }: ConnectionModalProps) { + const [formData, setFormData] = useState>(defaultForm) + const [connString, setConnString] = useState("") + const [connStringError, setConnStringError] = useState(false) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null) + + useEffect(() => { + if (open && editData) { + setFormData({ + name: editData.name, + driver: editData.details.driver, + host: editData.details.host, + port: editData.details.port, + database: editData.details.database, + username: editData.details.username, + password: editData.details.password, + ssl: editData.details.ssl === "true", + sshEnabled: editData.details.sshEnabled === "true", + sshHost: editData.details.sshHost || "", + sshPort: editData.details.sshPort || "22", + sshUser: editData.details.sshUser || "", + sshPassword: editData.details.sshPassword || "", + sshKeyPath: editData.details.sshKeyPath || "", + }) + setConnString("") + setConnStringError(false) + setTestResult(null) + } else if (open && !editData) { + setFormData(defaultForm) + setConnString("") + setConnStringError(false) + setTestResult(null) + } + }, [open, editData]) + + const handleConnStringPaste = (value: string) => { + setConnString(value) + setConnStringError(false) + if (!value.trim()) return + const parsed = parseConnectionString(value) + if (parsed) { + setFormData((prev) => ({ ...prev, ...parsed, name: prev.name || parsed.database || "" })) + } else { + setConnStringError(true) + } + } + + const isEditing = !!editData + + const handleTestConnection = async () => { + setTesting(true) + setTestResult(null) + try { + const key: [string, string, string, string, string, string] = [ + formData.username, + formData.password, + formData.database, + formData.host, + formData.port, + formData.ssl ? "true" : "false", + ] + const version = await pgsqlTestConnection(key) + setTestResult({ ok: true, message: version }) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + setTestResult({ ok: false, message: msg }) + } finally { + setTesting(false) + } + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const connection: ConnectionConfig = { + ...formData, + id: editData ? editData.name : `conn-${Date.now()}`, + } + onSave(connection) + onOpenChange(false) + } + + return ( + + + + + {isEditing ? "Edit Connection" : "New Connection"} + + + {isEditing ? "Update connection details" : "Add a new database connection"} + + +
+ {!isEditing && ( + + )} + + + + setFormData({ ...formData, name: value })} + disabled={isEditing} + /> + + setFormData({ ...formData, host: value })} + onPortChange={(value) => setFormData({ ...formData, port: value })} + /> + + setFormData({ ...formData, database: value })} + /> + + setFormData({ ...formData, username: value })} + /> + + setFormData({ ...formData, password: value })} + /> + + setFormData({ ...formData, ssl: checked })} + /> + + setFormData({ ...formData, sshEnabled: checked })} + onSshHostChange={(value) => setFormData({ ...formData, sshHost: value })} + onSshPortChange={(value) => setFormData({ ...formData, sshPort: value })} + onSshUserChange={(value) => setFormData({ ...formData, sshUser: value })} + onSshPasswordChange={(value) => setFormData({ ...formData, sshPassword: value })} + onSshKeyPathChange={(value) => setFormData({ ...formData, sshKeyPath: value })} + /> + + {testResult && ( +
+ {testResult.ok ? ( + + ) : ( + + )} + {testResult.message} +
+ )} + +
+ +
+ + +
+
+ +
+
+ ) +} diff --git a/src/components/connection-modal/ssh-config.tsx b/src/components/connection-modal/ssh-config.tsx new file mode 100644 index 0000000..a548fa0 --- /dev/null +++ b/src/components/connection-modal/ssh-config.tsx @@ -0,0 +1,106 @@ +import { Input } from "../ui/input" +import { Label } from "../ui/label" + +interface SshConfigProps { + enabled: boolean + sshHost: string + sshPort: string + sshUser: string + sshPassword: string + sshKeyPath: string + onEnabledChange: (enabled: boolean) => void + onSshHostChange: (value: string) => void + onSshPortChange: (value: string) => void + onSshUserChange: (value: string) => void + onSshPasswordChange: (value: string) => void + onSshKeyPathChange: (value: string) => void +} + +export function SshConfig({ + enabled, + sshHost, + sshPort, + sshUser, + sshPassword, + sshKeyPath, + onEnabledChange, + onSshHostChange, + onSshPortChange, + onSshUserChange, + onSshPasswordChange, + onSshKeyPathChange, +}: SshConfigProps) { + return ( +
+
+ onEnabledChange(e.target.checked)} + className="h-4 w-4 rounded border-border bg-input" + /> + +
+ {enabled && ( +
+
+
+ + onSshHostChange(e.target.value)} + placeholder="bastion.example.com" + className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg h-8" + /> +
+
+ + onSshPortChange(e.target.value)} + placeholder="22" + className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg h-8" + /> +
+
+
+ + onSshUserChange(e.target.value)} + placeholder="ubuntu" + className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg h-8" + /> +
+
+ + onSshPasswordChange(e.target.value)} + placeholder="••••••••" + className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg h-8" + /> +
+
+ + onSshKeyPathChange(e.target.value)} + placeholder="~/.ssh/id_rsa" + className="bg-input/80 border-border/50 text-foreground font-mono text-sm rounded-lg h-8" + /> +
+
+ )} +
+ ) +} diff --git a/src/components/erd-diagram.tsx b/src/components/erd-diagram.tsx deleted file mode 100644 index 9f42b32..0000000 --- a/src/components/erd-diagram.tsx +++ /dev/null @@ -1,636 +0,0 @@ -import { useState, useEffect, useRef, useMemo, useCallback } from "react"; -import { useProjectStore } from "@/stores/project-store"; -import { DriverFactory, type ForeignKey } from "@/lib/database-driver"; -import type { ColumnDetail, IndexDetail } from "@/types"; -import { Loader2, ZoomIn, ZoomOut, Maximize, Download } from "lucide-react"; - -interface ERDProps { - projectId: string; - schema: string; -} - -interface ERDColumn { - name: string; - type: string; - nullable: boolean; - isPK: boolean; - isFK: boolean; -} - -interface TableBox { - name: string; - columns: ERDColumn[]; - x: number; - y: number; - width: number; - height: number; -} - -const COL_HEIGHT = 22; -const HEADER_HEIGHT = 32; -const TABLE_PAD = 8; -const MIN_TABLE_WIDTH = 200; -const CHAR_WIDTH = 7; -const TABLE_GAP_X = 80; -const TABLE_GAP_Y = 50; -const SHADOW_FILTER_ID = "erd-shadow"; - -function measureTableWidth(name: string, columns: ERDColumn[]): number { - let maxLen = name.length; - for (const col of columns) { - const line = `${col.name} ${col.type}`; - if (line.length > maxLen) maxLen = line.length; - } - return Math.max(MIN_TABLE_WIDTH, maxLen * CHAR_WIDTH + 40); -} - -function layoutTables( - tables: { name: string; columns: ERDColumn[] }[], - fks: ForeignKey[], -): TableBox[] { - if (tables.length === 0) return []; - - // Build adjacency for connected-component ordering - const adj = new Map>(); - for (const t of tables) adj.set(t.name, new Set()); - for (const fk of fks) { - adj.get(fk.sourceTable)?.add(fk.targetTable); - adj.get(fk.targetTable)?.add(fk.sourceTable); - } - - // Sort: most connected tables first, then alphabetically - const sorted = [...tables].sort((a, b) => { - const ac = adj.get(a.name)?.size ?? 0; - const bc = adj.get(b.name)?.size ?? 0; - if (bc !== ac) return bc - ac; - return a.name.localeCompare(b.name); - }); - - const gridCols = Math.max(1, Math.ceil(Math.sqrt(sorted.length))); - const boxes: TableBox[] = []; - let col = 0; - let y = 30; - let maxRowHeight = 0; - const colXOffsets: number[] = []; - let currentX = 30; - - for (let i = 0; i < gridCols; i++) { - colXOffsets.push(currentX); - // Estimate width for this column based on tables that will go here - const colTables = sorted.filter((_, idx) => idx % gridCols === i); - const maxWidth = colTables.reduce((max, t) => { - const w = measureTableWidth(t.name, t.columns); - return w > max ? w : max; - }, MIN_TABLE_WIDTH); - currentX += maxWidth + TABLE_GAP_X; - } - - for (const t of sorted) { - const width = measureTableWidth(t.name, t.columns); - const height = HEADER_HEIGHT + t.columns.length * COL_HEIGHT + TABLE_PAD; - const x = colXOffsets[col] ?? 30; - - boxes.push({ ...t, x, y, width, height }); - - if (height > maxRowHeight) maxRowHeight = height; - col++; - if (col >= gridCols) { - col = 0; - y += maxRowHeight + TABLE_GAP_Y; - maxRowHeight = 0; - } - } - - return boxes; -} - -export function ERDDiagram({ projectId, schema }: ERDProps) { - const [fks, setFks] = useState([]); - const [loading, setLoading] = useState(true); - const [tablePositions, setTablePositions] = useState>(new Map()); - const [pan, setPan] = useState({ x: 0, y: 0 }); - const [zoom, setZoom] = useState(1); - const [dragging, setDragging] = useState<{ type: "pan" | "table"; tableName?: string } | null>(null); - const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); - const [hoveredTable, setHoveredTable] = useState(null); - const containerRef = useRef(null); - const svgRef = useRef(null); - const detailsLoadedRef = useRef(false); - - const tables = useProjectStore((s) => s.tables); - const columnDetails = useProjectStore((s) => s.columnDetails); - const indexes = useProjectStore((s) => s.indexes); - const loadColumnDetails = useProjectStore((s) => s.loadColumnDetails); - const loadIndexes = useProjectStore((s) => s.loadIndexes); - const loadTables = useProjectStore((s) => s.loadTables); - - const key = `${projectId}::${schema}`; - const schemaTables = tables[key] ?? []; - - // Load tables and FK data - useEffect(() => { - let cancelled = false; - setLoading(true); - detailsLoadedRef.current = false; - - async function load() { - // Read from store directly to avoid stale closure - const d = useProjectStore.getState().projects[projectId]; - if (!d) { - setLoading(false); - return; - } - - const driver = DriverFactory.getDriver(d.driver); - - // Step 1: load tables + FKs in parallel - let loadedFks: ForeignKey[] = []; - try { - const [, fkResult] = await Promise.allSettled([ - loadTables(projectId, schema), - driver.loadForeignKeys(projectId, schema), - ]); - if (fkResult.status === "fulfilled") { - loadedFks = fkResult.value; - } else { - console.warn("ERD: Failed to load foreign keys:", fkResult.reason); - } - } catch (e) { - console.warn("ERD: Error loading data:", e); - } - - if (cancelled) return; - setFks(loadedFks); - - // Step 2: get the current tables from the store - const currentTables = useProjectStore.getState().tables[`${projectId}::${schema}`] ?? []; - if (currentTables.length === 0) { - setLoading(false); - return; - } - - // Step 3: load column details + indexes for all tables (fire-and-forget) - const detailPromises = currentTables.map((t) => { - const detailKey = `${projectId}::${schema}::${t.name}`; - const state = useProjectStore.getState(); - const tasks: Promise[] = []; - if (!state.columnDetails[detailKey]) { - tasks.push(loadColumnDetails(projectId, schema, t.name).catch(() => {})); - } - if (!state.indexes[detailKey]) { - tasks.push(loadIndexes(projectId, schema, t.name).catch(() => {})); - } - return Promise.all(tasks); - }); - - Promise.all(detailPromises).finally(() => { - if (!cancelled) detailsLoadedRef.current = true; - }); - - if (!cancelled) setLoading(false); - } - - load(); - return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, schema]); - - // Derive whether we have enough detail data to render - const detailsReady = schemaTables.length === 0 || schemaTables.some((t) => { - const detailKey = `${projectId}::${schema}::${t.name}`; - return columnDetails[detailKey] != null; - }); - - // Build ERD table data with column types and PK/FK info - const tableData = useMemo(() => { - if (!detailsReady) return []; - - const fkColumns = new Set(); - for (const fk of fks) { - fkColumns.add(`${fk.sourceTable}.${fk.sourceColumn}`); - fkColumns.add(`${fk.targetTable}.${fk.targetColumn}`); - } - - return schemaTables.map((t) => { - const detailKey = `${projectId}::${schema}::${t.name}`; - const details: ColumnDetail[] = columnDetails[detailKey] ?? []; - const idxs: IndexDetail[] = indexes[detailKey] ?? []; - const pkCols = new Set(idxs.filter((i) => i.isPrimary).map((i) => i.columnName)); - - const cols: ERDColumn[] = details.map((d) => ({ - name: d.name, - type: d.dataType, - nullable: d.nullable, - isPK: pkCols.has(d.name), - isFK: fkColumns.has(`${t.name}.${d.name}`), - })); - - return { name: t.name, columns: cols }; - }); - }, [schemaTables, columnDetails, indexes, fks, projectId, schema, detailsReady]); - - const initialBoxes = useMemo(() => layoutTables(tableData, fks), [tableData, fks]); - - // Apply custom positions from dragging - const boxes = useMemo(() => { - if (tablePositions.size === 0) return initialBoxes; - return initialBoxes.map((b) => { - const pos = tablePositions.get(b.name); - return pos ? { ...b, x: pos.x, y: pos.y } : b; - }); - }, [initialBoxes, tablePositions]); - - const boxMap = useMemo(() => new Map(boxes.map((b) => [b.name, b])), [boxes]); - - const totalWidth = Math.max(800, ...boxes.map((b) => b.x + b.width + 60)); - const totalHeight = Math.max(600, ...boxes.map((b) => b.y + b.height + 60)); - - // Get connected tables for highlighting - const connectedTables = useMemo(() => { - if (!hoveredTable) return new Set(); - const connected = new Set(); - for (const fk of fks) { - if (fk.sourceTable === hoveredTable) connected.add(fk.targetTable); - if (fk.targetTable === hoveredTable) connected.add(fk.sourceTable); - } - return connected; - }, [hoveredTable, fks]); - - const connectedFKs = useMemo(() => { - if (!hoveredTable) return new Set(); - const set = new Set(); - fks.forEach((fk, i) => { - if (fk.sourceTable === hoveredTable || fk.targetTable === hoveredTable) set.add(i); - }); - return set; - }, [hoveredTable, fks]); - - const handleWheel = useCallback((e: React.WheelEvent) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? 0.9 : 1.1; - setZoom((z) => Math.min(3, Math.max(0.1, z * delta))); - }, []); - - const handleMouseDown = useCallback( - (e: React.MouseEvent, tableName?: string) => { - if (e.button !== 0) return; - e.stopPropagation(); - if (tableName) { - const box = boxMap.get(tableName); - if (!box) return; - setDragging({ type: "table", tableName }); - setDragStart({ x: e.clientX / zoom - box.x, y: e.clientY / zoom - box.y }); - } else { - setDragging({ type: "pan" }); - setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); - } - }, - [pan, zoom, boxMap], - ); - - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - if (!dragging) return; - if (dragging.type === "pan") { - setPan({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }); - } else if (dragging.type === "table" && dragging.tableName) { - const newX = e.clientX / zoom - dragStart.x; - const newY = e.clientY / zoom - dragStart.y; - setTablePositions((prev) => { - const next = new Map(prev); - next.set(dragging.tableName!, { x: Math.max(0, newX), y: Math.max(0, newY) }); - return next; - }); - } - }, - [dragging, dragStart, zoom], - ); - - const handleMouseUp = useCallback(() => { - setDragging(null); - }, []); - - const fitToView = useCallback(() => { - if (!containerRef.current || boxes.length === 0) return; - const rect = containerRef.current.getBoundingClientRect(); - const scaleX = rect.width / totalWidth; - const scaleY = rect.height / totalHeight; - const newZoom = Math.min(scaleX, scaleY) * 0.9; - setZoom(Math.min(2, Math.max(0.1, newZoom))); - setPan({ x: 10, y: 10 }); - }, [boxes, totalWidth, totalHeight]); - - const exportSVG = useCallback(() => { - if (!svgRef.current) return; - const clone = svgRef.current.cloneNode(true) as SVGSVGElement; - clone.setAttribute("width", String(totalWidth)); - clone.setAttribute("height", String(totalHeight)); - const blob = new Blob([clone.outerHTML], { type: "image/svg+xml" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `erd-${schema}.svg`; - a.click(); - URL.revokeObjectURL(url); - }, [totalWidth, totalHeight, schema]); - - if (loading || (!detailsReady && schemaTables.length > 0)) { - return ( -
- - Loading ERD... -
- ); - } - - if (boxes.length === 0) { - return ( -
- No tables found in schema "{schema}" -
- ); - } - - return ( -
- {/* Zoom controls */} -
- - - - -
- - {/* Status indicator */} -
- {boxes.length} tables - {fks.length} FKs - {Math.round(zoom * 100)}% -
- - {/* SVG Canvas */} -
handleMouseDown(e)} - onMouseMove={handleMouseMove} - onMouseUp={handleMouseUp} - onMouseLeave={handleMouseUp} - > - - - - - - {/* Arrow marker */} - - - - - - {/* Grid dots background */} - - - - - - {/* FK relationship lines */} - {fks.map((fk, i) => { - const src = boxMap.get(fk.sourceTable); - const tgt = boxMap.get(fk.targetTable); - if (!src || !tgt) return null; - - const srcIdx = src.columns.findIndex((c) => c.name === fk.sourceColumn); - const tgtIdx = tgt.columns.findIndex((c) => c.name === fk.targetColumn); - const srcY = src.y + HEADER_HEIGHT + (srcIdx >= 0 ? srcIdx : 0) * COL_HEIGHT + COL_HEIGHT / 2; - const tgtY = tgt.y + HEADER_HEIGHT + (tgtIdx >= 0 ? tgtIdx : 0) * COL_HEIGHT + COL_HEIGHT / 2; - - const srcRight = src.x + src.width; - const tgtLeft = tgt.x; - const srcLeft = src.x; - const tgtRight = tgt.x + tgt.width; - - let x1: number, x2: number; - if (srcRight + 20 < tgtLeft) { - x1 = srcRight; - x2 = tgtLeft; - } else if (tgtRight + 20 < srcLeft) { - x1 = srcLeft; - x2 = tgtRight; - } else { - x1 = srcRight; - x2 = tgtRight + 30; - } - - const isHighlighted = connectedFKs.has(i); - const midX = (x1 + x2) / 2; - const cpOffset = Math.max(40, Math.abs(x2 - x1) * 0.4); - - return ( - - - {/* One-to-many indicator: diamond at source, circle at target */} - - {/* Label */} - {isHighlighted && ( - - {fk.sourceColumn} → {fk.targetColumn} - - )} - - ); - })} - - {/* Table boxes */} - {boxes.map((box) => { - const isHovered = hoveredTable === box.name; - const isConnected = connectedTables.has(box.name); - const dimmed = hoveredTable !== null && !isHovered && !isConnected; - - return ( - handleMouseDown(e, box.name)} - onMouseEnter={() => setHoveredTable(box.name)} - onMouseLeave={() => setHoveredTable(null)} - className="cursor-grab active:cursor-grabbing" - > - {/* Shadow rect */} - - {/* Header bg */} - - {/* Header bottom rect to cover bottom corners */} - - {/* Table name */} - - {box.name.length > 28 ? box.name.slice(0, 26) + ".." : box.name} - - - {/* Column separator line */} - - - {/* Columns */} - {box.columns.map((col, ci) => { - const cy = box.y + HEADER_HEIGHT + ci * COL_HEIGHT; - const isAlt = ci % 2 === 1; - - return ( - - {/* Alternating row bg */} - {isAlt && ( - - )} - {/* PK/FK icon */} - {col.isPK && ( - - PK - - )} - {col.isFK && !col.isPK && ( - - FK - - )} - {/* Column name */} - - {col.name} - - {/* Column type */} - - {col.type} - {!col.nullable ? "" : "?"} - - - ); - })} - - ); - })} - -
-
- ); -} diff --git a/src/components/erd-diagram/index.tsx b/src/components/erd-diagram/index.tsx new file mode 100644 index 0000000..a3aadbc --- /dev/null +++ b/src/components/erd-diagram/index.tsx @@ -0,0 +1,277 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { useProjectStore } from "@/stores/project-store"; +import { DriverFactory } from "@/lib/database-driver"; +import type { ColumnDetail, IndexDetail } from "@/types"; +import { Loader2 } from "lucide-react"; +import type { ERDProps, ERDColumn, ForeignKey } from "./types"; +import { layoutTables } from "./layout"; +import { + createHandleWheel, + createHandleMouseDown, + createHandleMouseMove, + createHandleMouseUp, + ERDToolbar, + ERDStatusBar, +} from "./interactions"; +import { useTableDetails } from "./table-details"; +import { + ERDDefs, + ERDGridBackground, + ERDFKLines, + ERDTableBoxes, +} from "./rendering"; + +export function ERDDiagram({ projectId, schema }: ERDProps) { + const [fks, setFks] = useState([]); + const [loading, setLoading] = useState(true); + const [tablePositions, setTablePositions] = useState>(new Map()); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [dragging, setDragging] = useState<{ type: "pan" | "table"; tableName?: string } | null>(null); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [hoveredTable, setHoveredTable] = useState(null); + const containerRef = useRef(null); + const svgRef = useRef(null); + const detailsLoadedRef = useRef(false); + + const tables = useProjectStore((s) => s.tables); + const columnDetails = useProjectStore((s) => s.columnDetails); + const indexes = useProjectStore((s) => s.indexes); + const loadColumnDetails = useProjectStore((s) => s.loadColumnDetails); + const loadIndexes = useProjectStore((s) => s.loadIndexes); + const loadTables = useProjectStore((s) => s.loadTables); + + const key = `${projectId}::${schema}`; + const schemaTables = tables[key] ?? []; + + // Load tables and FK data + useEffect(() => { + let cancelled = false; + setLoading(true); + detailsLoadedRef.current = false; + + async function load() { + // Read from store directly to avoid stale closure + const d = useProjectStore.getState().projects[projectId]; + if (!d) { + setLoading(false); + return; + } + + const driver = DriverFactory.getDriver(d.driver); + + // Step 1: load tables + FKs in parallel + let loadedFks: ForeignKey[] = []; + try { + const [, fkResult] = await Promise.allSettled([ + loadTables(projectId, schema), + driver.loadForeignKeys(projectId, schema), + ]); + if (fkResult.status === "fulfilled") { + loadedFks = fkResult.value; + } else { + console.warn("ERD: Failed to load foreign keys:", fkResult.reason); + } + } catch (e) { + console.warn("ERD: Error loading data:", e); + } + + if (cancelled) return; + setFks(loadedFks); + + // Step 2: get the current tables from the store + const currentTables = useProjectStore.getState().tables[`${projectId}::${schema}`] ?? []; + if (currentTables.length === 0) { + setLoading(false); + return; + } + + // Step 3: load column details + indexes for all tables (fire-and-forget) + const detailPromises = currentTables.map((t) => { + const detailKey = `${projectId}::${schema}::${t.name}`; + const state = useProjectStore.getState(); + const tasks: Promise[] = []; + if (!state.columnDetails[detailKey]) { + tasks.push(loadColumnDetails(projectId, schema, t.name).catch(() => {})); + } + if (!state.indexes[detailKey]) { + tasks.push(loadIndexes(projectId, schema, t.name).catch(() => {})); + } + return Promise.all(tasks); + }); + + Promise.all(detailPromises).finally(() => { + if (!cancelled) detailsLoadedRef.current = true; + }); + + if (!cancelled) setLoading(false); + } + + load(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId, schema]); + + // Derive whether we have enough detail data to render + const detailsReady = schemaTables.length === 0 || schemaTables.some((t) => { + const detailKey = `${projectId}::${schema}::${t.name}`; + return columnDetails[detailKey] != null; + }); + + // Build ERD table data with column types and PK/FK info + const tableData = useMemo(() => { + if (!detailsReady) return []; + + const fkColumns = new Set(); + for (const fk of fks) { + fkColumns.add(`${fk.sourceTable}.${fk.sourceColumn}`); + fkColumns.add(`${fk.targetTable}.${fk.targetColumn}`); + } + + return schemaTables.map((t) => { + const detailKey = `${projectId}::${schema}::${t.name}`; + const details: ColumnDetail[] = columnDetails[detailKey] ?? []; + const idxs: IndexDetail[] = indexes[detailKey] ?? []; + const pkCols = new Set(idxs.filter((i) => i.isPrimary).map((i) => i.columnName)); + + const cols: ERDColumn[] = details.map((d) => ({ + name: d.name, + type: d.dataType, + nullable: d.nullable, + isPK: pkCols.has(d.name), + isFK: fkColumns.has(`${t.name}.${d.name}`), + })); + + return { name: t.name, columns: cols }; + }); + }, [schemaTables, columnDetails, indexes, fks, projectId, schema, detailsReady]); + + const initialBoxes = useMemo(() => layoutTables(tableData, fks), [tableData, fks]); + + // Apply custom positions from dragging + const boxes = useMemo(() => { + if (tablePositions.size === 0) return initialBoxes; + return initialBoxes.map((b) => { + const pos = tablePositions.get(b.name); + return pos ? { ...b, x: pos.x, y: pos.y } : b; + }); + }, [initialBoxes, tablePositions]); + + const boxMap = useMemo(() => new Map(boxes.map((b) => [b.name, b])), [boxes]); + + const totalWidth = Math.max(800, ...boxes.map((b) => b.x + b.width + 60)); + const totalHeight = Math.max(600, ...boxes.map((b) => b.y + b.height + 60)); + + // Get connected tables for highlighting + const { connectedTables, connectedFKs } = useTableDetails(hoveredTable, fks); + + const handleWheel = useCallback(createHandleWheel(setZoom), []); + + const handleMouseDown = useCallback( + createHandleMouseDown(pan, zoom, boxMap, setDragging, setDragStart), + [pan, zoom, boxMap], + ); + + const handleMouseMove = useCallback( + createHandleMouseMove(dragging, dragStart, zoom, setPan, setTablePositions), + [dragging, dragStart, zoom], + ); + + const handleMouseUp = useCallback(createHandleMouseUp(setDragging), []); + + const fitToView = useCallback(() => { + if (!containerRef.current || boxes.length === 0) return; + const rect = containerRef.current.getBoundingClientRect(); + const scaleX = rect.width / totalWidth; + const scaleY = rect.height / totalHeight; + const newZoom = Math.min(scaleX, scaleY) * 0.9; + setZoom(Math.min(2, Math.max(0.1, newZoom))); + setPan({ x: 10, y: 10 }); + }, [boxes, totalWidth, totalHeight]); + + const exportSVG = useCallback(() => { + if (!svgRef.current) return; + const clone = svgRef.current.cloneNode(true) as SVGSVGElement; + clone.setAttribute("width", String(totalWidth)); + clone.setAttribute("height", String(totalHeight)); + const blob = new Blob([clone.outerHTML], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `erd-${schema}.svg`; + a.click(); + URL.revokeObjectURL(url); + }, [totalWidth, totalHeight, schema]); + + if (loading || (!detailsReady && schemaTables.length > 0)) { + return ( +
+ + Loading ERD... +
+ ); + } + + if (boxes.length === 0) { + return ( +
+ No tables found in schema "{schema}" +
+ ); + } + + return ( +
+ {/* Zoom controls */} + + + {/* Status indicator */} + + + {/* SVG Canvas */} +
handleMouseDown(e)} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onMouseLeave={handleMouseUp} + > + + + + {/* Grid dots background */} + + + {/* FK relationship lines */} + + + {/* Table boxes */} + setHoveredTable(null)} + /> + +
+
+ ); +} diff --git a/src/components/erd-diagram/interactions.tsx b/src/components/erd-diagram/interactions.tsx new file mode 100644 index 0000000..c0be456 --- /dev/null +++ b/src/components/erd-diagram/interactions.tsx @@ -0,0 +1,128 @@ +import { ZoomIn, ZoomOut, Maximize, Download } from "lucide-react"; +import type { TableBox } from "./types"; + +export type DragState = { type: "pan" | "table"; tableName?: string } | null; +export type Point = { x: number; y: number }; + +interface ERDToolbarProps { + setZoom: React.Dispatch>; + fitToView: () => void; + exportSVG: () => void; +} + +export function ERDToolbar({ setZoom, fitToView, exportSVG }: ERDToolbarProps) { + return ( +
+ + + + +
+ ); +} + +interface ERDStatusBarProps { + boxCount: number; + fkCount: number; + zoom: number; +} + +export function ERDStatusBar({ boxCount, fkCount, zoom }: ERDStatusBarProps) { + return ( +
+ {boxCount} tables + {fkCount} FKs + {Math.round(zoom * 100)}% +
+ ); +} + +// Pure handler factories — invoked inside useCallback in index.tsx so the +// hook order in the parent component is preserved exactly. + +export function createHandleWheel( + setZoom: React.Dispatch>, +) { + return (e: React.WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? 0.9 : 1.1; + setZoom((z) => Math.min(3, Math.max(0.1, z * delta))); + }; +} + +export function createHandleMouseDown( + pan: Point, + zoom: number, + boxMap: Map, + setDragging: React.Dispatch>, + setDragStart: React.Dispatch>, +) { + return (e: React.MouseEvent, tableName?: string) => { + if (e.button !== 0) return; + e.stopPropagation(); + if (tableName) { + const box = boxMap.get(tableName); + if (!box) return; + setDragging({ type: "table", tableName }); + setDragStart({ x: e.clientX / zoom - box.x, y: e.clientY / zoom - box.y }); + } else { + setDragging({ type: "pan" }); + setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); + } + }; +} + +export function createHandleMouseMove( + dragging: DragState, + dragStart: Point, + zoom: number, + setPan: React.Dispatch>, + setTablePositions: React.Dispatch>>, +) { + return (e: React.MouseEvent) => { + if (!dragging) return; + if (dragging.type === "pan") { + setPan({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }); + } else if (dragging.type === "table" && dragging.tableName) { + const newX = e.clientX / zoom - dragStart.x; + const newY = e.clientY / zoom - dragStart.y; + setTablePositions((prev) => { + const next = new Map(prev); + next.set(dragging.tableName!, { x: Math.max(0, newX), y: Math.max(0, newY) }); + return next; + }); + } + }; +} + +export function createHandleMouseUp( + setDragging: React.Dispatch>, +) { + return () => { + setDragging(null); + }; +} diff --git a/src/components/erd-diagram/layout.ts b/src/components/erd-diagram/layout.ts new file mode 100644 index 0000000..0a1adb8 --- /dev/null +++ b/src/components/erd-diagram/layout.ts @@ -0,0 +1,79 @@ +import type { ForeignKey, ERDColumn, TableBox } from "./types"; + +export const COL_HEIGHT = 22; +export const HEADER_HEIGHT = 32; +export const TABLE_PAD = 8; +export const MIN_TABLE_WIDTH = 200; +export const CHAR_WIDTH = 7; +export const TABLE_GAP_X = 80; +export const TABLE_GAP_Y = 50; +export const SHADOW_FILTER_ID = "erd-shadow"; + +export function measureTableWidth(name: string, columns: ERDColumn[]): number { + let maxLen = name.length; + for (const col of columns) { + const line = `${col.name} ${col.type}`; + if (line.length > maxLen) maxLen = line.length; + } + return Math.max(MIN_TABLE_WIDTH, maxLen * CHAR_WIDTH + 40); +} + +export function layoutTables( + tables: { name: string; columns: ERDColumn[] }[], + fks: ForeignKey[], +): TableBox[] { + if (tables.length === 0) return []; + + // Build adjacency for connected-component ordering + const adj = new Map>(); + for (const t of tables) adj.set(t.name, new Set()); + for (const fk of fks) { + adj.get(fk.sourceTable)?.add(fk.targetTable); + adj.get(fk.targetTable)?.add(fk.sourceTable); + } + + // Sort: most connected tables first, then alphabetically + const sorted = [...tables].sort((a, b) => { + const ac = adj.get(a.name)?.size ?? 0; + const bc = adj.get(b.name)?.size ?? 0; + if (bc !== ac) return bc - ac; + return a.name.localeCompare(b.name); + }); + + const gridCols = Math.max(1, Math.ceil(Math.sqrt(sorted.length))); + const boxes: TableBox[] = []; + let col = 0; + let y = 30; + let maxRowHeight = 0; + const colXOffsets: number[] = []; + let currentX = 30; + + for (let i = 0; i < gridCols; i++) { + colXOffsets.push(currentX); + // Estimate width for this column based on tables that will go here + const colTables = sorted.filter((_, idx) => idx % gridCols === i); + const maxWidth = colTables.reduce((max, t) => { + const w = measureTableWidth(t.name, t.columns); + return w > max ? w : max; + }, MIN_TABLE_WIDTH); + currentX += maxWidth + TABLE_GAP_X; + } + + for (const t of sorted) { + const width = measureTableWidth(t.name, t.columns); + const height = HEADER_HEIGHT + t.columns.length * COL_HEIGHT + TABLE_PAD; + const x = colXOffsets[col] ?? 30; + + boxes.push({ ...t, x, y, width, height }); + + if (height > maxRowHeight) maxRowHeight = height; + col++; + if (col >= gridCols) { + col = 0; + y += maxRowHeight + TABLE_GAP_Y; + maxRowHeight = 0; + } + } + + return boxes; +} diff --git a/src/components/erd-diagram/rendering.tsx b/src/components/erd-diagram/rendering.tsx new file mode 100644 index 0000000..1944dbb --- /dev/null +++ b/src/components/erd-diagram/rendering.tsx @@ -0,0 +1,262 @@ +import type { ForeignKey, TableBox } from "./types"; +import { COL_HEIGHT, HEADER_HEIGHT, SHADOW_FILTER_ID } from "./layout"; + +export function ERDDefs() { + return ( + + + + + {/* Arrow marker */} + + + + + ); +} + +export function ERDGridBackground({ totalWidth, totalHeight }: { totalWidth: number; totalHeight: number }) { + return ( + <> + + + + + + ); +} + +interface FKLinesProps { + fks: ForeignKey[]; + boxMap: Map; + hoveredTable: string | null; + connectedFKs: Set; +} + +export function ERDFKLines({ fks, boxMap, hoveredTable, connectedFKs }: FKLinesProps) { + return ( + <> + {fks.map((fk, i) => { + const src = boxMap.get(fk.sourceTable); + const tgt = boxMap.get(fk.targetTable); + if (!src || !tgt) return null; + + const srcIdx = src.columns.findIndex((c) => c.name === fk.sourceColumn); + const tgtIdx = tgt.columns.findIndex((c) => c.name === fk.targetColumn); + const srcY = src.y + HEADER_HEIGHT + (srcIdx >= 0 ? srcIdx : 0) * COL_HEIGHT + COL_HEIGHT / 2; + const tgtY = tgt.y + HEADER_HEIGHT + (tgtIdx >= 0 ? tgtIdx : 0) * COL_HEIGHT + COL_HEIGHT / 2; + + const srcRight = src.x + src.width; + const tgtLeft = tgt.x; + const srcLeft = src.x; + const tgtRight = tgt.x + tgt.width; + + let x1: number, x2: number; + if (srcRight + 20 < tgtLeft) { + x1 = srcRight; + x2 = tgtLeft; + } else if (tgtRight + 20 < srcLeft) { + x1 = srcLeft; + x2 = tgtRight; + } else { + x1 = srcRight; + x2 = tgtRight + 30; + } + + const isHighlighted = connectedFKs.has(i); + const midX = (x1 + x2) / 2; + const cpOffset = Math.max(40, Math.abs(x2 - x1) * 0.4); + + return ( + + + {/* One-to-many indicator: diamond at source, circle at target */} + + {/* Label */} + {isHighlighted && ( + + {fk.sourceColumn} → {fk.targetColumn} + + )} + + ); + })} + + ); +} + +interface TableBoxesProps { + boxes: TableBox[]; + hoveredTable: string | null; + connectedTables: Set; + onMouseDown: (e: React.MouseEvent, tableName?: string) => void; + onTableEnter: (name: string) => void; + onTableLeave: () => void; +} + +export function ERDTableBoxes({ + boxes, + hoveredTable, + connectedTables, + onMouseDown, + onTableEnter, + onTableLeave, +}: TableBoxesProps) { + return ( + <> + {boxes.map((box) => { + const isHovered = hoveredTable === box.name; + const isConnected = connectedTables.has(box.name); + const dimmed = hoveredTable !== null && !isHovered && !isConnected; + + return ( + onMouseDown(e, box.name)} + onMouseEnter={() => onTableEnter(box.name)} + onMouseLeave={() => onTableLeave()} + className="cursor-grab active:cursor-grabbing" + > + {/* Shadow rect */} + + {/* Header bg */} + + {/* Header bottom rect to cover bottom corners */} + + {/* Table name */} + + {box.name.length > 28 ? box.name.slice(0, 26) + ".." : box.name} + + + {/* Column separator line */} + + + {/* Columns */} + {box.columns.map((col, ci) => { + const cy = box.y + HEADER_HEIGHT + ci * COL_HEIGHT; + const isAlt = ci % 2 === 1; + + return ( + + {/* Alternating row bg */} + {isAlt && ( + + )} + {/* PK/FK icon */} + {col.isPK && ( + + PK + + )} + {col.isFK && !col.isPK && ( + + FK + + )} + {/* Column name */} + + {col.name} + + {/* Column type */} + + {col.type} + {!col.nullable ? "" : "?"} + + + ); + })} + + ); + })} + + ); +} diff --git a/src/components/erd-diagram/table-details.tsx b/src/components/erd-diagram/table-details.tsx new file mode 100644 index 0000000..ea257fa --- /dev/null +++ b/src/components/erd-diagram/table-details.tsx @@ -0,0 +1,33 @@ +import { useMemo } from "react"; +import type { ForeignKey } from "./types"; + +/** + * Derives connection metadata for the currently hovered table. + * + * Returns the set of table names connected to the hovered table by a + * foreign key (in either direction) and the set of FK indices that + * touch the hovered table — used by the renderer to highlight related + * tables and dim everything else. + */ +export function useTableDetails(hoveredTable: string | null, fks: ForeignKey[]) { + const connectedTables = useMemo(() => { + if (!hoveredTable) return new Set(); + const connected = new Set(); + for (const fk of fks) { + if (fk.sourceTable === hoveredTable) connected.add(fk.targetTable); + if (fk.targetTable === hoveredTable) connected.add(fk.sourceTable); + } + return connected; + }, [hoveredTable, fks]); + + const connectedFKs = useMemo(() => { + if (!hoveredTable) return new Set(); + const set = new Set(); + fks.forEach((fk, i) => { + if (fk.sourceTable === hoveredTable || fk.targetTable === hoveredTable) set.add(i); + }); + return set; + }, [hoveredTable, fks]); + + return { connectedTables, connectedFKs }; +} diff --git a/src/components/erd-diagram/types.ts b/src/components/erd-diagram/types.ts new file mode 100644 index 0000000..1bbd930 --- /dev/null +++ b/src/components/erd-diagram/types.ts @@ -0,0 +1,25 @@ +import type { ForeignKey } from "@/lib/database-driver"; + +export type { ForeignKey }; + +export interface ERDProps { + projectId: string; + schema: string; +} + +export interface ERDColumn { + name: string; + type: string; + nullable: boolean; + isPK: boolean; + isFK: boolean; +} + +export interface TableBox { + name: string; + columns: ERDColumn[]; + x: number; + y: number; + width: number; + height: number; +} diff --git a/src/components/object-properties-modal.tsx b/src/components/object-properties-modal.tsx deleted file mode 100644 index 7a0f313..0000000 --- a/src/components/object-properties-modal.tsx +++ /dev/null @@ -1,2964 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import type { - DraftColumn, - DraftForeignKey, - DraftIndex, - DraftPrimaryKey, - DraftUniqueConstraint, - StructureEditorState, -} from "@/lib/alter-table-sql"; -import { - countChanges, - FK_ACTIONS, - generateAlterTableSQL, - PG_COMMON_TYPES, -} from "@/lib/alter-table-sql"; -import { DriverFactory } from "@/lib/database-driver"; -import { cn } from "@/lib/utils"; -import { useProjectStore } from "@/stores/project-store"; -import { useTabStore } from "@/stores/tab-store"; -import { - AlertTriangle, - ArrowRight, - Check, - Columns3, - Copy, - Database, - Eye, - FileCode, - HardDrive, - Key, - Layers, - Link2, - Loader2, - Lock, - Pencil, - Play, - Plus, - RefreshCw, - ScrollText, - Shield, - Table, - Trash2, - Zap, -} from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; - -type ObjectType = - | "table" - | "view" - | "matview" - | "function" - | "trigger-function"; - -type Tab = - | "overview" - | "columns" - | "indexes" - | "fkeys" - | "ddl" - | "actions" - | "structure"; - -interface ObjectPropertiesModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - objectType: ObjectType; - projectId: string; - schema: string; - name: string; -} - -interface TableStats { - rowEstimate: string; - tableSize: string; - indexSize: string; - totalSize: string; - lastVacuum: string; - lastAnalyze: string; - lastAutoVacuum: string; - lastAutoAnalyze: string; - deadTuples: string; - liveTuples: string; - seqScan: string; - idxScan: string; -} - -interface FKInfo { - constraintName: string; - sourceSchema: string; - sourceTable: string; - sourceColumn: string; - targetSchema: string; - targetTable: string; - targetColumn: string; - onUpdate: string; - onDelete: string; -} - -interface ViewInfo { - isUpdatable: string; - checkOption: string; - definition: string; -} - -interface FunctionMeta { - language: string; - volatility: string; - isStrict: boolean; - securityDefiner: boolean; - estimatedCost: string; - estimatedRows: string; - returnType: string; - arguments: string; - source: string; -} - -interface MatViewStats { - rowEstimate: string; - totalSize: string; - isPopulated: string; - definition: string; -} - -export function ObjectPropertiesModal({ - open, - onOpenChange, - objectType, - projectId, - schema, - name, -}: ObjectPropertiesModalProps) { - const defaultTab: Tab = - objectType === "table" - ? "overview" - : objectType === "function" || objectType === "trigger-function" - ? "overview" - : "overview"; - const [activeTab, setActiveTab] = useState(defaultTab); - const [ddl, setDdl] = useState(null); - const [ddlLoading, setDdlLoading] = useState(false); - const [ddlError, setDdlError] = useState(null); - const [copied, setCopied] = useState(null); - const [loading, setLoading] = useState(false); - - // Live fetched data - const [tableStats, setTableStats] = useState(null); - const [outgoingFKs, setOutgoingFKs] = useState([]); - const [incomingFKs, setIncomingFKs] = useState([]); - const [viewInfo, setViewInfo] = useState(null); - const [functionMeta, setFunctionMeta] = useState(null); - const [matViewStats, setMatViewStats] = useState(null); - const [actionResult, setActionResult] = useState<{ - type: "success" | "error"; - message: string; - } | null>(null); - const [actionLoading, setActionLoading] = useState(false); - const [confirmAction, setConfirmAction] = useState(null); - const [confirmInput, setConfirmInput] = useState(""); - - // Cached metadata from store - const columnDetails = useProjectStore((s) => s.columnDetails); - const indexes = useProjectStore((s) => s.indexes); - const constraints = useProjectStore((s) => s.constraints); - const triggers = useProjectStore((s) => s.triggers); - const rules = useProjectStore((s) => s.rules); - const policies = useProjectStore((s) => s.policies); - const projects = useProjectStore((s) => s.projects); - const storeLoadColumnDetails = useProjectStore((s) => s.loadColumnDetails); - const storeLoadIndexes = useProjectStore((s) => s.loadIndexes); - const openTab = useTabStore((s) => s.openTab); - - const metaKey = `${projectId}::${schema}::${name}`; - const cols = columnDetails[metaKey]; - const idxs = indexes[metaKey]; - const cons = constraints[metaKey]; - const trigs = triggers[metaKey]; - const rls = rules[metaKey]; - const pols = policies[metaKey]; - const pkCols = new Set( - (idxs ?? []).filter((i) => i.isPrimary).map((i) => i.columnName), - ); - - const getDriver = useCallback(() => { - const d = projects[projectId]; - if (!d) return null; - return DriverFactory.getDriver(d.driver); - }, [projects, projectId]); - - // Fetch live data on open - useEffect(() => { - if (!open) return; - setActiveTab(objectType === "table" ? "overview" : "overview"); - setDdl(null); - setDdlError(null); - setCopied(null); - setActionResult(null); - setConfirmAction(null); - setTableStats(null); - setOutgoingFKs([]); - setIncomingFKs([]); - setViewInfo(null); - setFunctionMeta(null); - setMatViewStats(null); - - void fetchLiveData(); - }, [open, objectType, projectId, schema, name]); - - const fetchLiveData = useCallback(async () => { - const driver = getDriver(); - if (!driver) return; - setLoading(true); - - try { - if (objectType === "table") { - const [statsResult, outFKResult, inFKResult] = await Promise.allSettled( - [ - driver.loadTableStatistics?.(projectId, schema, name), - driver.loadFKDetails?.(projectId, schema, name, "outgoing"), - driver.loadFKDetails?.(projectId, schema, name, "incoming"), - ], - ); - - // Ensure columns & indexes are loaded (may already be cached) - if (!columnDetails[metaKey]) { - storeLoadColumnDetails(projectId, schema, name).catch(() => {}); - } - if (!indexes[metaKey]) { - storeLoadIndexes(projectId, schema, name).catch(() => {}); - } - - if (statsResult.status === "fulfilled" && statsResult.value) { - const statsMap = Object.fromEntries(statsResult.value); - setTableStats({ - rowEstimate: statsMap.row_estimate ?? "0", - tableSize: statsMap.table_size ?? "-", - indexSize: statsMap.index_size ?? "-", - totalSize: statsMap.total_size ?? "-", - lastVacuum: statsMap.last_vacuum ?? "never", - lastAnalyze: statsMap.last_analyze ?? "never", - lastAutoVacuum: statsMap.last_autovacuum ?? "never", - lastAutoAnalyze: statsMap.last_autoanalyze ?? "never", - deadTuples: statsMap.dead_tuples ?? "0", - liveTuples: statsMap.live_tuples ?? "0", - seqScan: statsMap.seq_scan ?? "0", - idxScan: statsMap.idx_scan ?? "0", - }); - } - - const parseFKs = ( - result: PromiseSettledResult< - | [ - string, - string, - string, - string, - string, - string, - string, - string, - string, - ][] - | undefined - >, - ) => { - if (result.status !== "fulfilled" || !result.value) return []; - return result.value.map((r) => ({ - constraintName: r[0], - sourceSchema: r[1], - sourceTable: r[2], - sourceColumn: r[3], - targetSchema: r[4], - targetTable: r[5], - targetColumn: r[6], - onUpdate: r[7], - onDelete: r[8], - })); - }; - setOutgoingFKs(parseFKs(outFKResult)); - setIncomingFKs(parseFKs(inFKResult)); - } else if (objectType === "view") { - const info = await driver.loadViewInfo?.(projectId, schema, name); - if (info) { - const infoMap = Object.fromEntries(info); - setViewInfo({ - isUpdatable: infoMap.is_updatable ?? "NO", - checkOption: infoMap.check_option ?? "NONE", - definition: infoMap.definition ?? "", - }); - } - } else if (objectType === "matview") { - const info = await driver.loadMatviewInfo?.(projectId, schema, name); - if (info) { - const infoMap = Object.fromEntries(info); - setMatViewStats({ - rowEstimate: infoMap.row_estimate ?? "0", - totalSize: infoMap.total_size ?? "-", - isPopulated: infoMap.is_populated ?? "NO", - definition: infoMap.definition ?? "", - }); - } - } else if ( - objectType === "function" || - objectType === "trigger-function" - ) { - const info = await driver.loadFunctionInfo?.(projectId, schema, name); - if (info) { - const infoMap = Object.fromEntries(info); - setFunctionMeta({ - language: infoMap.language ?? "", - volatility: infoMap.volatility ?? "", - isStrict: infoMap.is_strict === "true", - securityDefiner: infoMap.security_definer === "true", - estimatedCost: infoMap.estimated_cost ?? "", - estimatedRows: infoMap.estimated_rows ?? "", - returnType: infoMap.return_type ?? "", - arguments: infoMap.arguments ?? "", - source: infoMap.source ?? "", - }); - } - } - } catch (err) { - console.error("Failed to fetch live data:", err); - } finally { - setLoading(false); - } - }, [ - getDriver, - objectType, - projectId, - schema, - name, - columnDetails, - indexes, - metaKey, - storeLoadColumnDetails, - storeLoadIndexes, - ]); - - // Fetch DDL via backend - const fetchDDL = useCallback(async () => { - const driver = getDriver(); - if (!driver) return; - - setDdlLoading(true); - setDdlError(null); - setDdl(null); - - try { - if (driver.generateDDL) { - const result = await driver.generateDDL( - projectId, - schema, - name, - objectType, - ); - setDdl(result || "No DDL available"); - } - } catch (err: any) { - setDdlError(err?.message ?? "Failed to fetch DDL"); - } finally { - setDdlLoading(false); - } - }, [getDriver, objectType, projectId, schema, name]); - - useEffect(() => { - if (open && activeTab === "ddl" && !ddl && !ddlLoading) { - void fetchDDL(); - } - }, [open, activeTab, ddl, ddlLoading, fetchDDL]); - - const copyText = (text: string, label: string) => { - navigator.clipboard.writeText(text); - setCopied(label); - setTimeout(() => setCopied(null), 2000); - }; - - const runAction = useCallback( - async (actionLabel: string) => { - const driver = getDriver(); - if (!driver?.tableAction) return; - setActionLoading(true); - setActionResult(null); - try { - const msg = await driver.tableAction(projectId, actionLabel, schema, name, objectType); - setActionResult({ type: "success", message: msg }); - // Refresh stats - void fetchLiveData(); - } catch (err: any) { - setActionResult({ - type: "error", - message: err?.message ?? "Action failed", - }); - } finally { - setActionLoading(false); - setConfirmAction(null); - } - }, - [getDriver, projectId, fetchLiveData], - ); - - const objectIcon: Record = { - table:
, - view: , - matview: , - function: , - "trigger-function": , - }; - - const objectLabel: Record = { - table: "Table", - view: "View", - matview: "Materialized View", - function: "Function", - "trigger-function": "Trigger Function", - }; - - // Build available tabs based on object type - const availableTabs: { key: Tab; label: string }[] = []; - availableTabs.push({ key: "overview", label: "Overview" }); - if (objectType === "table") { - availableTabs.push({ key: "structure", label: "Structure" }); - availableTabs.push({ - key: "columns", - label: `Columns${cols ? ` (${cols.length})` : ""}`, - }); - availableTabs.push({ - key: "indexes", - label: `Indexes${idxs ? ` (${new Set(idxs.map((i) => i.indexName)).size})` : ""}`, - }); - availableTabs.push({ key: "fkeys", label: `Foreign Keys` }); - } - availableTabs.push({ key: "ddl", label: "DDL" }); - availableTabs.push({ key: "actions", label: "Actions" }); - - const typeColor: Record = { - table: "from-primary/20 to-primary/5", - view: "from-blue-500/20 to-blue-500/5", - matview: "from-purple-500/20 to-purple-500/5", - function: "from-amber-500/20 to-amber-500/5", - "trigger-function": "from-orange-500/20 to-orange-500/5", - }; - - const tabIcons: Partial> = { - overview: , - columns: , - indexes: , - fkeys: , - structure: , - ddl: , - actions: , - }; - - return ( - - - {/* Header with gradient accent */} -
-
- -
-
- {objectIcon[objectType]} -
-
- - {name} - - - - - {objectLabel[objectType]} - - {schema} - | - - {projectId} - - {loading && } - -
-
-
- - {/* Tab switcher - pill style */} -
- {availableTabs.map((tab) => ( - - ))} -
-
- - {/* Content */} -
- {activeTab === "structure" && objectType === "table" && ( - { - void fetchLiveData(); - // Invalidate cached metadata so it re-fetches - useProjectStore.setState((s) => { - delete s.columnDetails[metaKey]; - delete s.indexes[metaKey]; - delete s.constraints[metaKey]; - }); - }} - openTab={openTab} - onOpenChange={onOpenChange} - /> - )} - {activeTab === "overview" && ( - - )} - {activeTab === "columns" && ( - - )} - {activeTab === "indexes" && } - {activeTab === "fkeys" && ( - - )} - {activeTab === "ddl" && ( - ddl && copyText(ddl, "ddl")} - onRetry={fetchDDL} - onOpenInTab={() => { - if (ddl) { - openTab(projectId, ddl); - onOpenChange(false); - } - }} - /> - )} - {activeTab === "actions" && ( - { - setConfirmAction(v); - setConfirmInput(""); - }} - confirmInput={confirmInput} - setConfirmInput={setConfirmInput} - runAction={runAction} - openTab={openTab} - projectId={projectId} - onOpenChange={onOpenChange} - /> - )} -
- -
- ); -} - -function OverviewContent({ - objectType, - tableStats, - viewInfo, - matViewStats, - functionMeta, - cons, - trigs, - rls, - pols, - copyText, - copied, -}: { - objectType: ObjectType; - tableStats: TableStats | null; - viewInfo: ViewInfo | null; - matViewStats: MatViewStats | null; - functionMeta: FunctionMeta | null; - cons?: import("@/types").ConstraintDetail[]; - trigs?: import("@/types").TriggerDetail[]; - rls?: import("@/types").RuleDetail[]; - pols?: import("@/types").PolicyDetail[]; - copyText: (text: string, label: string) => void; - copied: string | null; -}) { - if (objectType === "table") { - if (!tableStats) { - return ; - } - return ( -
- {/* Stats grid */} -
- } - /> - } - /> - } - /> - } - /> - } - /> - } - /> -
- - {/* Scan stats */} - } - > -
-
-
- Sequential Scans -
-
- {Number(tableStats.seqScan).toLocaleString()} -
-
-
-
- Index Scans -
-
- {Number(tableStats.idxScan).toLocaleString()} -
-
-
-
- - {/* Maintenance */} - } - > -
- - - - -
-
- - {/* Constraints summary */} - {cons && cons.length > 0 && ( - } - > -
- {Array.from(new Set(cons.map((c) => c.constraintName))).map( - (cName) => { - const f = cons.find((c) => c.constraintName === cName)!; - const entries = cons.filter( - (c) => c.constraintName === cName, - ); - return ( -
- - {cName} - - {f.constraintType} - - - ({entries.map((e) => e.columnName).join(", ")}) - -
- ); - }, - )} -
-
- )} - - {/* Triggers */} - {trigs && trigs.length > 0 && ( - } - > -
- {trigs.map((t) => ( -
- - {t.triggerName} - - {t.timing} {t.event} - -
- ))} -
-
- )} - - {/* RLS */} - {pols && pols.length > 0 && ( - } - > -
- {pols.map((p) => ( -
- - {p.policyName} - - {p.permissive} {p.command} - -
- ))} -
-
- )} - - {/* Rules */} - {rls && rls.length > 0 && ( - } - > -
- {rls.map((r) => ( -
- - {r.ruleName} - {r.event} -
- ))} -
-
- )} -
- ); - } - - if (objectType === "view") { - if (!viewInfo) return ; - return ( -
-
- } - /> - } - /> -
- } - > -
-            {viewInfo.definition}
-          
-
-
- ); - } - - if (objectType === "matview") { - if (!matViewStats) return ; - return ( -
-
- } - /> - } - /> - } - /> -
- } - > -
-            {matViewStats.definition}
-          
-
-
- ); - } - - if (objectType === "function" || objectType === "trigger-function") { - if (!functionMeta) return ; - return ( -
-
- } - /> - } - /> - } - /> -
-
- - - - -
- {functionMeta.arguments && ( - } - > -
- {functionMeta.arguments} -
-
- )} - } - > -
- -
-              {functionMeta.source}
-            
-
-
-
- ); - } - - return ; -} - -function ColumnsContent({ - cols, - pkCols, -}: { - cols?: import("@/types").ColumnDetail[]; - pkCols: Set; -}) { - if (!cols) { - return ; - } - - return ( -
-
-
- - - - - - - - - - - - {cols.map((c, i) => ( - - - - - - - - - ))} - -
- # - - Name - - Type - - Nullable - - Default -
- {i + 1} - - {pkCols.has(c.name) ? ( - - ) : ( - - )} - - {c.name} - - - {c.dataType} - - - {c.nullable ? ( - YES - ) : ( - - NOT NULL - - )} - - {c.defaultValue ?? ( - - - )} -
-
-
- ); -} - -function IndexesContent({ idxs }: { idxs?: import("@/types").IndexDetail[] }) { - if (!idxs) { - return ; - } - - if (idxs.length === 0) { - return ( -
- - No indexes found -
- ); - } - - const grouped = new Map(); - for (const idx of idxs) { - if (!grouped.has(idx.indexName)) grouped.set(idx.indexName, []); - grouped.get(idx.indexName)!.push(idx); - } - - return ( -
-
- - - - - - - - - - - {Array.from(grouped.entries()).map(([idxName, entries]) => { - const f = entries[0]; - return ( - - - - - - - ); - })} - -
- Index Name - - Columns - - Type -
- {f.isPrimary ? ( - - ) : f.isUnique ? ( - - ) : ( - - )} - - {idxName} - - {entries.map((e) => e.columnName).join(", ")} - - {f.isPrimary ? ( - - PRIMARY KEY - - ) : f.isUnique ? ( - - UNIQUE - - ) : ( - - INDEX - - )} -
-
-
- ); -} - -function ForeignKeysContent({ - outgoingFKs, - incomingFKs, - openTab, - projectId, - onOpenChange, -}: { - outgoingFKs: FKInfo[]; - incomingFKs: FKInfo[]; - openTab: (projectId?: string, sql?: string) => void; - projectId: string; - onOpenChange: (open: boolean) => void; -}) { - if (outgoingFKs.length === 0 && incomingFKs.length === 0) { - return ( -
- No foreign key relationships found. -
- ); - } - - return ( -
- {outgoingFKs.length > 0 && ( - } - > -
- - - - - - - - - - - - {outgoingFKs.map((fk, i) => ( - - - - - - - - ))} - -
- Constraint - - Column - - References - - ON DELETE - - ON UPDATE -
- {fk.constraintName} - - {fk.sourceColumn} - - - - {fk.onDelete} - - {fk.onUpdate} -
-
-
- )} - - {incomingFKs.length > 0 && ( - } - > -
- - - - - - - - - - - {incomingFKs.map((fk, i) => ( - - - - - - - ))} - -
- Constraint - - From Table - - Column - - ON DELETE -
- {fk.constraintName} - - - - {fk.sourceColumn} → {fk.targetColumn} - - {fk.onDelete} -
-
-
- )} -
- ); -} - -function DDLContent({ - ddl, - ddlLoading, - ddlError, - copied, - onCopy, - onRetry, - onOpenInTab, -}: { - ddl: string | null; - ddlLoading: boolean; - ddlError: string | null; - copied: string | null; - onCopy: () => void; - onRetry: () => void; - onOpenInTab: () => void; -}) { - if (ddlLoading) { - return ; - } - - if (ddlError) { - return ( -
-
- -
-

{ddlError}

- -
- ); - } - - if (!ddl) { - return ( -
- - No DDL available -
- ); - } - - return ( -
- {/* Code editor style container */} -
- {/* Title bar */} -
-
- - - DDL - -
-
- - -
-
-
-          {ddl}
-        
-
-
- ); -} - -function ActionsContent({ - objectType, - schema, - name, - actionResult, - actionLoading, - confirmAction, - setConfirmAction, - confirmInput, - setConfirmInput, - runAction, - openTab, - projectId, - onOpenChange, -}: { - objectType: ObjectType; - schema: string; - name: string; - actionResult: { type: "success" | "error"; message: string } | null; - actionLoading: boolean; - confirmAction: string | null; - setConfirmAction: (action: string | null) => void; - confirmInput: string; - setConfirmInput: (value: string) => void; - runAction: (actionLabel: string) => Promise; - openTab: (projectId?: string, sql?: string) => void; - projectId: string; - onOpenChange: (open: boolean) => void; -}) { - const qualified = `"${schema}"."${name}"`; - - const actions: { - label: string; - icon: React.ReactNode; - destructive?: boolean; - confirm?: boolean; - description: string; - }[] = []; - - if (objectType === "table") { - actions.push( - { label: "ANALYZE", icon: , confirm: true, description: "Update table statistics for the query planner." }, - { label: "VACUUM", icon: , confirm: true, description: "Reclaim storage occupied by dead tuples." }, - { label: "VACUUM FULL", icon: , confirm: true, description: "Rewrite table to reclaim max space. Locks table exclusively." }, - { label: "REINDEX", icon: , confirm: true, description: "Rebuild all indexes on this table." }, - { label: "TRUNCATE", icon: , destructive: true, confirm: true, description: "Remove all rows. Cannot be rolled back." }, - { label: "DROP TABLE", icon: , destructive: true, confirm: true, description: "Permanently delete this table and all its data." }, - ); - } else if (objectType === "view") { - actions.push( - { label: "DROP VIEW", icon: , destructive: true, confirm: true, description: "Permanently delete this view." }, - { label: "DROP VIEW CASCADE", icon: , destructive: true, confirm: true, description: "Drop view and all dependent objects." }, - ); - } else if (objectType === "matview") { - actions.push( - { label: "REFRESH", icon: , confirm: true, description: "Refresh data by re-executing the query." }, - { label: "REFRESH CONCURRENTLY", icon: , confirm: true, description: "Refresh without locking reads. Requires a unique index." }, - { label: "DROP MATERIALIZED VIEW", icon: , destructive: true, confirm: true, description: "Permanently delete this materialized view." }, - ); - } else if (objectType === "function" || objectType === "trigger-function") { - actions.push( - { label: "DROP FUNCTION", icon: , destructive: true, confirm: true, description: "Permanently delete this function." }, - { label: "DROP FUNCTION CASCADE", icon: , destructive: true, confirm: true, description: "Drop function and all dependent objects (triggers, etc.)." }, - ); - } - - return ( -
- {/* Quick open in tab */} - {objectType === "table" && ( -
-
- Quick Queries -
-
- - - -
-
- )} - - {/* Action result */} - {actionResult && ( -
- {actionResult.type === "success" ? ( - - ) : ( - - )} - {actionResult.message} -
- )} - - {/* Action buttons */} -
- Maintenance & Operations -
-
- {actions.map((action) => ( -
-
- - {action.icon} - -
-
- {action.label} -
-
- {action.description} -
-
- {confirmAction !== action.label && ( - - )} -
- {confirmAction === action.label && ( -
-
- - Type{" "} - - {name} - {" "} - to confirm - - setConfirmInput(e.target.value)} - placeholder={name} - autoFocus - className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/40 rounded-md outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 placeholder:text-muted-foreground/30" - /> -
- - -
- )} -
- ))} -
-
- ); -} - -type StructureSubTab = "columns" | "pk" | "fkeys" | "unique" | "indexes"; - -function uid() { - return crypto.randomUUID(); -} - -function initStructureState( - cols: import("@/types").ColumnDetail[] | undefined, - idxs: import("@/types").IndexDetail[] | undefined, - cons: import("@/types").ConstraintDetail[] | undefined, - outgoingFKs: FKInfo[], -): StructureEditorState { - const columns: DraftColumn[] = (cols ?? []).map((c) => ({ - _id: uid(), - _status: "existing" as const, - name: c.name, - dataType: c.dataType, - nullable: c.nullable, - defaultValue: c.defaultValue, - originalName: c.name, - originalDataType: c.dataType, - originalNullable: c.nullable, - originalDefault: c.defaultValue, - })); - - // Primary key from indexes - const pkEntries = (idxs ?? []).filter((i) => i.isPrimary); - const pkName = pkEntries[0]?.indexName ?? ""; - const primaryKey: DraftPrimaryKey | null = - pkEntries.length > 0 - ? { - constraintName: pkName, - columns: pkEntries.map((e) => e.columnName), - _status: "existing", - originalColumns: pkEntries.map((e) => e.columnName), - } - : null; - - // Unique constraints from constraints - const uniqueMap = new Map(); - for (const c of cons ?? []) { - if (c.constraintType === "UNIQUE") { - const existing = uniqueMap.get(c.constraintName) ?? []; - existing.push(c.columnName); - uniqueMap.set(c.constraintName, existing); - } - } - const uniqueConstraints: DraftUniqueConstraint[] = [ - ...uniqueMap.entries(), - ].map(([name, ucCols]) => ({ - _id: uid(), - _status: "existing" as const, - constraintName: name, - columns: ucCols, - })); - - // Non-primary, non-unique indexes - const idxMap = new Map(); - for (const i of idxs ?? []) { - if (i.isPrimary) continue; - // Skip indexes that back a unique constraint - if (uniqueMap.has(i.indexName)) continue; - const existing = idxMap.get(i.indexName) ?? { - columns: [], - isUnique: i.isUnique, - }; - existing.columns.push(i.columnName); - idxMap.set(i.indexName, existing); - } - const indexes: DraftIndex[] = [...idxMap.entries()].map(([name, info]) => ({ - _id: uid(), - _status: "existing" as const, - indexName: name, - columns: info.columns, - isUnique: info.isUnique, - })); - - // Foreign keys: group by constraintName - const fkMap = new Map(); - for (const fk of outgoingFKs) { - const existing = fkMap.get(fk.constraintName) ?? []; - existing.push(fk); - fkMap.set(fk.constraintName, existing); - } - const foreignKeys: DraftForeignKey[] = [...fkMap.entries()].map( - ([name, fks]) => ({ - _id: uid(), - _status: "existing" as const, - constraintName: name, - sourceColumns: fks.map((f) => f.sourceColumn), - targetSchema: fks[0].targetSchema, - targetTable: fks[0].targetTable, - targetColumns: fks.map((f) => f.targetColumn), - onUpdate: fks[0].onUpdate, - onDelete: fks[0].onDelete, - }), - ); - - return { columns, primaryKey, foreignKeys, uniqueConstraints, indexes }; -} - -function StructureEditorContent({ - projectId, - schema, - tableName, - cols, - idxs, - cons, - outgoingFKs, - getDriver, - onApplied, - openTab, - onOpenChange, -}: { - projectId: string; - schema: string; - tableName: string; - cols: import("@/types").ColumnDetail[] | undefined; - idxs: import("@/types").IndexDetail[] | undefined; - cons: import("@/types").ConstraintDetail[] | undefined; - outgoingFKs: FKInfo[]; - getDriver: () => ReturnType | null; - onApplied: () => void; - openTab: (projectId?: string, sql?: string) => void; - onOpenChange: (open: boolean) => void; -}) { - const [subTab, setSubTab] = useState("columns"); - const [applying, setApplying] = useState(false); - const [error, setError] = useState(null); - const [showSql, setShowSql] = useState(false); - - // Build initial state - const initialState = useMemo( - () => initStructureState(cols, idxs, cons, outgoingFKs), - [cols, idxs, cons, outgoingFKs], - ); - const [draft, setDraft] = useState(initialState); - - // Reset when initial state changes (e.g. modal re-opened) - useEffect(() => { - setDraft(initialState); - setError(null); - setShowSql(false); - }, [initialState]); - - const changes = countChanges(draft); - const activeColNames = draft.columns - .filter((c) => c._status !== "removed") - .map((c) => c.name); - - // Tables for FK target (from store) - const tables = useProjectStore((s) => s.tables); - const schemas = useProjectStore((s) => s.schemas); - const loadTables = useProjectStore((s) => s.loadTables); - const availableSchemas = schemas[projectId] ?? []; - const getTablesForSchema = (s: string) => - (tables[`${projectId}::${s}`] ?? []).map((t) => t.name); - - // SQL preview - const sqlStatements = useMemo( - () => generateAlterTableSQL(schema, tableName, initialState, draft), - [schema, tableName, initialState, draft], - ); - const sqlPreview = sqlStatements.join("\n"); - - // Apply changes - const applyChanges = useCallback(async () => { - const driver = getDriver(); - if (!driver || sqlStatements.length === 0) return; - setApplying(true); - setError(null); - try { - await driver.runQuery(projectId, "BEGIN"); - try { - for (const stmt of sqlStatements) { - await driver.runQuery(projectId, stmt); - } - await driver.runQuery(projectId, "COMMIT"); - } catch (err) { - await driver.runQuery(projectId, "ROLLBACK").catch(() => {}); - throw err; - } - toast.success("Table structure updated"); - onApplied(); - } catch (err: any) { - setError(err?.message ?? "Failed to apply changes"); - } finally { - setApplying(false); - } - }, [getDriver, projectId, sqlStatements, onApplied]); - - // Column helpers - const updateColumn = (id: string, updates: Partial) => { - setDraft((prev) => ({ - ...prev, - columns: prev.columns.map((c) => { - if (c._id !== id) return c; - const updated = { ...c, ...updates }; - // Mark as modified if it was existing and something changed - if (c._status === "existing") { - const changed = - updated.name !== c.originalName || - updated.dataType !== c.originalDataType || - updated.nullable !== c.originalNullable || - updated.defaultValue !== c.originalDefault; - updated._status = changed ? "modified" : "existing"; - } - return updated; - }), - })); - }; - - const addColumn = () => { - setDraft((prev) => ({ - ...prev, - columns: [ - ...prev.columns, - { - _id: uid(), - _status: "added", - name: `new_column_${prev.columns.length + 1}`, - dataType: "text", - nullable: true, - defaultValue: null, - }, - ], - })); - }; - - const removeColumn = (id: string) => { - setDraft((prev) => ({ - ...prev, - columns: prev.columns - .map((c) => - c._id === id - ? c._status === "added" - ? null - : { ...c, _status: "removed" as const } - : c, - ) - .filter(Boolean) as DraftColumn[], - })); - }; - - const restoreColumn = (id: string) => { - setDraft((prev) => ({ - ...prev, - columns: prev.columns.map((c) => - c._id === id ? { ...c, _status: "existing" as const } : c, - ), - })); - }; - - // Sub-tab list - const subTabs: { key: StructureSubTab; label: string }[] = [ - { key: "columns", label: "Columns" }, - { key: "pk", label: "Primary Key" }, - { key: "fkeys", label: "Foreign Keys" }, - { key: "unique", label: "Unique" }, - { key: "indexes", label: "Indexes" }, - ]; - - if (!cols) return ; - - return ( -
- {/* Sub-tab nav */} -
- {subTabs.map((t) => ( - - ))} -
- - {/* Sub-tab content */} -
- {subTab === "columns" && ( -
- {/* Header */} -
- Name - Type - Nullable - Default - -
- {draft.columns.map((col) => ( -
- - updateColumn(col._id, { name: e.target.value }) - } - className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 disabled:opacity-40" - /> - - updateColumn(col._id, { dataType: e.target.value }) - } - list="pg-types" - className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 disabled:opacity-40" - /> -
- - updateColumn(col._id, { nullable: e.target.checked }) - } - className="h-3.5 w-3.5 rounded border-border accent-primary" - /> -
- - updateColumn(col._id, { - defaultValue: e.target.value || null, - }) - } - placeholder="NULL" - className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 placeholder:text-muted-foreground/30 disabled:opacity-40" - /> -
- {col._status === "removed" ? ( - - ) : ( - - )} -
-
- ))} - - {/* HTML datalist for type suggestions */} - - {PG_COMMON_TYPES.map((t) => ( - -
- )} - - {subTab === "pk" && ( -
-
- Select columns for primary key -
-
- {activeColNames.map((colName) => { - const isInPK = - draft.primaryKey?.columns.includes(colName) && - draft.primaryKey._status !== "removed"; - return ( - - ); - })} -
- {draft.primaryKey && - draft.primaryKey._status !== "removed" && - draft.primaryKey.columns.length > 0 && ( -
- Constraint:{" "} - - {draft.primaryKey.constraintName} - - {" — "}({draft.primaryKey.columns.join(", ")}) -
- )} -
- )} - - {subTab === "fkeys" && ( -
- {draft.foreignKeys - .filter((fk) => fk._status !== "removed") - .map((fk) => ( - { - setDraft((prev) => ({ - ...prev, - foreignKeys: prev.foreignKeys.map((f) => - f._id === fk._id - ? { - ...f, - ...updates, - _status: - f._status === "existing" - ? "existing" - : f._status, - } - : f, - ), - })); - }} - onRemove={() => { - setDraft((prev) => ({ - ...prev, - foreignKeys: prev.foreignKeys - .map((f) => - f._id === fk._id - ? f._status === "added" - ? null - : { ...f, _status: "removed" as const } - : f, - ) - .filter(Boolean) as DraftForeignKey[], - })); - }} - /> - ))} - {draft.foreignKeys - .filter((fk) => fk._status === "removed") - .map((fk) => ( -
- - {fk.constraintName} - - -
- ))} - -
- )} - - {subTab === "unique" && ( -
- {draft.uniqueConstraints - .filter((uc) => uc._status !== "removed") - .map((uc) => ( -
-
- { - setDraft((prev) => ({ - ...prev, - uniqueConstraints: prev.uniqueConstraints.map((u) => - u._id === uc._id - ? { ...u, constraintName: e.target.value } - : u, - ), - })); - }} - className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50" - placeholder="Constraint name" - /> - -
-
- Columns -
-
- {activeColNames.map((colName) => { - const selected = uc.columns.includes(colName); - return ( - - ); - })} -
-
- ))} - {draft.uniqueConstraints - .filter((uc) => uc._status === "removed") - .map((uc) => ( -
- - {uc.constraintName} - - -
- ))} - -
- )} - - {subTab === "indexes" && ( -
- {draft.indexes - .filter((idx) => idx._status !== "removed") - .map((idx) => ( -
-
- { - setDraft((prev) => ({ - ...prev, - indexes: prev.indexes.map((i) => - i._id === idx._id - ? { ...i, indexName: e.target.value } - : i, - ), - })); - }} - className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50" - placeholder="Index name" - /> - - -
-
- Columns -
-
- {activeColNames.map((colName) => { - const selected = idx.columns.includes(colName); - return ( - - ); - })} -
-
- ))} - {draft.indexes - .filter((idx) => idx._status === "removed") - .map((idx) => ( -
- - {idx.indexName} - - -
- ))} - -
- )} -
- - {/* SQL preview panel */} - {showSql && sqlStatements.length > 0 && ( -
-
- - SQL Preview - -
- - -
-
-
-            {sqlPreview}
-          
-
- )} - - {/* Error */} - {error && ( -
- - {error} -
- )} - - {/* Bottom action bar */} - {changes > 0 && ( -
- - {changes} change{changes !== 1 ? "s" : ""} - -
- - - -
- )} -
- ); -} - -function FKCard({ - fk, - activeColNames, - availableSchemas, - getTablesForSchema, - loadTables, - projectId, - getDriver, - onChange, - onRemove, -}: { - fk: DraftForeignKey; - activeColNames: string[]; - availableSchemas: string[]; - getTablesForSchema: (schema: string) => string[]; - loadTables: (projectId: string, schema: string) => Promise; - projectId: string; - getDriver: () => ReturnType | null; - onChange: (updates: Partial) => void; - onRemove: () => void; -}) { - const [targetCols, setTargetCols] = useState([]); - - // Load target table columns when target changes - useEffect(() => { - if (!fk.targetTable || !fk.targetSchema) { - setTargetCols([]); - return; - } - const driver = getDriver(); - if (!driver) return; - driver - .loadColumns(projectId, fk.targetSchema, fk.targetTable) - .then(setTargetCols) - .catch(() => setTargetCols([])); - }, [fk.targetSchema, fk.targetTable, projectId, getDriver]); - - // Ensure tables are loaded for the selected schema - useEffect(() => { - if (fk.targetSchema) { - loadTables(projectId, fk.targetSchema).catch(() => {}); - } - }, [fk.targetSchema, projectId, loadTables]); - - const targetTableNames = getTablesForSchema(fk.targetSchema); - - return ( -
- {/* Name + delete */} -
- onChange({ constraintName: e.target.value })} - className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50" - placeholder="Constraint name" - /> - -
- - {/* Target: schema + table */} -
-
-
- Target Schema -
- -
-
-
- Target Table -
- -
-
- - {/* Column mapping: source → target (paired rows) */} -
-
-
Column Mapping
-
-
-
- Source - - Target - -
- {fk.sourceColumns.map((srcCol, idx) => ( -
- - - - -
- ))} - -
-
- - {/* ON UPDATE / ON DELETE */} -
-
-
- On Update -
- -
-
-
- On Delete -
- -
-
-
- ); -} - -function StatCard({ - label, - value, - icon, - accent, -}: { - label: string; - value: string; - icon: React.ReactNode; - accent?: string; -}) { - return ( -
-
-
-
- {icon} -
-
-
- {label} -
-
- {value} -
-
-
-
- ); -} - -function InfoRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - - {value} - -
- ); -} - -function PropertySection({ - title, - icon, - children, -}: { - title: string; - icon: React.ReactNode; - children: React.ReactNode; -}) { - return ( -
-
-
- {icon} -
- - {title} - -
-
- {children} -
- ); -} - -function ConstraintIcon({ type }: { type: string }) { - if (type === "PRIMARY KEY") - return ; - if (type === "FOREIGN KEY") - return ; - if (type === "UNIQUE") - return ; - if (type === "CHECK") - return ; - return ; -} - -function LoadingPlaceholder() { - return ( -
-
-
- -
- Loading... -
- ); -} - -function formatTimestamp(ts: string): string { - if (ts === "never") return "never"; - try { - const d = new Date(ts); - if (isNaN(d.getTime())) return ts; - const now = Date.now(); - const diff = now - d.getTime(); - if (diff < 60000) return "just now"; - if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; - if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; - if (diff < 2592000000) return `${Math.floor(diff / 86400000)}d ago`; - return d.toLocaleDateString(); - } catch { - return ts; - } -} diff --git a/src/components/object-properties-modal/actions-tab.tsx b/src/components/object-properties-modal/actions-tab.tsx new file mode 100644 index 0000000..3f07435 --- /dev/null +++ b/src/components/object-properties-modal/actions-tab.tsx @@ -0,0 +1,261 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + AlertTriangle, + Check, + Key, + Loader2, + Play, + RefreshCw, + Trash2, +} from "lucide-react"; +import type { ObjectType } from "./types"; + +export function ActionsContent({ + objectType, + schema, + name, + actionResult, + actionLoading, + confirmAction, + setConfirmAction, + confirmInput, + setConfirmInput, + runAction, + openTab, + projectId, + onOpenChange, +}: { + objectType: ObjectType; + schema: string; + name: string; + actionResult: { type: "success" | "error"; message: string } | null; + actionLoading: boolean; + confirmAction: string | null; + setConfirmAction: (action: string | null) => void; + confirmInput: string; + setConfirmInput: (value: string) => void; + runAction: (actionLabel: string) => Promise; + openTab: (projectId?: string, sql?: string) => void; + projectId: string; + onOpenChange: (open: boolean) => void; +}) { + const qualified = `"${schema}"."${name}"`; + + const actions: { + label: string; + icon: React.ReactNode; + destructive?: boolean; + confirm?: boolean; + description: string; + }[] = []; + + if (objectType === "table") { + actions.push( + { label: "ANALYZE", icon: , confirm: true, description: "Update table statistics for the query planner." }, + { label: "VACUUM", icon: , confirm: true, description: "Reclaim storage occupied by dead tuples." }, + { label: "VACUUM FULL", icon: , confirm: true, description: "Rewrite table to reclaim max space. Locks table exclusively." }, + { label: "REINDEX", icon: , confirm: true, description: "Rebuild all indexes on this table." }, + { label: "TRUNCATE", icon: , destructive: true, confirm: true, description: "Remove all rows. Cannot be rolled back." }, + { label: "DROP TABLE", icon: , destructive: true, confirm: true, description: "Permanently delete this table and all its data." }, + ); + } else if (objectType === "view") { + actions.push( + { label: "DROP VIEW", icon: , destructive: true, confirm: true, description: "Permanently delete this view." }, + { label: "DROP VIEW CASCADE", icon: , destructive: true, confirm: true, description: "Drop view and all dependent objects." }, + ); + } else if (objectType === "matview") { + actions.push( + { label: "REFRESH", icon: , confirm: true, description: "Refresh data by re-executing the query." }, + { label: "REFRESH CONCURRENTLY", icon: , confirm: true, description: "Refresh without locking reads. Requires a unique index." }, + { label: "DROP MATERIALIZED VIEW", icon: , destructive: true, confirm: true, description: "Permanently delete this materialized view." }, + ); + } else if (objectType === "function" || objectType === "trigger-function") { + actions.push( + { label: "DROP FUNCTION", icon: , destructive: true, confirm: true, description: "Permanently delete this function." }, + { label: "DROP FUNCTION CASCADE", icon: , destructive: true, confirm: true, description: "Drop function and all dependent objects (triggers, etc.)." }, + ); + } + + return ( +
+ {/* Quick open in tab */} + {objectType === "table" && ( +
+
+ Quick Queries +
+
+ + + +
+
+ )} + + {/* Action result */} + {actionResult && ( +
+ {actionResult.type === "success" ? ( + + ) : ( + + )} + {actionResult.message} +
+ )} + + {/* Action buttons */} +
+ Maintenance & Operations +
+
+ {actions.map((action) => ( +
+
+ + {action.icon} + +
+
+ {action.label} +
+
+ {action.description} +
+
+ {confirmAction !== action.label && ( + + )} +
+ {confirmAction === action.label && ( +
+
+ + Type{" "} + + {name} + {" "} + to confirm + + setConfirmInput(e.target.value)} + placeholder={name} + autoFocus + className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/40 rounded-md outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 placeholder:text-muted-foreground/30" + /> +
+ + +
+ )} +
+ ))} +
+
+ ); +} diff --git a/src/components/object-properties-modal/columns-tab.tsx b/src/components/object-properties-modal/columns-tab.tsx new file mode 100644 index 0000000..26512d9 --- /dev/null +++ b/src/components/object-properties-modal/columns-tab.tsx @@ -0,0 +1,87 @@ +import { Columns3, Key } from "lucide-react"; +import { LoadingPlaceholder } from "./shared"; + +export function ColumnsContent({ + cols, + pkCols, +}: { + cols?: import("@/types").ColumnDetail[]; + pkCols: Set; +}) { + if (!cols) { + return ; + } + + return ( +
+
+ + + + + + + + + + + + + {cols.map((c, i) => ( + + + + + + + + + ))} + +
+ # + + Name + + Type + + Nullable + + Default +
+ {i + 1} + + {pkCols.has(c.name) ? ( + + ) : ( + + )} + + {c.name} + + + {c.dataType} + + + {c.nullable ? ( + YES + ) : ( + + NOT NULL + + )} + + {c.defaultValue ?? ( + - + )} +
+
+
+ ); +} diff --git a/src/components/object-properties-modal/ddl-tab.tsx b/src/components/object-properties-modal/ddl-tab.tsx new file mode 100644 index 0000000..6a95388 --- /dev/null +++ b/src/components/object-properties-modal/ddl-tab.tsx @@ -0,0 +1,110 @@ +import { Button } from "@/components/ui/button"; +import { + AlertTriangle, + Check, + Copy, + FileCode, + Play, + RefreshCw, +} from "lucide-react"; +import { LoadingPlaceholder } from "./shared"; + +export function DDLContent({ + ddl, + ddlLoading, + ddlError, + copied, + onCopy, + onRetry, + onOpenInTab, +}: { + ddl: string | null; + ddlLoading: boolean; + ddlError: string | null; + copied: string | null; + onCopy: () => void; + onRetry: () => void; + onOpenInTab: () => void; +}) { + if (ddlLoading) { + return ; + } + + if (ddlError) { + return ( +
+
+ +
+

{ddlError}

+ +
+ ); + } + + if (!ddl) { + return ( +
+ + No DDL available +
+ ); + } + + return ( +
+ {/* Code editor style container */} +
+ {/* Title bar */} +
+
+ + + DDL + +
+
+ + +
+
+
+          {ddl}
+        
+
+
+ ); +} diff --git a/src/components/object-properties-modal/foreign-keys-tab.tsx b/src/components/object-properties-modal/foreign-keys-tab.tsx new file mode 100644 index 0000000..16eaa19 --- /dev/null +++ b/src/components/object-properties-modal/foreign-keys-tab.tsx @@ -0,0 +1,155 @@ +import { ArrowRight } from "lucide-react"; +import { PropertySection } from "./shared"; +import type { FKInfo } from "./types"; + +export function ForeignKeysContent({ + outgoingFKs, + incomingFKs, + openTab, + projectId, + onOpenChange, +}: { + outgoingFKs: FKInfo[]; + incomingFKs: FKInfo[]; + openTab: (projectId?: string, sql?: string) => void; + projectId: string; + onOpenChange: (open: boolean) => void; +}) { + if (outgoingFKs.length === 0 && incomingFKs.length === 0) { + return ( +
+ No foreign key relationships found. +
+ ); + } + + return ( +
+ {outgoingFKs.length > 0 && ( + } + > +
+ + + + + + + + + + + + {outgoingFKs.map((fk, i) => ( + + + + + + + + ))} + +
+ Constraint + + Column + + References + + ON DELETE + + ON UPDATE +
+ {fk.constraintName} + + {fk.sourceColumn} + + + + {fk.onDelete} + + {fk.onUpdate} +
+
+
+ )} + + {incomingFKs.length > 0 && ( + } + > +
+ + + + + + + + + + + {incomingFKs.map((fk, i) => ( + + + + + + + ))} + +
+ Constraint + + From Table + + Column + + ON DELETE +
+ {fk.constraintName} + + + + {fk.sourceColumn} → {fk.targetColumn} + + {fk.onDelete} +
+
+
+ )} +
+ ); +} diff --git a/src/components/object-properties-modal/index.tsx b/src/components/object-properties-modal/index.tsx new file mode 100644 index 0000000..f992179 --- /dev/null +++ b/src/components/object-properties-modal/index.tsx @@ -0,0 +1,256 @@ +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { useProjectStore } from "@/stores/project-store"; +import { useTabStore } from "@/stores/tab-store"; +import { useCallback, useEffect, useState } from "react"; +import { ActionsContent } from "./actions-tab"; +import { ColumnsContent } from "./columns-tab"; +import { DDLContent } from "./ddl-tab"; +import { ForeignKeysContent } from "./foreign-keys-tab"; +import { IndexesContent } from "./indexes-tab"; +import { ModalHeader } from "./modal-header"; +import { OverviewContent } from "./overview-tab"; +import { StructureEditorContent } from "./structure-editor"; +import type { ObjectPropertiesModalProps, Tab } from "./types"; +import { useObjectData } from "./use-object-data"; + +export function ObjectPropertiesModal({ + open, + onOpenChange, + objectType, + projectId, + schema, + name, +}: ObjectPropertiesModalProps) { + const defaultTab: Tab = + objectType === "table" + ? "overview" + : objectType === "function" || objectType === "trigger-function" + ? "overview" + : "overview"; + const [activeTab, setActiveTab] = useState(defaultTab); + const [copied, setCopied] = useState(null); + const [actionResult, setActionResult] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); + const [actionLoading, setActionLoading] = useState(false); + const [confirmAction, setConfirmAction] = useState(null); + const [confirmInput, setConfirmInput] = useState(""); + + const { + ddl, + ddlLoading, + ddlError, + loading, + tableStats, + outgoingFKs, + incomingFKs, + viewInfo, + functionMeta, + matViewStats, + metaKey, + getDriver, + fetchLiveData, + fetchDDL, + } = useObjectData(projectId, schema, name, objectType, open); + + // Cached metadata from store + const columnDetails = useProjectStore((s) => s.columnDetails); + const indexes = useProjectStore((s) => s.indexes); + const constraints = useProjectStore((s) => s.constraints); + const triggers = useProjectStore((s) => s.triggers); + const rules = useProjectStore((s) => s.rules); + const policies = useProjectStore((s) => s.policies); + const openTab = useTabStore((s) => s.openTab); + + const cols = columnDetails[metaKey]; + const idxs = indexes[metaKey]; + const cons = constraints[metaKey]; + const trigs = triggers[metaKey]; + const rls = rules[metaKey]; + const pols = policies[metaKey]; + const pkCols = new Set( + (idxs ?? []).filter((i) => i.isPrimary).map((i) => i.columnName), + ); + + // Reset UI state on open + useEffect(() => { + if (!open) return; + setActiveTab(objectType === "table" ? "overview" : "overview"); + setCopied(null); + setActionResult(null); + setConfirmAction(null); + }, [open, objectType, projectId, schema, name]); + + useEffect(() => { + if (open && activeTab === "ddl" && !ddl && !ddlLoading) { + void fetchDDL(); + } + }, [open, activeTab, ddl, ddlLoading, fetchDDL]); + + const copyText = (text: string, label: string) => { + navigator.clipboard.writeText(text); + setCopied(label); + setTimeout(() => setCopied(null), 2000); + }; + + const runAction = useCallback( + async (actionLabel: string) => { + const driver = getDriver(); + if (!driver?.tableAction) return; + setActionLoading(true); + setActionResult(null); + try { + const msg = await driver.tableAction(projectId, actionLabel, schema, name, objectType); + setActionResult({ type: "success", message: msg }); + // Refresh stats + void fetchLiveData(); + } catch (err: any) { + setActionResult({ + type: "error", + message: err?.message ?? "Action failed", + }); + } finally { + setActionLoading(false); + setConfirmAction(null); + } + }, + [getDriver, projectId, schema, name, objectType, fetchLiveData], + ); + + // Build available tabs based on object type + const availableTabs: { key: Tab; label: string }[] = []; + availableTabs.push({ key: "overview", label: "Overview" }); + if (objectType === "table") { + availableTabs.push({ key: "structure", label: "Structure" }); + availableTabs.push({ + key: "columns", + label: `Columns${cols ? ` (${cols.length})` : ""}`, + }); + availableTabs.push({ + key: "indexes", + label: `Indexes${idxs ? ` (${new Set(idxs.map((i) => i.indexName)).size})` : ""}`, + }); + availableTabs.push({ key: "fkeys", label: `Foreign Keys` }); + } + availableTabs.push({ key: "ddl", label: "DDL" }); + availableTabs.push({ key: "actions", label: "Actions" }); + + return ( + + + + + {/* Content */} +
+ {activeTab === "structure" && objectType === "table" && ( + { + void fetchLiveData(); + // Invalidate cached metadata so it re-fetches + useProjectStore.setState((s) => { + delete s.columnDetails[metaKey]; + delete s.indexes[metaKey]; + delete s.constraints[metaKey]; + }); + }} + openTab={openTab} + onOpenChange={onOpenChange} + /> + )} + {activeTab === "overview" && ( + + )} + {activeTab === "columns" && ( + + )} + {activeTab === "indexes" && } + {activeTab === "fkeys" && ( + + )} + {activeTab === "ddl" && ( + ddl && copyText(ddl, "ddl")} + onRetry={fetchDDL} + onOpenInTab={() => { + if (ddl) { + openTab(projectId, ddl); + onOpenChange(false); + } + }} + /> + )} + {activeTab === "actions" && ( + { + setConfirmAction(v); + setConfirmInput(""); + }} + confirmInput={confirmInput} + setConfirmInput={setConfirmInput} + runAction={runAction} + openTab={openTab} + projectId={projectId} + onOpenChange={onOpenChange} + /> + )} +
+
+
+ ); +} diff --git a/src/components/object-properties-modal/indexes-tab.tsx b/src/components/object-properties-modal/indexes-tab.tsx new file mode 100644 index 0000000..a5d2e87 --- /dev/null +++ b/src/components/object-properties-modal/indexes-tab.tsx @@ -0,0 +1,92 @@ +import { Key, Shield } from "lucide-react"; +import { LoadingPlaceholder } from "./shared"; + +export function IndexesContent({ + idxs, +}: { + idxs?: import("@/types").IndexDetail[]; +}) { + if (!idxs) { + return ; + } + + if (idxs.length === 0) { + return ( +
+ + No indexes found +
+ ); + } + + const grouped = new Map(); + for (const idx of idxs) { + if (!grouped.has(idx.indexName)) grouped.set(idx.indexName, []); + grouped.get(idx.indexName)!.push(idx); + } + + return ( +
+
+ + + + + + + + + + + {Array.from(grouped.entries()).map(([idxName, entries]) => { + const f = entries[0]; + return ( + + + + + + + ); + })} + +
+ Index Name + + Columns + + Type +
+ {f.isPrimary ? ( + + ) : f.isUnique ? ( + + ) : ( + + )} + + {idxName} + + {entries.map((e) => e.columnName).join(", ")} + + {f.isPrimary ? ( + + PRIMARY KEY + + ) : f.isUnique ? ( + + UNIQUE + + ) : ( + + INDEX + + )} +
+
+
+ ); +} diff --git a/src/components/object-properties-modal/modal-header.tsx b/src/components/object-properties-modal/modal-header.tsx new file mode 100644 index 0000000..581be4c --- /dev/null +++ b/src/components/object-properties-modal/modal-header.tsx @@ -0,0 +1,144 @@ +import { + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { + Check, + Columns3, + Copy, + Database, + Eye, + FileCode, + Key, + Layers, + Link2, + Loader2, + Pencil, + Table, + Zap, +} from "lucide-react"; +import type { ObjectType, Tab } from "./types"; + +const objectIcon: Record = { + table: , + view: , + matview: , + function: , + "trigger-function": , +}; + +const objectLabel: Record = { + table: "Table", + view: "View", + matview: "Materialized View", + function: "Function", + "trigger-function": "Trigger Function", +}; + +export const typeColor: Record = { + table: "from-primary/20 to-primary/5", + view: "from-blue-500/20 to-blue-500/5", + matview: "from-purple-500/20 to-purple-500/5", + function: "from-amber-500/20 to-amber-500/5", + "trigger-function": "from-orange-500/20 to-orange-500/5", +}; + +const tabIcons: Partial> = { + overview: , + columns: , + indexes: , + fkeys: , + structure: , + ddl: , + actions: , +}; + +export function ModalHeader({ + objectType, + schema, + name, + projectId, + loading, + copied, + copyText, + availableTabs, + activeTab, + setActiveTab, +}: { + objectType: ObjectType; + schema: string; + name: string; + projectId: string; + loading: boolean; + copied: string | null; + copyText: (text: string, label: string) => void; + availableTabs: { key: Tab; label: string }[]; + activeTab: Tab; + setActiveTab: (tab: Tab) => void; +}) { + return ( +
+
+ +
+
+ {objectIcon[objectType]} +
+
+ + {name} + + + + + {objectLabel[objectType]} + + {schema} + | + + {projectId} + + {loading && } + +
+
+
+ + {/* Tab switcher - pill style */} +
+ {availableTabs.map((tab) => ( + + ))} +
+
+ ); +} diff --git a/src/components/object-properties-modal/overview-function.tsx b/src/components/object-properties-modal/overview-function.tsx new file mode 100644 index 0000000..c99d85e --- /dev/null +++ b/src/components/object-properties-modal/overview-function.tsx @@ -0,0 +1,97 @@ +import { + ArrowRight, + Check, + Columns3, + Copy, + FileCode, + RefreshCw, +} from "lucide-react"; +import { + InfoRow, + LoadingPlaceholder, + PropertySection, + StatCard, +} from "./shared"; +import type { FunctionMeta } from "./types"; + +export function FunctionOverview({ + functionMeta, + copyText, + copied, +}: { + functionMeta: FunctionMeta | null; + copyText: (text: string, label: string) => void; + copied: string | null; +}) { + if (!functionMeta) return ; + return ( +
+
+ } + /> + } + /> + } + /> +
+
+ + + + +
+ {functionMeta.arguments && ( + } + > +
+ {functionMeta.arguments} +
+
+ )} + } + > +
+ +
+            {functionMeta.source}
+          
+
+
+
+ ); +} diff --git a/src/components/object-properties-modal/overview-tab.tsx b/src/components/object-properties-modal/overview-tab.tsx new file mode 100644 index 0000000..cfc8d41 --- /dev/null +++ b/src/components/object-properties-modal/overview-tab.tsx @@ -0,0 +1,269 @@ +import { + AlertTriangle, + Check, + Database, + HardDrive, + Key, + Link2, + Lock, + RefreshCw, + ScrollText, + Zap, +} from "lucide-react"; +import { + ConstraintIcon, + InfoRow, + LoadingPlaceholder, + PropertySection, + StatCard, + formatTimestamp, +} from "./shared"; +import type { + FunctionMeta, + MatViewStats, + ObjectType, + TableStats, + ViewInfo, +} from "./types"; +import { FunctionOverview } from "./overview-function"; +import { ViewOverview } from "./overview-view"; + +export function OverviewContent({ + objectType, + tableStats, + viewInfo, + matViewStats, + functionMeta, + cons, + trigs, + rls, + pols, + copyText, + copied, +}: { + objectType: ObjectType; + tableStats: TableStats | null; + viewInfo: ViewInfo | null; + matViewStats: MatViewStats | null; + functionMeta: FunctionMeta | null; + cons?: import("@/types").ConstraintDetail[]; + trigs?: import("@/types").TriggerDetail[]; + rls?: import("@/types").RuleDetail[]; + pols?: import("@/types").PolicyDetail[]; + copyText: (text: string, label: string) => void; + copied: string | null; +}) { + if (objectType === "table") { + if (!tableStats) { + return ; + } + return ( +
+ {/* Stats grid */} +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + /> +
+ + {/* Scan stats */} + } + > +
+
+
+ Sequential Scans +
+
+ {Number(tableStats.seqScan).toLocaleString()} +
+
+
+
+ Index Scans +
+
+ {Number(tableStats.idxScan).toLocaleString()} +
+
+
+
+ + {/* Maintenance */} + } + > +
+ + + + +
+
+ + {/* Constraints summary */} + {cons && cons.length > 0 && ( + } + > +
+ {Array.from(new Set(cons.map((c) => c.constraintName))).map( + (cName) => { + const f = cons.find((c) => c.constraintName === cName)!; + const entries = cons.filter( + (c) => c.constraintName === cName, + ); + return ( +
+ + {cName} + + {f.constraintType} + + + ({entries.map((e) => e.columnName).join(", ")}) + +
+ ); + }, + )} +
+
+ )} + + {/* Triggers */} + {trigs && trigs.length > 0 && ( + } + > +
+ {trigs.map((t) => ( +
+ + {t.triggerName} + + {t.timing} {t.event} + +
+ ))} +
+
+ )} + + {/* RLS */} + {pols && pols.length > 0 && ( + } + > +
+ {pols.map((p) => ( +
+ + {p.policyName} + + {p.permissive} {p.command} + +
+ ))} +
+
+ )} + + {/* Rules */} + {rls && rls.length > 0 && ( + } + > +
+ {rls.map((r) => ( +
+ + {r.ruleName} + {r.event} +
+ ))} +
+
+ )} +
+ ); + } + + if (objectType === "view") { + return ; + } + + if (objectType === "matview") { + return ; + } + + if (objectType === "function" || objectType === "trigger-function") { + return ( + + ); + } + + return ; +} diff --git a/src/components/object-properties-modal/overview-view.tsx b/src/components/object-properties-modal/overview-view.tsx new file mode 100644 index 0000000..0f6bd3d --- /dev/null +++ b/src/components/object-properties-modal/overview-view.tsx @@ -0,0 +1,85 @@ +import { + Check, + Database, + Eye, + FileCode, + HardDrive, + Shield, +} from "lucide-react"; +import { + LoadingPlaceholder, + PropertySection, + StatCard, +} from "./shared"; +import type { MatViewStats, ViewInfo } from "./types"; + +export function ViewOverview({ + viewInfo, + matViewStats, +}: { + viewInfo?: ViewInfo | null; + matViewStats?: MatViewStats | null; +}) { + if (viewInfo !== undefined) { + if (!viewInfo) return ; + return ( +
+
+ } + /> + } + /> +
+ } + > +
+            {viewInfo.definition}
+          
+
+
+ ); + } + + if (matViewStats !== undefined) { + if (!matViewStats) return ; + return ( +
+
+ } + /> + } + /> + } + /> +
+ } + > +
+            {matViewStats.definition}
+          
+
+
+ ); + } + + return ; +} diff --git a/src/components/object-properties-modal/shared.tsx b/src/components/object-properties-modal/shared.tsx new file mode 100644 index 0000000..08e74fc --- /dev/null +++ b/src/components/object-properties-modal/shared.tsx @@ -0,0 +1,121 @@ +import { cn } from "@/lib/utils"; +import { + Check, + Key, + Link2, + Loader2, + Shield, +} from "lucide-react"; + +export function StatCard({ + label, + value, + icon, + accent, +}: { + label: string; + value: string; + icon: React.ReactNode; + accent?: string; +}) { + return ( +
+
+
+
+ {icon} +
+
+
+ {label} +
+
+ {value} +
+
+
+
+ ); +} + +export function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +export function PropertySection({ + title, + icon, + children, +}: { + title: string; + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+
+ {icon} +
+ + {title} + +
+
+ {children} +
+ ); +} + +export function ConstraintIcon({ type }: { type: string }) { + if (type === "PRIMARY KEY") + return ; + if (type === "FOREIGN KEY") + return ; + if (type === "UNIQUE") + return ; + if (type === "CHECK") + return ; + return ; +} + +export function LoadingPlaceholder() { + return ( +
+
+
+ +
+ Loading... +
+ ); +} + +export function formatTimestamp(ts: string): string { + if (ts === "never") return "never"; + try { + const d = new Date(ts); + if (isNaN(d.getTime())) return ts; + const now = Date.now(); + const diff = now - d.getTime(); + if (diff < 60000) return "just now"; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + if (diff < 2592000000) return `${Math.floor(diff / 86400000)}d ago`; + return d.toLocaleDateString(); + } catch { + return ts; + } +} diff --git a/src/components/object-properties-modal/structure-editor/columns-section.tsx b/src/components/object-properties-modal/structure-editor/columns-section.tsx new file mode 100644 index 0000000..a9dc691 --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/columns-section.tsx @@ -0,0 +1,177 @@ +import type { DraftColumn, StructureEditorState } from "@/lib/alter-table-sql"; +import { PG_COMMON_TYPES } from "@/lib/alter-table-sql"; +import { cn } from "@/lib/utils"; +import { Plus, RefreshCw, Trash2 } from "lucide-react"; +import { uid } from "./initialization"; + +export function ColumnsSection({ + draft, + setDraft, +}: { + draft: StructureEditorState; + setDraft: React.Dispatch>; +}) { + // Column helpers + const updateColumn = (id: string, updates: Partial) => { + setDraft((prev) => ({ + ...prev, + columns: prev.columns.map((c) => { + if (c._id !== id) return c; + const updated = { ...c, ...updates }; + // Mark as modified if it was existing and something changed + if (c._status === "existing") { + const changed = + updated.name !== c.originalName || + updated.dataType !== c.originalDataType || + updated.nullable !== c.originalNullable || + updated.defaultValue !== c.originalDefault; + updated._status = changed ? "modified" : "existing"; + } + return updated; + }), + })); + }; + + const addColumn = () => { + setDraft((prev) => ({ + ...prev, + columns: [ + ...prev.columns, + { + _id: uid(), + _status: "added", + name: `new_column_${prev.columns.length + 1}`, + dataType: "text", + nullable: true, + defaultValue: null, + }, + ], + })); + }; + + const removeColumn = (id: string) => { + setDraft((prev) => ({ + ...prev, + columns: prev.columns + .map((c) => + c._id === id + ? c._status === "added" + ? null + : { ...c, _status: "removed" as const } + : c, + ) + .filter(Boolean) as DraftColumn[], + })); + }; + + const restoreColumn = (id: string) => { + setDraft((prev) => ({ + ...prev, + columns: prev.columns.map((c) => + c._id === id ? { ...c, _status: "existing" as const } : c, + ), + })); + }; + + return ( +
+ {/* Header */} +
+ Name + Type + Nullable + Default + +
+ {draft.columns.map((col) => ( +
+ + updateColumn(col._id, { name: e.target.value }) + } + className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 disabled:opacity-40" + /> + + updateColumn(col._id, { dataType: e.target.value }) + } + list="pg-types" + className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 disabled:opacity-40" + /> +
+ + updateColumn(col._id, { nullable: e.target.checked }) + } + className="h-3.5 w-3.5 rounded border-border accent-primary" + /> +
+ + updateColumn(col._id, { + defaultValue: e.target.value || null, + }) + } + placeholder="NULL" + className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 placeholder:text-muted-foreground/30 disabled:opacity-40" + /> +
+ {col._status === "removed" ? ( + + ) : ( + + )} +
+
+ ))} + + {/* HTML datalist for type suggestions */} + + {PG_COMMON_TYPES.map((t) => ( + +
+ ); +} diff --git a/src/components/object-properties-modal/structure-editor/fk-card.tsx b/src/components/object-properties-modal/structure-editor/fk-card.tsx new file mode 100644 index 0000000..f273003 --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/fk-card.tsx @@ -0,0 +1,232 @@ +import type { DraftForeignKey } from "@/lib/alter-table-sql"; +import { FK_ACTIONS } from "@/lib/alter-table-sql"; +import { DriverFactory } from "@/lib/database-driver"; +import { cn } from "@/lib/utils"; +import { ArrowRight, Plus, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; + +export function FKCard({ + fk, + activeColNames, + availableSchemas, + getTablesForSchema, + loadTables, + projectId, + getDriver, + onChange, + onRemove, +}: { + fk: DraftForeignKey; + activeColNames: string[]; + availableSchemas: string[]; + getTablesForSchema: (schema: string) => string[]; + loadTables: (projectId: string, schema: string) => Promise; + projectId: string; + getDriver: () => ReturnType | null; + onChange: (updates: Partial) => void; + onRemove: () => void; +}) { + const [targetCols, setTargetCols] = useState([]); + + // Load target table columns when target changes + useEffect(() => { + if (!fk.targetTable || !fk.targetSchema) { + setTargetCols([]); + return; + } + const driver = getDriver(); + if (!driver) return; + driver + .loadColumns(projectId, fk.targetSchema, fk.targetTable) + .then(setTargetCols) + .catch(() => setTargetCols([])); + }, [fk.targetSchema, fk.targetTable, projectId, getDriver]); + + // Ensure tables are loaded for the selected schema + useEffect(() => { + if (fk.targetSchema) { + loadTables(projectId, fk.targetSchema).catch(() => {}); + } + }, [fk.targetSchema, projectId, loadTables]); + + const targetTableNames = getTablesForSchema(fk.targetSchema); + + return ( +
+ {/* Name + delete */} +
+ onChange({ constraintName: e.target.value })} + className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50" + placeholder="Constraint name" + /> + +
+ + {/* Target: schema + table */} +
+
+
+ Target Schema +
+ +
+
+
+ Target Table +
+ +
+
+ + {/* Column mapping: source → target (paired rows) */} +
+
+
Column Mapping
+
+
+
+ Source + + Target + +
+ {fk.sourceColumns.map((srcCol, idx) => ( +
+ + + + +
+ ))} + +
+
+ + {/* ON UPDATE / ON DELETE */} +
+
+
+ On Update +
+ +
+
+
+ On Delete +
+ +
+
+
+ ); +} diff --git a/src/components/object-properties-modal/structure-editor/fkeys-section.tsx b/src/components/object-properties-modal/structure-editor/fkeys-section.tsx new file mode 100644 index 0000000..75c4208 --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/fkeys-section.tsx @@ -0,0 +1,133 @@ +import type { + DraftForeignKey, + StructureEditorState, +} from "@/lib/alter-table-sql"; +import { DriverFactory } from "@/lib/database-driver"; +import { Plus } from "lucide-react"; +import { FKCard } from "./fk-card"; +import { uid } from "./initialization"; + +export function FkeysSection({ + draft, + setDraft, + activeColNames, + tableName, + schema, + availableSchemas, + getTablesForSchema, + loadTables, + projectId, + getDriver, +}: { + draft: StructureEditorState; + setDraft: React.Dispatch>; + activeColNames: string[]; + tableName: string; + schema: string; + availableSchemas: string[]; + getTablesForSchema: (schema: string) => string[]; + loadTables: (projectId: string, schema: string) => Promise; + projectId: string; + getDriver: () => ReturnType | null; +}) { + return ( +
+ {draft.foreignKeys + .filter((fk) => fk._status !== "removed") + .map((fk) => ( + { + setDraft((prev) => ({ + ...prev, + foreignKeys: prev.foreignKeys.map((f) => + f._id === fk._id + ? { + ...f, + ...updates, + _status: + f._status === "existing" + ? "existing" + : f._status, + } + : f, + ), + })); + }} + onRemove={() => { + setDraft((prev) => ({ + ...prev, + foreignKeys: prev.foreignKeys + .map((f) => + f._id === fk._id + ? f._status === "added" + ? null + : { ...f, _status: "removed" as const } + : f, + ) + .filter(Boolean) as DraftForeignKey[], + })); + }} + /> + ))} + {draft.foreignKeys + .filter((fk) => fk._status === "removed") + .map((fk) => ( +
+ + {fk.constraintName} + + +
+ ))} + +
+ ); +} diff --git a/src/components/object-properties-modal/structure-editor/index.tsx b/src/components/object-properties-modal/structure-editor/index.tsx new file mode 100644 index 0000000..169dd91 --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/index.tsx @@ -0,0 +1,287 @@ +import { Button } from "@/components/ui/button"; +import type { StructureEditorState } from "@/lib/alter-table-sql"; +import { + countChanges, + generateAlterTableSQL, +} from "@/lib/alter-table-sql"; +import { DriverFactory } from "@/lib/database-driver"; +import { cn } from "@/lib/utils"; +import { useProjectStore } from "@/stores/project-store"; +import { + AlertTriangle, + Copy, + FileCode, + Loader2, + Play, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { LoadingPlaceholder } from "../shared"; +import type { FKInfo } from "../types"; +import { ColumnsSection } from "./columns-section"; +import { FkeysSection } from "./fkeys-section"; +import { IndexesSection } from "./indexes-section"; +import { + initStructureState, + type StructureSubTab, +} from "./initialization"; +import { PkSection } from "./pk-section"; +import { UniqueSection } from "./unique-section"; + +export function StructureEditorContent({ + projectId, + schema, + tableName, + cols, + idxs, + cons, + outgoingFKs, + getDriver, + onApplied, + openTab, + onOpenChange, +}: { + projectId: string; + schema: string; + tableName: string; + cols: import("@/types").ColumnDetail[] | undefined; + idxs: import("@/types").IndexDetail[] | undefined; + cons: import("@/types").ConstraintDetail[] | undefined; + outgoingFKs: FKInfo[]; + getDriver: () => ReturnType | null; + onApplied: () => void; + openTab: (projectId?: string, sql?: string) => void; + onOpenChange: (open: boolean) => void; +}) { + const [subTab, setSubTab] = useState("columns"); + const [applying, setApplying] = useState(false); + const [error, setError] = useState(null); + const [showSql, setShowSql] = useState(false); + + // Build initial state + const initialState = useMemo( + () => initStructureState(cols, idxs, cons, outgoingFKs), + [cols, idxs, cons, outgoingFKs], + ); + const [draft, setDraft] = useState(initialState); + + // Reset when initial state changes (e.g. modal re-opened) + useEffect(() => { + setDraft(initialState); + setError(null); + setShowSql(false); + }, [initialState]); + + const changes = countChanges(draft); + const activeColNames = draft.columns + .filter((c) => c._status !== "removed") + .map((c) => c.name); + + // Tables for FK target (from store) + const tables = useProjectStore((s) => s.tables); + const schemas = useProjectStore((s) => s.schemas); + const loadTables = useProjectStore((s) => s.loadTables); + const availableSchemas = schemas[projectId] ?? []; + const getTablesForSchema = (s: string) => + (tables[`${projectId}::${s}`] ?? []).map((t) => t.name); + + // SQL preview + const sqlStatements = useMemo( + () => generateAlterTableSQL(schema, tableName, initialState, draft), + [schema, tableName, initialState, draft], + ); + const sqlPreview = sqlStatements.join("\n"); + + // Apply changes + const applyChanges = useCallback(async () => { + const driver = getDriver(); + if (!driver || sqlStatements.length === 0) return; + setApplying(true); + setError(null); + try { + await driver.runQuery(projectId, "BEGIN"); + try { + for (const stmt of sqlStatements) { + await driver.runQuery(projectId, stmt); + } + await driver.runQuery(projectId, "COMMIT"); + } catch (err) { + await driver.runQuery(projectId, "ROLLBACK").catch(() => {}); + throw err; + } + toast.success("Table structure updated"); + onApplied(); + } catch (err: any) { + setError(err?.message ?? "Failed to apply changes"); + } finally { + setApplying(false); + } + }, [getDriver, projectId, sqlStatements, onApplied]); + + // Sub-tab list + const subTabs: { key: StructureSubTab; label: string }[] = [ + { key: "columns", label: "Columns" }, + { key: "pk", label: "Primary Key" }, + { key: "fkeys", label: "Foreign Keys" }, + { key: "unique", label: "Unique" }, + { key: "indexes", label: "Indexes" }, + ]; + + if (!cols) return ; + + return ( +
+ {/* Sub-tab nav */} +
+ {subTabs.map((t) => ( + + ))} +
+ + {/* Sub-tab content */} +
+ {subTab === "columns" && ( + + )} + + {subTab === "pk" && ( + + )} + + {subTab === "fkeys" && ( + + )} + + {subTab === "unique" && ( + + )} + + {subTab === "indexes" && ( + + )} +
+ + {/* SQL preview panel */} + {showSql && sqlStatements.length > 0 && ( +
+
+ + SQL Preview + +
+ + +
+
+
+            {sqlPreview}
+          
+
+ )} + + {/* Error */} + {error && ( +
+ + {error} +
+ )} + + {/* Bottom action bar */} + {changes > 0 && ( +
+ + {changes} change{changes !== 1 ? "s" : ""} + +
+ + + +
+ )} +
+ ); +} diff --git a/src/components/object-properties-modal/structure-editor/indexes-section.tsx b/src/components/object-properties-modal/structure-editor/indexes-section.tsx new file mode 100644 index 0000000..186ed45 --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/indexes-section.tsx @@ -0,0 +1,170 @@ +import type { DraftIndex, StructureEditorState } from "@/lib/alter-table-sql"; +import { cn } from "@/lib/utils"; +import { Plus, Trash2 } from "lucide-react"; +import { uid } from "./initialization"; + +export function IndexesSection({ + draft, + setDraft, + activeColNames, + tableName, +}: { + draft: StructureEditorState; + setDraft: React.Dispatch>; + activeColNames: string[]; + tableName: string; +}) { + return ( +
+ {draft.indexes + .filter((idx) => idx._status !== "removed") + .map((idx) => ( +
+
+ { + setDraft((prev) => ({ + ...prev, + indexes: prev.indexes.map((i) => + i._id === idx._id + ? { ...i, indexName: e.target.value } + : i, + ), + })); + }} + className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50" + placeholder="Index name" + /> + + +
+
+ Columns +
+
+ {activeColNames.map((colName) => { + const selected = idx.columns.includes(colName); + return ( + + ); + })} +
+
+ ))} + {draft.indexes + .filter((idx) => idx._status === "removed") + .map((idx) => ( +
+ + {idx.indexName} + + +
+ ))} + +
+ ); +} diff --git a/src/components/object-properties-modal/structure-editor/initialization.ts b/src/components/object-properties-modal/structure-editor/initialization.ts new file mode 100644 index 0000000..62114a4 --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/initialization.ts @@ -0,0 +1,115 @@ +import type { + DraftColumn, + DraftForeignKey, + DraftIndex, + DraftPrimaryKey, + DraftUniqueConstraint, + StructureEditorState, +} from "@/lib/alter-table-sql"; +import type { FKInfo } from "../types"; + +export type StructureSubTab = + | "columns" + | "pk" + | "fkeys" + | "unique" + | "indexes"; + +export function uid() { + return crypto.randomUUID(); +} + +export function initStructureState( + cols: import("@/types").ColumnDetail[] | undefined, + idxs: import("@/types").IndexDetail[] | undefined, + cons: import("@/types").ConstraintDetail[] | undefined, + outgoingFKs: FKInfo[], +): StructureEditorState { + const columns: DraftColumn[] = (cols ?? []).map((c) => ({ + _id: uid(), + _status: "existing" as const, + name: c.name, + dataType: c.dataType, + nullable: c.nullable, + defaultValue: c.defaultValue, + originalName: c.name, + originalDataType: c.dataType, + originalNullable: c.nullable, + originalDefault: c.defaultValue, + })); + + // Primary key from indexes + const pkEntries = (idxs ?? []).filter((i) => i.isPrimary); + const pkName = pkEntries[0]?.indexName ?? ""; + const primaryKey: DraftPrimaryKey | null = + pkEntries.length > 0 + ? { + constraintName: pkName, + columns: pkEntries.map((e) => e.columnName), + _status: "existing", + originalColumns: pkEntries.map((e) => e.columnName), + } + : null; + + // Unique constraints from constraints + const uniqueMap = new Map(); + for (const c of cons ?? []) { + if (c.constraintType === "UNIQUE") { + const existing = uniqueMap.get(c.constraintName) ?? []; + existing.push(c.columnName); + uniqueMap.set(c.constraintName, existing); + } + } + const uniqueConstraints: DraftUniqueConstraint[] = [ + ...uniqueMap.entries(), + ].map(([name, ucCols]) => ({ + _id: uid(), + _status: "existing" as const, + constraintName: name, + columns: ucCols, + })); + + // Non-primary, non-unique indexes + const idxMap = new Map(); + for (const i of idxs ?? []) { + if (i.isPrimary) continue; + // Skip indexes that back a unique constraint + if (uniqueMap.has(i.indexName)) continue; + const existing = idxMap.get(i.indexName) ?? { + columns: [], + isUnique: i.isUnique, + }; + existing.columns.push(i.columnName); + idxMap.set(i.indexName, existing); + } + const indexes: DraftIndex[] = [...idxMap.entries()].map(([name, info]) => ({ + _id: uid(), + _status: "existing" as const, + indexName: name, + columns: info.columns, + isUnique: info.isUnique, + })); + + // Foreign keys: group by constraintName + const fkMap = new Map(); + for (const fk of outgoingFKs) { + const existing = fkMap.get(fk.constraintName) ?? []; + existing.push(fk); + fkMap.set(fk.constraintName, existing); + } + const foreignKeys: DraftForeignKey[] = [...fkMap.entries()].map( + ([name, fks]) => ({ + _id: uid(), + _status: "existing" as const, + constraintName: name, + sourceColumns: fks.map((f) => f.sourceColumn), + targetSchema: fks[0].targetSchema, + targetTable: fks[0].targetTable, + targetColumns: fks.map((f) => f.targetColumn), + onUpdate: fks[0].onUpdate, + onDelete: fks[0].onDelete, + }), + ); + + return { columns, primaryKey, foreignKeys, uniqueConstraints, indexes }; +} diff --git a/src/components/object-properties-modal/structure-editor/pk-section.tsx b/src/components/object-properties-modal/structure-editor/pk-section.tsx new file mode 100644 index 0000000..7bd1e5d --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/pk-section.tsx @@ -0,0 +1,115 @@ +import type { StructureEditorState } from "@/lib/alter-table-sql"; +import { cn } from "@/lib/utils"; +import { Key } from "lucide-react"; + +export function PkSection({ + draft, + setDraft, + activeColNames, + tableName, +}: { + draft: StructureEditorState; + setDraft: React.Dispatch>; + activeColNames: string[]; + tableName: string; +}) { + return ( +
+
+ Select columns for primary key +
+
+ {activeColNames.map((colName) => { + const isInPK = + draft.primaryKey?.columns.includes(colName) && + draft.primaryKey._status !== "removed"; + return ( + + ); + })} +
+ {draft.primaryKey && + draft.primaryKey._status !== "removed" && + draft.primaryKey.columns.length > 0 && ( +
+ Constraint:{" "} + + {draft.primaryKey.constraintName} + + {" — "}({draft.primaryKey.columns.join(", ")}) +
+ )} +
+ ); +} diff --git a/src/components/object-properties-modal/structure-editor/unique-section.tsx b/src/components/object-properties-modal/structure-editor/unique-section.tsx new file mode 100644 index 0000000..e5c7d00 --- /dev/null +++ b/src/components/object-properties-modal/structure-editor/unique-section.tsx @@ -0,0 +1,156 @@ +import type { + DraftUniqueConstraint, + StructureEditorState, +} from "@/lib/alter-table-sql"; +import { cn } from "@/lib/utils"; +import { Plus, Trash2 } from "lucide-react"; +import { uid } from "./initialization"; + +export function UniqueSection({ + draft, + setDraft, + activeColNames, + tableName, +}: { + draft: StructureEditorState; + setDraft: React.Dispatch>; + activeColNames: string[]; + tableName: string; +}) { + return ( +
+ {draft.uniqueConstraints + .filter((uc) => uc._status !== "removed") + .map((uc) => ( +
+
+ { + setDraft((prev) => ({ + ...prev, + uniqueConstraints: prev.uniqueConstraints.map((u) => + u._id === uc._id + ? { ...u, constraintName: e.target.value } + : u, + ), + })); + }} + className="flex-1 h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50" + placeholder="Constraint name" + /> + +
+
+ Columns +
+
+ {activeColNames.map((colName) => { + const selected = uc.columns.includes(colName); + return ( + + ); + })} +
+
+ ))} + {draft.uniqueConstraints + .filter((uc) => uc._status === "removed") + .map((uc) => ( +
+ + {uc.constraintName} + + +
+ ))} + +
+ ); +} diff --git a/src/components/object-properties-modal/types.ts b/src/components/object-properties-modal/types.ts new file mode 100644 index 0000000..f0cca0c --- /dev/null +++ b/src/components/object-properties-modal/types.ts @@ -0,0 +1,76 @@ +export type ObjectType = + | "table" + | "view" + | "matview" + | "function" + | "trigger-function"; + +export type Tab = + | "overview" + | "columns" + | "indexes" + | "fkeys" + | "ddl" + | "actions" + | "structure"; + +export interface ObjectPropertiesModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + objectType: ObjectType; + projectId: string; + schema: string; + name: string; +} + +export interface TableStats { + rowEstimate: string; + tableSize: string; + indexSize: string; + totalSize: string; + lastVacuum: string; + lastAnalyze: string; + lastAutoVacuum: string; + lastAutoAnalyze: string; + deadTuples: string; + liveTuples: string; + seqScan: string; + idxScan: string; +} + +export interface FKInfo { + constraintName: string; + sourceSchema: string; + sourceTable: string; + sourceColumn: string; + targetSchema: string; + targetTable: string; + targetColumn: string; + onUpdate: string; + onDelete: string; +} + +export interface ViewInfo { + isUpdatable: string; + checkOption: string; + definition: string; +} + +export interface FunctionMeta { + language: string; + volatility: string; + isStrict: boolean; + securityDefiner: boolean; + estimatedCost: string; + estimatedRows: string; + returnType: string; + arguments: string; + source: string; +} + +export interface MatViewStats { + rowEstimate: string; + totalSize: string; + isPopulated: string; + definition: string; +} diff --git a/src/components/object-properties-modal/use-object-data.ts b/src/components/object-properties-modal/use-object-data.ts new file mode 100644 index 0000000..2f0c105 --- /dev/null +++ b/src/components/object-properties-modal/use-object-data.ts @@ -0,0 +1,238 @@ +import { DriverFactory } from "@/lib/database-driver"; +import { useProjectStore } from "@/stores/project-store"; +import { useCallback, useEffect, useState } from "react"; +import type { + FKInfo, + FunctionMeta, + MatViewStats, + ObjectType, + TableStats, + ViewInfo, +} from "./types"; + +export function useObjectData( + projectId: string, + schema: string, + name: string, + objectType: ObjectType, + open: boolean, +) { + const [ddl, setDdl] = useState(null); + const [ddlLoading, setDdlLoading] = useState(false); + const [ddlError, setDdlError] = useState(null); + const [loading, setLoading] = useState(false); + + // Live fetched data + const [tableStats, setTableStats] = useState(null); + const [outgoingFKs, setOutgoingFKs] = useState([]); + const [incomingFKs, setIncomingFKs] = useState([]); + const [viewInfo, setViewInfo] = useState(null); + const [functionMeta, setFunctionMeta] = useState(null); + const [matViewStats, setMatViewStats] = useState(null); + + // Cached metadata from store + const columnDetails = useProjectStore((s) => s.columnDetails); + const indexes = useProjectStore((s) => s.indexes); + const projects = useProjectStore((s) => s.projects); + const storeLoadColumnDetails = useProjectStore((s) => s.loadColumnDetails); + const storeLoadIndexes = useProjectStore((s) => s.loadIndexes); + + const metaKey = `${projectId}::${schema}::${name}`; + + const getDriver = useCallback(() => { + const d = projects[projectId]; + if (!d) return null; + return DriverFactory.getDriver(d.driver); + }, [projects, projectId]); + + const fetchLiveData = useCallback(async () => { + const driver = getDriver(); + if (!driver) return; + setLoading(true); + + try { + if (objectType === "table") { + const [statsResult, outFKResult, inFKResult] = await Promise.allSettled( + [ + driver.loadTableStatistics?.(projectId, schema, name), + driver.loadFKDetails?.(projectId, schema, name, "outgoing"), + driver.loadFKDetails?.(projectId, schema, name, "incoming"), + ], + ); + + // Ensure columns & indexes are loaded (may already be cached) + if (!columnDetails[metaKey]) { + storeLoadColumnDetails(projectId, schema, name).catch(() => {}); + } + if (!indexes[metaKey]) { + storeLoadIndexes(projectId, schema, name).catch(() => {}); + } + + if (statsResult.status === "fulfilled" && statsResult.value) { + const statsMap = Object.fromEntries(statsResult.value); + setTableStats({ + rowEstimate: statsMap.row_estimate ?? "0", + tableSize: statsMap.table_size ?? "-", + indexSize: statsMap.index_size ?? "-", + totalSize: statsMap.total_size ?? "-", + lastVacuum: statsMap.last_vacuum ?? "never", + lastAnalyze: statsMap.last_analyze ?? "never", + lastAutoVacuum: statsMap.last_autovacuum ?? "never", + lastAutoAnalyze: statsMap.last_autoanalyze ?? "never", + deadTuples: statsMap.dead_tuples ?? "0", + liveTuples: statsMap.live_tuples ?? "0", + seqScan: statsMap.seq_scan ?? "0", + idxScan: statsMap.idx_scan ?? "0", + }); + } + + const parseFKs = ( + result: PromiseSettledResult< + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + ][] + | undefined + >, + ) => { + if (result.status !== "fulfilled" || !result.value) return []; + return result.value.map((r) => ({ + constraintName: r[0], + sourceSchema: r[1], + sourceTable: r[2], + sourceColumn: r[3], + targetSchema: r[4], + targetTable: r[5], + targetColumn: r[6], + onUpdate: r[7], + onDelete: r[8], + })); + }; + setOutgoingFKs(parseFKs(outFKResult)); + setIncomingFKs(parseFKs(inFKResult)); + } else if (objectType === "view") { + const info = await driver.loadViewInfo?.(projectId, schema, name); + if (info) { + const infoMap = Object.fromEntries(info); + setViewInfo({ + isUpdatable: infoMap.is_updatable ?? "NO", + checkOption: infoMap.check_option ?? "NONE", + definition: infoMap.definition ?? "", + }); + } + } else if (objectType === "matview") { + const info = await driver.loadMatviewInfo?.(projectId, schema, name); + if (info) { + const infoMap = Object.fromEntries(info); + setMatViewStats({ + rowEstimate: infoMap.row_estimate ?? "0", + totalSize: infoMap.total_size ?? "-", + isPopulated: infoMap.is_populated ?? "NO", + definition: infoMap.definition ?? "", + }); + } + } else if ( + objectType === "function" || + objectType === "trigger-function" + ) { + const info = await driver.loadFunctionInfo?.(projectId, schema, name); + if (info) { + const infoMap = Object.fromEntries(info); + setFunctionMeta({ + language: infoMap.language ?? "", + volatility: infoMap.volatility ?? "", + isStrict: infoMap.is_strict === "true", + securityDefiner: infoMap.security_definer === "true", + estimatedCost: infoMap.estimated_cost ?? "", + estimatedRows: infoMap.estimated_rows ?? "", + returnType: infoMap.return_type ?? "", + arguments: infoMap.arguments ?? "", + source: infoMap.source ?? "", + }); + } + } + } catch (err) { + console.error("Failed to fetch live data:", err); + } finally { + setLoading(false); + } + }, [ + getDriver, + objectType, + projectId, + schema, + name, + columnDetails, + indexes, + metaKey, + storeLoadColumnDetails, + storeLoadIndexes, + ]); + + // Reset & fetch on open + useEffect(() => { + if (!open) return; + setDdl(null); + setDdlError(null); + setTableStats(null); + setOutgoingFKs([]); + setIncomingFKs([]); + setViewInfo(null); + setFunctionMeta(null); + setMatViewStats(null); + + void fetchLiveData(); + }, [open, objectType, projectId, schema, name]); + + // Fetch DDL via backend + const fetchDDL = useCallback(async () => { + const driver = getDriver(); + if (!driver) return; + + setDdlLoading(true); + setDdlError(null); + setDdl(null); + + try { + if (driver.generateDDL) { + const result = await driver.generateDDL( + projectId, + schema, + name, + objectType, + ); + setDdl(result || "No DDL available"); + } + } catch (err: any) { + setDdlError(err?.message ?? "Failed to fetch DDL"); + } finally { + setDdlLoading(false); + } + }, [getDriver, objectType, projectId, schema, name]); + + return { + // state + ddl, + ddlLoading, + ddlError, + loading, + tableStats, + outgoingFKs, + incomingFKs, + viewInfo, + functionMeta, + matViewStats, + // helpers + metaKey, + getDriver, + fetchLiveData, + fetchDDL, + }; +} diff --git a/src/components/performance-monitor.tsx b/src/components/performance-monitor.tsx deleted file mode 100644 index 4c05637..0000000 --- a/src/components/performance-monitor.tsx +++ /dev/null @@ -1,690 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { DriverFactory } from "@/lib/database-driver"; -import { useProjectStore } from "@/stores/project-store"; -import { useHistoryStore } from "@/stores/history-store"; -import { cn } from "@/lib/utils"; -import { - Activity, - BarChart3, - Clock, - Database, - Gauge, - HardDrive, - Loader2, - Lock, - Pause, - Play, - RefreshCw, - Search, - Table, - Users, - Zap, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; - -interface ActivityRow { - pid: string; - user: string; - database: string; - state: string; - waitEventType: string; - waitEvent: string; - query: string; - durationSec: string; - backendType: string; - clientAddr: string; -} - -interface TableStatRow { - schema: string; - table: string; - seqScan: string; - seqTupRead: string; - idxScan: string; - idxTupFetch: string; - inserts: string; - updates: string; - deletes: string; - liveTuples: string; - deadTuples: string; - lastVacuum: string; - lastAutovacuum: string; - lastAnalyze: string; -} - -interface LockRow { - pid: string; - user: string; - mode: string; - locktype: string; - status: string; - relation: string; - schema: string; - query: string; - duration: string; - waitEvent: string; -} - -interface IndexUsageRow { - schema: string; - table: string; - index: string; - size: string; - scans: string; - tuplesRead: string; - tuplesFetched: string; - status: string; - definition: string; -} - -interface BloatRow { - schema: string; - table: string; - liveTuples: string; - deadTuples: string; - bloatPct: string; - totalSize: string; - lastVacuum: string; - lastAutovacuum: string; - lastAnalyze: string; - lastAutoanalyze: string; -} - -type MonitorTab = "overview" | "activity" | "tables" | "history" | "locks" | "indexes" | "bloat"; - -export function PerformanceMonitor({ projectId }: { projectId: string }) { - const projects = useProjectStore((s) => s.projects); - const details = projects[projectId]; - const historyEntries = useHistoryStore((s) => s.entries); - - const [tab, setTab] = useState("overview"); - const [dbStats, setDbStats] = useState<[string, string][]>([]); - const [activity, setActivity] = useState([]); - const [tableStats, setTableStats] = useState([]); - const [locks, setLocks] = useState([]); - const [indexUsage, setIndexUsage] = useState([]); - const [bloat, setBloat] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [autoRefresh, setAutoRefresh] = useState(false); - const [lastRefresh, setLastRefresh] = useState(null); - const intervalRef = useRef | null>(null); - - const refresh = useCallback(async () => { - if (!details) return; - setIsLoading(true); - try { - const driver = DriverFactory.getDriver(details.driver); - - const basePromises = Promise.allSettled([ - driver.loadDatabaseStats(projectId), - driver.loadActivity(projectId), - driver.loadTableStats(projectId), - ]); - - const extraPromises = Promise.allSettled([ - driver.loadLocks ? driver.loadLocks(projectId) : Promise.resolve(undefined), - driver.loadIndexUsage ? driver.loadIndexUsage(projectId) : Promise.resolve(undefined), - driver.loadTableBloat ? driver.loadTableBloat(projectId) : Promise.resolve(undefined), - ]); - - const [baseResults, extraResults] = await Promise.all([basePromises, extraPromises]); - - const [stats, act, tStats] = baseResults; - const [lk, iu, bl] = extraResults; - - if (stats.status === "fulfilled") setDbStats(stats.value); - if (act.status === "fulfilled") { - setActivity( - act.value.map((r) => ({ - pid: r[0], - user: r[1], - database: r[2], - state: r[3], - waitEventType: r[4], - waitEvent: r[5], - query: r[6], - durationSec: r[7], - backendType: r[8], - clientAddr: r[9], - })) - ); - } - if (tStats.status === "fulfilled") { - setTableStats( - tStats.value.map((r) => ({ - schema: r[0], - table: r[1], - seqScan: r[2], - seqTupRead: r[3], - idxScan: r[4], - idxTupFetch: r[5], - inserts: r[6], - updates: r[7], - deletes: r[8], - liveTuples: r[9], - deadTuples: r[10], - lastVacuum: r[11], - lastAutovacuum: r[12], - lastAnalyze: r[13], - })) - ); - } - if (lk.status === "fulfilled" && lk.value) { - setLocks( - lk.value.map((r) => ({ - pid: r[0], - user: r[1], - mode: r[2], - locktype: r[3], - status: r[4], - relation: r[5], - schema: r[6], - query: r[7], - duration: r[8], - waitEvent: r[9], - })) - ); - } - if (iu.status === "fulfilled" && iu.value) { - setIndexUsage( - iu.value.map((r) => ({ - schema: r[0], - table: r[1], - index: r[2], - size: r[3], - scans: r[4], - tuplesRead: r[5], - tuplesFetched: r[6], - status: r[7], - definition: r[8], - })) - ); - } - if (bl.status === "fulfilled" && bl.value) { - setBloat( - bl.value.map((r) => ({ - schema: r[0], - table: r[1], - liveTuples: r[2], - deadTuples: r[3], - bloatPct: r[4], - totalSize: r[5], - lastVacuum: r[6], - lastAutovacuum: r[7], - lastAnalyze: r[8], - lastAutoanalyze: r[9], - })) - ); - } - setLastRefresh(new Date()); - } catch (e) { - console.error("Performance monitor refresh failed:", e); - } finally { - setIsLoading(false); - } - }, [projectId, details]); - - useEffect(() => { - void refresh(); - }, [refresh]); - - useEffect(() => { - if (autoRefresh) { - intervalRef.current = setInterval(() => void refresh(), 5000); - } - return () => { - if (intervalRef.current) clearInterval(intervalRef.current); - }; - }, [autoRefresh, refresh]); - - // Project-specific history - const projectHistory = historyEntries.filter((e) => e.projectId === projectId); - const avgTime = projectHistory.length > 0 - ? projectHistory.reduce((sum, e) => sum + e.executionTime, 0) / projectHistory.length - : 0; - const failedQueries = projectHistory.filter((e) => !e.success).length; - const slowQueries = [...projectHistory].sort((a, b) => b.executionTime - a.executionTime).slice(0, 10); - - const statValue = (name: string) => dbStats.find(([n]) => n === name)?.[1] ?? "N/A"; - - const unusedIndexCount = indexUsage.filter((i) => i.status === "unused").length; - const tablesNeedingVacuum = bloat.filter((b) => parseFloat(b.bloatPct) > 10); - const waitingLocks = locks.filter((l) => l.status === "waiting"); - - const tabs: { id: MonitorTab; label: string; icon: React.ReactNode }[] = [ - { id: "overview", label: "Overview", icon: }, - { id: "activity", label: "Activity", icon: }, - { id: "tables", label: "Table Stats", icon:
}, - { id: "history", label: "Query History", icon: }, - { id: "locks", label: "Locks", icon: }, - { id: "indexes", label: "Index Advisor", icon: }, - { id: "bloat", label: "Bloat", icon: }, - ]; - - return ( -
- {/* Header */} -
-
- - Performance Monitor - - {details?.database ?? projectId} - -
-
- {lastRefresh && ( - - Refreshed at {lastRefresh.toLocaleTimeString()} - - )} - - -
-
- - {/* Tab bar */} -
- {tabs.map((t) => ( - - ))} -
- - {/* Content */} -
- {tab === "overview" && ( -
- {/* Stat cards */} -
- } label="Active Connections" value={statValue("Active Connections")} /> - } label="Database Size" value={statValue("Database Size")} /> - } label="Cache Hit Ratio" value={statValue("Cache Hit Ratio")} /> - } label="Deadlocks" value={statValue("Deadlocks")} /> -
- - {/* All stats table */} -
-
- Database Statistics -
-
- {dbStats.map(([name, val]) => ( -
- {name} - {val} -
- ))} - {dbStats.length === 0 && ( -
- No stats available -
- )} -
-
- - {/* Session history summary */} -
-
- Session Query Summary -
-
-
-
{projectHistory.length}
-
Total Queries
-
-
-
{avgTime.toFixed(1)}ms
-
Avg Execution Time
-
-
-
0 && "text-destructive")}>{failedQueries}
-
Failed Queries
-
-
-
-
- )} - - {tab === "activity" && ( -
-
-
- - - {["PID", "User", "State", "Duration", "Wait", "Backend", "Client", "Query"].map((h) => ( - - ))} - - - - {activity.map((row) => ( - - - - - - - - - - - ))} - {activity.length === 0 && ( - - - - )} - -
{h}
{row.pid}{row.user} - - {row.state} - - {parseFloat(row.durationSec).toFixed(1)}s{row.waitEvent || "-"}{row.backendType}{row.clientAddr}{row.query}
No active connections
-
-
- )} - - {tab === "tables" && ( -
-

- Cumulative stats since server start or last pg_stat_reset(). Source: pg_stat_user_tables -

-
-
- - - - {["Schema", "Table", "Seq Scan", "Idx Scan", "Live Tuples", "Dead Tuples", "Inserts", "Updates", "Deletes", "Last Vacuum", "Last Analyze"].map((h) => ( - - ))} - - - - {tableStats.map((row) => { - const deadRatio = parseInt(row.liveTuples) > 0 - ? (parseInt(row.deadTuples) / parseInt(row.liveTuples)) * 100 - : 0; - return ( - - - - - - - - - - - - - - ); - })} - {tableStats.length === 0 && ( - - - - )} - -
{h}
{row.schema}{row.table}{parseInt(row.seqScan).toLocaleString()}{parseInt(row.idxScan).toLocaleString()}{parseInt(row.liveTuples).toLocaleString()} 10 && "text-destructive font-medium")}> - {parseInt(row.deadTuples).toLocaleString()} - {deadRatio > 10 && ({deadRatio.toFixed(0)}%)} - {parseInt(row.inserts).toLocaleString()}{parseInt(row.updates).toLocaleString()}{parseInt(row.deletes).toLocaleString()}{row.lastVacuum === "never" ? "never" : new Date(row.lastVacuum).toLocaleDateString()}{row.lastAnalyze === "never" ? "never" : new Date(row.lastAnalyze).toLocaleDateString()}
No table stats available
-
-
-
- )} - - {tab === "history" && ( -
- {/* Slow queries */} -
-
- Slowest Queries (Session) -
-
- {slowQueries.map((q) => ( -
-
- - {new Date(q.timestamp).toLocaleTimeString()} - {q.success ? `${q.rowCount} rows` : "FAILED"} - - 1000 && "text-destructive")}> - {q.executionTime.toFixed(1)}ms - -
-
-                      {q.sql.slice(0, 200)}{q.sql.length > 200 ? "..." : ""}
-                    
- {q.error && ( -
{q.error}
- )} -
- ))} - {slowQueries.length === 0 && ( -
- No queries executed yet in this session -
- )} -
-
-
- )} - - {tab === "locks" && ( -
- {waitingLocks.length > 0 && ( -
- - {waitingLocks.length} lock{waitingLocks.length !== 1 ? "s" : ""} waiting to be granted - -
- )} -
-
- - - - {["PID", "User", "Mode", "Lock Type", "Status", "Relation", "Duration", "Query"].map((h) => ( - - ))} - - - - {locks.map((row, idx) => ( - - - - - - - - - - - ))} - {locks.length === 0 && ( - - - - )} - -
{h}
{row.pid}{row.user}{row.mode}{row.locktype} - - {row.status} - - {row.relation || "-"}{parseFloat(row.duration || "0").toFixed(1)}s{row.query}
No active locks
-
-
-
- )} - - {tab === "indexes" && ( -
- {unusedIndexCount > 0 && ( -
- - {unusedIndexCount} unused index{unusedIndexCount !== 1 ? "es" : ""} found -- consider removing to save space and improve write performance - -
- )} -
-
- - - - {["Schema", "Table", "Index", "Size", "Scans", "Status", "Definition"].map((h) => ( - - ))} - - - - {indexUsage.map((row, idx) => ( - - - - - - - - - - ))} - {indexUsage.length === 0 && ( - - - - )} - -
{h}
{row.schema}{row.table}{row.index}{row.size}{parseInt(row.scans).toLocaleString()} - - {row.status === "rarely_used" ? "rarely used" : row.status} - - {row.definition}
No non-primary indexes found
-
-
-
- )} - - {tab === "bloat" && ( -
- {tablesNeedingVacuum.length > 0 && ( -
- - {tablesNeedingVacuum.length} table{tablesNeedingVacuum.length !== 1 ? "s" : ""} with {">"} 10% bloat -- consider running VACUUM - -
- )} -
-
- - - - {["Schema", "Table", "Live Tuples", "Dead Tuples", "Bloat %", "Total Size", "Last Vacuum", "Last Analyze"].map((h) => ( - - ))} - - - - {bloat.map((row, idx) => { - const pct = parseFloat(row.bloatPct) || 0; - const barColor = pct > 30 ? "bg-red-500" : pct > 10 ? "bg-yellow-500" : "bg-green-500"; - return ( - - - - - - - - - - - ); - })} - {bloat.length === 0 && ( - - - - )} - -
{h}
{row.schema}{row.table}{parseInt(row.liveTuples).toLocaleString()}{parseInt(row.deadTuples).toLocaleString()} -
-
-
-
- 30 && "text-red-600 dark:text-red-400 font-medium", - pct > 10 && pct <= 30 && "text-yellow-600 dark:text-yellow-400", - pct <= 10 && "text-muted-foreground", - )}> - {pct.toFixed(1)}% - -
-
{row.totalSize}{row.lastVacuum === "never" ? "never" : new Date(row.lastVacuum).toLocaleDateString()}{row.lastAnalyze === "never" ? "never" : new Date(row.lastAnalyze).toLocaleDateString()}
No table bloat data available
-
-
-
- )} -
-
- ); -} - -function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { - return ( -
-
- {icon} - {label} -
-
{value}
-
- ); -} diff --git a/src/components/performance-monitor/activity-tab.tsx b/src/components/performance-monitor/activity-tab.tsx new file mode 100644 index 0000000..73c74a6 --- /dev/null +++ b/src/components/performance-monitor/activity-tab.tsx @@ -0,0 +1,52 @@ +import { cn } from "@/lib/utils"; +import type { ActivityRow } from "./types"; + +interface ActivityTabProps { + activity: ActivityRow[]; +} + +export function ActivityTab({ activity }: ActivityTabProps) { + return ( +
+
+ + + + {["PID", "User", "State", "Duration", "Wait", "Backend", "Client", "Query"].map((h) => ( + + ))} + + + + {activity.map((row) => ( + + + + + + + + + + + ))} + {activity.length === 0 && ( + + + + )} + +
{h}
{row.pid}{row.user} + + {row.state} + + {parseFloat(row.durationSec).toFixed(1)}s{row.waitEvent || "-"}{row.backendType}{row.clientAddr}{row.query}
No active connections
+
+
+ ); +} diff --git a/src/components/performance-monitor/bloat-tab.tsx b/src/components/performance-monitor/bloat-tab.tsx new file mode 100644 index 0000000..e5615ab --- /dev/null +++ b/src/components/performance-monitor/bloat-tab.tsx @@ -0,0 +1,74 @@ +import { cn } from "@/lib/utils"; +import type { BloatRow } from "./types"; + +interface BloatTabProps { + bloat: BloatRow[]; + tablesNeedingVacuum: BloatRow[]; +} + +export function BloatTab({ bloat, tablesNeedingVacuum }: BloatTabProps) { + return ( +
+ {tablesNeedingVacuum.length > 0 && ( +
+ + {tablesNeedingVacuum.length} table{tablesNeedingVacuum.length !== 1 ? "s" : ""} with {">"} 10% bloat -- consider running VACUUM + +
+ )} +
+
+ + + + {["Schema", "Table", "Live Tuples", "Dead Tuples", "Bloat %", "Total Size", "Last Vacuum", "Last Analyze"].map((h) => ( + + ))} + + + + {bloat.map((row, idx) => { + const pct = parseFloat(row.bloatPct) || 0; + const barColor = pct > 30 ? "bg-red-500" : pct > 10 ? "bg-yellow-500" : "bg-green-500"; + return ( + + + + + + + + + + + ); + })} + {bloat.length === 0 && ( + + + + )} + +
{h}
{row.schema}{row.table}{parseInt(row.liveTuples).toLocaleString()}{parseInt(row.deadTuples).toLocaleString()} +
+
+
+
+ 30 && "text-red-600 dark:text-red-400 font-medium", + pct > 10 && pct <= 30 && "text-yellow-600 dark:text-yellow-400", + pct <= 10 && "text-muted-foreground", + )}> + {pct.toFixed(1)}% + +
+
{row.totalSize}{row.lastVacuum === "never" ? "never" : new Date(row.lastVacuum).toLocaleDateString()}{row.lastAnalyze === "never" ? "never" : new Date(row.lastAnalyze).toLocaleDateString()}
No table bloat data available
+
+
+
+ ); +} diff --git a/src/components/performance-monitor/history-tab.tsx b/src/components/performance-monitor/history-tab.tsx new file mode 100644 index 0000000..572bb5d --- /dev/null +++ b/src/components/performance-monitor/history-tab.tsx @@ -0,0 +1,44 @@ +import { cn } from "@/lib/utils"; +import type { HistoryEntry } from "@/stores/history-store"; + +interface HistoryTabProps { + slowQueries: HistoryEntry[]; +} + +export function HistoryTab({ slowQueries }: HistoryTabProps) { + return ( +
+ {/* Slow queries */} +
+
+ Slowest Queries (Session) +
+
+ {slowQueries.map((q) => ( +
+
+ + {new Date(q.timestamp).toLocaleTimeString()} - {q.success ? `${q.rowCount} rows` : "FAILED"} + + 1000 && "text-destructive")}> + {q.executionTime.toFixed(1)}ms + +
+
+                {q.sql.slice(0, 200)}{q.sql.length > 200 ? "..." : ""}
+              
+ {q.error && ( +
{q.error}
+ )} +
+ ))} + {slowQueries.length === 0 && ( +
+ No queries executed yet in this session +
+ )} +
+
+
+ ); +} diff --git a/src/components/performance-monitor/index.tsx b/src/components/performance-monitor/index.tsx new file mode 100644 index 0000000..2a2c2fb --- /dev/null +++ b/src/components/performance-monitor/index.tsx @@ -0,0 +1,283 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { DriverFactory } from "@/lib/database-driver"; +import { useProjectStore } from "@/stores/project-store"; +import { useHistoryStore } from "@/stores/history-store"; +import { cn } from "@/lib/utils"; +import { + Activity, + BarChart3, + Clock, + Gauge, + Loader2, + Lock, + Pause, + Play, + RefreshCw, + Search, + Table, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { + ActivityRow, + BloatRow, + IndexUsageRow, + LockRow, + MonitorTab, + TableStatRow, +} from "./types"; +import { OverviewTab } from "./overview-tab"; +import { ActivityTab } from "./activity-tab"; +import { TableStatsTab } from "./table-stats-tab"; +import { HistoryTab } from "./history-tab"; +import { LocksTab } from "./locks-tab"; +import { IndexesTab } from "./indexes-tab"; +import { BloatTab } from "./bloat-tab"; + +export function PerformanceMonitor({ projectId }: { projectId: string }) { + const projects = useProjectStore((s) => s.projects); + const details = projects[projectId]; + const historyEntries = useHistoryStore((s) => s.entries); + + const [tab, setTab] = useState("overview"); + const [dbStats, setDbStats] = useState<[string, string][]>([]); + const [activity, setActivity] = useState([]); + const [tableStats, setTableStats] = useState([]); + const [locks, setLocks] = useState([]); + const [indexUsage, setIndexUsage] = useState([]); + const [bloat, setBloat] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(false); + const [lastRefresh, setLastRefresh] = useState(null); + const intervalRef = useRef | null>(null); + + const refresh = useCallback(async () => { + if (!details) return; + setIsLoading(true); + try { + const driver = DriverFactory.getDriver(details.driver); + + const basePromises = Promise.allSettled([ + driver.loadDatabaseStats(projectId), + driver.loadActivity(projectId), + driver.loadTableStats(projectId), + ]); + + const extraPromises = Promise.allSettled([ + driver.loadLocks ? driver.loadLocks(projectId) : Promise.resolve(undefined), + driver.loadIndexUsage ? driver.loadIndexUsage(projectId) : Promise.resolve(undefined), + driver.loadTableBloat ? driver.loadTableBloat(projectId) : Promise.resolve(undefined), + ]); + + const [baseResults, extraResults] = await Promise.all([basePromises, extraPromises]); + + const [stats, act, tStats] = baseResults; + const [lk, iu, bl] = extraResults; + + if (stats.status === "fulfilled") setDbStats(stats.value); + if (act.status === "fulfilled") { + setActivity( + act.value.map((r) => ({ + pid: r[0], + user: r[1], + database: r[2], + state: r[3], + waitEventType: r[4], + waitEvent: r[5], + query: r[6], + durationSec: r[7], + backendType: r[8], + clientAddr: r[9], + })) + ); + } + if (tStats.status === "fulfilled") { + setTableStats( + tStats.value.map((r) => ({ + schema: r[0], + table: r[1], + seqScan: r[2], + seqTupRead: r[3], + idxScan: r[4], + idxTupFetch: r[5], + inserts: r[6], + updates: r[7], + deletes: r[8], + liveTuples: r[9], + deadTuples: r[10], + lastVacuum: r[11], + lastAutovacuum: r[12], + lastAnalyze: r[13], + })) + ); + } + if (lk.status === "fulfilled" && lk.value) { + setLocks( + lk.value.map((r) => ({ + pid: r[0], + user: r[1], + mode: r[2], + locktype: r[3], + status: r[4], + relation: r[5], + schema: r[6], + query: r[7], + duration: r[8], + waitEvent: r[9], + })) + ); + } + if (iu.status === "fulfilled" && iu.value) { + setIndexUsage( + iu.value.map((r) => ({ + schema: r[0], + table: r[1], + index: r[2], + size: r[3], + scans: r[4], + tuplesRead: r[5], + tuplesFetched: r[6], + status: r[7], + definition: r[8], + })) + ); + } + if (bl.status === "fulfilled" && bl.value) { + setBloat( + bl.value.map((r) => ({ + schema: r[0], + table: r[1], + liveTuples: r[2], + deadTuples: r[3], + bloatPct: r[4], + totalSize: r[5], + lastVacuum: r[6], + lastAutovacuum: r[7], + lastAnalyze: r[8], + lastAutoanalyze: r[9], + })) + ); + } + setLastRefresh(new Date()); + } catch (e) { + console.error("Performance monitor refresh failed:", e); + } finally { + setIsLoading(false); + } + }, [projectId, details]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (autoRefresh) { + intervalRef.current = setInterval(() => void refresh(), 5000); + } + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [autoRefresh, refresh]); + + // Project-specific history + const projectHistory = historyEntries.filter((e) => e.projectId === projectId); + const avgTime = projectHistory.length > 0 + ? projectHistory.reduce((sum, e) => sum + e.executionTime, 0) / projectHistory.length + : 0; + const failedQueries = projectHistory.filter((e) => !e.success).length; + const slowQueries = [...projectHistory].sort((a, b) => b.executionTime - a.executionTime).slice(0, 10); + + const unusedIndexCount = indexUsage.filter((i) => i.status === "unused").length; + const tablesNeedingVacuum = bloat.filter((b) => parseFloat(b.bloatPct) > 10); + const waitingLocks = locks.filter((l) => l.status === "waiting"); + + const tabs: { id: MonitorTab; label: string; icon: React.ReactNode }[] = [ + { id: "overview", label: "Overview", icon: }, + { id: "activity", label: "Activity", icon: }, + { id: "tables", label: "Table Stats", icon: }, + { id: "history", label: "Query History", icon: }, + { id: "locks", label: "Locks", icon: }, + { id: "indexes", label: "Index Advisor", icon: }, + { id: "bloat", label: "Bloat", icon: }, + ]; + + return ( +
+ {/* Header */} +
+
+ + Performance Monitor + + {details?.database ?? projectId} + +
+
+ {lastRefresh && ( + + Refreshed at {lastRefresh.toLocaleTimeString()} + + )} + + +
+
+ + {/* Tab bar */} +
+ {tabs.map((t) => ( + + ))} +
+ + {/* Content */} +
+ {tab === "overview" && ( + + )} + + {tab === "activity" && } + + {tab === "tables" && } + + {tab === "history" && } + + {tab === "locks" && } + + {tab === "indexes" && ( + + )} + + {tab === "bloat" && ( + + )} +
+
+ ); +} diff --git a/src/components/performance-monitor/indexes-tab.tsx b/src/components/performance-monitor/indexes-tab.tsx new file mode 100644 index 0000000..747bb5e --- /dev/null +++ b/src/components/performance-monitor/indexes-tab.tsx @@ -0,0 +1,61 @@ +import { cn } from "@/lib/utils"; +import type { IndexUsageRow } from "./types"; + +interface IndexesTabProps { + indexUsage: IndexUsageRow[]; + unusedIndexCount: number; +} + +export function IndexesTab({ indexUsage, unusedIndexCount }: IndexesTabProps) { + return ( +
+ {unusedIndexCount > 0 && ( +
+ + {unusedIndexCount} unused index{unusedIndexCount !== 1 ? "es" : ""} found -- consider removing to save space and improve write performance + +
+ )} +
+
+
+ + + {["Schema", "Table", "Index", "Size", "Scans", "Status", "Definition"].map((h) => ( + + ))} + + + + {indexUsage.map((row, idx) => ( + + + + + + + + + + ))} + {indexUsage.length === 0 && ( + + + + )} + +
{h}
{row.schema}{row.table}{row.index}{row.size}{parseInt(row.scans).toLocaleString()} + + {row.status === "rarely_used" ? "rarely used" : row.status} + + {row.definition}
No non-primary indexes found
+ + + + ); +} diff --git a/src/components/performance-monitor/locks-tab.tsx b/src/components/performance-monitor/locks-tab.tsx new file mode 100644 index 0000000..c92fc1e --- /dev/null +++ b/src/components/performance-monitor/locks-tab.tsx @@ -0,0 +1,67 @@ +import { cn } from "@/lib/utils"; +import type { LockRow } from "./types"; + +interface LocksTabProps { + locks: LockRow[]; + waitingLocks: LockRow[]; +} + +export function LocksTab({ locks, waitingLocks }: LocksTabProps) { + return ( +
+ {waitingLocks.length > 0 && ( +
+ + {waitingLocks.length} lock{waitingLocks.length !== 1 ? "s" : ""} waiting to be granted + +
+ )} +
+
+ + + + {["PID", "User", "Mode", "Lock Type", "Status", "Relation", "Duration", "Query"].map((h) => ( + + ))} + + + + {locks.map((row, idx) => ( + + + + + + + + + + + ))} + {locks.length === 0 && ( + + + + )} + +
{h}
{row.pid}{row.user}{row.mode}{row.locktype} + + {row.status} + + {row.relation || "-"}{parseFloat(row.duration || "0").toFixed(1)}s{row.query}
No active locks
+
+
+
+ ); +} diff --git a/src/components/performance-monitor/overview-tab.tsx b/src/components/performance-monitor/overview-tab.tsx new file mode 100644 index 0000000..c263373 --- /dev/null +++ b/src/components/performance-monitor/overview-tab.tsx @@ -0,0 +1,79 @@ +import { cn } from "@/lib/utils"; +import { Database, HardDrive, Users, Zap } from "lucide-react"; +import type { HistoryEntry } from "@/stores/history-store"; + +interface OverviewTabProps { + dbStats: [string, string][]; + projectHistory: HistoryEntry[]; + avgTime: number; + failedQueries: number; +} + +export function OverviewTab({ dbStats, projectHistory, avgTime, failedQueries }: OverviewTabProps) { + const statValue = (name: string) => dbStats.find(([n]) => n === name)?.[1] ?? "N/A"; + + return ( +
+ {/* Stat cards */} +
+ } label="Active Connections" value={statValue("Active Connections")} /> + } label="Database Size" value={statValue("Database Size")} /> + } label="Cache Hit Ratio" value={statValue("Cache Hit Ratio")} /> + } label="Deadlocks" value={statValue("Deadlocks")} /> +
+ + {/* All stats table */} +
+
+ Database Statistics +
+
+ {dbStats.map(([name, val]) => ( +
+ {name} + {val} +
+ ))} + {dbStats.length === 0 && ( +
+ No stats available +
+ )} +
+
+ + {/* Session history summary */} +
+
+ Session Query Summary +
+
+
+
{projectHistory.length}
+
Total Queries
+
+
+
{avgTime.toFixed(1)}ms
+
Avg Execution Time
+
+
+
0 && "text-destructive")}>{failedQueries}
+
Failed Queries
+
+
+
+
+ ); +} + +function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} diff --git a/src/components/performance-monitor/table-stats-tab.tsx b/src/components/performance-monitor/table-stats-tab.tsx new file mode 100644 index 0000000..48049d8 --- /dev/null +++ b/src/components/performance-monitor/table-stats-tab.tsx @@ -0,0 +1,59 @@ +import { cn } from "@/lib/utils"; +import type { TableStatRow } from "./types"; + +interface TableStatsTabProps { + tableStats: TableStatRow[]; +} + +export function TableStatsTab({ tableStats }: TableStatsTabProps) { + return ( +
+

+ Cumulative stats since server start or last pg_stat_reset(). Source: pg_stat_user_tables +

+
+
+ + + + {["Schema", "Table", "Seq Scan", "Idx Scan", "Live Tuples", "Dead Tuples", "Inserts", "Updates", "Deletes", "Last Vacuum", "Last Analyze"].map((h) => ( + + ))} + + + + {tableStats.map((row) => { + const deadRatio = parseInt(row.liveTuples) > 0 + ? (parseInt(row.deadTuples) / parseInt(row.liveTuples)) * 100 + : 0; + return ( + + + + + + + + + + + + + + ); + })} + {tableStats.length === 0 && ( + + + + )} + +
{h}
{row.schema}{row.table}{parseInt(row.seqScan).toLocaleString()}{parseInt(row.idxScan).toLocaleString()}{parseInt(row.liveTuples).toLocaleString()} 10 && "text-destructive font-medium")}> + {parseInt(row.deadTuples).toLocaleString()} + {deadRatio > 10 && ({deadRatio.toFixed(0)}%)} + {parseInt(row.inserts).toLocaleString()}{parseInt(row.updates).toLocaleString()}{parseInt(row.deletes).toLocaleString()}{row.lastVacuum === "never" ? "never" : new Date(row.lastVacuum).toLocaleDateString()}{row.lastAnalyze === "never" ? "never" : new Date(row.lastAnalyze).toLocaleDateString()}
No table stats available
+
+
+
+ ); +} diff --git a/src/components/performance-monitor/types.ts b/src/components/performance-monitor/types.ts new file mode 100644 index 0000000..0046508 --- /dev/null +++ b/src/components/performance-monitor/types.ts @@ -0,0 +1,69 @@ +export interface ActivityRow { + pid: string; + user: string; + database: string; + state: string; + waitEventType: string; + waitEvent: string; + query: string; + durationSec: string; + backendType: string; + clientAddr: string; +} + +export interface TableStatRow { + schema: string; + table: string; + seqScan: string; + seqTupRead: string; + idxScan: string; + idxTupFetch: string; + inserts: string; + updates: string; + deletes: string; + liveTuples: string; + deadTuples: string; + lastVacuum: string; + lastAutovacuum: string; + lastAnalyze: string; +} + +export interface LockRow { + pid: string; + user: string; + mode: string; + locktype: string; + status: string; + relation: string; + schema: string; + query: string; + duration: string; + waitEvent: string; +} + +export interface IndexUsageRow { + schema: string; + table: string; + index: string; + size: string; + scans: string; + tuplesRead: string; + tuplesFetched: string; + status: string; + definition: string; +} + +export interface BloatRow { + schema: string; + table: string; + liveTuples: string; + deadTuples: string; + bloatPct: string; + totalSize: string; + lastVacuum: string; + lastAutovacuum: string; + lastAnalyze: string; + lastAutoanalyze: string; +} + +export type MonitorTab = "overview" | "activity" | "tables" | "history" | "locks" | "indexes" | "bloat"; diff --git a/src/components/results-grid.tsx b/src/components/results-grid/index.tsx similarity index 65% rename from src/components/results-grid.tsx rename to src/components/results-grid/index.tsx index a6c2060..d61ab1e 100644 --- a/src/components/results-grid.tsx +++ b/src/components/results-grid/index.tsx @@ -12,8 +12,17 @@ import DataEditor, { } from "@glideapps/glide-data-grid"; import "@glideapps/glide-data-grid/dist/index.css"; import { useUIStore } from "@/stores/ui-store"; -import * as virtualCache from "@/lib/virtual-cache"; import type { VirtualQuery } from "@/types"; +import { + GRID_ROW_HEIGHT, + DELETED_OVERRIDE, + FK_OVERRIDE, + buildModifiedOverride, + computeGridColumns, + computeFkColIndices, + buildCellContent, + buildGridTheme, +} from "./rendering"; interface ResultsGridProps { columns: string[]; @@ -34,22 +43,6 @@ interface ResultsGridProps { gridRef?: MutableRefObject<{ invalidatePage: (pageIndex: number) => void } | null>; } -const MIN_COL_WIDTH = 80; -const MAX_COL_WIDTH = 400; -const CHAR_WIDTH = 7.5; -const PADDING = 24; -const GRID_ROW_HEIGHT = 32; - -// Pre-allocated static cell for unloaded virtual rows — avoids GC pressure -const LOADING_CELL: GridCell = { - kind: GridCellKind.Text, - data: "", - displayData: "\u2026", - allowOverlay: false, - readonly: true, - themeOverride: { textDark: "#888", textLight: "#666" }, -}; - export function ResultsGrid({ columns, rows, @@ -121,39 +114,15 @@ export function ResultsGrid({ }, []); // Calculate column widths based on content - const gridColumns = useMemo((): GridColumn[] => { - const sampleRows = rows.slice(0, 100); - return columns.map((col, colIdx) => { - let maxLen = col.length + 2; - for (const row of sampleRows) { - const cellLen = (row[colIdx] ?? "").length; - if (cellLen > maxLen) maxLen = cellLen; - } - const width = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, maxLen * CHAR_WIDTH + PADDING)); - return { title: col, id: col, width }; - }); - }, [columns, rows]); + const gridColumns = useMemo((): GridColumn[] => computeGridColumns(columns, rows), [columns, rows]); // Build set of FK column indices for fast lookup - const fkColIndices = useMemo(() => { - if (!fkColumns || fkColumns.size === 0) return new Set(); - const s = new Set(); - columns.forEach((col, idx) => { - if (fkColumns.has(col)) s.add(idx); - }); - return s; - }, [columns, fkColumns]); + const fkColIndices = useMemo(() => computeFkColIndices(columns, fkColumns), [columns, fkColumns]); // Pre-compute theme override objects — avoids creating new objects per cell render - const deletedOverride = useMemo(() => ( - { bgCell: "rgba(239, 68, 68, 0.1)", textDark: "#999", textLight: "#999" } - ), []); - const modifiedOverride = useMemo(() => ( - { bgCell: theme === "dark" ? "rgba(245, 158, 11, 0.15)" : "rgba(245, 158, 11, 0.1)" } - ), [theme]); - const fkOverride = useMemo(() => ( - { textDark: "hsl(220, 70%, 50%)", textLight: "hsl(220, 70%, 65%)" } - ), []); + const deletedOverride = useMemo(() => DELETED_OVERRIDE, []); + const modifiedOverride = useMemo(() => buildModifiedOverride(theme), [theme]); + const fkOverride = useMemo(() => FK_OVERRIDE, []); // Total row count: virtual mode uses totalRows, otherwise rows.length const totalRowCount = virtualQuery ? virtualQuery.totalRows : rows.length; @@ -178,59 +147,18 @@ export function ResultsGrid({ // Get cell content callback (the core of glide-data-grid) const getCellContent = useCallback( - (cell: Item): GridCell => { - const [colIdx, rowIdx] = cell; - - // Virtual mode: read from cache - if (virtualQuery) { - const pageIndex = Math.floor(rowIdx / virtualQuery.pageSize); - const row = virtualCache.getRow(virtualQuery.queryId, rowIdx, virtualQuery.pageSize); - if (!row) { - onPageNeeded?.(pageIndex); - const fallbackRow = rows[rowIdx]; - if (!fallbackRow) return LOADING_CELL; - const value = fallbackRow[colIdx] ?? ""; - return { - kind: GridCellKind.Text, - data: value, - displayData: value, - allowOverlay: false, - readonly: true, - }; - } - const value = row[colIdx] ?? ""; - return { - kind: GridCellKind.Text, - data: value, - displayData: value, - allowOverlay: false, - readonly: true, - }; - } - - const key = `${rowIdx}:${colIdx}`; - const isModified = cellEdits?.has(key); - const isDeleted = deletedRows?.has(rowIdx); - const isFK = fkColIndices.has(colIdx) && !isEditing; - const value = isModified ? cellEdits!.get(key)! : (rows[rowIdx]?.[colIdx] ?? ""); - - const baseCell: GridCell = { - kind: GridCellKind.Text, - data: value, - displayData: isFK && value !== "null" ? `${value} \u2192` : value, - allowOverlay: !!isEditing && !isDeleted, - readonly: !isEditing || !!isDeleted, - themeOverride: isDeleted - ? deletedOverride - : isModified - ? modifiedOverride - : isFK && value !== "null" - ? fkOverride - : undefined, - }; - - return baseCell; - }, + (cell: Item): GridCell => buildCellContent(cell, { + rows, + cellEdits, + deletedRows, + isEditing, + fkColIndices, + virtualQuery, + onPageNeeded, + deletedOverride, + modifiedOverride, + fkOverride, + }), // eslint-disable-next-line react-hooks/exhaustive-deps [rows, cellEdits, deletedRows, isEditing, theme, fkColIndices, virtualQuery, onPageNeeded], ); @@ -291,49 +219,7 @@ export function ResultsGrid({ ); // Theme for glide-data-grid - const gridTheme = useMemo((): Partial => { - if (theme === "dark") { - return { - accentColor: "hsl(260, 70%, 60%)", - accentLight: "hsla(260, 70%, 60%, 0.15)", - bgCell: "hsl(250, 15%, 13%)", - bgCellMedium: "hsl(250, 15%, 15%)", - bgHeader: "hsl(250, 15%, 18%)", - bgHeaderHasFocus: "hsl(250, 15%, 22%)", - bgHeaderHovered: "hsl(250, 15%, 20%)", - borderColor: "hsl(250, 12%, 22%)", - drilldownBorder: "hsl(250, 12%, 30%)", - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", - headerFontStyle: "bold 12px", - baseFontStyle: "12px", - textDark: "hsl(250, 15%, 85%)", - textMedium: "hsl(250, 10%, 60%)", - textLight: "hsl(250, 10%, 45%)", - textHeader: "hsl(250, 15%, 75%)", - textHeaderSelected: "hsl(260, 70%, 75%)", - bgBubble: "hsl(250, 15%, 20%)", - bgBubbleSelected: "hsl(260, 70%, 60%)", - textBubble: "hsl(250, 15%, 85%)", - }; - } - return { - accentColor: "hsl(260, 70%, 55%)", - accentLight: "hsla(260, 70%, 55%, 0.1)", - bgCell: "#ffffff", - bgCellMedium: "#fafafa", - bgHeader: "#f5f5f8", - bgHeaderHasFocus: "#eeeef2", - bgHeaderHovered: "#f0f0f4", - borderColor: "#e2e2e8", - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", - headerFontStyle: "bold 12px", - baseFontStyle: "12px", - textDark: "#1a1a2e", - textMedium: "#666680", - textLight: "#9999a8", - textHeader: "#333340", - }; - }, [theme]); + const gridTheme = useMemo((): Partial => buildGridTheme(theme), [theme]); // Row markers for delete buttons when editing const rowMarkers = isEditing ? ("checkbox-visible" as const) : ("none" as const); diff --git a/src/components/results-grid/rendering.tsx b/src/components/results-grid/rendering.tsx new file mode 100644 index 0000000..a504707 --- /dev/null +++ b/src/components/results-grid/rendering.tsx @@ -0,0 +1,181 @@ +import { + type GridColumn, + type GridCell, + GridCellKind, + type Item, + type Theme, +} from "@glideapps/glide-data-grid"; +import * as virtualCache from "@/lib/virtual-cache"; +import type { VirtualQuery } from "@/types"; + +export const MIN_COL_WIDTH = 80; +export const MAX_COL_WIDTH = 400; +export const CHAR_WIDTH = 7.5; +export const PADDING = 24; +export const GRID_ROW_HEIGHT = 32; + +// Pre-allocated static cell for unloaded virtual rows — avoids GC pressure +export const LOADING_CELL: GridCell = { + kind: GridCellKind.Text, + data: "", + displayData: "…", + allowOverlay: false, + readonly: true, + themeOverride: { textDark: "#888", textLight: "#666" }, +}; + +export const DELETED_OVERRIDE = { bgCell: "rgba(239, 68, 68, 0.1)", textDark: "#999", textLight: "#999" }; + +export function buildModifiedOverride(theme: string) { + return { bgCell: theme === "dark" ? "rgba(245, 158, 11, 0.15)" : "rgba(245, 158, 11, 0.1)" }; +} + +export const FK_OVERRIDE = { textDark: "hsl(220, 70%, 50%)", textLight: "hsl(220, 70%, 65%)" }; + +export function computeGridColumns(columns: string[], rows: string[][]): GridColumn[] { + const sampleRows = rows.slice(0, 100); + return columns.map((col, colIdx) => { + let maxLen = col.length + 2; + for (const row of sampleRows) { + const cellLen = (row[colIdx] ?? "").length; + if (cellLen > maxLen) maxLen = cellLen; + } + const width = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, maxLen * CHAR_WIDTH + PADDING)); + return { title: col, id: col, width }; + }); +} + +export function computeFkColIndices( + columns: string[], + fkColumns?: Map, +): Set { + if (!fkColumns || fkColumns.size === 0) return new Set(); + const s = new Set(); + columns.forEach((col, idx) => { + if (fkColumns.has(col)) s.add(idx); + }); + return s; +} + +export interface CellContentContext { + rows: string[][]; + cellEdits?: Map; + deletedRows?: Set; + isEditing?: boolean; + fkColIndices: Set; + virtualQuery?: VirtualQuery; + onPageNeeded?: (pageIndex: number) => void; + deletedOverride: typeof DELETED_OVERRIDE; + modifiedOverride: ReturnType; + fkOverride: typeof FK_OVERRIDE; +} + +export function buildCellContent(cell: Item, ctx: CellContentContext): GridCell { + const [colIdx, rowIdx] = cell; + const { + rows, + cellEdits, + deletedRows, + isEditing, + fkColIndices, + virtualQuery, + onPageNeeded, + deletedOverride, + modifiedOverride, + fkOverride, + } = ctx; + + // Virtual mode: read from cache + if (virtualQuery) { + const pageIndex = Math.floor(rowIdx / virtualQuery.pageSize); + const row = virtualCache.getRow(virtualQuery.queryId, rowIdx, virtualQuery.pageSize); + if (!row) { + onPageNeeded?.(pageIndex); + const fallbackRow = rows[rowIdx]; + if (!fallbackRow) return LOADING_CELL; + const value = fallbackRow[colIdx] ?? ""; + return { + kind: GridCellKind.Text, + data: value, + displayData: value, + allowOverlay: false, + readonly: true, + }; + } + const value = row[colIdx] ?? ""; + return { + kind: GridCellKind.Text, + data: value, + displayData: value, + allowOverlay: false, + readonly: true, + }; + } + + const key = `${rowIdx}:${colIdx}`; + const isModified = cellEdits?.has(key); + const isDeleted = deletedRows?.has(rowIdx); + const isFK = fkColIndices.has(colIdx) && !isEditing; + const value = isModified ? cellEdits!.get(key)! : (rows[rowIdx]?.[colIdx] ?? ""); + + const baseCell: GridCell = { + kind: GridCellKind.Text, + data: value, + displayData: isFK && value !== "null" ? `${value} →` : value, + allowOverlay: !!isEditing && !isDeleted, + readonly: !isEditing || !!isDeleted, + themeOverride: isDeleted + ? deletedOverride + : isModified + ? modifiedOverride + : isFK && value !== "null" + ? fkOverride + : undefined, + }; + + return baseCell; +} + +export function buildGridTheme(theme: string): Partial { + if (theme === "dark") { + return { + accentColor: "hsl(260, 70%, 60%)", + accentLight: "hsla(260, 70%, 60%, 0.15)", + bgCell: "hsl(250, 15%, 13%)", + bgCellMedium: "hsl(250, 15%, 15%)", + bgHeader: "hsl(250, 15%, 18%)", + bgHeaderHasFocus: "hsl(250, 15%, 22%)", + bgHeaderHovered: "hsl(250, 15%, 20%)", + borderColor: "hsl(250, 12%, 22%)", + drilldownBorder: "hsl(250, 12%, 30%)", + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + headerFontStyle: "bold 12px", + baseFontStyle: "12px", + textDark: "hsl(250, 15%, 85%)", + textMedium: "hsl(250, 10%, 60%)", + textLight: "hsl(250, 10%, 45%)", + textHeader: "hsl(250, 15%, 75%)", + textHeaderSelected: "hsl(260, 70%, 75%)", + bgBubble: "hsl(250, 15%, 20%)", + bgBubbleSelected: "hsl(260, 70%, 60%)", + textBubble: "hsl(250, 15%, 85%)", + }; + } + return { + accentColor: "hsl(260, 70%, 55%)", + accentLight: "hsla(260, 70%, 55%, 0.1)", + bgCell: "#ffffff", + bgCellMedium: "#fafafa", + bgHeader: "#f5f5f8", + bgHeaderHasFocus: "#eeeef2", + bgHeaderHovered: "#f0f0f4", + borderColor: "#e2e2e8", + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + headerFontStyle: "bold 12px", + baseFontStyle: "12px", + textDark: "#1a1a2e", + textMedium: "#666680", + textLight: "#9999a8", + textHeader: "#333340", + }; +} diff --git a/src/components/results-panel.tsx b/src/components/results-panel.tsx deleted file mode 100644 index 0d52db1..0000000 --- a/src/components/results-panel.tsx +++ /dev/null @@ -1,1112 +0,0 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from "react"; -import { createPortal } from "react-dom"; -import { invoke } from "@tauri-apps/api/core"; -import { useActiveTab } from "@/stores/tab-store"; -import { useTabStore } from "@/stores/tab-store"; -import { useUIStore } from "@/stores/ui-store"; -import { useProjectStore } from "@/stores/project-store"; -import { DriverFactory } from "@/lib/database-driver"; -import { - CheckCircle2, - Clock, - Copy, - Diff, - Download, - Edit3, - GitBranch, - History, - Loader2, - Pin, - Save, - Search, - Square, - X, - XCircle, - Trash2, -} from "lucide-react"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./ui/dialog"; -import { ResultsGrid } from "./results-grid"; -import { ResultsRecord } from "./results-record"; -import { QueryHistory } from "./query-history"; -import { ExplainPanel } from "./explain-panel"; -import { exportResults, copyToClipboard, type ExportFormat } from "@/lib/export"; -import { parseSelectTable, generateUpdate, generateDelete, quoteIdent, quoteLiteral } from "@/lib/sql-utils"; -import { ResultsMap, hasGeometryColumn } from "./results-map"; -import type { ForeignKey } from "@/lib/database-driver"; -import * as virtualCache from "@/lib/virtual-cache"; - -const CELL_SEP = "\x1F"; -const ROW_SEP = "\x1E"; -const MAX_CONCURRENT_PAGE_FETCHES = 6; -const MAX_QUEUED_PAGE_FETCHES = 32; -const CACHE_WINDOW_PAGES = 24; - -type PanelView = "grid" | "record" | "history" | "explain" | "diff" | "map"; - -interface EditState { - schema: string; - table: string; - pkColumns: string[]; - cellEdits: Map; - deletedRows: Set; -} - -export function ResultsPanel() { - const activeTab = useActiveTab(); - const viewMode = useUIStore((s) => s.viewMode); - const setViewMode = useUIStore((s) => s.setViewMode); - const pinnedResult = useUIStore((s) => s.pinnedResult); - const [panelView, setPanelView] = useState("grid"); - const [searchTerm, setSearchTerm] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - - // Debounce search term — avoids filtering 50K rows on every keystroke - useEffect(() => { - if (!searchTerm.trim()) { - setDebouncedSearch(""); - return; - } - const timer = setTimeout(() => setDebouncedSearch(searchTerm), 200); - return () => clearTimeout(timer); - }, [searchTerm]); - const [isEditing, setIsEditing] = useState(false); - const [editState, setEditState] = useState(null); - const [editError, setEditError] = useState(null); - const [isCommitting, setIsCommitting] = useState(false); - const [pendingDeleteCount, setPendingDeleteCount] = useState(0); - const result = activeTab?.result; - const isExecuting = activeTab?.isExecuting; - const vq = activeTab?.virtualQuery; - - // Cancel running query - const handleCancel = useCallback(async () => { - if (!activeTab?.projectId || !activeTab.isExecuting) return; - const d = useProjectStore.getState().projects[activeTab.projectId]; - if (!d) return; - try { - const driver = DriverFactory.getDriver(d.driver); - await driver.cancelQuery?.(activeTab.projectId); - } catch (err) { - console.error("Failed to cancel query:", err); - } - }, [activeTab?.projectId, activeTab?.isExecuting]); - - // Virtual page loading - const loadingPages = useRef(new Set()); - const queuedPages = useRef([]); - const queuedPageSet = useRef(new Set()); - const activeFetches = useRef(0); - const latestRequestedPage = useRef(0); - const gridRef = useRef<{ invalidatePage: (pageIndex: number) => void }>(null); - const virtualViewportRows = useRef(new Map()); - - useEffect(() => { - loadingPages.current.clear(); - queuedPages.current = []; - queuedPageSet.current.clear(); - activeFetches.current = 0; - }, [vq?.queryId, activeTab?.projectId]); - - const handleViewportRowChange = useCallback((rowIndex: number) => { - if (!vq?.queryId) return; - virtualViewportRows.current.set(vq.queryId, rowIndex); - }, [vq?.queryId]); - - const restoreRowIndex = vq?.queryId - ? (virtualViewportRows.current.get(vq.queryId) ?? 0) - : 0; - - const fetchPage = useCallback(async (pageIndex: number) => { - if (!vq || !activeTab?.projectId) return; - const d = useProjectStore.getState().projects[activeTab.projectId]; - if (!d) return; - const driver = DriverFactory.getDriver(d.driver); - if (!driver.fetchPage) return; - - const offset = pageIndex * vq.pageSize; - const packed = await driver.fetchPage(activeTab.projectId, vq.queryId, vq.colCount, offset, vq.pageSize); - - // Drop stale page responses after tab/query switches. - const selectedIdx = useTabStore.getState().selectedTabIndex; - const selectedTab = useTabStore.getState().tabs[selectedIdx]; - if (selectedTab?.virtualQuery?.queryId !== vq.queryId) return; - - const rows = packed ? packed.split(ROW_SEP).map((r) => r.split(CELL_SEP)) : []; - const expectedRows = Math.max(0, Math.min(vq.pageSize, vq.totalRows - offset)); - if (expectedRows > 0 && rows.length === 0) { - // Keep page as "missing" so viewport observer can retry instead of caching a permanent empty page. - return; - } - virtualCache.setPage(vq.queryId, pageIndex, rows); - // Evict around the user's latest viewport, not the page that happened to resolve last. - virtualCache.evictDistant(vq.queryId, latestRequestedPage.current, CACHE_WINDOW_PAGES); - gridRef.current?.invalidatePage(pageIndex); - }, [vq, activeTab?.projectId]); - - const pumpQueue = useCallback(() => { - if (!vq || !activeTab?.projectId) return; - - if (queuedPages.current.length > 1) { - const target = latestRequestedPage.current; - queuedPages.current.sort((a, b) => Math.abs(a - target) - Math.abs(b - target)); - } - - while (activeFetches.current < MAX_CONCURRENT_PAGE_FETCHES && queuedPages.current.length > 0) { - const pageIndex = queuedPages.current.shift()!; - queuedPageSet.current.delete(pageIndex); - - if (loadingPages.current.has(pageIndex) || virtualCache.hasPage(vq.queryId, pageIndex)) { - continue; - } - - loadingPages.current.add(pageIndex); - activeFetches.current += 1; - - void fetchPage(pageIndex).finally(() => { - loadingPages.current.delete(pageIndex); - activeFetches.current -= 1; - pumpQueue(); - }); - } - }, [vq, activeTab?.projectId, fetchPage]); - - const handlePageNeeded = useCallback((pageIndex: number) => { - if (!vq || !activeTab?.projectId) return; - latestRequestedPage.current = pageIndex; - if ( - loadingPages.current.has(pageIndex) - || virtualCache.hasPage(vq.queryId, pageIndex) - || queuedPageSet.current.has(pageIndex) - ) { - return; - } - - if (queuedPages.current.length >= MAX_QUEUED_PAGE_FETCHES) { - queuedPages.current = queuedPages.current.filter((p) => Math.abs(p - pageIndex) <= 8); - queuedPageSet.current = new Set(queuedPages.current); - } - - queuedPages.current.push(pageIndex); - queuedPageSet.current.add(pageIndex); - pumpQueue(); - }, [vq, activeTab?.projectId, pumpQueue]); - - useEffect(() => { - if (!vq) return; - const anchorPage = Math.max(0, Math.floor(restoreRowIndex / vq.pageSize)); - const startPage = Math.max(0, anchorPage - 1); - const endPage = Math.min(anchorPage + 3, Math.ceil(vq.totalRows / vq.pageSize) - 1); - for (let p = startPage; p <= endPage; p++) { - handlePageNeeded(p); - } - }, [vq?.queryId, vq?.totalRows, vq?.pageSize, restoreRowIndex, handlePageNeeded]); - - const filteredRows = useMemo(() => { - if (isEditing) return result?.rows ?? []; - if (!result || !debouncedSearch.trim()) return result?.rows ?? []; - const term = debouncedSearch.toLowerCase(); - return result.rows.filter((row) => - row.some((cell) => cell.toLowerCase().includes(term)), - ); - }, [result, debouncedSearch, isEditing]); - - const explainResult = activeTab?.explainResult; - const hasExplain = !!explainResult; - - // Detect if query is a simple SELECT (editable) - const editableTable = useMemo(() => { - if (!activeTab?.editorValue) return null; - return parseSelectTable(activeTab.editorValue); - }, [activeTab?.editorValue]); - - // FK column map: columnName → { targetSchema, targetTable, targetColumn } - const [fkMap, setFkMap] = useState>(new Map()); - - useEffect(() => { - if (!editableTable || !activeTab?.projectId) { - setFkMap(new Map()); - return; - } - const pid = activeTab.projectId; - const d = useProjectStore.getState().projects[pid]; - if (!d) return; - - const driver = DriverFactory.getDriver(d.driver); - driver.loadForeignKeys(pid, editableTable.schema).then((fks: ForeignKey[]) => { - const map = new Map(); - for (const fk of fks) { - if (fk.sourceTable === editableTable.table) { - map.set(fk.sourceColumn, { - schema: editableTable.schema, - table: fk.targetTable, - column: fk.targetColumn, - }); - } - } - setFkMap(map); - }).catch(() => setFkMap(new Map())); - }, [editableTable, activeTab?.projectId]); - - // FK navigate handler - opens a new tab and auto-executes the query - const handleFKNavigate = useCallback( - (colName: string, value: string) => { - const target = fkMap.get(colName); - if (!target || !activeTab?.projectId) return; - - const pid = activeTab.projectId; - const sql = `SELECT * FROM ${quoteIdent(target.schema)}.${quoteIdent(target.table)} WHERE ${quoteIdent(target.column)} = ${quoteLiteral(value)} LIMIT 100`; - useTabStore.getState().openTab(pid, sql); - - // Auto-execute the query in the new tab - const d = useProjectStore.getState().projects[pid]; - if (!d) return; - const newTabIdx = useTabStore.getState().tabs.length - 1; - useTabStore.getState().setExecuting(newTabIdx, true); - const driver = DriverFactory.getDriver(d.driver); - driver.runQuery(pid, sql).then(([cols, rows, time]) => { - useTabStore.getState().updateResult(newTabIdx, { columns: cols, rows, time }); - }).catch(() => { - useTabStore.getState().setExecuting(newTabIdx, false); - }); - }, - [fkMap, activeTab?.projectId], - ); - - // Enter edit mode - const handleEnterEdit = useCallback(async () => { - if (!editableTable || !activeTab?.projectId) return; - const d = useProjectStore.getState().projects[activeTab.projectId]; - if (!d) return; - setEditError(null); - - try { - const driver = DriverFactory.getDriver(d.driver); - const indexes = await driver.loadIndexes( - activeTab.projectId, - editableTable.schema, - editableTable.table, - ); - const pkColumns = [...new Set(indexes.filter((i) => i.isPrimary).map((i) => i.columnName))]; - - if (pkColumns.length === 0) { - setEditError("No primary key found. Inline editing requires a primary key."); - return; - } - - // Check that PK columns exist in result columns - const resultCols = result?.columns ?? []; - const missingPKs = pkColumns.filter((pk) => !resultCols.includes(pk)); - if (missingPKs.length > 0) { - setEditError(`Primary key column(s) ${missingPKs.join(", ")} not in query results. Select all PK columns to edit.`); - return; - } - - setEditState({ - schema: editableTable.schema, - table: editableTable.table, - pkColumns, - cellEdits: new Map(), - deletedRows: new Set(), - }); - setIsEditing(true); - } catch (err: any) { - setEditError(err?.message ?? "Failed to load table info"); - } - }, [editableTable, activeTab?.projectId, result?.columns]); - - // Discard edits - const handleDiscard = useCallback(() => { - setIsEditing(false); - setEditState(null); - setEditError(null); - }, []); - - // Run statements + refresh results helper - const runAndRefresh = useCallback(async (statements: string[]) => { - if (!activeTab?.projectId || statements.length === 0) return; - setIsCommitting(true); - setEditError(null); - - try { - const d = useProjectStore.getState().projects[activeTab.projectId]; - if (!d) throw new Error("Project not found"); - const driver = DriverFactory.getDriver(d.driver); - - const txnSql = ["BEGIN", ...statements, "COMMIT"].join(";\n"); - await driver.runQuery(activeTab.projectId, txnSql, 30000); - - const [cols, rows, time] = await driver.runQuery(activeTab.projectId, activeTab.editorValue); - const tabIdx = useTabStore.getState().selectedTabIndex; - useTabStore.getState().updateResult(tabIdx, { columns: cols, rows, time }); - - setIsEditing(false); - setEditState(null); - setPendingDeleteCount(0); - } catch (err: any) { - setEditError(err?.message ?? "Commit failed"); - } finally { - setIsCommitting(false); - } - }, [activeTab?.projectId, activeTab?.editorValue]); - - // Commit — only cell edits (UPDATEs), no deletes - const handleCommit = useCallback(() => { - if (!editState || !result) return; - const { schema, table, pkColumns, cellEdits, deletedRows } = editState; - const columns = result.columns; - const originalRows = result.rows; - - const editsByRow = new Map>(); - for (const [key, value] of cellEdits) { - const [rowStr, colStr] = key.split(":"); - const rowIdx = parseInt(rowStr); - const colIdx = parseInt(colStr); - if (deletedRows.has(rowIdx)) continue; - if (!editsByRow.has(rowIdx)) editsByRow.set(rowIdx, new Map()); - editsByRow.get(rowIdx)!.set(colIdx, value); - } - - const statements: string[] = []; - for (const [rowIdx, changes] of editsByRow) { - statements.push(generateUpdate(schema, table, columns, originalRows[rowIdx], changes, pkColumns)); - } - - if (statements.length === 0) { - handleDiscard(); - return; - } - - void runAndRefresh(statements); - }, [editState, result, handleDiscard, runAndRefresh]); - - // Delete — only checked rows (DELETEs), with inline confirmation - const handleDeleteRows = useCallback(() => { - if (!editState || editState.deletedRows.size === 0) return; - setPendingDeleteCount(editState.deletedRows.size); - }, [editState]); - - const handleConfirmDelete = useCallback(() => { - if (!editState || !result) return; - const { schema, table, pkColumns, deletedRows } = editState; - const columns = result.columns; - const originalRows = result.rows; - - const statements: string[] = []; - for (const rowIdx of deletedRows) { - statements.push(generateDelete(schema, table, columns, originalRows[rowIdx], pkColumns)); - } - - setPendingDeleteCount(0); - void runAndRefresh(statements); - }, [editState, result, runAndRefresh]); - - const handleCancelDelete = useCallback(() => { - setPendingDeleteCount(0); - }, []); - - // Cell edit handler - const handleCellEdit = useCallback( - (rowIndex: number, colIndex: number, value: string) => { - setEditState((prev) => { - if (!prev) return prev; - const newEdits = new Map(prev.cellEdits); - const original = result?.rows[rowIndex]?.[colIndex] ?? ""; - if (value === original) { - newEdits.delete(`${rowIndex}:${colIndex}`); - } else { - newEdits.set(`${rowIndex}:${colIndex}`, value); - } - return { ...prev, cellEdits: newEdits }; - }); - }, - [result], - ); - - const handleRowDelete = useCallback((rowIndex: number) => { - setEditState((prev) => { - if (!prev) return prev; - const newDeleted = new Set(prev.deletedRows); - newDeleted.add(rowIndex); - return { ...prev, deletedRows: newDeleted }; - }); - }, []); - - const handleRowRestore = useCallback((rowIndex: number) => { - setEditState((prev) => { - if (!prev) return prev; - const newDeleted = new Set(prev.deletedRows); - newDeleted.delete(rowIndex); - return { ...prev, deletedRows: newDeleted }; - }); - }, []); - - // Common toolbar props - const toolbarProps = { - panelView, - setPanelView, - searchTerm, - setSearchTerm, - setViewMode, - viewMode, - hasExplain, - isExecuting: !!isExecuting, - isEditing, - editState, - editableTable: !!editableTable && !vq, - isCommitting, - editError, - onEnterEdit: handleEnterEdit, - onCommit: handleCommit, - onDeleteRows: handleDeleteRows, - onConfirmDelete: handleConfirmDelete, - onCancelDelete: handleCancelDelete, - pendingDeleteCount, - onDiscard: handleDiscard, - onCancel: handleCancel, - virtualQuery: vq, - }; - - if (panelView === "explain" && hasExplain) { - return ( -
- - -
- ); - } - - if (panelView !== "history" && isExecuting && !result) { - return ( -
- -
- - Executing query... -
-
- ); - } - - if (panelView === "history") { - return ( -
- - -
- ); - } - - if (panelView === "diff" && pinnedResult && result) { - return ( -
- - -
- ); - } - - if (panelView === "map" && result) { - return ( -
- - -
- ); - } - - if (!result) { - return ( -
- -
- No data to display -
-
- ); - } - - return ( -
- - {editError && !isEditing && ( -
- - {editError} - -
- )} - {viewMode === "grid" ? ( - - ) : ( - - )} -
- ); -} - -interface ToolbarProps { - panelView: PanelView; - setPanelView: (v: PanelView) => void; - result: { rows: string[][]; time: number; capped?: boolean } | null; - columns: string[]; - filteredRows: string[][]; - searchTerm: string; - setSearchTerm: (v: string) => void; - filteredCount: number; - setViewMode: (mode: "grid" | "record") => void; - viewMode: "grid" | "record"; - hasExplain: boolean; - isExecuting: boolean; - isEditing: boolean; - editState: EditState | null; - editableTable: boolean; - isCommitting: boolean; - editError: string | null; - onEnterEdit: () => void; - onCommit: () => void; - onDeleteRows: () => void; - onConfirmDelete: () => void; - onCancelDelete: () => void; - pendingDeleteCount: number; - onDiscard: () => void; - onCancel?: () => void; - virtualQuery?: { queryId: string; totalRows: number; time: number; pageSize: number }; -} - -function ResultsToolbar(props: ToolbarProps) { - const { - panelView, - setPanelView, - result, - columns, - filteredRows, - searchTerm, - setSearchTerm, - filteredCount, - setViewMode, - viewMode, - hasExplain, - isExecuting, - isEditing, - editState, - editableTable, - isCommitting, - editError, - onEnterEdit, - onCommit, - onDeleteRows, - onConfirmDelete, - onCancelDelete, - pendingDeleteCount, - onDiscard, - onCancel, - virtualQuery, - } = props; - - const [exportOpen, setExportOpen] = useState(false); - const exportRef = useRef(null); - const pinnedResult = useUIStore((s) => s.pinnedResult); - const pinResult = useUIStore((s) => s.pinResult); - const clearPinnedResult = useUIStore((s) => s.clearPinnedResult); - - const handleExport = (format: ExportFormat) => { - if (!result) return; - exportResults(format, columns, filteredRows); - setExportOpen(false); - }; - - const handleCopy = (format: ExportFormat) => { - if (!result) return; - void copyToClipboard(format, columns, filteredRows); - setExportOpen(false); - }; - - return ( -
-
- {/* Panel tabs — segment control */} -
- - - {hasExplain && ( - - )} - - {result && hasGeometryColumn(columns, filteredRows) && ( - - )} -
- - {/* Result stats */} - {panelView !== "history" && result && ( -
- {isExecuting ? ( - - ) : ( - - )} - - {virtualQuery - ? `${virtualQuery.totalRows.toLocaleString()} rows (virtual)` - : searchTerm - ? `${filteredCount.toLocaleString()} / ${result.rows.length.toLocaleString()} rows` - : `${result.rows.length.toLocaleString()} rows`} - {result.capped && !virtualQuery && ( - (capped at 500K) - )} - - - - {result.time.toFixed(0)}ms - {isEditing && editState?.cellEdits.size ? ( - <> - - {editState.cellEdits.size} edit{editState.cellEdits.size !== 1 ? "s" : ""} - - ) : null} - {isEditing && editState?.deletedRows.size ? ( - <> - - {editState.deletedRows.size} delete{editState.deletedRows.size !== 1 ? "s" : ""} - - ) : null} -
- )} - - {/* Stop button — visible while executing */} - {isExecuting && onCancel && ( - - )} -
- -
- {/* Edit mode controls */} - {isEditing ? ( - <> - {editError && ( - - {editError} - - )} - - - 0} onOpenChange={(open) => { if (!open) onCancelDelete(); }}> - - - Delete rows - - Are you sure you want to permanently delete {pendingDeleteCount} row{pendingDeleteCount !== 1 ? "s" : ""}? This action cannot be undone. - - - - - - - - - - - ) : ( - <> - {/* Edit button */} - {panelView !== "history" && editableTable && result && result.rows.length > 0 && ( - - )} - - {/* Pin / Diff */} - {panelView !== "history" && result && result.rows.length > 0 && !virtualQuery && ( - <> - {pinnedResult ? ( -
- - Pinned: {pinnedResult.label} - -
- ) : ( - - )} - {pinnedResult && ( - - )} - - )} - - {/* Export dropdown */} - {panelView !== "history" && result && result.rows.length > 0 && !virtualQuery && ( -
- - {exportOpen && createPortal( - <> -
setExportOpen(false)} /> -
{ const r = exportRef.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0; })(), - left: (() => { const r = exportRef.current?.getBoundingClientRect(); return r ? Math.max(0, r.right - 208) : 0; })(), - }} - > -
- Download -
- {(["csv", "json", "sql", "markdown", "xml"] as ExportFormat[]).map((fmt) => ( - - ))} -
-
- Copy to clipboard -
- {(["csv", "json", "sql", "markdown"] as ExportFormat[]).map((fmt) => ( - - ))} -
- , - document.body - )} -
- )} - - {/* Search */} - {panelView !== "history" && result && !virtualQuery && ( -
- - setSearchTerm(e.target.value)} - className="h-7 w-48 rounded border border-border bg-input pl-7 pr-7 text-xs font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" - /> - {searchTerm && ( - - )} -
- )} - - )} -
-
- ); -} - -function DiffView({ - pinnedColumns, - pinnedRows, - currentColumns, - currentRows, -}: { - pinnedColumns: string[]; - pinnedRows: string[][]; - currentColumns: string[]; - currentRows: string[][]; -}) { - const [diffResult, setDiffResult] = useState<{ - added: string[][]; - removed: string[][]; - unchangedCount: number; - } | null>(null); - const [computing, setComputing] = useState(false); - - const colsMatch = - pinnedColumns.length === currentColumns.length && pinnedColumns.every((c, i) => c === currentColumns[i]); - - // Compute diff in Rust backend for performance - const prevKeyRef = useRef(""); - const diffKey = `${pinnedRows.length}:${currentRows.length}`; - if (diffKey !== prevKeyRef.current && colsMatch) { - prevKeyRef.current = diffKey; - setComputing(true); - setDiffResult(null); - - // Pack rows into the compact wire format for Rust - const CELL_SEP = "\x1F"; - const ROW_SEP = "\x1E"; - const packRows = (columns: string[], rows: string[][]) => { - const header = columns.join(CELL_SEP); - if (rows.length === 0) return header; - return header + ROW_SEP + rows.map((r) => r.join(CELL_SEP)).join(ROW_SEP); - }; - - const pinnedPacked = packRows(pinnedColumns, pinnedRows); - const currentPacked = packRows(currentColumns, currentRows); - - invoke<[string, string, number]>("compute_diff", { - pinned_packed: pinnedPacked, - current_packed: currentPacked, - }).then(([addedPacked, removedPacked, unchangedCount]) => { - const unpackRows = (packed: string): string[][] => { - if (!packed) return []; - const parts = packed.split(ROW_SEP); - // Skip header (index 0) - return parts.slice(1).map((r) => r.split(CELL_SEP)); - }; - setDiffResult({ - added: unpackRows(addedPacked), - removed: unpackRows(removedPacked), - unchangedCount, - }); - setComputing(false); - }).catch(() => { - // Fallback: compute in JS if Rust command fails - const pinnedSet = new Set(pinnedRows.map((r) => r.join(CELL_SEP))); - const currentSet = new Set(currentRows.map((r) => r.join(CELL_SEP))); - setDiffResult({ - added: currentRows.filter((r) => !pinnedSet.has(r.join(CELL_SEP))), - removed: pinnedRows.filter((r) => !currentSet.has(r.join(CELL_SEP))), - unchangedCount: currentRows.filter((r) => pinnedSet.has(r.join(CELL_SEP))).length, - }); - setComputing(false); - }); - } - - if (!colsMatch) { - return ( -
- -
Column structures differ
-
Pinned: {pinnedColumns.join(", ")}
-
Current: {currentColumns.join(", ")}
-
- ); - } - - if (computing || !diffResult) { - return ( -
- - Computing diff... -
- ); - } - - const { added, removed, unchangedCount } = diffResult; - - return ( -
-
- - +{added.length} added - - - -{removed.length} removed - - ={unchangedCount} unchanged -
- - - - - - ))} - - - - {removed.map((row, i) => ( - - - {row.map((cell, j) => ( - - ))} - - ))} - {added.map((row, i) => ( - - - {row.map((cell, j) => ( - - ))} - - ))} - -
- {pinnedColumns.map((col) => ( - - {col} -
- - {cell} -
+ - {cell} -
-
- ); -} diff --git a/src/components/results-panel/constants.ts b/src/components/results-panel/constants.ts new file mode 100644 index 0000000..de8d9af --- /dev/null +++ b/src/components/results-panel/constants.ts @@ -0,0 +1,5 @@ +export const CELL_SEP = "\x1F"; +export const ROW_SEP = "\x1E"; +export const MAX_CONCURRENT_PAGE_FETCHES = 6; +export const MAX_QUEUED_PAGE_FETCHES = 32; +export const CACHE_WINDOW_PAGES = 24; diff --git a/src/components/results-panel/diff-view.tsx b/src/components/results-panel/diff-view.tsx new file mode 100644 index 0000000..a2a3f3e --- /dev/null +++ b/src/components/results-panel/diff-view.tsx @@ -0,0 +1,145 @@ +import { useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Diff, Loader2 } from "lucide-react"; + +export function DiffView({ + pinnedColumns, + pinnedRows, + currentColumns, + currentRows, +}: { + pinnedColumns: string[]; + pinnedRows: string[][]; + currentColumns: string[]; + currentRows: string[][]; +}) { + const [diffResult, setDiffResult] = useState<{ + added: string[][]; + removed: string[][]; + unchangedCount: number; + } | null>(null); + const [computing, setComputing] = useState(false); + + const colsMatch = + pinnedColumns.length === currentColumns.length && pinnedColumns.every((c, i) => c === currentColumns[i]); + + // Compute diff in Rust backend for performance + const prevKeyRef = useRef(""); + const diffKey = `${pinnedRows.length}:${currentRows.length}`; + if (diffKey !== prevKeyRef.current && colsMatch) { + prevKeyRef.current = diffKey; + setComputing(true); + setDiffResult(null); + + // Pack rows into the compact wire format for Rust + const CELL_SEP = "\x1F"; + const ROW_SEP = "\x1E"; + const packRows = (columns: string[], rows: string[][]) => { + const header = columns.join(CELL_SEP); + if (rows.length === 0) return header; + return header + ROW_SEP + rows.map((r) => r.join(CELL_SEP)).join(ROW_SEP); + }; + + const pinnedPacked = packRows(pinnedColumns, pinnedRows); + const currentPacked = packRows(currentColumns, currentRows); + + invoke<[string, string, number]>("compute_diff", { + pinned_packed: pinnedPacked, + current_packed: currentPacked, + }).then(([addedPacked, removedPacked, unchangedCount]) => { + const unpackRows = (packed: string): string[][] => { + if (!packed) return []; + const parts = packed.split(ROW_SEP); + // Skip header (index 0) + return parts.slice(1).map((r) => r.split(CELL_SEP)); + }; + setDiffResult({ + added: unpackRows(addedPacked), + removed: unpackRows(removedPacked), + unchangedCount, + }); + setComputing(false); + }).catch(() => { + // Fallback: compute in JS if Rust command fails + const pinnedSet = new Set(pinnedRows.map((r) => r.join(CELL_SEP))); + const currentSet = new Set(currentRows.map((r) => r.join(CELL_SEP))); + setDiffResult({ + added: currentRows.filter((r) => !pinnedSet.has(r.join(CELL_SEP))), + removed: pinnedRows.filter((r) => !currentSet.has(r.join(CELL_SEP))), + unchangedCount: currentRows.filter((r) => pinnedSet.has(r.join(CELL_SEP))).length, + }); + setComputing(false); + }); + } + + if (!colsMatch) { + return ( +
+ +
Column structures differ
+
Pinned: {pinnedColumns.join(", ")}
+
Current: {currentColumns.join(", ")}
+
+ ); + } + + if (computing || !diffResult) { + return ( +
+ + Computing diff... +
+ ); + } + + const { added, removed, unchangedCount } = diffResult; + + return ( +
+
+ + +{added.length} added + + + -{removed.length} removed + + ={unchangedCount} unchanged +
+ + + + + + ))} + + + + {removed.map((row, i) => ( + + + {row.map((cell, j) => ( + + ))} + + ))} + {added.map((row, i) => ( + + + {row.map((cell, j) => ( + + ))} + + ))} + +
+ {pinnedColumns.map((col) => ( + + {col} +
- + {cell} +
+ + {cell} +
+
+ ); +} diff --git a/src/components/results-panel/index.tsx b/src/components/results-panel/index.tsx new file mode 100644 index 0000000..6ef3141 --- /dev/null +++ b/src/components/results-panel/index.tsx @@ -0,0 +1,225 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useActiveTab } from "@/stores/tab-store"; +import { useUIStore } from "@/stores/ui-store"; +import { useProjectStore } from "@/stores/project-store"; +import { DriverFactory } from "@/lib/database-driver"; +import { Loader2, X, XCircle } from "lucide-react"; +import { ResultsGrid } from "../results-grid"; +import { ResultsRecord } from "../results-record"; +import { QueryHistory } from "../query-history"; +import { ExplainPanel } from "../explain-panel"; +import { ResultsMap } from "../results-map"; +import { ResultsToolbar } from "./toolbar"; +import { DiffView } from "./diff-view"; +import { useVirtualPaging } from "./use-virtual-paging"; +import { useEditMode } from "./use-edit-mode"; +import type { PanelView } from "./types"; + +export function ResultsPanel() { + const activeTab = useActiveTab(); + const viewMode = useUIStore((s) => s.viewMode); + const setViewMode = useUIStore((s) => s.setViewMode); + const pinnedResult = useUIStore((s) => s.pinnedResult); + const [panelView, setPanelView] = useState("grid"); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + // Debounce search term — avoids filtering 50K rows on every keystroke + useEffect(() => { + if (!searchTerm.trim()) { + setDebouncedSearch(""); + return; + } + const timer = setTimeout(() => setDebouncedSearch(searchTerm), 200); + return () => clearTimeout(timer); + }, [searchTerm]); + + const result = activeTab?.result; + const isExecuting = activeTab?.isExecuting; + const vq = activeTab?.virtualQuery; + + // Cancel running query + const handleCancel = useCallback(async () => { + if (!activeTab?.projectId || !activeTab.isExecuting) return; + const d = useProjectStore.getState().projects[activeTab.projectId]; + if (!d) return; + try { + const driver = DriverFactory.getDriver(d.driver); + await driver.cancelQuery?.(activeTab.projectId); + } catch (err) { + console.error("Failed to cancel query:", err); + } + }, [activeTab?.projectId, activeTab?.isExecuting]); + + // Virtual page loading + const { gridRef, handlePageNeeded, handleViewportRowChange, restoreRowIndex } = useVirtualPaging({ + vq, + projectId: activeTab?.projectId, + }); + + // Edit mode + const { + isEditing, + editState, + editError, + setEditError, + isCommitting, + pendingDeleteCount, + editableTable, + fkMap, + handleFKNavigate, + handleEnterEdit, + handleDiscard, + handleCommit, + handleDeleteRows, + handleConfirmDelete, + handleCancelDelete, + handleCellEdit, + handleRowDelete, + handleRowRestore, + } = useEditMode({ + projectId: activeTab?.projectId, + editorValue: activeTab?.editorValue, + result, + }); + + const filteredRows = useMemo(() => { + if (isEditing) return result?.rows ?? []; + if (!result || !debouncedSearch.trim()) return result?.rows ?? []; + const term = debouncedSearch.toLowerCase(); + return result.rows.filter((row) => + row.some((cell) => cell.toLowerCase().includes(term)), + ); + }, [result, debouncedSearch, isEditing]); + + const explainResult = activeTab?.explainResult; + const hasExplain = !!explainResult; + + // Common toolbar props + const toolbarProps = { + panelView, + setPanelView, + searchTerm, + setSearchTerm, + setViewMode, + viewMode, + hasExplain, + isExecuting: !!isExecuting, + isEditing, + editState, + editableTable: !!editableTable && !vq, + isCommitting, + editError, + onEnterEdit: handleEnterEdit, + onCommit: handleCommit, + onDeleteRows: handleDeleteRows, + onConfirmDelete: handleConfirmDelete, + onCancelDelete: handleCancelDelete, + pendingDeleteCount, + onDiscard: handleDiscard, + onCancel: handleCancel, + virtualQuery: vq, + }; + + if (panelView === "explain" && hasExplain) { + return ( +
+ + +
+ ); + } + + if (panelView !== "history" && isExecuting && !result) { + return ( +
+ +
+ + Executing query... +
+
+ ); + } + + if (panelView === "history") { + return ( +
+ + +
+ ); + } + + if (panelView === "diff" && pinnedResult && result) { + return ( +
+ + +
+ ); + } + + if (panelView === "map" && result) { + return ( +
+ + +
+ ); + } + + if (!result) { + return ( +
+ +
+ No data to display +
+
+ ); + } + + return ( +
+ + {editError && !isEditing && ( +
+ + {editError} + +
+ )} + {viewMode === "grid" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/components/results-panel/toolbar-edit.tsx b/src/components/results-panel/toolbar-edit.tsx new file mode 100644 index 0000000..2a22d4e --- /dev/null +++ b/src/components/results-panel/toolbar-edit.tsx @@ -0,0 +1,89 @@ +import { Loader2, Save, Trash2, X } from "lucide-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../ui/dialog"; +import type { EditState } from "./types"; + +interface ToolbarEditProps { + editState: EditState | null; + editError: string | null; + isCommitting: boolean; + pendingDeleteCount: number; + onCommit: () => void; + onDeleteRows: () => void; + onConfirmDelete: () => void; + onCancelDelete: () => void; + onDiscard: () => void; +} + +export function ToolbarEdit({ + editState, + editError, + isCommitting, + pendingDeleteCount, + onCommit, + onDeleteRows, + onConfirmDelete, + onCancelDelete, + onDiscard, +}: ToolbarEditProps) { + return ( + <> + {editError && ( + + {editError} + + )} + + + 0} onOpenChange={(open) => { if (!open) onCancelDelete(); }}> + + + Delete rows + + Are you sure you want to permanently delete {pendingDeleteCount} row{pendingDeleteCount !== 1 ? "s" : ""}? This action cannot be undone. + + + + + + + + + + + ); +} diff --git a/src/components/results-panel/toolbar-export.tsx b/src/components/results-panel/toolbar-export.tsx new file mode 100644 index 0000000..2a6adc4 --- /dev/null +++ b/src/components/results-panel/toolbar-export.tsx @@ -0,0 +1,81 @@ +import { useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Copy, Download } from "lucide-react"; +import { exportResults, copyToClipboard, type ExportFormat } from "@/lib/export"; + +interface ToolbarExportProps { + columns: string[]; + filteredRows: string[][]; + hasResult: boolean; +} + +export function ToolbarExport({ columns, filteredRows, hasResult }: ToolbarExportProps) { + const [exportOpen, setExportOpen] = useState(false); + const exportRef = useRef(null); + + const handleExport = (format: ExportFormat) => { + if (!hasResult) return; + exportResults(format, columns, filteredRows); + setExportOpen(false); + }; + + const handleCopy = (format: ExportFormat) => { + if (!hasResult) return; + void copyToClipboard(format, columns, filteredRows); + setExportOpen(false); + }; + + return ( +
+ + {exportOpen && createPortal( + <> +
setExportOpen(false)} /> +
{ const r = exportRef.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0; })(), + left: (() => { const r = exportRef.current?.getBoundingClientRect(); return r ? Math.max(0, r.right - 208) : 0; })(), + }} + > +
+ Download +
+ {(["csv", "json", "sql", "markdown", "xml"] as ExportFormat[]).map((fmt) => ( + + ))} +
+
+ Copy to clipboard +
+ {(["csv", "json", "sql", "markdown"] as ExportFormat[]).map((fmt) => ( + + ))} +
+ , + document.body + )} +
+ ); +} diff --git a/src/components/results-panel/toolbar.tsx b/src/components/results-panel/toolbar.tsx new file mode 100644 index 0000000..8bbd055 --- /dev/null +++ b/src/components/results-panel/toolbar.tsx @@ -0,0 +1,277 @@ +import { + CheckCircle2, + Clock, + Diff, + Edit3, + GitBranch, + History, + Loader2, + Pin, + Search, + Square, + X, +} from "lucide-react"; +import { useUIStore } from "@/stores/ui-store"; +import { hasGeometryColumn } from "../results-map"; +import { ToolbarExport } from "./toolbar-export"; +import { ToolbarEdit } from "./toolbar-edit"; +import type { ToolbarProps } from "./types"; + +export function ResultsToolbar(props: ToolbarProps) { + const { + panelView, + setPanelView, + result, + columns, + filteredRows, + searchTerm, + setSearchTerm, + filteredCount, + setViewMode, + viewMode, + hasExplain, + isExecuting, + isEditing, + editState, + editableTable, + isCommitting, + editError, + onEnterEdit, + onCommit, + onDeleteRows, + onConfirmDelete, + onCancelDelete, + pendingDeleteCount, + onDiscard, + onCancel, + virtualQuery, + } = props; + + const pinnedResult = useUIStore((s) => s.pinnedResult); + const pinResult = useUIStore((s) => s.pinResult); + const clearPinnedResult = useUIStore((s) => s.clearPinnedResult); + + return ( +
+
+ {/* Panel tabs — segment control */} +
+ + + {hasExplain && ( + + )} + + {result && hasGeometryColumn(columns, filteredRows) && ( + + )} +
+ + {/* Result stats */} + {panelView !== "history" && result && ( +
+ {isExecuting ? ( + + ) : ( + + )} + + {virtualQuery + ? `${virtualQuery.totalRows.toLocaleString()} rows (virtual)` + : searchTerm + ? `${filteredCount.toLocaleString()} / ${result.rows.length.toLocaleString()} rows` + : `${result.rows.length.toLocaleString()} rows`} + {result.capped && !virtualQuery && ( + (capped at 500K) + )} + + + + {result.time.toFixed(0)}ms + {isEditing && editState?.cellEdits.size ? ( + <> + + {editState.cellEdits.size} edit{editState.cellEdits.size !== 1 ? "s" : ""} + + ) : null} + {isEditing && editState?.deletedRows.size ? ( + <> + + {editState.deletedRows.size} delete{editState.deletedRows.size !== 1 ? "s" : ""} + + ) : null} +
+ )} + + {/* Stop button — visible while executing */} + {isExecuting && onCancel && ( + + )} +
+ +
+ {/* Edit mode controls */} + {isEditing ? ( + + ) : ( + <> + {/* Edit button */} + {panelView !== "history" && editableTable && result && result.rows.length > 0 && ( + + )} + + {/* Pin / Diff */} + {panelView !== "history" && result && result.rows.length > 0 && !virtualQuery && ( + <> + {pinnedResult ? ( +
+ + Pinned: {pinnedResult.label} + +
+ ) : ( + + )} + {pinnedResult && ( + + )} + + )} + + {/* Export dropdown */} + {panelView !== "history" && result && result.rows.length > 0 && !virtualQuery && ( + + )} + + {/* Search */} + {panelView !== "history" && result && !virtualQuery && ( +
+ + setSearchTerm(e.target.value)} + className="h-7 w-48 rounded border border-border bg-input pl-7 pr-7 text-xs font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> + {searchTerm && ( + + )} +
+ )} + + )} +
+
+ ); +} diff --git a/src/components/results-panel/types.ts b/src/components/results-panel/types.ts new file mode 100644 index 0000000..03d2c10 --- /dev/null +++ b/src/components/results-panel/types.ts @@ -0,0 +1,38 @@ +export type PanelView = "grid" | "record" | "history" | "explain" | "diff" | "map"; + +export interface EditState { + schema: string; + table: string; + pkColumns: string[]; + cellEdits: Map; + deletedRows: Set; +} + +export interface ToolbarProps { + panelView: PanelView; + setPanelView: (v: PanelView) => void; + result: { rows: string[][]; time: number; capped?: boolean } | null; + columns: string[]; + filteredRows: string[][]; + searchTerm: string; + setSearchTerm: (v: string) => void; + filteredCount: number; + setViewMode: (mode: "grid" | "record") => void; + viewMode: "grid" | "record"; + hasExplain: boolean; + isExecuting: boolean; + isEditing: boolean; + editState: EditState | null; + editableTable: boolean; + isCommitting: boolean; + editError: string | null; + onEnterEdit: () => void; + onCommit: () => void; + onDeleteRows: () => void; + onConfirmDelete: () => void; + onCancelDelete: () => void; + pendingDeleteCount: number; + onDiscard: () => void; + onCancel?: () => void; + virtualQuery?: { queryId: string; totalRows: number; time: number; pageSize: number }; +} diff --git a/src/components/results-panel/use-edit-mode.ts b/src/components/results-panel/use-edit-mode.ts new file mode 100644 index 0000000..458dd72 --- /dev/null +++ b/src/components/results-panel/use-edit-mode.ts @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTabStore } from "@/stores/tab-store"; +import { useProjectStore } from "@/stores/project-store"; +import { DriverFactory } from "@/lib/database-driver"; +import { parseSelectTable, generateUpdate, generateDelete, quoteIdent, quoteLiteral } from "@/lib/sql-utils"; +import type { ForeignKey } from "@/lib/database-driver"; +import type { EditState } from "./types"; + +interface UseEditModeArgs { + projectId: string | undefined; + editorValue: string | undefined; + result: { columns: string[]; rows: string[][]; time: number; capped?: boolean } | null | undefined; +} + +export function useEditMode({ projectId, editorValue, result }: UseEditModeArgs) { + const [isEditing, setIsEditing] = useState(false); + const [editState, setEditState] = useState(null); + const [editError, setEditError] = useState(null); + const [isCommitting, setIsCommitting] = useState(false); + const [pendingDeleteCount, setPendingDeleteCount] = useState(0); + + // Detect if query is a simple SELECT (editable) + const editableTable = useMemo(() => { + if (!editorValue) return null; + return parseSelectTable(editorValue); + }, [editorValue]); + + // FK column map: columnName → { targetSchema, targetTable, targetColumn } + const [fkMap, setFkMap] = useState>(new Map()); + + useEffect(() => { + if (!editableTable || !projectId) { + setFkMap(new Map()); + return; + } + const pid = projectId; + const d = useProjectStore.getState().projects[pid]; + if (!d) return; + + const driver = DriverFactory.getDriver(d.driver); + driver.loadForeignKeys(pid, editableTable.schema).then((fks: ForeignKey[]) => { + const map = new Map(); + for (const fk of fks) { + if (fk.sourceTable === editableTable.table) { + map.set(fk.sourceColumn, { + schema: editableTable.schema, + table: fk.targetTable, + column: fk.targetColumn, + }); + } + } + setFkMap(map); + }).catch(() => setFkMap(new Map())); + }, [editableTable, projectId]); + + // FK navigate handler - opens a new tab and auto-executes the query + const handleFKNavigate = useCallback( + (colName: string, value: string) => { + const target = fkMap.get(colName); + if (!target || !projectId) return; + + const pid = projectId; + const sql = `SELECT * FROM ${quoteIdent(target.schema)}.${quoteIdent(target.table)} WHERE ${quoteIdent(target.column)} = ${quoteLiteral(value)} LIMIT 100`; + useTabStore.getState().openTab(pid, sql); + + // Auto-execute the query in the new tab + const d = useProjectStore.getState().projects[pid]; + if (!d) return; + const newTabIdx = useTabStore.getState().tabs.length - 1; + useTabStore.getState().setExecuting(newTabIdx, true); + const driver = DriverFactory.getDriver(d.driver); + driver.runQuery(pid, sql).then(([cols, rows, time]) => { + useTabStore.getState().updateResult(newTabIdx, { columns: cols, rows, time }); + }).catch(() => { + useTabStore.getState().setExecuting(newTabIdx, false); + }); + }, + [fkMap, projectId], + ); + + // Enter edit mode + const handleEnterEdit = useCallback(async () => { + if (!editableTable || !projectId) return; + const d = useProjectStore.getState().projects[projectId]; + if (!d) return; + setEditError(null); + + try { + const driver = DriverFactory.getDriver(d.driver); + const indexes = await driver.loadIndexes( + projectId, + editableTable.schema, + editableTable.table, + ); + const pkColumns = [...new Set(indexes.filter((i) => i.isPrimary).map((i) => i.columnName))]; + + if (pkColumns.length === 0) { + setEditError("No primary key found. Inline editing requires a primary key."); + return; + } + + // Check that PK columns exist in result columns + const resultCols = result?.columns ?? []; + const missingPKs = pkColumns.filter((pk) => !resultCols.includes(pk)); + if (missingPKs.length > 0) { + setEditError(`Primary key column(s) ${missingPKs.join(", ")} not in query results. Select all PK columns to edit.`); + return; + } + + setEditState({ + schema: editableTable.schema, + table: editableTable.table, + pkColumns, + cellEdits: new Map(), + deletedRows: new Set(), + }); + setIsEditing(true); + } catch (err: any) { + setEditError(err?.message ?? "Failed to load table info"); + } + }, [editableTable, projectId, result?.columns]); + + // Discard edits + const handleDiscard = useCallback(() => { + setIsEditing(false); + setEditState(null); + setEditError(null); + }, []); + + // Run statements + refresh results helper + const runAndRefresh = useCallback(async (statements: string[]) => { + if (!projectId || statements.length === 0) return; + setIsCommitting(true); + setEditError(null); + + try { + const d = useProjectStore.getState().projects[projectId]; + if (!d) throw new Error("Project not found"); + const driver = DriverFactory.getDriver(d.driver); + + const txnSql = ["BEGIN", ...statements, "COMMIT"].join(";\n"); + await driver.runQuery(projectId, txnSql, 30000); + + const [cols, rows, time] = await driver.runQuery(projectId, editorValue ?? ""); + const tabIdx = useTabStore.getState().selectedTabIndex; + useTabStore.getState().updateResult(tabIdx, { columns: cols, rows, time }); + + setIsEditing(false); + setEditState(null); + setPendingDeleteCount(0); + } catch (err: any) { + setEditError(err?.message ?? "Commit failed"); + } finally { + setIsCommitting(false); + } + }, [projectId, editorValue]); + + // Commit — only cell edits (UPDATEs), no deletes + const handleCommit = useCallback(() => { + if (!editState || !result) return; + const { schema, table, pkColumns, cellEdits, deletedRows } = editState; + const columns = result.columns; + const originalRows = result.rows; + + const editsByRow = new Map>(); + for (const [key, value] of cellEdits) { + const [rowStr, colStr] = key.split(":"); + const rowIdx = parseInt(rowStr); + const colIdx = parseInt(colStr); + if (deletedRows.has(rowIdx)) continue; + if (!editsByRow.has(rowIdx)) editsByRow.set(rowIdx, new Map()); + editsByRow.get(rowIdx)!.set(colIdx, value); + } + + const statements: string[] = []; + for (const [rowIdx, changes] of editsByRow) { + statements.push(generateUpdate(schema, table, columns, originalRows[rowIdx], changes, pkColumns)); + } + + if (statements.length === 0) { + handleDiscard(); + return; + } + + void runAndRefresh(statements); + }, [editState, result, handleDiscard, runAndRefresh]); + + // Delete — only checked rows (DELETEs), with inline confirmation + const handleDeleteRows = useCallback(() => { + if (!editState || editState.deletedRows.size === 0) return; + setPendingDeleteCount(editState.deletedRows.size); + }, [editState]); + + const handleConfirmDelete = useCallback(() => { + if (!editState || !result) return; + const { schema, table, pkColumns, deletedRows } = editState; + const columns = result.columns; + const originalRows = result.rows; + + const statements: string[] = []; + for (const rowIdx of deletedRows) { + statements.push(generateDelete(schema, table, columns, originalRows[rowIdx], pkColumns)); + } + + setPendingDeleteCount(0); + void runAndRefresh(statements); + }, [editState, result, runAndRefresh]); + + const handleCancelDelete = useCallback(() => { + setPendingDeleteCount(0); + }, []); + + // Cell edit handler + const handleCellEdit = useCallback( + (rowIndex: number, colIndex: number, value: string) => { + setEditState((prev) => { + if (!prev) return prev; + const newEdits = new Map(prev.cellEdits); + const original = result?.rows[rowIndex]?.[colIndex] ?? ""; + if (value === original) { + newEdits.delete(`${rowIndex}:${colIndex}`); + } else { + newEdits.set(`${rowIndex}:${colIndex}`, value); + } + return { ...prev, cellEdits: newEdits }; + }); + }, + [result], + ); + + const handleRowDelete = useCallback((rowIndex: number) => { + setEditState((prev) => { + if (!prev) return prev; + const newDeleted = new Set(prev.deletedRows); + newDeleted.add(rowIndex); + return { ...prev, deletedRows: newDeleted }; + }); + }, []); + + const handleRowRestore = useCallback((rowIndex: number) => { + setEditState((prev) => { + if (!prev) return prev; + const newDeleted = new Set(prev.deletedRows); + newDeleted.delete(rowIndex); + return { ...prev, deletedRows: newDeleted }; + }); + }, []); + + return { + isEditing, + editState, + editError, + setEditError, + isCommitting, + pendingDeleteCount, + editableTable, + fkMap, + handleFKNavigate, + handleEnterEdit, + handleDiscard, + handleCommit, + handleDeleteRows, + handleConfirmDelete, + handleCancelDelete, + handleCellEdit, + handleRowDelete, + handleRowRestore, + }; +} diff --git a/src/components/results-panel/use-virtual-paging.ts b/src/components/results-panel/use-virtual-paging.ts new file mode 100644 index 0000000..8579833 --- /dev/null +++ b/src/components/results-panel/use-virtual-paging.ts @@ -0,0 +1,144 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useTabStore } from "@/stores/tab-store"; +import { useProjectStore } from "@/stores/project-store"; +import { DriverFactory } from "@/lib/database-driver"; +import * as virtualCache from "@/lib/virtual-cache"; +import { + CACHE_WINDOW_PAGES, + CELL_SEP, + MAX_CONCURRENT_PAGE_FETCHES, + MAX_QUEUED_PAGE_FETCHES, + ROW_SEP, +} from "./constants"; + +interface VirtualQuery { + queryId: string; + totalRows: number; + time: number; + pageSize: number; + colCount: number; +} + +interface UseVirtualPagingArgs { + vq: VirtualQuery | undefined; + projectId: string | undefined; +} + +export function useVirtualPaging({ vq, projectId }: UseVirtualPagingArgs) { + // Virtual page loading + const loadingPages = useRef(new Set()); + const queuedPages = useRef([]); + const queuedPageSet = useRef(new Set()); + const activeFetches = useRef(0); + const latestRequestedPage = useRef(0); + const gridRef = useRef<{ invalidatePage: (pageIndex: number) => void }>(null); + const virtualViewportRows = useRef(new Map()); + + useEffect(() => { + loadingPages.current.clear(); + queuedPages.current = []; + queuedPageSet.current.clear(); + activeFetches.current = 0; + }, [vq?.queryId, projectId]); + + const handleViewportRowChange = useCallback((rowIndex: number) => { + if (!vq?.queryId) return; + virtualViewportRows.current.set(vq.queryId, rowIndex); + }, [vq?.queryId]); + + const restoreRowIndex = vq?.queryId + ? (virtualViewportRows.current.get(vq.queryId) ?? 0) + : 0; + + const fetchPage = useCallback(async (pageIndex: number) => { + if (!vq || !projectId) return; + const d = useProjectStore.getState().projects[projectId]; + if (!d) return; + const driver = DriverFactory.getDriver(d.driver); + if (!driver.fetchPage) return; + + const offset = pageIndex * vq.pageSize; + const packed = await driver.fetchPage(projectId, vq.queryId, vq.colCount, offset, vq.pageSize); + + // Drop stale page responses after tab/query switches. + const selectedIdx = useTabStore.getState().selectedTabIndex; + const selectedTab = useTabStore.getState().tabs[selectedIdx]; + if (selectedTab?.virtualQuery?.queryId !== vq.queryId) return; + + const rows = packed ? packed.split(ROW_SEP).map((r) => r.split(CELL_SEP)) : []; + const expectedRows = Math.max(0, Math.min(vq.pageSize, vq.totalRows - offset)); + if (expectedRows > 0 && rows.length === 0) { + // Keep page as "missing" so viewport observer can retry instead of caching a permanent empty page. + return; + } + virtualCache.setPage(vq.queryId, pageIndex, rows); + // Evict around the user's latest viewport, not the page that happened to resolve last. + virtualCache.evictDistant(vq.queryId, latestRequestedPage.current, CACHE_WINDOW_PAGES); + gridRef.current?.invalidatePage(pageIndex); + }, [vq, projectId]); + + const pumpQueue = useCallback(() => { + if (!vq || !projectId) return; + + if (queuedPages.current.length > 1) { + const target = latestRequestedPage.current; + queuedPages.current.sort((a, b) => Math.abs(a - target) - Math.abs(b - target)); + } + + while (activeFetches.current < MAX_CONCURRENT_PAGE_FETCHES && queuedPages.current.length > 0) { + const pageIndex = queuedPages.current.shift()!; + queuedPageSet.current.delete(pageIndex); + + if (loadingPages.current.has(pageIndex) || virtualCache.hasPage(vq.queryId, pageIndex)) { + continue; + } + + loadingPages.current.add(pageIndex); + activeFetches.current += 1; + + void fetchPage(pageIndex).finally(() => { + loadingPages.current.delete(pageIndex); + activeFetches.current -= 1; + pumpQueue(); + }); + } + }, [vq, projectId, fetchPage]); + + const handlePageNeeded = useCallback((pageIndex: number) => { + if (!vq || !projectId) return; + latestRequestedPage.current = pageIndex; + if ( + loadingPages.current.has(pageIndex) + || virtualCache.hasPage(vq.queryId, pageIndex) + || queuedPageSet.current.has(pageIndex) + ) { + return; + } + + if (queuedPages.current.length >= MAX_QUEUED_PAGE_FETCHES) { + queuedPages.current = queuedPages.current.filter((p) => Math.abs(p - pageIndex) <= 8); + queuedPageSet.current = new Set(queuedPages.current); + } + + queuedPages.current.push(pageIndex); + queuedPageSet.current.add(pageIndex); + pumpQueue(); + }, [vq, projectId, pumpQueue]); + + useEffect(() => { + if (!vq) return; + const anchorPage = Math.max(0, Math.floor(restoreRowIndex / vq.pageSize)); + const startPage = Math.max(0, anchorPage - 1); + const endPage = Math.min(anchorPage + 3, Math.ceil(vq.totalRows / vq.pageSize) - 1); + for (let p = startPage; p <= endPage; p++) { + handlePageNeeded(p); + } + }, [vq?.queryId, vq?.totalRows, vq?.pageSize, restoreRowIndex, handlePageNeeded]); + + return { + gridRef, + handlePageNeeded, + handleViewportRowChange, + restoreRowIndex, + }; +} diff --git a/src/components/server-sidebar.tsx b/src/components/server-sidebar.tsx deleted file mode 100644 index 641eb54..0000000 --- a/src/components/server-sidebar.tsx +++ /dev/null @@ -1,950 +0,0 @@ -import React from "react"; -import { Button } from "@/components/ui/button"; -import { ContextMenu, useContextMenu } from "@/components/ui/context-menu"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { ObjectPropertiesModal } from "@/components/object-properties-modal"; -import { CSVImportModal } from "@/components/csv-import-modal"; -import { cn } from "@/lib/utils"; -import { useProjectStore } from "@/stores/project-store"; -import { useUIStore } from "@/stores/ui-store"; -import { useTabStore } from "@/stores/tab-store"; -import { useQueryStore } from "@/stores/query-store"; -import { ProjectConnectionStatus } from "@/types"; -import type { ProjectDetails } from "@/types"; -import { - Activity, - ChevronDown, - ChevronRight, - Columns3, - Copy, - Database, - Edit3, - Eye, - FileCode, - FileText, - FileUp, - FolderOpen, - HardDrive, - Key, - Layers, - Link2, - List, - Loader2, - Lock, - Package, - Plus, - RefreshCw, - ScrollText, - Server, - Settings, - Settings2, - Shield, - Table, - Trash2, - Zap, -} from "lucide-react"; - -// Indent levels (px) -const I = { server: 4, cat: 14, db: 24, schema: 32, schemaObj: 40, table: 48, section: 56, item: 64 }; - -// DDL query generators -function ddlTableQuery(schema: string, table: string): string { - return `-- Generate CREATE TABLE DDL for "${schema}"."${table}" -SELECT 'CREATE TABLE "' || schemaname || '"."' || tablename || '" (' || E'\\n' || - string_agg(' "' || column_name || '" ' || data_type || - CASE WHEN character_maximum_length IS NOT NULL THEN '(' || character_maximum_length || ')' ELSE '' END || - CASE WHEN is_nullable = 'NO' THEN ' NOT NULL' ELSE '' END || - CASE WHEN column_default IS NOT NULL THEN ' DEFAULT ' || column_default ELSE '' END, - ',' || E'\\n' ORDER BY ordinal_position) || E'\\n' || ');' AS ddl -FROM information_schema.columns c -JOIN pg_tables t ON t.schemaname = c.table_schema AND t.tablename = c.table_name -WHERE c.table_schema = '${schema}' AND c.table_name = '${table}' -GROUP BY schemaname, tablename;`; -} - -function ddlViewQuery(schema: string, view: string): string { - return `-- View definition for "${schema}"."${view}" -SELECT pg_get_viewdef('"${schema}"."${view}"'::regclass, true) AS view_definition;`; -} - -function ddlFunctionQuery(schema: string, fnName: string): string { - return `-- Function definition for "${schema}"."${fnName}" -SELECT pg_get_functiondef(p.oid) AS function_definition -FROM pg_proc p -JOIN pg_namespace n ON n.oid = p.pronamespace -WHERE n.nspname = '${schema}' AND p.proname = '${fnName}' -LIMIT 1;`; -} - -export function ServerSidebar({ - onEditConnection, -}: { - onEditConnection?: (projectId: string) => void; -}) { - const projects = useProjectStore((s) => s.projects); - const status = useProjectStore((s) => s.status); - const serverDatabases = useProjectStore((s) => s.serverDatabases); - const serverTablespaces = useProjectStore((s) => s.serverTablespaces); - const schemas = useProjectStore((s) => s.schemas); - const tables = useProjectStore((s) => s.tables); - const columnDetails = useProjectStore((s) => s.columnDetails); - const indexes = useProjectStore((s) => s.indexes); - const constraints = useProjectStore((s) => s.constraints); - const triggers = useProjectStore((s) => s.triggers); - const rules = useProjectStore((s) => s.rules); - const policies = useProjectStore((s) => s.policies); - const views = useProjectStore((s) => s.views); - const materializedViews = useProjectStore((s) => s.materializedViews); - const functions = useProjectStore((s) => s.functions); - const triggerFunctions = useProjectStore((s) => s.triggerFunctions); - const connect = useProjectStore((s) => s.connect); - const loadTables = useProjectStore((s) => s.loadTables); - const loadColumns = useProjectStore((s) => s.loadColumns); - const loadTableMetadata = useProjectStore((s) => s.loadTableMetadata); - const loadSchemaObjects = useProjectStore((s) => s.loadSchemaObjects); - const refreshConnection = useProjectStore((s) => s.refreshConnection); - const deleteProject = useProjectStore((s) => s.deleteProject); - const addDatabaseToServer = useProjectStore((s) => s.addDatabaseToServer); - const setConnectionModalOpen = useUIStore((s) => s.setConnectionModalOpen); - const openTab = useTabStore((s) => s.openTab); - const openMonitorTab = useTabStore((s) => s.openMonitorTab); - const openERDTab = useTabStore((s) => s.openERDTab); - const openNotifyTab = useTabStore((s) => s.openNotifyTab); - const openRolesTab = useTabStore((s) => s.openRolesTab); - const openSchemaDiffTab = useTabStore((s) => s.openSchemaDiffTab); - const openExtensionsTab = useTabStore((s) => s.openExtensionsTab); - const openEnumsTab = useTabStore((s) => s.openEnumsTab); - const openPgSettingsTab = useTabStore((s) => s.openPgSettingsTab); - const savedQueries = useQueryStore((s) => s.queries); - const loadQueries = useQueryStore((s) => s.loadQueries); - const queriesLoaded = useQueryStore((s) => s.loaded); - const removeQuery = useQueryStore((s) => s.removeQuery); - const { menu, showMenu, closeMenu } = useContextMenu(); - - // Object properties modal state - const [propsModal, setPropsModal] = React.useState<{ - open: boolean; - objectType: "table" | "view" | "matview" | "function" | "trigger-function"; - projectId: string; - schema: string; - name: string; - }>({ open: false, objectType: "table", projectId: "", schema: "", name: "" }); - - const openProperties = (objectType: "table" | "view" | "matview" | "function" | "trigger-function", projectId: string, schema: string, name: string) => { - setPropsModal({ open: true, objectType, projectId, schema, name }); - }; - - // CSV Import modal state - const [csvImportTarget, setCsvImportTarget] = React.useState<{projectId: string; schema: string; table: string; columns: string[]} | null>(null); - - React.useEffect(() => { - if (!queriesLoaded) void loadQueries(); - }, [queriesLoaded, loadQueries]); - - const [addDbSource, setAddDbSource] = React.useState(null); - const [expanded, setExpanded] = React.useState>({}); - const [loading, setLoading] = React.useState>({}); - const [selectedItem, setSelectedItem] = React.useState(null); - - const toggle = (key: string) => setExpanded((p) => ({ ...p, [key]: !p[key] })); - const isOpen = (key: string, defaultOpen = false) => expanded[key] ?? defaultOpen; - - const setLoad = (key: string, v: boolean) => setLoading((p) => ({ ...p, [key]: v })); - - const onConnect = async (projectId: string) => { - setLoad(projectId, true); - await connect(projectId); - setLoad(projectId, false); - }; - - const onExpandSchema = async (projectId: string, schema: string) => { - const key = `schema::${projectId}::${schema}`; - toggle(key); - if (!isOpen(key)) { - const tKey = `${projectId}::${schema}`; - if (!tables[tKey]) { - setLoad(key, true); - try { - await Promise.all([ - loadTables(projectId, schema), - loadSchemaObjects(projectId, schema), - ]); - } catch (e) { - console.error("Failed to load schema objects:", e); - } finally { - setLoad(key, false); - } - } - } - }; - - const onExpandTable = async (projectId: string, schema: string, table: string) => { - const key = `table::${projectId}::${schema}::${table}`; - toggle(key); - const metaKey = `${projectId}::${schema}::${table}`; - if (!isOpen(key) && !columnDetails[metaKey]) { - setLoad(key, true); - try { - await loadTableMetadata(projectId, schema, table); - } catch (e) { - console.error("Failed to load table metadata:", e); - } finally { - setLoad(key, false); - } - } - }; - - const onOpenTableQuery = (projectId: string, schema: string, table: string) => { - openTab(projectId, `SELECT * FROM "${schema}"."${table}" LIMIT 100;`); - }; - - const copy = (text: string) => navigator.clipboard.writeText(text); - - return ( -
-
- CONNECTIONS - -
-
- {(() => { - const entries = Object.entries(projects); - // Auto-group by server fingerprint (host:port:user:ssh) - const serverFp = (d: ProjectDetails) => - `${d.host}\0${d.port}\0${d.username}\0${d.sshEnabled === "true" ? `${d.sshHost}:${d.sshPort}` : ""}`; - const serverGroups = new Map(); - for (const [pid, d] of entries) { - const fp = serverFp(d); - if (!serverGroups.has(fp)) serverGroups.set(fp, []); - serverGroups.get(fp)!.push(pid); - } - - /** Render schemas + tables/views/functions for a connected database project */ - const renderSchemas = (pid: string) => { - const projectSchemas = schemas[pid] || []; - const isConnected = status[pid] === ProjectConnectionStatus.Connected; - if (!isConnected || !projectSchemas.length) return null; - - return projectSchemas.map((schema) => { - const sKey = `schema::${pid}::${schema}`; - const schemaStoreKey = `${pid}::${schema}`; - const schemaTables = tables[schemaStoreKey]; - const schemaViews = views[schemaStoreKey]; - const schemaMatViews = materializedViews[schemaStoreKey]; - const schemaFns = functions[schemaStoreKey]; - const schemaTrigFns = triggerFunctions[schemaStoreKey]; - const isSchemaOpen = isOpen(sKey); - - return ( -
- } - label={schema} - expanded={isSchemaOpen} - loading={loading[sKey]} - onClick={() => void onExpandSchema(pid, schema)} - onContextMenu={(e) => showMenu(e, [ - { label: "ERD Diagram", icon: , onClick: () => openERDTab(pid, schema) }, - { label: "Copy Schema Name", icon: , onClick: () => copy(schema) }, - { label: "New Query", icon: , onClick: () => openTab(pid, `-- Schema: ${schema}\n`) }, - ])} - /> - - {isSchemaOpen && ( - <> - {/* Tables category */} - } sectionKey={`${sKey}::tables`} - expanded={isOpen(`${sKey}::tables`, true)} onClick={() => toggle(`${sKey}::tables`)} /> - {isOpen(`${sKey}::tables`, true) && schemaTables?.map((ti) => { - const tKey = `table::${pid}::${schema}::${ti.name}`; - const metaKey = `${pid}::${schema}::${ti.name}`; - const isTableOpen = isOpen(tKey); - const cols = columnDetails[metaKey]; - const idxs = indexes[metaKey]; - const cons = constraints[metaKey]; - const trigs = triggers[metaKey]; - const rls = rules[metaKey]; - const pols = policies[metaKey]; - const pkCols = new Set((idxs ?? []).filter((i) => i.isPrimary).map((i) => i.columnName)); - - return ( -
- } - label={ti.name} - expanded={isTableOpen} - loading={loading[tKey]} - selected={selectedItem === tKey} - onClick={() => { setSelectedItem(tKey); void onExpandTable(pid, schema, ti.name); }} - onDoubleClick={() => onOpenTableQuery(pid, schema, ti.name)} - onContextMenu={(e) => { setSelectedItem(tKey); showMenu(e, [ - { header: "Query" }, - { label: "SELECT TOP 100", icon: , onClick: () => onOpenTableQuery(pid, schema, ti.name) }, - { label: "SELECT COUNT(*)", icon:
, onClick: () => openTab(pid, `SELECT COUNT(*) FROM "${schema}"."${ti.name}";`) }, - { separator: true as const }, - { label: "Import CSV", icon: , onClick: () => { - void loadColumns(pid, schema, ti.name).then((cols) => { - setCsvImportTarget({ projectId: pid, schema, table: ti.name, columns: cols }); - }); - }}, - { separator: true as const }, - { label: "Properties", icon: , onClick: () => openProperties("table", pid, schema, ti.name) }, - { label: "Show CREATE TABLE", icon: , onClick: () => openTab(pid, ddlTableQuery(schema, ti.name)) }, - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(`"${schema}"."${ti.name}"`), shortcut: navigator.platform.includes("Mac") ? "\u2318C" : "Ctrl+C" }, - ]); }} - trailing={{ti.size}} - /> - {isTableOpen && cols && ( - <> - {/* Columns */} - } sectionKey={`${tKey}::cols`} - expanded={isOpen(`${tKey}::cols`, true)} onClick={() => toggle(`${tKey}::cols`)} /> - {isOpen(`${tKey}::cols`, true) && cols.map((c) => ( -
{ e.preventDefault(); e.stopPropagation(); showMenu(e, [ - { label: "Copy Column Name", icon: , onClick: () => copy(c.name) }, - ]); }}> - {pkCols.has(c.name) ? : } - {c.name} - {c.dataType} - {c.nullable && NULL} -
- ))} - - {/* Indexes */} - {idxs && idxs.length > 0 && ( - <> - i.indexName)).size})`} - icon={} sectionKey={`${tKey}::idx`} - expanded={isOpen(`${tKey}::idx`)} onClick={() => toggle(`${tKey}::idx`)} /> - {isOpen(`${tKey}::idx`) && Array.from(new Set(idxs.map((i) => i.indexName))).map((name) => { - const idxEntries = idxs.filter((i) => i.indexName === name); - const f = idxEntries[0]; - return ( -
- {f.isPrimary ? : f.isUnique ? : } - {name} - ({idxEntries.map((e) => e.columnName).join(", ")}) - {f.isUnique && UNIQUE} -
- ); - })} - - )} - - {/* Constraints */} - {cons && cons.length > 0 && ( - <> - c.constraintName)).size})`} - icon={} sectionKey={`${tKey}::con`} - expanded={isOpen(`${tKey}::con`)} onClick={() => toggle(`${tKey}::con`)} /> - {isOpen(`${tKey}::con`) && Array.from(new Set(cons.map((c) => c.constraintName))).map((name) => { - const f = cons.find((c) => c.constraintName === name)!; - return ( -
- - {name} - {f.constraintType} -
- ); - })} - - )} - - {/* Triggers */} - {trigs && trigs.length > 0 && ( - <> - } sectionKey={`${tKey}::trig`} - expanded={isOpen(`${tKey}::trig`)} onClick={() => toggle(`${tKey}::trig`)} /> - {isOpen(`${tKey}::trig`) && trigs.map((t) => ( -
- - {t.triggerName} - {t.timing} {t.event} -
- ))} - - )} - - {/* Rules */} - {rls && rls.length > 0 && ( - <> - } sectionKey={`${tKey}::rules`} - expanded={isOpen(`${tKey}::rules`)} onClick={() => toggle(`${tKey}::rules`)} /> - {isOpen(`${tKey}::rules`) && rls.map((r) => ( -
- - {r.ruleName} - {r.event} -
- ))} - - )} - - {/* RLS Policies */} - {pols && pols.length > 0 && ( - <> - } sectionKey={`${tKey}::pol`} - expanded={isOpen(`${tKey}::pol`)} onClick={() => toggle(`${tKey}::pol`)} /> - {isOpen(`${tKey}::pol`) && pols.map((p) => ( -
- - {p.policyName} - {p.permissive} {p.command} -
- ))} - - )} - - )} - - ); - })} - - {/* Views category */} - {schemaViews && schemaViews.length > 0 && ( - <> - } sectionKey={`${sKey}::views`} - expanded={isOpen(`${sKey}::views`)} onClick={() => toggle(`${sKey}::views`)} /> - {isOpen(`${sKey}::views`) && schemaViews.map((v) => { - const vKey = `view::${pid}::${schema}::${v}`; - return ( - } - label={v} - selected={selectedItem === vKey} - onClick={() => { setSelectedItem(vKey); onOpenTableQuery(pid, schema, v); }} - onContextMenu={(e) => { setSelectedItem(vKey); showMenu(e, [ - { label: "SELECT TOP 100", icon: , onClick: () => onOpenTableQuery(pid, schema, v) }, - { separator: true as const }, - { label: "Properties", icon: , onClick: () => openProperties("view", pid, schema, v) }, - { label: "Show CREATE VIEW", icon: , onClick: () => openTab(pid, ddlViewQuery(schema, v)) }, - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(`"${schema}"."${v}"`) }, - ]); }} - /> - ); - })} - - )} - - {/* Materialized Views category */} - {schemaMatViews && schemaMatViews.length > 0 && ( - <> - } sectionKey={`${sKey}::matviews`} - expanded={isOpen(`${sKey}::matviews`)} onClick={() => toggle(`${sKey}::matviews`)} /> - {isOpen(`${sKey}::matviews`) && schemaMatViews.map((mv) => { - const mvKey = `matview::${pid}::${schema}::${mv}`; - return ( - } - label={mv} - selected={selectedItem === mvKey} - onClick={() => { setSelectedItem(mvKey); onOpenTableQuery(pid, schema, mv); }} - onContextMenu={(e) => { setSelectedItem(mvKey); showMenu(e, [ - { label: "SELECT TOP 100", icon: , onClick: () => onOpenTableQuery(pid, schema, mv) }, - { label: "REFRESH", icon: , onClick: () => openTab(pid, `REFRESH MATERIALIZED VIEW "${schema}"."${mv}";`) }, - { separator: true as const }, - { label: "Properties", icon: , onClick: () => openProperties("matview", pid, schema, mv) }, - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(`"${schema}"."${mv}"`) }, - ]); }} - /> - ); - })} - - )} - - {/* Functions category */} - {schemaFns && schemaFns.length > 0 && ( - <> - } sectionKey={`${sKey}::fns`} - expanded={isOpen(`${sKey}::fns`)} onClick={() => toggle(`${sKey}::fns`)} /> - {isOpen(`${sKey}::fns`) && schemaFns.map((fn, i) => { - const fnKey = `fn::${pid}::${schema}::${fn.name}::${i}`; - return ( -
setSelectedItem(fnKey)} - onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setSelectedItem(fnKey); showMenu(e, [ - { label: "Show Definition", icon: , onClick: () => openTab(pid, ddlFunctionQuery(schema, fn.name)) }, - { label: "Properties", icon: , onClick: () => openProperties("function", pid, schema, fn.name) }, - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(fn.name) }, - ]); }}> - - {fn.name}({fn.arguments ? "..." : ""}) - {fn.returnType} -
- ); - })} - - )} - - {/* Trigger Functions category */} - {schemaTrigFns && schemaTrigFns.length > 0 && ( - <> - } sectionKey={`${sKey}::trigfns`} - expanded={isOpen(`${sKey}::trigfns`)} onClick={() => toggle(`${sKey}::trigfns`)} /> - {isOpen(`${sKey}::trigfns`) && schemaTrigFns.map((fn, i) => { - const tfKey = `trigfn::${pid}::${schema}::${fn.name}::${i}`; - return ( -
setSelectedItem(tfKey)} - onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setSelectedItem(tfKey); showMenu(e, [ - { label: "Show Definition", icon: , onClick: () => openTab(pid, ddlFunctionQuery(schema, fn.name)) }, - { label: "Properties", icon: , onClick: () => openProperties("trigger-function", pid, schema, fn.name) }, - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(fn.name) }, - ]); }}> - - {fn.name}() - trigger -
- ); - })} - - )} - - )} - - ); - }); - }; - - return ( - <> - {Array.from(serverGroups.entries()).map(([fp, pids]) => { - const primaryDetails = projects[pids[0]]; - if (!primaryDetails) return null; - - const gKey = `srv::${fp}`; - const dbCatKey = `${gKey}::databases`; - - // Server label: project name for single-project, host:port for multi - const serverLabel = pids.length === 1 ? pids[0] : `${primaryDetails.host}:${primaryDetails.port}`; - const connectedPid = pids.find((p) => status[p] === ProjectConnectionStatus.Connected); - - // Collect all databases: discovered from pg_database + project database names - const discoveredDbs = new Set(); - for (const pid of pids) { - const dbs = serverDatabases[pid]; - if (dbs) dbs.forEach((db) => discoveredDbs.add(db)); - const d = projects[pid]; - if (d?.database) discoveredDbs.add(d.database); - } - const dbToProject = new Map(); - for (const pid of pids) { - const d = projects[pid]; - if (d?.database) dbToProject.set(d.database, pid); - } - const allDbs = Array.from(discoveredDbs).sort(); - - const anyConnected = pids.some((p) => status[p] === ProjectConnectionStatus.Connected); - const anyConnecting = pids.some((p) => status[p] === ProjectConnectionStatus.Connecting); - - return ( -
- {/* Server */} - } - label={serverLabel} - bold - expanded={isOpen(gKey, true)} - onClick={() => toggle(gKey)} - onContextMenu={(e) => showMenu(e, [ - { header: "Server" }, - ...(connectedPid ? [ - { label: "New Query", icon: , onClick: () => openTab(connectedPid) }, - { label: "Performance Monitor", icon: , onClick: () => openMonitorTab(connectedPid) }, - { label: "PG Settings", icon: , onClick: () => openPgSettingsTab(connectedPid) }, - ] : []), - { label: "Add Database", icon: , onClick: () => setAddDbSource(pids[0]) }, - ...(onEditConnection ? [{ label: "Edit Connection", icon: , onClick: () => onEditConnection(pids[0]) }] : []), - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(serverLabel) }, - { separator: true as const }, - { label: "Delete", icon: , onClick: () => { for (const pid of pids) void deleteProject(pid); }, destructive: true }, - ])} - trailing={ -
- } - /> - - {isOpen(gKey, true) && ( - <> - {/* Databases category */} - } - label={`Databases${allDbs.length > 0 ? ` (${allDbs.length})` : ""}`} - expanded={isOpen(dbCatKey, true)} - onClick={() => toggle(dbCatKey)} - /> - - {isOpen(dbCatKey, true) && allDbs.map((dbName) => { - const dbPid = dbToProject.get(dbName); - const dbKey = `db::${fp}::${dbName}`; - - if (dbPid) { - // Database has a project entry - const dbConn = status[dbPid]; - const isDbConnected = dbConn === ProjectConnectionStatus.Connected; - const isDbConnecting = dbConn === ProjectConnectionStatus.Connecting; - const isDbFailed = dbConn === ProjectConnectionStatus.Failed; - return ( -
- - : } - label={dbName} - expanded={isDbConnected ? isOpen(dbKey, true) : undefined} - onClick={() => { - if (!isDbConnected && !isDbConnecting) void onConnect(dbPid); - else if (isDbConnected) toggle(dbKey); - }} - onContextMenu={(e) => showMenu(e, [ - { header: "Database" }, - { label: "New Query", icon: , onClick: () => openTab(dbPid) }, - { label: isDbConnected ? "Reconnect" : "Connect", icon: , onClick: () => void onConnect(dbPid) }, - ...(isDbConnected ? [ - { label: "Refresh", icon: , onClick: () => void refreshConnection(dbPid) }, - { label: "LISTEN/NOTIFY", icon: , onClick: () => openNotifyTab(dbPid) }, - { label: "Schema Diff", icon: , onClick: () => openSchemaDiffTab(dbPid) }, - { label: "Extensions", icon: , onClick: () => openExtensionsTab(dbPid) }, - { label: "Enum Types", icon: , onClick: () => openEnumsTab(dbPid) }, - ] : []), - ...(onEditConnection ? [{ separator: true as const }, { label: "Edit Connection", icon: , onClick: () => onEditConnection(dbPid) }] : []), - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(dbName) }, - { separator: true as const }, - { label: "Delete", icon: , onClick: () => void deleteProject(dbPid), destructive: true }, - ])} - trailing={ -
- } - /> - - {/* Schemas for connected database */} - {isDbConnected && isOpen(dbKey, true) && renderSchemas(dbPid)} -
- ); - } else { - // Discovered database without a project — click to auto-create - return ( - } - label={dbName} - onClick={() => void addDatabaseToServer(pids[0], dbName, dbName)} - onContextMenu={(e) => showMenu(e, [ - { label: "Connect", icon: , onClick: () => void addDatabaseToServer(pids[0], dbName, dbName) }, - { label: "Copy Name", icon: , onClick: () => copy(dbName) }, - ])} - /> - ); - } - })} - - {/* Login/Group Roles */} - } - label="Login/Group Roles" - onClick={() => { - if (connectedPid) { openRolesTab(connectedPid); } - else { void onConnect(pids[0]).then(() => { const p = pids.find((id) => status[id] === ProjectConnectionStatus.Connected); if (p) openRolesTab(p); }); } - }} - /> - - {/* Tablespaces */} - {(() => { - const tspCatKey = `${gKey}::tablespaces`; - const tspData = connectedPid ? (serverTablespaces[connectedPid] || []) : []; - return ( - <> - } - label={`Tablespaces${tspData.length > 0 ? ` (${tspData.length})` : ""}`} - expanded={isOpen(tspCatKey)} - onClick={() => { - if (connectedPid) { toggle(tspCatKey); } - else { void onConnect(pids[0]); } - }} - /> - {isOpen(tspCatKey) && tspData.map(([name, owner, location]) => ( -
{ e.preventDefault(); e.stopPropagation(); showMenu(e, [ - { label: "Copy Name", icon: , onClick: () => copy(name) }, - ]); }}> - - {name} - {owner} - {location && {location}} -
- ))} - - ); - })()} - - )} -
- ); - })} - - ); - })()} -
- - {/* Saved Queries — always visible */} -
-
- SAVED QUERIES - {savedQueries.length > 0 && ( - {savedQueries.length} - )} -
- {savedQueries.length > 0 ? ( -
- {savedQueries.map((q) => ( - } - label={q.title} - selected={selectedItem === `query::${q.id}`} - onClick={() => { setSelectedItem(`query::${q.id}`); openTab(q.projectId, q.sql); }} - onContextMenu={(e) => { setSelectedItem(`query::${q.id}`); showMenu(e, [ - { label: "Open in Tab", icon: , onClick: () => openTab(q.projectId, q.sql) }, - { label: "Copy SQL", icon: , onClick: () => copy(q.sql) }, - { separator: true as const }, - { label: "Delete", icon: , onClick: () => void removeQuery(q.id), destructive: true }, - ]); }} - trailing={ - {q.projectId} - } - /> - ))} -
- ) : ( -
- No saved queries yet. Use the Save button in the toolbar to save the current query. -
- )} -
- - { if (!open) setAddDbSource(null); }} - sourceProjectId={addDbSource ?? ""} - projects={projects} - onAdd={async (name, database) => { - if (addDbSource) { - await addDatabaseToServer(addDbSource, name, database); - setAddDbSource(null); - } - }} - /> - - {menu && } - setPropsModal((p) => ({ ...p, open }))} - objectType={propsModal.objectType} - projectId={propsModal.projectId} - schema={propsModal.schema} - name={propsModal.name} - /> - {csvImportTarget && ( - { if (!open) setCsvImportTarget(null); }} - projectId={csvImportTarget.projectId} - schema={csvImportTarget.schema} - table={csvImportTarget.table} - tableColumns={csvImportTarget.columns} - /> - )} -
- ); -} - -/** Indent guide lines */ -function IndentGuides({ indent }: { indent: number }) { - const guides: number[] = []; - // Draw guides at each nesting level (every 12px starting from the first nested level) - for (let x = I.cat + 4; x < indent; x += 12) { - guides.push(x); - } - return ( - <> - {guides.map((x) => ( - - ))} - - ); -} - -/** Generic tree row */ -function TreeRow({ - indent, icon, label, bold, expanded, loading: isLoading, trailing, selected, - onClick, onDoubleClick, onContextMenu, -}: { - indent: number; - icon: React.ReactNode; - label: string; - bold?: boolean; - expanded?: boolean; - loading?: boolean; - trailing?: React.ReactNode; - selected?: boolean; - onClick?: () => void; - onDoubleClick?: () => void; - onContextMenu?: (e: React.MouseEvent) => void; -}) { - return ( - - ); -} - -/** Collapsible section header */ -function SectionHeader({ - indent, label, icon, expanded, onClick, -}: { - indent: number; - label: string; - icon: React.ReactNode; - sectionKey?: string; - expanded: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -/** Dialog for adding a database to a server group */ -function AddDatabaseDialog({ - open, onOpenChange, sourceProjectId, projects, onAdd, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - sourceProjectId: string; - projects: Record; - onAdd: (name: string, database: string) => Promise; -}) { - const [dbName, setDbName] = React.useState(""); - const [connName, setConnName] = React.useState(""); - const source = projects[sourceProjectId]; - - React.useEffect(() => { - if (open) { setDbName(""); setConnName(""); } - }, [open]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!dbName.trim()) return; - void onAdd(connName.trim() || dbName.trim(), dbName.trim()); - }; - - return ( - - - - Add Database - - Add a database to {source?.host}:{source?.port} - - -
-
- - { setDbName(e.target.value); if (!connName) setConnName(""); }} - placeholder="analytics_db" - autoFocus - className="font-mono text-sm h-8" - /> -
-
- - setConnName(e.target.value)} - placeholder={dbName || "optional"} - className="font-mono text-sm h-8" - /> -
-
- - -
- -
-
- ); -} diff --git a/src/components/server-sidebar/add-database-dialog.tsx b/src/components/server-sidebar/add-database-dialog.tsx new file mode 100644 index 0000000..e86b718 --- /dev/null +++ b/src/components/server-sidebar/add-database-dialog.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { ProjectDetails } from "@/types"; + +/** Dialog for adding a database to a server group */ +export function AddDatabaseDialog({ + open, onOpenChange, sourceProjectId, projects, onAdd, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + sourceProjectId: string; + projects: Record; + onAdd: (name: string, database: string) => Promise; +}) { + const [dbName, setDbName] = React.useState(""); + const [connName, setConnName] = React.useState(""); + const source = projects[sourceProjectId]; + + React.useEffect(() => { + if (open) { setDbName(""); setConnName(""); } + }, [open]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!dbName.trim()) return; + void onAdd(connName.trim() || dbName.trim(), dbName.trim()); + }; + + return ( + + + + Add Database + + Add a database to {source?.host}:{source?.port} + + +
+
+ + { setDbName(e.target.value); if (!connName) setConnName(""); }} + placeholder="analytics_db" + autoFocus + className="font-mono text-sm h-8" + /> +
+
+ + setConnName(e.target.value)} + placeholder={dbName || "optional"} + className="font-mono text-sm h-8" + /> +
+
+ + +
+ +
+
+ ); +} diff --git a/src/components/server-sidebar/constants.ts b/src/components/server-sidebar/constants.ts new file mode 100644 index 0000000..2952b91 --- /dev/null +++ b/src/components/server-sidebar/constants.ts @@ -0,0 +1,2 @@ +// Indent levels (px) +export const I = { server: 4, cat: 14, db: 24, schema: 32, schemaObj: 40, table: 48, section: 56, item: 64 }; diff --git a/src/components/server-sidebar/ddl-queries.ts b/src/components/server-sidebar/ddl-queries.ts new file mode 100644 index 0000000..de18057 --- /dev/null +++ b/src/components/server-sidebar/ddl-queries.ts @@ -0,0 +1,28 @@ +// DDL query generators +export function ddlTableQuery(schema: string, table: string): string { + return `-- Generate CREATE TABLE DDL for "${schema}"."${table}" +SELECT 'CREATE TABLE "' || schemaname || '"."' || tablename || '" (' || E'\\n' || + string_agg(' "' || column_name || '" ' || data_type || + CASE WHEN character_maximum_length IS NOT NULL THEN '(' || character_maximum_length || ')' ELSE '' END || + CASE WHEN is_nullable = 'NO' THEN ' NOT NULL' ELSE '' END || + CASE WHEN column_default IS NOT NULL THEN ' DEFAULT ' || column_default ELSE '' END, + ',' || E'\\n' ORDER BY ordinal_position) || E'\\n' || ');' AS ddl +FROM information_schema.columns c +JOIN pg_tables t ON t.schemaname = c.table_schema AND t.tablename = c.table_name +WHERE c.table_schema = '${schema}' AND c.table_name = '${table}' +GROUP BY schemaname, tablename;`; +} + +export function ddlViewQuery(schema: string, view: string): string { + return `-- View definition for "${schema}"."${view}" +SELECT pg_get_viewdef('"${schema}"."${view}"'::regclass, true) AS view_definition;`; +} + +export function ddlFunctionQuery(schema: string, fnName: string): string { + return `-- Function definition for "${schema}"."${fnName}" +SELECT pg_get_functiondef(p.oid) AS function_definition +FROM pg_proc p +JOIN pg_namespace n ON n.oid = p.pronamespace +WHERE n.nspname = '${schema}' AND p.proname = '${fnName}' +LIMIT 1;`; +} diff --git a/src/components/server-sidebar/indent-guides.tsx b/src/components/server-sidebar/indent-guides.tsx new file mode 100644 index 0000000..fdd847d --- /dev/null +++ b/src/components/server-sidebar/indent-guides.tsx @@ -0,0 +1,17 @@ +import { I } from "./constants"; + +/** Indent guide lines */ +export function IndentGuides({ indent }: { indent: number }) { + const guides: number[] = []; + // Draw guides at each nesting level (every 12px starting from the first nested level) + for (let x = I.cat + 4; x < indent; x += 12) { + guides.push(x); + } + return ( + <> + {guides.map((x) => ( + + ))} + + ); +} diff --git a/src/components/server-sidebar/index.tsx b/src/components/server-sidebar/index.tsx new file mode 100644 index 0000000..29080f3 --- /dev/null +++ b/src/components/server-sidebar/index.tsx @@ -0,0 +1,219 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { ContextMenu, useContextMenu } from "@/components/ui/context-menu"; +import { ObjectPropertiesModal } from "@/components/object-properties-modal"; +import { CSVImportModal } from "@/components/csv-import-modal"; +import { useProjectStore } from "@/stores/project-store"; +import { useUIStore } from "@/stores/ui-store"; +import { useTabStore } from "@/stores/tab-store"; +import { useQueryStore } from "@/stores/query-store"; +import type { ProjectDetails } from "@/types"; +import { Plus } from "lucide-react"; +import { AddDatabaseDialog } from "./add-database-dialog"; +import { renderServerGroup } from "./render-server-group"; +import { renderSavedQueries } from "./render-saved-queries"; +import type { CsvImportTarget, PropsModalState, SidebarRenderCtx, ObjectKind } from "./types"; + +export function ServerSidebar({ + onEditConnection, +}: { + onEditConnection?: (projectId: string) => void; +}) { + const projects = useProjectStore((s) => s.projects); + const status = useProjectStore((s) => s.status); + const serverDatabases = useProjectStore((s) => s.serverDatabases); + const serverTablespaces = useProjectStore((s) => s.serverTablespaces); + const schemas = useProjectStore((s) => s.schemas); + const tables = useProjectStore((s) => s.tables); + const columnDetails = useProjectStore((s) => s.columnDetails); + const indexes = useProjectStore((s) => s.indexes); + const constraints = useProjectStore((s) => s.constraints); + const triggers = useProjectStore((s) => s.triggers); + const rules = useProjectStore((s) => s.rules); + const policies = useProjectStore((s) => s.policies); + const views = useProjectStore((s) => s.views); + const materializedViews = useProjectStore((s) => s.materializedViews); + const functions = useProjectStore((s) => s.functions); + const triggerFunctions = useProjectStore((s) => s.triggerFunctions); + const connect = useProjectStore((s) => s.connect); + const loadTables = useProjectStore((s) => s.loadTables); + const loadColumns = useProjectStore((s) => s.loadColumns); + const loadTableMetadata = useProjectStore((s) => s.loadTableMetadata); + const loadSchemaObjects = useProjectStore((s) => s.loadSchemaObjects); + const refreshConnection = useProjectStore((s) => s.refreshConnection); + const deleteProject = useProjectStore((s) => s.deleteProject); + const addDatabaseToServer = useProjectStore((s) => s.addDatabaseToServer); + const setConnectionModalOpen = useUIStore((s) => s.setConnectionModalOpen); + const openTab = useTabStore((s) => s.openTab); + const openMonitorTab = useTabStore((s) => s.openMonitorTab); + const openERDTab = useTabStore((s) => s.openERDTab); + const openNotifyTab = useTabStore((s) => s.openNotifyTab); + const openRolesTab = useTabStore((s) => s.openRolesTab); + const openSchemaDiffTab = useTabStore((s) => s.openSchemaDiffTab); + const openExtensionsTab = useTabStore((s) => s.openExtensionsTab); + const openEnumsTab = useTabStore((s) => s.openEnumsTab); + const openPgSettingsTab = useTabStore((s) => s.openPgSettingsTab); + const savedQueries = useQueryStore((s) => s.queries); + const loadQueries = useQueryStore((s) => s.loadQueries); + const queriesLoaded = useQueryStore((s) => s.loaded); + const removeQuery = useQueryStore((s) => s.removeQuery); + const { menu, showMenu, closeMenu } = useContextMenu(); + + // Object properties modal state + const [propsModal, setPropsModal] = React.useState({ + open: false, + objectType: "table", + projectId: "", + schema: "", + name: "", + }); + + const openProperties = (objectType: ObjectKind, projectId: string, schema: string, name: string) => { + setPropsModal({ open: true, objectType, projectId, schema, name }); + }; + + // CSV Import modal state + const [csvImportTarget, setCsvImportTarget] = React.useState(null); + + React.useEffect(() => { + if (!queriesLoaded) void loadQueries(); + }, [queriesLoaded, loadQueries]); + + const [addDbSource, setAddDbSource] = React.useState(null); + const [expanded, setExpanded] = React.useState>({}); + const [loading, setLoading] = React.useState>({}); + const [selectedItem, setSelectedItem] = React.useState(null); + + const toggle = (key: string) => setExpanded((p) => ({ ...p, [key]: !p[key] })); + const isOpen = (key: string, defaultOpen = false) => expanded[key] ?? defaultOpen; + + const setLoad = (key: string, v: boolean) => setLoading((p) => ({ ...p, [key]: v })); + + const onConnect = async (projectId: string) => { + setLoad(projectId, true); + await connect(projectId); + setLoad(projectId, false); + }; + + const onExpandSchema = async (projectId: string, schema: string) => { + const key = `schema::${projectId}::${schema}`; + toggle(key); + if (!isOpen(key)) { + const tKey = `${projectId}::${schema}`; + if (!tables[tKey]) { + setLoad(key, true); + try { + await Promise.all([ + loadTables(projectId, schema), + loadSchemaObjects(projectId, schema), + ]); + } catch (e) { + console.error("Failed to load schema objects:", e); + } finally { + setLoad(key, false); + } + } + } + }; + + const onExpandTable = async (projectId: string, schema: string, table: string) => { + const key = `table::${projectId}::${schema}::${table}`; + toggle(key); + const metaKey = `${projectId}::${schema}::${table}`; + if (!isOpen(key) && !columnDetails[metaKey]) { + setLoad(key, true); + try { + await loadTableMetadata(projectId, schema, table); + } catch (e) { + console.error("Failed to load table metadata:", e); + } finally { + setLoad(key, false); + } + } + }; + + const onOpenTableQuery = (projectId: string, schema: string, table: string) => { + openTab(projectId, `SELECT * FROM "${schema}"."${table}" LIMIT 100;`); + }; + + const copy = (text: string) => navigator.clipboard.writeText(text); + + const ctx: SidebarRenderCtx = { + projects, status, serverDatabases, serverTablespaces, schemas, tables, + columnDetails, indexes, constraints, triggers, rules, policies, + views, materializedViews, functions, triggerFunctions, + connect, loadColumns, refreshConnection, deleteProject, addDatabaseToServer, + openTab, openMonitorTab, openERDTab, openNotifyTab, openRolesTab, + openSchemaDiffTab, openExtensionsTab, openEnumsTab, openPgSettingsTab, + loading, selectedItem, setSelectedItem, setCsvImportTarget, setAddDbSource, + openProperties, toggle, isOpen, onConnect, onExpandSchema, onExpandTable, + onOpenTableQuery, copy, showMenu, onEditConnection, + }; + + return ( +
+
+ CONNECTIONS + +
+
+ {(() => { + const entries = Object.entries(projects); + // Auto-group by server fingerprint (host:port:user:ssh) + const serverFp = (d: ProjectDetails) => + `${d.host}\0${d.port}\0${d.username}\0${d.sshEnabled === "true" ? `${d.sshHost}:${d.sshPort}` : ""}`; + const serverGroups = new Map(); + for (const [pid, d] of entries) { + const fp = serverFp(d); + if (!serverGroups.has(fp)) serverGroups.set(fp, []); + serverGroups.get(fp)!.push(pid); + } + + return ( + <> + {Array.from(serverGroups.entries()).map(([fp, pids]) => renderServerGroup(ctx, fp, pids))} + + ); + })()} +
+ + {/* Saved Queries — always visible */} + {renderSavedQueries(ctx, savedQueries, removeQuery)} + + { if (!open) setAddDbSource(null); }} + sourceProjectId={addDbSource ?? ""} + projects={projects} + onAdd={async (name, database) => { + if (addDbSource) { + await addDatabaseToServer(addDbSource, name, database); + setAddDbSource(null); + } + }} + /> + + {menu && } + setPropsModal((p) => ({ ...p, open }))} + objectType={propsModal.objectType} + projectId={propsModal.projectId} + schema={propsModal.schema} + name={propsModal.name} + /> + {csvImportTarget && ( + { if (!open) setCsvImportTarget(null); }} + projectId={csvImportTarget.projectId} + schema={csvImportTarget.schema} + table={csvImportTarget.table} + tableColumns={csvImportTarget.columns} + /> + )} +
+ ); +} diff --git a/src/components/server-sidebar/render-saved-queries.tsx b/src/components/server-sidebar/render-saved-queries.tsx new file mode 100644 index 0000000..b03690b --- /dev/null +++ b/src/components/server-sidebar/render-saved-queries.tsx @@ -0,0 +1,51 @@ +import { Copy, FileText, Trash2 } from "lucide-react"; +import { I } from "./constants"; +import { TreeRow } from "./tree-row"; +import type { SavedQuery } from "@/stores/query-store"; +import type { SidebarRenderCtx } from "./types"; + +/** Saved queries bottom panel — always visible */ +export function renderSavedQueries( + ctx: SidebarRenderCtx, + savedQueries: SavedQuery[], + removeQuery: (id: string) => Promise, +) { + const { selectedItem, setSelectedItem, openTab, showMenu, copy } = ctx; + return ( +
+
+ SAVED QUERIES + {savedQueries.length > 0 && ( + {savedQueries.length} + )} +
+ {savedQueries.length > 0 ? ( +
+ {savedQueries.map((q) => ( + } + label={q.title} + selected={selectedItem === `query::${q.id}`} + onClick={() => { setSelectedItem(`query::${q.id}`); openTab(q.projectId, q.sql); }} + onContextMenu={(e) => { setSelectedItem(`query::${q.id}`); showMenu(e, [ + { label: "Open in Tab", icon: , onClick: () => openTab(q.projectId, q.sql) }, + { label: "Copy SQL", icon: , onClick: () => copy(q.sql) }, + { separator: true as const }, + { label: "Delete", icon: , onClick: () => void removeQuery(q.id), destructive: true }, + ]); }} + trailing={ + {q.projectId} + } + /> + ))} +
+ ) : ( +
+ No saved queries yet. Use the Save button in the toolbar to save the current query. +
+ )} +
+ ); +} diff --git a/src/components/server-sidebar/render-schema-objects.tsx b/src/components/server-sidebar/render-schema-objects.tsx new file mode 100644 index 0000000..6fbf77a --- /dev/null +++ b/src/components/server-sidebar/render-schema-objects.tsx @@ -0,0 +1,220 @@ +import { + Copy, + Eye, + FileCode, + FileUp, + FolderOpen, + Layers, + Plus, + RefreshCw, + Settings2, + Table, + Zap, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ProjectConnectionStatus } from "@/types"; +import { I } from "./constants"; +import { TreeRow } from "./tree-row"; +import { SectionHeader } from "./section-header"; +import { ddlTableQuery, ddlViewQuery, ddlFunctionQuery } from "./ddl-queries"; +import { renderTableDetails } from "./render-table-details"; +import type { SidebarRenderCtx } from "./types"; + +/** Render schemas + tables/views/functions for a connected database project */ +export function renderSchemas(ctx: SidebarRenderCtx, pid: string) { + const { + schemas, status, tables, views, materializedViews, functions, triggerFunctions, + loading, selectedItem, setSelectedItem, setCsvImportTarget, openProperties, + isOpen, toggle, onExpandSchema, onExpandTable, onOpenTableQuery, + openTab, openERDTab, loadColumns, showMenu, copy, + } = ctx; + + const projectSchemas = schemas[pid] || []; + const isConnected = status[pid] === ProjectConnectionStatus.Connected; + if (!isConnected || !projectSchemas.length) return null; + + return projectSchemas.map((schema) => { + const sKey = `schema::${pid}::${schema}`; + const schemaStoreKey = `${pid}::${schema}`; + const schemaTables = tables[schemaStoreKey]; + const schemaViews = views[schemaStoreKey]; + const schemaMatViews = materializedViews[schemaStoreKey]; + const schemaFns = functions[schemaStoreKey]; + const schemaTrigFns = triggerFunctions[schemaStoreKey]; + const isSchemaOpen = isOpen(sKey); + + return ( +
+ } + label={schema} + expanded={isSchemaOpen} + loading={loading[sKey]} + onClick={() => void onExpandSchema(pid, schema)} + onContextMenu={(e) => showMenu(e, [ + { label: "ERD Diagram", icon: , onClick: () => openERDTab(pid, schema) }, + { label: "Copy Schema Name", icon: , onClick: () => copy(schema) }, + { label: "New Query", icon: , onClick: () => openTab(pid, `-- Schema: ${schema}\n`) }, + ])} + /> + + {isSchemaOpen && ( + <> + {/* Tables category */} + } sectionKey={`${sKey}::tables`} + expanded={isOpen(`${sKey}::tables`, true)} onClick={() => toggle(`${sKey}::tables`)} /> + {isOpen(`${sKey}::tables`, true) && schemaTables?.map((ti) => { + const tKey = `table::${pid}::${schema}::${ti.name}`; + const isTableOpen = isOpen(tKey); + + return ( +
+ } + label={ti.name} + expanded={isTableOpen} + loading={loading[tKey]} + selected={selectedItem === tKey} + onClick={() => { setSelectedItem(tKey); void onExpandTable(pid, schema, ti.name); }} + onDoubleClick={() => onOpenTableQuery(pid, schema, ti.name)} + onContextMenu={(e) => { setSelectedItem(tKey); showMenu(e, [ + { header: "Query" }, + { label: "SELECT TOP 100", icon:
, onClick: () => onOpenTableQuery(pid, schema, ti.name) }, + { label: "SELECT COUNT(*)", icon:
, onClick: () => openTab(pid, `SELECT COUNT(*) FROM "${schema}"."${ti.name}";`) }, + { separator: true as const }, + { label: "Import CSV", icon: , onClick: () => { + void loadColumns(pid, schema, ti.name).then((cols) => { + setCsvImportTarget({ projectId: pid, schema, table: ti.name, columns: cols }); + }); + }}, + { separator: true as const }, + { label: "Properties", icon: , onClick: () => openProperties("table", pid, schema, ti.name) }, + { label: "Show CREATE TABLE", icon: , onClick: () => openTab(pid, ddlTableQuery(schema, ti.name)) }, + { separator: true as const }, + { label: "Copy Name", icon: , onClick: () => copy(`"${schema}"."${ti.name}"`), shortcut: navigator.platform.includes("Mac") ? "⌘C" : "Ctrl+C" }, + ]); }} + trailing={{ti.size}} + /> + {isTableOpen && renderTableDetails(ctx, pid, schema, ti.name)} + + ); + })} + + {/* Views category */} + {schemaViews && schemaViews.length > 0 && ( + <> + } sectionKey={`${sKey}::views`} + expanded={isOpen(`${sKey}::views`)} onClick={() => toggle(`${sKey}::views`)} /> + {isOpen(`${sKey}::views`) && schemaViews.map((v) => { + const vKey = `view::${pid}::${schema}::${v}`; + return ( + } + label={v} + selected={selectedItem === vKey} + onClick={() => { setSelectedItem(vKey); onOpenTableQuery(pid, schema, v); }} + onContextMenu={(e) => { setSelectedItem(vKey); showMenu(e, [ + { label: "SELECT TOP 100", icon: , onClick: () => onOpenTableQuery(pid, schema, v) }, + { separator: true as const }, + { label: "Properties", icon: , onClick: () => openProperties("view", pid, schema, v) }, + { label: "Show CREATE VIEW", icon: , onClick: () => openTab(pid, ddlViewQuery(schema, v)) }, + { separator: true as const }, + { label: "Copy Name", icon: , onClick: () => copy(`"${schema}"."${v}"`) }, + ]); }} + /> + ); + })} + + )} + + {/* Materialized Views category */} + {schemaMatViews && schemaMatViews.length > 0 && ( + <> + } sectionKey={`${sKey}::matviews`} + expanded={isOpen(`${sKey}::matviews`)} onClick={() => toggle(`${sKey}::matviews`)} /> + {isOpen(`${sKey}::matviews`) && schemaMatViews.map((mv) => { + const mvKey = `matview::${pid}::${schema}::${mv}`; + return ( + } + label={mv} + selected={selectedItem === mvKey} + onClick={() => { setSelectedItem(mvKey); onOpenTableQuery(pid, schema, mv); }} + onContextMenu={(e) => { setSelectedItem(mvKey); showMenu(e, [ + { label: "SELECT TOP 100", icon: , onClick: () => onOpenTableQuery(pid, schema, mv) }, + { label: "REFRESH", icon: , onClick: () => openTab(pid, `REFRESH MATERIALIZED VIEW "${schema}"."${mv}";`) }, + { separator: true as const }, + { label: "Properties", icon: , onClick: () => openProperties("matview", pid, schema, mv) }, + { separator: true as const }, + { label: "Copy Name", icon: , onClick: () => copy(`"${schema}"."${mv}"`) }, + ]); }} + /> + ); + })} + + )} + + {/* Functions category */} + {schemaFns && schemaFns.length > 0 && ( + <> + } sectionKey={`${sKey}::fns`} + expanded={isOpen(`${sKey}::fns`)} onClick={() => toggle(`${sKey}::fns`)} /> + {isOpen(`${sKey}::fns`) && schemaFns.map((fn, i) => { + const fnKey = `fn::${pid}::${schema}::${fn.name}::${i}`; + return ( +
setSelectedItem(fnKey)} + onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setSelectedItem(fnKey); showMenu(e, [ + { label: "Show Definition", icon: , onClick: () => openTab(pid, ddlFunctionQuery(schema, fn.name)) }, + { label: "Properties", icon: , onClick: () => openProperties("function", pid, schema, fn.name) }, + { separator: true as const }, + { label: "Copy Name", icon: , onClick: () => copy(fn.name) }, + ]); }}> + + {fn.name}({fn.arguments ? "..." : ""}) + {fn.returnType} +
+ ); + })} + + )} + + {/* Trigger Functions category */} + {schemaTrigFns && schemaTrigFns.length > 0 && ( + <> + } sectionKey={`${sKey}::trigfns`} + expanded={isOpen(`${sKey}::trigfns`)} onClick={() => toggle(`${sKey}::trigfns`)} /> + {isOpen(`${sKey}::trigfns`) && schemaTrigFns.map((fn, i) => { + const tfKey = `trigfn::${pid}::${schema}::${fn.name}::${i}`; + return ( +
setSelectedItem(tfKey)} + onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setSelectedItem(tfKey); showMenu(e, [ + { label: "Show Definition", icon: , onClick: () => openTab(pid, ddlFunctionQuery(schema, fn.name)) }, + { label: "Properties", icon: , onClick: () => openProperties("trigger-function", pid, schema, fn.name) }, + { separator: true as const }, + { label: "Copy Name", icon: , onClick: () => copy(fn.name) }, + ]); }}> + + {fn.name}() + trigger +
+ ); + })} + + )} + + )} + + ); + }); +} diff --git a/src/components/server-sidebar/render-server-group.tsx b/src/components/server-sidebar/render-server-group.tsx new file mode 100644 index 0000000..32fc608 --- /dev/null +++ b/src/components/server-sidebar/render-server-group.tsx @@ -0,0 +1,235 @@ +import { + Activity, + Columns3, + Copy, + Database, + Edit3, + HardDrive, + Link2, + List, + Loader2, + Package, + Plus, + RefreshCw, + Server, + Settings, + Shield, + Trash2, + Zap, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ProjectConnectionStatus } from "@/types"; +import type { ProjectDetails } from "@/types"; +import { I } from "./constants"; +import { TreeRow } from "./tree-row"; +import { renderSchemas } from "./render-schema-objects"; +import type { SidebarRenderCtx } from "./types"; + +/** + * Render a single server-fingerprint group with its databases, roles + * and tablespaces. Auto-grouping by host:port:user:ssh fingerprint. + */ +export function renderServerGroup( + ctx: SidebarRenderCtx, + fp: string, + pids: string[], +) { + const { + projects, status, serverDatabases, serverTablespaces, + isOpen, toggle, onConnect, addDatabaseToServer, deleteProject, refreshConnection, + openTab, openMonitorTab, openNotifyTab, openRolesTab, openSchemaDiffTab, + openExtensionsTab, openEnumsTab, openPgSettingsTab, + setAddDbSource, showMenu, copy, onEditConnection, + } = ctx; + + const primaryDetails: ProjectDetails | undefined = projects[pids[0]]; + if (!primaryDetails) return null; + + const gKey = `srv::${fp}`; + const dbCatKey = `${gKey}::databases`; + + // Server label: project name for single-project, host:port for multi + const serverLabel = pids.length === 1 ? pids[0] : `${primaryDetails.host}:${primaryDetails.port}`; + const connectedPid = pids.find((p) => status[p] === ProjectConnectionStatus.Connected); + + // Collect all databases: discovered from pg_database + project database names + const discoveredDbs = new Set(); + for (const pid of pids) { + const dbs = serverDatabases[pid]; + if (dbs) dbs.forEach((db) => discoveredDbs.add(db)); + const d = projects[pid]; + if (d?.database) discoveredDbs.add(d.database); + } + const dbToProject = new Map(); + for (const pid of pids) { + const d = projects[pid]; + if (d?.database) dbToProject.set(d.database, pid); + } + const allDbs = Array.from(discoveredDbs).sort(); + + const anyConnected = pids.some((p) => status[p] === ProjectConnectionStatus.Connected); + const anyConnecting = pids.some((p) => status[p] === ProjectConnectionStatus.Connecting); + + return ( +
+ {/* Server */} + } + label={serverLabel} + bold + expanded={isOpen(gKey, true)} + onClick={() => toggle(gKey)} + onContextMenu={(e) => showMenu(e, [ + { header: "Server" }, + ...(connectedPid ? [ + { label: "New Query", icon: , onClick: () => openTab(connectedPid) }, + { label: "Performance Monitor", icon: , onClick: () => openMonitorTab(connectedPid) }, + { label: "PG Settings", icon: , onClick: () => openPgSettingsTab(connectedPid) }, + ] : []), + { label: "Add Database", icon: , onClick: () => setAddDbSource(pids[0]) }, + ...(onEditConnection ? [{ label: "Edit Connection", icon: , onClick: () => onEditConnection(pids[0]) }] : []), + { separator: true as const }, + { label: "Copy Name", icon: , onClick: () => copy(serverLabel) }, + { separator: true as const }, + { label: "Delete", icon: , onClick: () => { for (const pid of pids) void deleteProject(pid); }, destructive: true }, + ])} + trailing={ +
+ } + /> + + {isOpen(gKey, true) && ( + <> + {/* Databases category */} + } + label={`Databases${allDbs.length > 0 ? ` (${allDbs.length})` : ""}`} + expanded={isOpen(dbCatKey, true)} + onClick={() => toggle(dbCatKey)} + /> + + {isOpen(dbCatKey, true) && allDbs.map((dbName) => { + const dbPid = dbToProject.get(dbName); + const dbKey = `db::${fp}::${dbName}`; + + if (dbPid) { + // Database has a project entry + const dbConn = status[dbPid]; + const isDbConnected = dbConn === ProjectConnectionStatus.Connected; + const isDbConnecting = dbConn === ProjectConnectionStatus.Connecting; + const isDbFailed = dbConn === ProjectConnectionStatus.Failed; + return ( +
+ + : } + label={dbName} + expanded={isDbConnected ? isOpen(dbKey, true) : undefined} + onClick={() => { + if (!isDbConnected && !isDbConnecting) void onConnect(dbPid); + else if (isDbConnected) toggle(dbKey); + }} + onContextMenu={(e) => showMenu(e, [ + { header: "Database" }, + { label: "New Query", icon: , onClick: () => openTab(dbPid) }, + { label: isDbConnected ? "Reconnect" : "Connect", icon: , onClick: () => void onConnect(dbPid) }, + ...(isDbConnected ? [ + { label: "Refresh", icon: , onClick: () => void refreshConnection(dbPid) }, + { label: "LISTEN/NOTIFY", icon: , onClick: () => openNotifyTab(dbPid) }, + { label: "Schema Diff", icon: , onClick: () => openSchemaDiffTab(dbPid) }, + { label: "Extensions", icon: , onClick: () => openExtensionsTab(dbPid) }, + { label: "Enum Types", icon: , onClick: () => openEnumsTab(dbPid) }, + ] : []), + ...(onEditConnection ? [{ separator: true as const }, { label: "Edit Connection", icon: , onClick: () => onEditConnection(dbPid) }] : []), + { separator: true as const }, + { label: "Copy Name", icon: , onClick: () => copy(dbName) }, + { separator: true as const }, + { label: "Delete", icon: , onClick: () => void deleteProject(dbPid), destructive: true }, + ])} + trailing={ +
+ } + /> + + {/* Schemas for connected database */} + {isDbConnected && isOpen(dbKey, true) && renderSchemas(ctx, dbPid)} +
+ ); + } else { + // Discovered database without a project — click to auto-create + return ( + } + label={dbName} + onClick={() => void addDatabaseToServer(pids[0], dbName, dbName)} + onContextMenu={(e) => showMenu(e, [ + { label: "Connect", icon: , onClick: () => void addDatabaseToServer(pids[0], dbName, dbName) }, + { label: "Copy Name", icon: , onClick: () => copy(dbName) }, + ])} + /> + ); + } + })} + + {/* Login/Group Roles */} + } + label="Login/Group Roles" + onClick={() => { + if (connectedPid) { openRolesTab(connectedPid); } + else { void onConnect(pids[0]).then(() => { const p = pids.find((id) => status[id] === ProjectConnectionStatus.Connected); if (p) openRolesTab(p); }); } + }} + /> + + {/* Tablespaces */} + {(() => { + const tspCatKey = `${gKey}::tablespaces`; + const tspData = connectedPid ? (serverTablespaces[connectedPid] || []) : []; + return ( + <> + } + label={`Tablespaces${tspData.length > 0 ? ` (${tspData.length})` : ""}`} + expanded={isOpen(tspCatKey)} + onClick={() => { + if (connectedPid) { toggle(tspCatKey); } + else { void onConnect(pids[0]); } + }} + /> + {isOpen(tspCatKey) && tspData.map(([name, owner, location]) => ( +
{ e.preventDefault(); e.stopPropagation(); showMenu(e, [ + { label: "Copy Name", icon: , onClick: () => copy(name) }, + ]); }}> + + {name} + {owner} + {location && {location}} +
+ ))} + + ); + })()} + + )} +
+ ); +} diff --git a/src/components/server-sidebar/render-table-details.tsx b/src/components/server-sidebar/render-table-details.tsx new file mode 100644 index 0000000..774c332 --- /dev/null +++ b/src/components/server-sidebar/render-table-details.tsx @@ -0,0 +1,146 @@ +import { + Columns3, + Copy, + Key, + Link2, + Lock, + ScrollText, + Shield, + Zap, +} from "lucide-react"; +import { I } from "./constants"; +import { SectionHeader } from "./section-header"; +import type { SidebarRenderCtx } from "./types"; + +/** + * Renders the inner details of an expanded table: columns, indexes, + * constraints, triggers, rules, and RLS policies. + */ +export function renderTableDetails( + ctx: SidebarRenderCtx, + pid: string, + schema: string, + tableName: string, +) { + const { columnDetails, indexes, constraints, triggers, rules, policies, isOpen, toggle, showMenu, copy } = ctx; + const tKey = `table::${pid}::${schema}::${tableName}`; + const metaKey = `${pid}::${schema}::${tableName}`; + const cols = columnDetails[metaKey]; + const idxs = indexes[metaKey]; + const cons = constraints[metaKey]; + const trigs = triggers[metaKey]; + const rls = rules[metaKey]; + const pols = policies[metaKey]; + const pkCols = new Set((idxs ?? []).filter((i) => i.isPrimary).map((i) => i.columnName)); + + if (!cols) return null; + + return ( + <> + {/* Columns */} + } sectionKey={`${tKey}::cols`} + expanded={isOpen(`${tKey}::cols`, true)} onClick={() => toggle(`${tKey}::cols`)} /> + {isOpen(`${tKey}::cols`, true) && cols.map((c) => ( +
{ e.preventDefault(); e.stopPropagation(); showMenu(e, [ + { label: "Copy Column Name", icon: , onClick: () => copy(c.name) }, + ]); }}> + {pkCols.has(c.name) ? : } + {c.name} + {c.dataType} + {c.nullable && NULL} +
+ ))} + + {/* Indexes */} + {idxs && idxs.length > 0 && ( + <> + i.indexName)).size})`} + icon={} sectionKey={`${tKey}::idx`} + expanded={isOpen(`${tKey}::idx`)} onClick={() => toggle(`${tKey}::idx`)} /> + {isOpen(`${tKey}::idx`) && Array.from(new Set(idxs.map((i) => i.indexName))).map((name) => { + const idxEntries = idxs.filter((i) => i.indexName === name); + const f = idxEntries[0]; + return ( +
+ {f.isPrimary ? : f.isUnique ? : } + {name} + ({idxEntries.map((e) => e.columnName).join(", ")}) + {f.isUnique && UNIQUE} +
+ ); + })} + + )} + + {/* Constraints */} + {cons && cons.length > 0 && ( + <> + c.constraintName)).size})`} + icon={} sectionKey={`${tKey}::con`} + expanded={isOpen(`${tKey}::con`)} onClick={() => toggle(`${tKey}::con`)} /> + {isOpen(`${tKey}::con`) && Array.from(new Set(cons.map((c) => c.constraintName))).map((name) => { + const f = cons.find((c) => c.constraintName === name)!; + return ( +
+ + {name} + {f.constraintType} +
+ ); + })} + + )} + + {/* Triggers */} + {trigs && trigs.length > 0 && ( + <> + } sectionKey={`${tKey}::trig`} + expanded={isOpen(`${tKey}::trig`)} onClick={() => toggle(`${tKey}::trig`)} /> + {isOpen(`${tKey}::trig`) && trigs.map((t) => ( +
+ + {t.triggerName} + {t.timing} {t.event} +
+ ))} + + )} + + {/* Rules */} + {rls && rls.length > 0 && ( + <> + } sectionKey={`${tKey}::rules`} + expanded={isOpen(`${tKey}::rules`)} onClick={() => toggle(`${tKey}::rules`)} /> + {isOpen(`${tKey}::rules`) && rls.map((r) => ( +
+ + {r.ruleName} + {r.event} +
+ ))} + + )} + + {/* RLS Policies */} + {pols && pols.length > 0 && ( + <> + } sectionKey={`${tKey}::pol`} + expanded={isOpen(`${tKey}::pol`)} onClick={() => toggle(`${tKey}::pol`)} /> + {isOpen(`${tKey}::pol`) && pols.map((p) => ( +
+ + {p.policyName} + {p.permissive} {p.command} +
+ ))} + + )} + + ); +} diff --git a/src/components/server-sidebar/section-header.tsx b/src/components/server-sidebar/section-header.tsx new file mode 100644 index 0000000..35324f9 --- /dev/null +++ b/src/components/server-sidebar/section-header.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { IndentGuides } from "./indent-guides"; + +/** Collapsible section header */ +export function SectionHeader({ + indent, label, icon, expanded, onClick, +}: { + indent: number; + label: string; + icon: React.ReactNode; + sectionKey?: string; + expanded: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/components/server-sidebar/tree-row.tsx b/src/components/server-sidebar/tree-row.tsx new file mode 100644 index 0000000..4b99111 --- /dev/null +++ b/src/components/server-sidebar/tree-row.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { ChevronDown, ChevronRight, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { IndentGuides } from "./indent-guides"; + +/** Generic tree row */ +export function TreeRow({ + indent, icon, label, bold, expanded, loading: isLoading, trailing, selected, + onClick, onDoubleClick, onContextMenu, +}: { + indent: number; + icon: React.ReactNode; + label: string; + bold?: boolean; + expanded?: boolean; + loading?: boolean; + trailing?: React.ReactNode; + selected?: boolean; + onClick?: () => void; + onDoubleClick?: () => void; + onContextMenu?: (e: React.MouseEvent) => void; +}) { + return ( + + ); +} diff --git a/src/components/server-sidebar/types.ts b/src/components/server-sidebar/types.ts new file mode 100644 index 0000000..22ac96e --- /dev/null +++ b/src/components/server-sidebar/types.ts @@ -0,0 +1,93 @@ +import React from "react"; +import type { + ProjectMap, + ProjectConnectionStatus, + TableInfo, + ColumnDetail, + IndexDetail, + ConstraintDetail, + TriggerDetail, + RuleDetail, + PolicyDetail, + FunctionInfo, + TriggerFunctionInfo, +} from "@/types"; +import type { ContextMenuEntry } from "@/components/ui/context-menu"; + +export type ObjectKind = "table" | "view" | "matview" | "function" | "trigger-function"; + +export type CsvImportTarget = { + projectId: string; + schema: string; + table: string; + columns: string[]; +}; + +export type PropsModalState = { + open: boolean; + objectType: ObjectKind; + projectId: string; + schema: string; + name: string; +}; + +/** + * Bundle of state slices + handlers passed to render helpers. + * This is plain prop-drilling, NOT React Context. + */ +export interface SidebarRenderCtx { + // store slices + projects: ProjectMap; + status: Record; + serverDatabases: Record; + serverTablespaces: Record; + schemas: Record; + tables: Record; + columnDetails: Record; + indexes: Record; + constraints: Record; + triggers: Record; + rules: Record; + policies: Record; + views: Record; + materializedViews: Record; + functions: Record; + triggerFunctions: Record; + + // store actions + connect: (projectId: string) => Promise; + loadColumns: (projectId: string, schema: string, table: string) => Promise; + refreshConnection: (projectId: string) => Promise; + deleteProject: (projectId: string) => Promise; + addDatabaseToServer: (sourceProjectId: string, name: string, database: string) => Promise; + openTab: (projectId?: string, sql?: string) => void; + openMonitorTab: (projectId: string) => void; + openERDTab: (projectId: string, schema: string) => void; + openNotifyTab: (projectId: string) => void; + openRolesTab: (projectId: string) => void; + openSchemaDiffTab: (projectId: string) => void; + openExtensionsTab: (projectId: string) => void; + openEnumsTab: (projectId: string) => void; + openPgSettingsTab: (projectId: string) => void; + + // local component state + setters + loading: Record; + selectedItem: string | null; + setSelectedItem: (k: string | null) => void; + setCsvImportTarget: (t: CsvImportTarget | null) => void; + setAddDbSource: (id: string | null) => void; + openProperties: (objectType: ObjectKind, projectId: string, schema: string, name: string) => void; + + // computed helpers + toggle: (key: string) => void; + isOpen: (key: string, defaultOpen?: boolean) => boolean; + onConnect: (projectId: string) => Promise; + onExpandSchema: (projectId: string, schema: string) => Promise; + onExpandTable: (projectId: string, schema: string, table: string) => Promise; + onOpenTableQuery: (projectId: string, schema: string, table: string) => void; + + // misc + copy: (text: string) => void; + showMenu: (e: React.MouseEvent, items: ContextMenuEntry[]) => void; + onEditConnection?: (projectId: string) => void; +} diff --git a/src/hooks/use-app-startup.ts b/src/hooks/use-app-startup.ts new file mode 100644 index 0000000..9d3fea8 --- /dev/null +++ b/src/hooks/use-app-startup.ts @@ -0,0 +1,15 @@ +import { useEffect } from "react"; +import { startBackgroundUpdateCheck } from "@/lib/updater"; +import { useProjectStore } from "@/stores/project-store"; + +export function useAppStartup() { + const loadProjects = useProjectStore((s) => s.loadProjects); + + useEffect(() => { + void loadProjects(); + }, [loadProjects]); + + useEffect(() => { + startBackgroundUpdateCheck(); + }, []); +} diff --git a/src/hooks/use-query-lifecycle.ts b/src/hooks/use-query-lifecycle.ts new file mode 100644 index 0000000..b07b651 --- /dev/null +++ b/src/hooks/use-query-lifecycle.ts @@ -0,0 +1,294 @@ +import { useEffect, useCallback } from "react"; +import { DriverFactory } from "@/lib/database-driver"; +import * as virtualCache from "@/lib/virtual-cache"; +import { + CELL_SEP, + PAGE_SIZE, + ROW_SEP, + isQueryCancelledError, + notifyQueryComplete, +} from "@/lib/query-helpers"; +import { useProjectStore } from "@/stores/project-store"; +import { useTabStore } from "@/stores/tab-store"; +import { useUIStore } from "@/stores/ui-store"; +import { useHistoryStore } from "@/stores/history-store"; + +interface UseQueryLifecycleArgs { + setCommandPaletteOpen: (updater: (v: boolean) => boolean) => void; +} + +export function useQueryLifecycle({ setCommandPaletteOpen }: UseQueryLifecycleArgs) { + const updateResult = useTabStore((s) => s.updateResult); + const setExecuting = useTabStore((s) => s.setExecuting); + const closeTab = useTabStore((s) => s.closeTab); + const setExplainResult = useTabStore((s) => s.setExplainResult); + const setVirtualQuery = useTabStore((s) => s.setVirtualQuery); + const setSplitResult = useTabStore((s) => s.setSplitResult); + const setSplitExecuting = useTabStore((s) => s.setSplitExecuting); + const addHistoryEntry = useHistoryStore((s) => s.addEntry); + const connectProject = useProjectStore((s) => s.connect); + + const runQuery = useCallback(async () => { + const { tabs, selectedTabIndex: idx } = useTabStore.getState(); + const tab = tabs[idx]; + if (!tab?.projectId || !tab.editorValue.trim()) return; + + const d = useProjectStore.getState().projects[tab.projectId]; + if (!d) return; + + // Auto-connect if not connected + const connStatus = useProjectStore.getState().status[tab.projectId]; + if (connStatus !== "Connected") { + await connectProject(tab.projectId); + const newStatus = useProjectStore.getState().status[tab.projectId]; + if (newStatus !== "Connected") return; + } + + setExecuting(idx, true); + const startTime = Date.now(); + try { + const driver = DriverFactory.getDriver(d.driver); + + // Clean up previous virtual query + const prevVQ = tab.virtualQuery; + if (prevVQ?.queryId) { + await driver.closeVirtual?.(tab.projectId, prevVQ.queryId).catch(() => {}); + virtualCache.clearQuery(prevVQ.queryId); + setVirtualQuery(idx, undefined); + } + + const timeoutMs = tab.queryTimeout || undefined; + + if (driver.executeVirtual) { + const sql = tab.editorValue; + const queryId = crypto.randomUUID().replace(/-/g, "").slice(0, 12); + const [colsPacked, totalRows, pagePacked, elapsed] = + await driver.executeVirtual(tab.projectId, sql, queryId, PAGE_SIZE, timeoutMs); + + if (!colsPacked) { + // Fallback format from backend: header + rows in one packed string. + const parts = pagePacked ? pagePacked.split(ROW_SEP) : []; + const columns = parts[0] ? parts[0].split(CELL_SEP) : []; + const rows = parts.slice(1).map((r) => r.split(CELL_SEP)); + + await driver.closeVirtual?.(tab.projectId, queryId).catch(() => {}); + updateResult(idx, { columns, rows, time: elapsed }); + notifyQueryComplete(tab.editorValue, elapsed, true, rows.length); + + addHistoryEntry({ + projectId: tab.projectId, + database: d.database, + sql: tab.editorValue.trim(), + executionTime: elapsed, + rowCount: rows.length, + success: true, + timestamp: startTime, + }); + } else { + const columns = colsPacked.split(CELL_SEP); + const firstPage = pagePacked + ? pagePacked.split(ROW_SEP).map((r) => r.split(CELL_SEP)) + : []; + + if (totalRows <= PAGE_SIZE) { + await driver.closeVirtual?.(tab.projectId, queryId).catch(() => {}); + updateResult(idx, { columns, rows: firstPage, time: elapsed }); + notifyQueryComplete(tab.editorValue, elapsed, true, firstPage.length); + } else { + virtualCache.setPage(queryId, 0, firstPage); + setVirtualQuery(idx, { queryId, columns, totalRows, pageSize: PAGE_SIZE, colCount: columns.length, time: elapsed }); + updateResult(idx, { columns, rows: firstPage, time: elapsed }); + notifyQueryComplete(tab.editorValue, elapsed, true, totalRows); + } + + addHistoryEntry({ + projectId: tab.projectId, + database: d.database, + sql: tab.editorValue.trim(), + executionTime: elapsed, + rowCount: totalRows > PAGE_SIZE ? totalRows : firstPage.length, + success: true, + timestamp: startTime, + }); + } + } else { + // One-shot fallback + const [cols, rows, time] = await driver.runQuery(tab.projectId, tab.editorValue, timeoutMs); + updateResult(idx, { columns: cols, rows, time }); + notifyQueryComplete(tab.editorValue, time, true, rows.length); + addHistoryEntry({ + projectId: tab.projectId, + database: d.database, + sql: tab.editorValue.trim(), + executionTime: time, + rowCount: rows.length, + success: true, + timestamp: startTime, + }); + } + } catch (err: any) { + const elapsed = Date.now() - startTime; + const errorMsg = err?.message ?? String(err); + const cancelled = isQueryCancelledError(errorMsg); + updateResult(idx, { + columns: [cancelled ? "Info" : "Error"], + rows: [[cancelled ? "Query cancelled" : errorMsg]], + time: 0, + }); + if (!cancelled) { + notifyQueryComplete(tab.editorValue, elapsed, false); + } + addHistoryEntry({ + projectId: tab.projectId, + database: d.database, + sql: tab.editorValue.trim(), + executionTime: elapsed, + rowCount: 0, + success: false, + error: cancelled ? "Query cancelled" : errorMsg, + timestamp: startTime, + }); + } + useUIStore.getState().setSelectedRow(0); + }, [setExecuting, updateResult, setVirtualQuery, addHistoryEntry, connectProject]); + + const runExplain = useCallback(async () => { + const { tabs, selectedTabIndex: idx } = useTabStore.getState(); + const tab = tabs[idx]; + if (!tab?.projectId || !tab.editorValue.trim()) return; + + const d = useProjectStore.getState().projects[tab.projectId]; + if (!d) return; + + // Auto-connect if not connected + const connStatus = useProjectStore.getState().status[tab.projectId]; + if (connStatus !== "Connected") { + await connectProject(tab.projectId); + const newStatus = useProjectStore.getState().status[tab.projectId]; + if (newStatus !== "Connected") return; + } + + setExecuting(idx, true); + try { + const driver = DriverFactory.getDriver(d.driver); + // Strip trailing semicolons from user's query to avoid syntax errors + const userSql = tab.editorValue.replace(/;\s*$/, ""); + const sql = `EXPLAIN (ANALYZE, FORMAT JSON) ${userSql}`; + const [, rows] = await driver.runQuery(tab.projectId, sql); + // PG returns the JSON plan as a single text cell; join all rows + const jsonText = rows.map((r) => r[0]).join("\n"); + let plans: unknown; + try { + plans = JSON.parse(jsonText); + } catch { + // Some drivers return each row separately or wrap in brackets + // Try finding valid JSON within the text + const match = jsonText.match(/\[[\s\S]*\]/); + if (match) { + plans = JSON.parse(match[0]); + } else { + throw new Error(`Could not parse EXPLAIN output:\n${jsonText.slice(0, 500)}`); + } + } + if (Array.isArray(plans) && plans.length > 0) { + setExplainResult(idx, plans[0]); + } + } catch (err: any) { + const errorMsg = err?.message ?? String(err); + const cancelled = isQueryCancelledError(errorMsg); + updateResult(idx, { + columns: [cancelled ? "Info" : "Explain Error"], + rows: [[cancelled ? "Explain cancelled" : errorMsg]], + time: 0, + }); + setExplainResult(idx, undefined); + } + setExecuting(idx, false); + }, [setExecuting, updateResult, setExplainResult, connectProject]); + + const cancelQuery = useCallback(async () => { + const { tabs, selectedTabIndex: idx } = useTabStore.getState(); + const tab = tabs[idx]; + if (!tab?.projectId || !tab.isExecuting) return; + + const d = useProjectStore.getState().projects[tab.projectId]; + if (!d) return; + + try { + const driver = DriverFactory.getDriver(d.driver); + await driver.cancelQuery?.(tab.projectId); + } catch (err) { + console.error("Failed to cancel query:", err); + } + }, []); + + const runSplitQuery = useCallback(async () => { + const { tabs, selectedTabIndex: idx } = useTabStore.getState(); + const tab = tabs[idx]; + if (!tab?.projectId || !tab.splitEditorValue?.trim()) return; + + const d = useProjectStore.getState().projects[tab.projectId]; + if (!d) return; + + const connStatus = useProjectStore.getState().status[tab.projectId]; + if (connStatus !== "Connected") { + await connectProject(tab.projectId); + const newStatus = useProjectStore.getState().status[tab.projectId]; + if (newStatus !== "Connected") return; + } + + setSplitExecuting(idx, true); + try { + const driver = DriverFactory.getDriver(d.driver); + const [cols, rows, time] = await driver.runQuery(tab.projectId, tab.splitEditorValue); + setSplitResult(idx, { columns: cols, rows, time }); + } catch (err: any) { + const errorMsg = err?.message ?? String(err); + const cancelled = isQueryCancelledError(errorMsg); + setSplitResult(idx, { + columns: [cancelled ? "Info" : "Error"], + rows: [[cancelled ? "Query cancelled" : errorMsg]], + time: 0, + }); + } + }, [setSplitExecuting, setSplitResult, connectProject]); + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "w") { + e.preventDefault(); + const { tabs: t, selectedTabIndex: idx } = useTabStore.getState(); + if (t.length > 0) { + const closingTab = t[idx]; + if (closingTab?.virtualQuery?.queryId && closingTab.projectId) { + const dd = useProjectStore.getState().projects[closingTab.projectId]; + if (dd) DriverFactory.getDriver(dd.driver).closeVirtual?.(closingTab.projectId, closingTab.virtualQuery.queryId).catch(() => {}); + virtualCache.clearQuery(closingTab.virtualQuery.queryId); + } + closeTab(idx); + } + } + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "Enter") { + e.preventDefault(); + void runExplain(); + } + if ((e.metaKey || e.ctrlKey) && (e.key === "p" || e.key === "k")) { + e.preventDefault(); + setCommandPaletteOpen((v) => !v); + } + if ((e.metaKey || e.ctrlKey) && e.key === "`") { + e.preventDefault(); + useTabStore.getState().openTerminalTab(); + } + if ((e.metaKey || e.ctrlKey) && e.key === ".") { + e.preventDefault(); + void cancelQuery(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [cancelQuery, closeTab, runExplain]); + + return { runQuery, runExplain, cancelQuery, runSplitQuery }; +} diff --git a/src/lib/database-driver/factory.ts b/src/lib/database-driver/factory.ts new file mode 100644 index 0000000..b2ece62 --- /dev/null +++ b/src/lib/database-driver/factory.ts @@ -0,0 +1,32 @@ +import type { DriverType } from "@/types"; +import type { DatabaseDriver } from "./index"; +import { PostgreSQLDriver } from "./pgsql"; + +export class DriverFactory { + private static drivers: Map = new Map([ + ["PGSQL", new PostgreSQLDriver()], + ]); + + static getDriver(driverType: DriverType): DatabaseDriver { + const driver = this.drivers.get(driverType); + if (!driver) { + throw new Error(`Driver ${driverType} not found`); + } + return driver; + } + + static getSupportedDrivers(): DriverType[] { + return Array.from(this.drivers.keys()); + } +} + +export interface DriverConfig { + name: string; + defaultPort: string; +} + +export const DRIVER_CONFIGS: Record = { + PGSQL: { name: "PostgreSQL", defaultPort: "5432" }, +}; + +export type { DriverType }; diff --git a/src/lib/database-driver/index.ts b/src/lib/database-driver/index.ts new file mode 100644 index 0000000..48f1943 --- /dev/null +++ b/src/lib/database-driver/index.ts @@ -0,0 +1,154 @@ +import { ProjectConnectionStatus } from "@/types"; +import type { + ColumnDetail, IndexDetail, ConstraintDetail, + TriggerDetail, RuleDetail, PolicyDetail, FunctionInfo, TriggerFunctionInfo, + PgRole, TableGrant, DbGrant, SchemaObject, +} from "@/types"; + +// Wire types from Rust (tuples) +export type WireTableInfo = [string, string]; +export type WireQueryResult = [string[], string[][], number]; +export type WirePackedResult = [string, number]; // [packed_string, elapsed_ms] + +export const CELL_SEP = "\x1F"; // Unit Separator +export const ROW_SEP = "\x1E"; // Record Separator + +/** Parse packed result format into columns, rows, and time */ +export function unpackResult(packed: string, time: number): WireQueryResult { + if (!packed) return [[], [], time]; + const parts = packed.split(ROW_SEP); + const columns = parts[0].split(CELL_SEP); + const rows = parts.slice(1).map((r) => r.split(CELL_SEP)); + return [columns, rows, time]; +} +export type WireColumnDetail = [string, string, boolean, string | null]; +export type WireIndexDetail = [string, string, boolean, boolean]; +export type WireConstraintDetail = [string, string, string]; +export type WireTriggerDetail = [string, string, string]; +export type WireRuleDetail = [string, string]; +export type WirePolicyDetail = [string, string, string]; +export type WireFunctionInfo = [string, string, string]; +export type WireTriggerFunctionInfo = [string, string]; +export type WireForeignKeyInfo = [string, string, string, string]; + +export interface ForeignKey { + sourceTable: string; + sourceColumn: string; + targetTable: string; + targetColumn: string; +} + +/** Events received during streamed query execution */ +export type QueryStreamEvent = + | { type: "columns"; columns: string; total_rows: number } + | { type: "chunk"; data: string } + | { type: "done"; elapsed: number; capped: boolean }; + +export interface StreamCallbacks { + onColumns: (columns: string[], totalRows: number) => void; + onChunk: (rows: string[][]) => void; + onDone: (elapsed: number, capped: boolean) => void; +} + +export interface DatabaseDriver { + connect(projectId: string, key: [string, string, string, string, string, string], ssh?: string[]): Promise; + cancelQuery?(projectId: string): Promise; + loadSchemas(projectId: string): Promise; + loadTables(projectId: string, schema: string): Promise; + loadColumns(projectId: string, schema: string, table: string): Promise; + loadColumnDetails(projectId: string, schema: string, table: string): Promise; + loadIndexes(projectId: string, schema: string, table: string): Promise; + loadConstraints(projectId: string, schema: string, table: string): Promise; + loadTriggers(projectId: string, schema: string, table: string): Promise; + loadRules(projectId: string, schema: string, table: string): Promise; + loadPolicies(projectId: string, schema: string, table: string): Promise; + loadViews(projectId: string, schema: string): Promise; + loadMaterializedViews(projectId: string, schema: string): Promise; + loadFunctions(projectId: string, schema: string): Promise; + loadTriggerFunctions(projectId: string, schema: string): Promise; + runQuery(projectId: string, sql: string, timeoutMs?: number): Promise; + runQueryStreamed?(projectId: string, sql: string, streamId: string, callbacks: StreamCallbacks): Promise; + executeVirtual?(projectId: string, sql: string, queryId: string, pageSize: number, timeoutMs?: number): Promise<[string, number, string, number]>; + fetchPage?(projectId: string, queryId: string, colCount: number, offset: number, limit: number): Promise; + closeVirtual?(projectId: string, queryId: string): Promise; + loadActivity(projectId: string): Promise; + loadDatabaseStats(projectId: string): Promise<[string, string][]>; + loadTableStats(projectId: string): Promise; + loadForeignKeys(projectId: string, schema: string): Promise; + loadTableStatistics?(projectId: string, schema: string, table: string): Promise<[string, string][]>; + loadFKDetails?(projectId: string, schema: string, table: string, direction: string): Promise<[string, string, string, string, string, string, string, string, string][]>; + loadViewInfo?(projectId: string, schema: string, view: string): Promise<[string, string][]>; + loadMatviewInfo?(projectId: string, schema: string, matview: string): Promise<[string, string][]>; + loadFunctionInfo?(projectId: string, schema: string, funcName: string): Promise<[string, string][]>; + generateDDL?(projectId: string, schema: string, name: string, objectType: string): Promise; + csvPreview?(filePath: string): Promise<[string[], string[][]]>; + csvImport?(projectId: string, filePath: string, schema: string, table: string, columnMapping: [number, string][]): Promise; + listenStart?(projectId: string, channel: string): Promise; + listenStop?(projectId: string, channel: string): Promise; + notifySend?(projectId: string, channel: string, payload: string): Promise; + discoverChannels?(projectId: string): Promise; + loadRoles?(projectId: string): Promise; + loadTableGrants?(projectId: string, roleName: string): Promise; + loadDatabaseGrants?(projectId: string, roleName: string): Promise; + extractSchemaObjects?(projectId: string, schema: string): Promise; + loadLocks?(projectId: string): Promise; + loadIndexUsage?(projectId: string): Promise; + loadTableBloat?(projectId: string): Promise; + loadDatabases?(projectId: string): Promise; + loadTablespaces?(projectId: string): Promise<[string, string, string][]>; + loadExtensions?(projectId: string): Promise; + loadAvailableExtensions?(projectId: string): Promise; + loadEnumTypes?(projectId: string): Promise; + loadPgSettings?(projectId: string): Promise; + tableAction?(projectId: string, action: string, schema: string, table: string, objectType: string): Promise; +} + +export function parseColumnDetails(wire: WireColumnDetail[]): ColumnDetail[] { + return wire.map(([name, dataType, nullable, defaultValue]) => ({ + name, dataType, nullable, defaultValue, + })); +} + +export function parseIndexDetails(wire: WireIndexDetail[]): IndexDetail[] { + return wire.map(([indexName, columnName, isUnique, isPrimary]) => ({ + indexName, columnName, isUnique, isPrimary, + })); +} + +export function parseConstraintDetails(wire: WireConstraintDetail[]): ConstraintDetail[] { + return wire.map(([constraintName, constraintType, columnName]) => ({ + constraintName, constraintType, columnName, + })); +} + +export function parseTriggerDetails(wire: WireTriggerDetail[]): TriggerDetail[] { + return wire.map(([triggerName, event, timing]) => ({ + triggerName, event, timing, + })); +} + +export function parseRuleDetails(wire: WireRuleDetail[]): RuleDetail[] { + return wire.map(([ruleName, event]) => ({ ruleName, event })); +} + +export function parsePolicyDetails(wire: WirePolicyDetail[]): PolicyDetail[] { + return wire.map(([policyName, permissive, command]) => ({ + policyName, permissive, command, + })); +} + +export function parseFunctionInfo(wire: WireFunctionInfo[]): FunctionInfo[] { + return wire.map(([name, returnType, arguments_]) => ({ + name, returnType, arguments: arguments_, + })); +} + +export function parseTriggerFunctionInfo(wire: WireTriggerFunctionInfo[]): TriggerFunctionInfo[] { + return wire.map(([name, arguments_]) => ({ + name, arguments: arguments_, + })); +} + +// Public re-exports so external imports continue to work unchanged. +export { DriverFactory, DRIVER_CONFIGS } from "./factory"; +export type { DriverConfig, DriverType } from "./factory"; diff --git a/src/lib/database-driver.ts b/src/lib/database-driver/pgsql.ts similarity index 56% rename from src/lib/database-driver.ts rename to src/lib/database-driver/pgsql.ts index 39a0959..62de7de 100644 --- a/src/lib/database-driver.ts +++ b/src/lib/database-driver/pgsql.ts @@ -2,156 +2,39 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { ProjectConnectionStatus } from "@/types"; import type { - DriverType, ColumnDetail, IndexDetail, ConstraintDetail, - TriggerDetail, RuleDetail, PolicyDetail, FunctionInfo, TriggerFunctionInfo, PgRole, TableGrant, DbGrant, SchemaObject, } from "@/types"; +import { + CELL_SEP, + ROW_SEP, + unpackResult, + parseColumnDetails, + parseIndexDetails, + parseConstraintDetails, + parseTriggerDetails, + parseRuleDetails, + parsePolicyDetails, + parseFunctionInfo, + parseTriggerFunctionInfo, +} from "./index"; +import type { + DatabaseDriver, + StreamCallbacks, + QueryStreamEvent, + WireTableInfo, + WirePackedResult, + WireColumnDetail, + WireIndexDetail, + WireConstraintDetail, + WireTriggerDetail, + WireRuleDetail, + WirePolicyDetail, + WireFunctionInfo, + WireTriggerFunctionInfo, + WireForeignKeyInfo, +} from "./index"; -// Wire types from Rust (tuples) -type WireTableInfo = [string, string]; -type WireQueryResult = [string[], string[][], number]; -type WirePackedResult = [string, number]; // [packed_string, elapsed_ms] - -const CELL_SEP = "\x1F"; // Unit Separator -const ROW_SEP = "\x1E"; // Record Separator - -/** Parse packed result format into columns, rows, and time */ -function unpackResult(packed: string, time: number): WireQueryResult { - if (!packed) return [[], [], time]; - const parts = packed.split(ROW_SEP); - const columns = parts[0].split(CELL_SEP); - const rows = parts.slice(1).map((r) => r.split(CELL_SEP)); - return [columns, rows, time]; -} -type WireColumnDetail = [string, string, boolean, string | null]; -type WireIndexDetail = [string, string, boolean, boolean]; -type WireConstraintDetail = [string, string, string]; -type WireTriggerDetail = [string, string, string]; -type WireRuleDetail = [string, string]; -type WirePolicyDetail = [string, string, string]; -type WireFunctionInfo = [string, string, string]; -type WireTriggerFunctionInfo = [string, string]; -type WireForeignKeyInfo = [string, string, string, string]; - -export interface ForeignKey { - sourceTable: string; - sourceColumn: string; - targetTable: string; - targetColumn: string; -} - -/** Events received during streamed query execution */ -type QueryStreamEvent = - | { type: "columns"; columns: string; total_rows: number } - | { type: "chunk"; data: string } - | { type: "done"; elapsed: number; capped: boolean }; - -export interface StreamCallbacks { - onColumns: (columns: string[], totalRows: number) => void; - onChunk: (rows: string[][]) => void; - onDone: (elapsed: number, capped: boolean) => void; -} - -export interface DatabaseDriver { - connect(projectId: string, key: [string, string, string, string, string, string], ssh?: string[]): Promise; - cancelQuery?(projectId: string): Promise; - loadSchemas(projectId: string): Promise; - loadTables(projectId: string, schema: string): Promise; - loadColumns(projectId: string, schema: string, table: string): Promise; - loadColumnDetails(projectId: string, schema: string, table: string): Promise; - loadIndexes(projectId: string, schema: string, table: string): Promise; - loadConstraints(projectId: string, schema: string, table: string): Promise; - loadTriggers(projectId: string, schema: string, table: string): Promise; - loadRules(projectId: string, schema: string, table: string): Promise; - loadPolicies(projectId: string, schema: string, table: string): Promise; - loadViews(projectId: string, schema: string): Promise; - loadMaterializedViews(projectId: string, schema: string): Promise; - loadFunctions(projectId: string, schema: string): Promise; - loadTriggerFunctions(projectId: string, schema: string): Promise; - runQuery(projectId: string, sql: string, timeoutMs?: number): Promise; - runQueryStreamed?(projectId: string, sql: string, streamId: string, callbacks: StreamCallbacks): Promise; - executeVirtual?(projectId: string, sql: string, queryId: string, pageSize: number, timeoutMs?: number): Promise<[string, number, string, number]>; - fetchPage?(projectId: string, queryId: string, colCount: number, offset: number, limit: number): Promise; - closeVirtual?(projectId: string, queryId: string): Promise; - loadActivity(projectId: string): Promise; - loadDatabaseStats(projectId: string): Promise<[string, string][]>; - loadTableStats(projectId: string): Promise; - loadForeignKeys(projectId: string, schema: string): Promise; - loadTableStatistics?(projectId: string, schema: string, table: string): Promise<[string, string][]>; - loadFKDetails?(projectId: string, schema: string, table: string, direction: string): Promise<[string, string, string, string, string, string, string, string, string][]>; - loadViewInfo?(projectId: string, schema: string, view: string): Promise<[string, string][]>; - loadMatviewInfo?(projectId: string, schema: string, matview: string): Promise<[string, string][]>; - loadFunctionInfo?(projectId: string, schema: string, funcName: string): Promise<[string, string][]>; - generateDDL?(projectId: string, schema: string, name: string, objectType: string): Promise; - csvPreview?(filePath: string): Promise<[string[], string[][]]>; - csvImport?(projectId: string, filePath: string, schema: string, table: string, columnMapping: [number, string][]): Promise; - listenStart?(projectId: string, channel: string): Promise; - listenStop?(projectId: string, channel: string): Promise; - notifySend?(projectId: string, channel: string, payload: string): Promise; - discoverChannels?(projectId: string): Promise; - loadRoles?(projectId: string): Promise; - loadTableGrants?(projectId: string, roleName: string): Promise; - loadDatabaseGrants?(projectId: string, roleName: string): Promise; - extractSchemaObjects?(projectId: string, schema: string): Promise; - loadLocks?(projectId: string): Promise; - loadIndexUsage?(projectId: string): Promise; - loadTableBloat?(projectId: string): Promise; - loadDatabases?(projectId: string): Promise; - loadTablespaces?(projectId: string): Promise<[string, string, string][]>; - loadExtensions?(projectId: string): Promise; - loadAvailableExtensions?(projectId: string): Promise; - loadEnumTypes?(projectId: string): Promise; - loadPgSettings?(projectId: string): Promise; - tableAction?(projectId: string, action: string, schema: string, table: string, objectType: string): Promise; -} - -function parseColumnDetails(wire: WireColumnDetail[]): ColumnDetail[] { - return wire.map(([name, dataType, nullable, defaultValue]) => ({ - name, dataType, nullable, defaultValue, - })); -} - -function parseIndexDetails(wire: WireIndexDetail[]): IndexDetail[] { - return wire.map(([indexName, columnName, isUnique, isPrimary]) => ({ - indexName, columnName, isUnique, isPrimary, - })); -} - -function parseConstraintDetails(wire: WireConstraintDetail[]): ConstraintDetail[] { - return wire.map(([constraintName, constraintType, columnName]) => ({ - constraintName, constraintType, columnName, - })); -} - -function parseTriggerDetails(wire: WireTriggerDetail[]): TriggerDetail[] { - return wire.map(([triggerName, event, timing]) => ({ - triggerName, event, timing, - })); -} - -function parseRuleDetails(wire: WireRuleDetail[]): RuleDetail[] { - return wire.map(([ruleName, event]) => ({ ruleName, event })); -} - -function parsePolicyDetails(wire: WirePolicyDetail[]): PolicyDetail[] { - return wire.map(([policyName, permissive, command]) => ({ - policyName, permissive, command, - })); -} - -function parseFunctionInfo(wire: WireFunctionInfo[]): FunctionInfo[] { - return wire.map(([name, returnType, arguments_]) => ({ - name, returnType, arguments: arguments_, - })); -} - -function parseTriggerFunctionInfo(wire: WireTriggerFunctionInfo[]): TriggerFunctionInfo[] { - return wire.map(([name, arguments_]) => ({ - name, arguments: arguments_, - })); -} - -class PostgreSQLDriver implements DatabaseDriver { +export class PostgreSQLDriver implements DatabaseDriver { async connect(projectId: string, key: [string, string, string, string, string, string], ssh?: string[]) { return invoke("pgsql_connector", { project_id: projectId, key, ssh: ssh ?? null }); } @@ -372,32 +255,3 @@ class PostgreSQLDriver implements DatabaseDriver { return invoke("pgsql_table_action", { project_id: projectId, action, schema, table, object_type: objectType }); } } - -export class DriverFactory { - private static drivers: Map = new Map([ - ["PGSQL", new PostgreSQLDriver()], - ]); - - static getDriver(driverType: DriverType): DatabaseDriver { - const driver = this.drivers.get(driverType); - if (!driver) { - throw new Error(`Driver ${driverType} not found`); - } - return driver; - } - - static getSupportedDrivers(): DriverType[] { - return Array.from(this.drivers.keys()); - } -} - -export interface DriverConfig { - name: string; - defaultPort: string; -} - -export const DRIVER_CONFIGS: Record = { - PGSQL: { name: "PostgreSQL", defaultPort: "5432" }, -}; - -export type { DriverType }; diff --git a/src/lib/query-helpers.ts b/src/lib/query-helpers.ts new file mode 100644 index 0000000..fed0070 --- /dev/null +++ b/src/lib/query-helpers.ts @@ -0,0 +1,28 @@ +const NOTIFY_THRESHOLD_MS = 5000; +const DEFAULT_PAGE_SIZE = 2_000; +const PAGE_SIZE_RAW = Number(import.meta.env.VITE_PAGE_SIZE ?? DEFAULT_PAGE_SIZE); +export const PAGE_SIZE = Number.isFinite(PAGE_SIZE_RAW) && PAGE_SIZE_RAW >= 100 + ? Math.floor(PAGE_SIZE_RAW) + : DEFAULT_PAGE_SIZE; +export const CELL_SEP = "\x1F"; +export const ROW_SEP = "\x1E"; + +export function isQueryCancelledError(message: string): boolean { + const lower = message.toLowerCase(); + return lower.includes("canceling statement due to user request") + || lower.includes("cancelling statement due to user request") + || lower.includes("query canceled") + || lower.includes("query cancelled") + || lower.includes("statement timeout"); +} + +export function notifyQueryComplete(sql: string, time: number, success: boolean, rowCount?: number) { + if (document.hasFocus() || time < NOTIFY_THRESHOLD_MS) return; + if (!("Notification" in window)) return; + if (Notification.permission !== "granted") return; + const preview = sql.slice(0, 60).replace(/\n/g, " "); + const body = success + ? `${rowCount?.toLocaleString() ?? 0} rows in ${(time / 1000).toFixed(1)}s` + : `Query failed after ${(time / 1000).toFixed(1)}s`; + new Notification(success ? "Query Complete" : "Query Failed", { body: `${preview}\n${body}` }); +} diff --git a/src/monaco/completion-provider.ts b/src/monaco/completion-provider.ts deleted file mode 100644 index e612df9..0000000 --- a/src/monaco/completion-provider.ts +++ /dev/null @@ -1,519 +0,0 @@ -import type * as Monaco from "monaco-editor"; -import { useProjectStore } from "@/stores/project-store"; -import { useTabStore } from "@/stores/tab-store"; -import { DriverFactory } from "@/lib/database-driver"; -import type { TableInfo } from "@/types"; - -type TableRef = { schema?: string; table: string }; - -let registered = false; - -function stripQuotes(s: string) { - return s.replaceAll('"', ""); -} - -function extractAliasMap(sql: string): Record { - const map: Record = {}; - const re = - /(from|join)\s+("?[A-Za-z0-9_]+"?)(?:\s*\.\s*("?[A-Za-z0-9_]+"?))?(?:\s+as)?\s+("?[A-Za-z0-9_]+"?)/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(sql)) !== null) { - const schemaMaybe = m[3] ? stripQuotes(m[2]) : undefined; - const table = stripQuotes(m[3] ?? m[2]); - const alias = stripQuotes(m[4]); - map[alias] = { schema: schemaMaybe, table }; - } - return map; -} - -function genAlias(table: string) { - const raw = table.replace(/"/g, ""); - const parts = raw.split("_").filter(Boolean); - if (parts.length === 0) return raw.slice(0, 1); - if (parts.length === 1) return parts[0].slice(0, 1); - return parts.map((p) => p[0]).join(""); -} - -async function resolveTableRef( - projectId: string, - ref: TableRef, -): Promise<{ schema: string; table: string } | null> { - if (ref.schema) return { schema: ref.schema, table: ref.table }; - - const state = useProjectStore.getState(); - const projSchemas = state.schemas[projectId] || []; - const d = state.projects[projectId]; - if (!d) return { schema: "public", table: ref.table }; - - const driver = DriverFactory.getDriver(d.driver); - - for (const schema of projSchemas) { - const key = `${projectId}::${schema}`; - let t = state.tables[key]; - if (!t) { - try { - const rawRows = await driver.loadTables(projectId, schema); - t = rawRows.map(([name, size]) => ({ name, size })); - useProjectStore.setState((s) => { s.tables[key] = t!; }); - } catch { - continue; - } - } - const match = - t && - t.find( - (ti: TableInfo) => ti.name.toLowerCase() === ref.table.toLowerCase(), - ); - if (match) { - return { schema, table: match.name }; - } - } - return { schema: "public", table: ref.table }; -} - -async function ensureColumns( - projectId: string, - schema: string, - table: string, -): Promise { - const colKey = `${projectId}::${schema}::${table}`; - const state = useProjectStore.getState(); - if (state.columns[colKey]) return state.columns[colKey]; - - const d = state.projects[projectId]; - if (!d) return []; - const driver = DriverFactory.getDriver(d.driver); - - try { - const cols = await driver.loadColumns(projectId, schema, table); - useProjectStore.setState((s) => { s.columns[colKey] = cols; }); - return cols; - } catch { - return []; - } -} - -async function ensureTables( - projectId: string, - schema: string, -): Promise { - const key = `${projectId}::${schema}`; - const state = useProjectStore.getState(); - if (state.tables[key]) return state.tables[key]; - - const d = state.projects[projectId]; - if (!d) return []; - const driver = DriverFactory.getDriver(d.driver); - - try { - const rawRows = await driver.loadTables(projectId, schema); - const t = rawRows.map(([name, size]) => ({ name, size })); - useProjectStore.setState((s) => { s.tables[key] = t; }); - return t; - } catch { - return []; - } -} - -export function registerContextAwareCompletions(monaco: typeof Monaco) { - if (registered) return; - registered = true; - - monaco.languages.registerCompletionItemProvider("pgsql", { - triggerCharacters: [".", " ", '"'], - provideCompletionItems: async (model, position) => { - const suggestions: any[] = []; - - const add = ( - label: string, - kind: Monaco.languages.CompletionItemKind, - insert?: string, - snippet?: boolean, - detail?: string, - ) => { - const item: any = { - label, - kind, - insertText: insert ?? label, - detail, - range: undefined, - }; - if (snippet) { - item.insertTextRules = - monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet; - } - suggestions.push(item); - }; - - const textUntilPosition = model.getValueInRange({ - startLineNumber: 1, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column, - }); - const context = textUntilPosition.slice(-1000); - - // Get active project context - const { tabs, selectedTabIndex } = useTabStore.getState(); - const activeTab = tabs[selectedTabIndex]; - const projectId = activeTab?.projectId; - const state = useProjectStore.getState(); - const d = projectId ? state.projects[projectId] : undefined; - - // Context-aware completions (require active connection) - if (projectId && d) { - const aliasMap = extractAliasMap(context); - const tableCtx = /([A-Za-z0-9_"]+)\s*\.\s*([A-Za-z0-9_"]*)$/i.exec( - context, - ); - - if (tableCtx) { - const left = stripQuotes(tableCtx[1]); - const right = stripQuotes(tableCtx[2]); - - // Alias -> column completion - const aliasKey = Object.keys(aliasMap).find( - (k) => k.toLowerCase() === left.toLowerCase(), - ); - if (aliasKey && aliasMap[aliasKey]) { - const resolved = await resolveTableRef( - projectId, - aliasMap[aliasKey], - ); - if (resolved) { - const cols = await ensureColumns( - projectId, - resolved.schema, - resolved.table, - ); - cols.forEach((c) => - add( - c, - monaco.languages.CompletionItemKind.Property, - `"${c}"`, - false, - `${resolved.table}.${c}`, - ), - ); - return { suggestions }; - } - } - - // schema. -> table completion - if (right.length === 0) { - const t = await ensureTables(projectId, left); - for (const ti of t) { - const alias = genAlias(ti.name); - add( - `${left}.${ti.name} ${alias}`, - monaco.languages.CompletionItemKind.Field, - `"${left}"."${ti.name}" \${1:${alias}}`, - true, - ti.size, - ); - } - return { suggestions }; - } - - // schema.table. -> column completion - const cols = await ensureColumns(projectId, left, right); - cols.forEach((c) => - add( - c, - monaco.languages.CompletionItemKind.Property, - `"${c}"`, - false, - `${right}.${c}`, - ), - ); - return { suggestions }; - } - - // FROM/JOIN context -> table completion - const fromCtx = /(from|join)\s+([A-Za-z0-9_".]*)$/i.exec(context); - if (fromCtx) { - const projSchemas = state.schemas[projectId] || []; - for (const schema of projSchemas) { - const t = await ensureTables(projectId, schema); - for (const ti of t) { - const alias = genAlias(ti.name); - add( - `${schema}.${ti.name} ${alias}`, - monaco.languages.CompletionItemKind.Field, - `"${schema}"."${ti.name}" \${1:${alias}}`, - true, - ti.size, - ); - } - } - return { suggestions }; - } - - // Schema names - const projSchemas = state.schemas[projectId] || []; - projSchemas.forEach((s) => - add( - s, - monaco.languages.CompletionItemKind.Module, - `"${s}"`, - false, - "schema", - ), - ); - } - - for (const kw of SQL_KEYWORDS) { - add(kw, monaco.languages.CompletionItemKind.Keyword, kw); - } - - for (const snip of SQL_SNIPPETS) { - add( - snip.label, - monaco.languages.CompletionItemKind.Snippet, - snip.insert, - true, - snip.detail, - ); - } - - return { suggestions }; - }, - }); -} - -const SQL_KEYWORDS = [ - "SELECT", - "FROM", - "WHERE", - "AND", - "OR", - "NOT", - "IN", - "EXISTS", - "INSERT", - "INTO", - "VALUES", - "UPDATE", - "SET", - "DELETE", - "CREATE", - "ALTER", - "DROP", - "TABLE", - "INDEX", - "VIEW", - "JOIN", - "INNER", - "LEFT", - "RIGHT", - "FULL", - "OUTER", - "CROSS", - "ON", - "GROUP", - "BY", - "ORDER", - "ASC", - "DESC", - "HAVING", - "LIMIT", - "OFFSET", - "DISTINCT", - "AS", - "CASE", - "WHEN", - "THEN", - "ELSE", - "END", - "UNION", - "ALL", - "INTERSECT", - "EXCEPT", - "NULL", - "IS", - "BETWEEN", - "LIKE", - "ILIKE", - "TRUE", - "FALSE", - "DEFAULT", - "BEGIN", - "COMMIT", - "ROLLBACK", - "SAVEPOINT", - "PRIMARY", - "KEY", - "FOREIGN", - "REFERENCES", - "UNIQUE", - "CHECK", - "CONSTRAINT", - "NOT NULL", - "SERIAL", - "BIGSERIAL", - "TEXT", - "INTEGER", - "BIGINT", - "SMALLINT", - "BOOLEAN", - "NUMERIC", - "DECIMAL", - "TIMESTAMP", - "TIMESTAMPTZ", - "DATE", - "TIME", - "INTERVAL", - "UUID", - "JSONB", - "JSON", - "VARCHAR", - "CHAR", - "BYTEA", - "FLOAT", - "DOUBLE PRECISION", - "REAL", - "COUNT", - "SUM", - "AVG", - "MIN", - "MAX", - "COALESCE", - "NULLIF", - "ARRAY_AGG", - "STRING_AGG", - "ROW_NUMBER", - "RANK", - "DENSE_RANK", - "OVER", - "PARTITION", - "WINDOW", - "WITH", - "RECURSIVE", - "EXPLAIN", - "ANALYZE", - "VERBOSE", - "GRANT", - "REVOKE", - "TRUNCATE", - "RETURNING", - "ON CONFLICT", - "DO NOTHING", - "DO UPDATE", - "LATERAL", - "FETCH", - "FIRST", - "NEXT", - "ROWS", - "ONLY", -]; - -const SQL_SNIPPETS = [ - { - label: "sel", - detail: "SELECT ... FROM ... WHERE", - insert: - "SELECT ${1:*}\nFROM ${2:table_name}\nWHERE ${3:condition}\nLIMIT ${4:100};", - }, - { - label: "selc", - detail: "SELECT COUNT(*)", - insert: "SELECT COUNT(*)\nFROM ${1:table_name}\nWHERE ${2:1=1};", - }, - { - label: "seld", - detail: "SELECT DISTINCT", - insert: "SELECT DISTINCT ${1:column}\nFROM ${2:table_name};", - }, - { - label: "ins", - detail: "INSERT INTO ... VALUES", - insert: "INSERT INTO ${1:table_name} (${2:columns})\nVALUES (${3:values});", - }, - { - label: "upd", - detail: "UPDATE ... SET ... WHERE", - insert: - "UPDATE ${1:table_name}\nSET ${2:column} = ${3:value}\nWHERE ${4:condition};", - }, - { - label: "del", - detail: "DELETE FROM ... WHERE", - insert: "DELETE FROM ${1:table_name}\nWHERE ${2:condition};", - }, - { - label: "crt", - detail: "CREATE TABLE", - insert: - "CREATE TABLE ${1:table_name} (\n ${2:id} SERIAL PRIMARY KEY,\n ${3:column} ${4:TEXT} NOT NULL\n);", - }, - { - label: "alt", - detail: "ALTER TABLE ADD COLUMN", - insert: - "ALTER TABLE ${1:table_name}\nADD COLUMN ${2:column_name} ${3:TEXT};", - }, - { - label: "idx", - detail: "CREATE INDEX", - insert: "CREATE INDEX ${1:idx_name}\nON ${2:table_name} (${3:column});", - }, - { - label: "jn", - detail: "SELECT ... JOIN ... ON", - insert: - "SELECT ${1:*}\nFROM ${2:table1} t1\nJOIN ${3:table2} t2 ON t1.${4:id} = t2.${5:t1_id}\nWHERE ${6:1=1};", - }, - { - label: "lj", - detail: "SELECT ... LEFT JOIN", - insert: - "SELECT ${1:*}\nFROM ${2:table1} t1\nLEFT JOIN ${3:table2} t2 ON t1.${4:id} = t2.${5:t1_id};", - }, - { - label: "grp", - detail: "SELECT ... GROUP BY ... ORDER BY", - insert: - "SELECT ${1:column}, COUNT(*)\nFROM ${2:table_name}\nGROUP BY ${1:column}\nORDER BY COUNT(*) DESC;", - }, - { - label: "cte", - detail: "WITH ... AS (...) SELECT", - insert: - "WITH ${1:cte_name} AS (\n SELECT ${2:*}\n FROM ${3:table_name}\n WHERE ${4:condition}\n)\nSELECT * FROM ${1:cte_name};", - }, - { - label: "exist", - detail: "SELECT ... WHERE EXISTS", - insert: - "SELECT *\nFROM ${1:table_name} t1\nWHERE EXISTS (\n SELECT 1\n FROM ${2:other_table} t2\n WHERE t2.${3:fk} = t1.${4:id}\n);", - }, - { - label: "upsert", - detail: "INSERT ... ON CONFLICT DO UPDATE", - insert: - "INSERT INTO ${1:table_name} (${2:columns})\nVALUES (${3:values})\nON CONFLICT (${4:constraint})\nDO UPDATE SET ${5:column} = EXCLUDED.${5:column};", - }, - { - label: "vw", - detail: "CREATE VIEW", - insert: - "CREATE OR REPLACE VIEW ${1:view_name} AS\nSELECT ${2:*}\nFROM ${3:table_name}\nWHERE ${4:condition};", - }, - { - label: "fn", - detail: "CREATE FUNCTION", - insert: - "CREATE OR REPLACE FUNCTION ${1:func_name}(${2:params})\nRETURNS ${3:return_type}\nLANGUAGE plpgsql\nAS \\$\\$\nBEGIN\n ${4:-- body}\nEND;\n\\$\\$;", - }, - { - label: "trg", - detail: "CREATE TRIGGER", - insert: - "CREATE TRIGGER ${1:trigger_name}\n${2:BEFORE} ${3:INSERT} ON ${4:table_name}\nFOR EACH ROW\nEXECUTE FUNCTION ${5:func_name}();", - }, - { - label: "txn", - detail: "BEGIN ... COMMIT", - insert: "BEGIN;\n ${1:-- statements}\nCOMMIT;", - }, -]; diff --git a/src/monaco/completion-provider/alias-parser.ts b/src/monaco/completion-provider/alias-parser.ts new file mode 100644 index 0000000..fc61cbf --- /dev/null +++ b/src/monaco/completion-provider/alias-parser.ts @@ -0,0 +1,27 @@ +export type TableRef = { schema?: string; table: string }; + +export function stripQuotes(s: string) { + return s.replaceAll('"', ""); +} + +export function extractAliasMap(sql: string): Record { + const map: Record = {}; + const re = + /(from|join)\s+("?[A-Za-z0-9_]+"?)(?:\s*\.\s*("?[A-Za-z0-9_]+"?))?(?:\s+as)?\s+("?[A-Za-z0-9_]+"?)/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(sql)) !== null) { + const schemaMaybe = m[3] ? stripQuotes(m[2]) : undefined; + const table = stripQuotes(m[3] ?? m[2]); + const alias = stripQuotes(m[4]); + map[alias] = { schema: schemaMaybe, table }; + } + return map; +} + +export function genAlias(table: string) { + const raw = table.replace(/"/g, ""); + const parts = raw.split("_").filter(Boolean); + if (parts.length === 0) return raw.slice(0, 1); + if (parts.length === 1) return parts[0].slice(0, 1); + return parts.map((p) => p[0]).join(""); +} diff --git a/src/monaco/completion-provider/index.ts b/src/monaco/completion-provider/index.ts new file mode 100644 index 0000000..3b2b9b4 --- /dev/null +++ b/src/monaco/completion-provider/index.ts @@ -0,0 +1,175 @@ +import type * as Monaco from "monaco-editor"; +import { useProjectStore } from "@/stores/project-store"; +import { useTabStore } from "@/stores/tab-store"; +import { extractAliasMap, genAlias, stripQuotes } from "./alias-parser"; +import { ensureColumns, ensureTables, resolveTableRef } from "./resolver"; +import { SQL_KEYWORDS } from "./keywords"; +import { SQL_SNIPPETS } from "./snippets"; + +let registered = false; + +export function registerContextAwareCompletions(monaco: typeof Monaco) { + if (registered) return; + registered = true; + + monaco.languages.registerCompletionItemProvider("pgsql", { + triggerCharacters: [".", " ", '"'], + provideCompletionItems: async (model, position) => { + const suggestions: any[] = []; + + const add = ( + label: string, + kind: Monaco.languages.CompletionItemKind, + insert?: string, + snippet?: boolean, + detail?: string, + ) => { + const item: any = { + label, + kind, + insertText: insert ?? label, + detail, + range: undefined, + }; + if (snippet) { + item.insertTextRules = + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet; + } + suggestions.push(item); + }; + + const textUntilPosition = model.getValueInRange({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + const context = textUntilPosition.slice(-1000); + + // Get active project context + const { tabs, selectedTabIndex } = useTabStore.getState(); + const activeTab = tabs[selectedTabIndex]; + const projectId = activeTab?.projectId; + const state = useProjectStore.getState(); + const d = projectId ? state.projects[projectId] : undefined; + + // Context-aware completions (require active connection) + if (projectId && d) { + const aliasMap = extractAliasMap(context); + const tableCtx = /([A-Za-z0-9_"]+)\s*\.\s*([A-Za-z0-9_"]*)$/i.exec( + context, + ); + + if (tableCtx) { + const left = stripQuotes(tableCtx[1]); + const right = stripQuotes(tableCtx[2]); + + // Alias -> column completion + const aliasKey = Object.keys(aliasMap).find( + (k) => k.toLowerCase() === left.toLowerCase(), + ); + if (aliasKey && aliasMap[aliasKey]) { + const resolved = await resolveTableRef( + projectId, + aliasMap[aliasKey], + ); + if (resolved) { + const cols = await ensureColumns( + projectId, + resolved.schema, + resolved.table, + ); + cols.forEach((c) => + add( + c, + monaco.languages.CompletionItemKind.Property, + `"${c}"`, + false, + `${resolved.table}.${c}`, + ), + ); + return { suggestions }; + } + } + + // schema. -> table completion + if (right.length === 0) { + const t = await ensureTables(projectId, left); + for (const ti of t) { + const alias = genAlias(ti.name); + add( + `${left}.${ti.name} ${alias}`, + monaco.languages.CompletionItemKind.Field, + `"${left}"."${ti.name}" \${1:${alias}}`, + true, + ti.size, + ); + } + return { suggestions }; + } + + // schema.table. -> column completion + const cols = await ensureColumns(projectId, left, right); + cols.forEach((c) => + add( + c, + monaco.languages.CompletionItemKind.Property, + `"${c}"`, + false, + `${right}.${c}`, + ), + ); + return { suggestions }; + } + + // FROM/JOIN context -> table completion + const fromCtx = /(from|join)\s+([A-Za-z0-9_".]*)$/i.exec(context); + if (fromCtx) { + const projSchemas = state.schemas[projectId] || []; + for (const schema of projSchemas) { + const t = await ensureTables(projectId, schema); + for (const ti of t) { + const alias = genAlias(ti.name); + add( + `${schema}.${ti.name} ${alias}`, + monaco.languages.CompletionItemKind.Field, + `"${schema}"."${ti.name}" \${1:${alias}}`, + true, + ti.size, + ); + } + } + return { suggestions }; + } + + // Schema names + const projSchemas = state.schemas[projectId] || []; + projSchemas.forEach((s) => + add( + s, + monaco.languages.CompletionItemKind.Module, + `"${s}"`, + false, + "schema", + ), + ); + } + + for (const kw of SQL_KEYWORDS) { + add(kw, monaco.languages.CompletionItemKind.Keyword, kw); + } + + for (const snip of SQL_SNIPPETS) { + add( + snip.label, + monaco.languages.CompletionItemKind.Snippet, + snip.insert, + true, + snip.detail, + ); + } + + return { suggestions }; + }, + }); +} diff --git a/src/monaco/completion-provider/keywords.ts b/src/monaco/completion-provider/keywords.ts new file mode 100644 index 0000000..7828283 --- /dev/null +++ b/src/monaco/completion-provider/keywords.ts @@ -0,0 +1,125 @@ +export const SQL_KEYWORDS = [ + "SELECT", + "FROM", + "WHERE", + "AND", + "OR", + "NOT", + "IN", + "EXISTS", + "INSERT", + "INTO", + "VALUES", + "UPDATE", + "SET", + "DELETE", + "CREATE", + "ALTER", + "DROP", + "TABLE", + "INDEX", + "VIEW", + "JOIN", + "INNER", + "LEFT", + "RIGHT", + "FULL", + "OUTER", + "CROSS", + "ON", + "GROUP", + "BY", + "ORDER", + "ASC", + "DESC", + "HAVING", + "LIMIT", + "OFFSET", + "DISTINCT", + "AS", + "CASE", + "WHEN", + "THEN", + "ELSE", + "END", + "UNION", + "ALL", + "INTERSECT", + "EXCEPT", + "NULL", + "IS", + "BETWEEN", + "LIKE", + "ILIKE", + "TRUE", + "FALSE", + "DEFAULT", + "BEGIN", + "COMMIT", + "ROLLBACK", + "SAVEPOINT", + "PRIMARY", + "KEY", + "FOREIGN", + "REFERENCES", + "UNIQUE", + "CHECK", + "CONSTRAINT", + "NOT NULL", + "SERIAL", + "BIGSERIAL", + "TEXT", + "INTEGER", + "BIGINT", + "SMALLINT", + "BOOLEAN", + "NUMERIC", + "DECIMAL", + "TIMESTAMP", + "TIMESTAMPTZ", + "DATE", + "TIME", + "INTERVAL", + "UUID", + "JSONB", + "JSON", + "VARCHAR", + "CHAR", + "BYTEA", + "FLOAT", + "DOUBLE PRECISION", + "REAL", + "COUNT", + "SUM", + "AVG", + "MIN", + "MAX", + "COALESCE", + "NULLIF", + "ARRAY_AGG", + "STRING_AGG", + "ROW_NUMBER", + "RANK", + "DENSE_RANK", + "OVER", + "PARTITION", + "WINDOW", + "WITH", + "RECURSIVE", + "EXPLAIN", + "ANALYZE", + "VERBOSE", + "GRANT", + "REVOKE", + "TRUNCATE", + "RETURNING", + "ON CONFLICT", + "DO NOTHING", + "DO UPDATE", + "LATERAL", + "FETCH", + "FIRST", + "NEXT", + "ROWS", + "ONLY", +]; diff --git a/src/monaco/completion-provider/resolver.ts b/src/monaco/completion-provider/resolver.ts new file mode 100644 index 0000000..a90e41d --- /dev/null +++ b/src/monaco/completion-provider/resolver.ts @@ -0,0 +1,85 @@ +import { useProjectStore } from "@/stores/project-store"; +import { DriverFactory } from "@/lib/database-driver"; +import type { TableInfo } from "@/types"; +import type { TableRef } from "./alias-parser"; + +export async function resolveTableRef( + projectId: string, + ref: TableRef, +): Promise<{ schema: string; table: string } | null> { + if (ref.schema) return { schema: ref.schema, table: ref.table }; + + const state = useProjectStore.getState(); + const projSchemas = state.schemas[projectId] || []; + const d = state.projects[projectId]; + if (!d) return { schema: "public", table: ref.table }; + + const driver = DriverFactory.getDriver(d.driver); + + for (const schema of projSchemas) { + const key = `${projectId}::${schema}`; + let t = state.tables[key]; + if (!t) { + try { + const rawRows = await driver.loadTables(projectId, schema); + t = rawRows.map(([name, size]) => ({ name, size })); + useProjectStore.setState((s) => { s.tables[key] = t!; }); + } catch { + continue; + } + } + const match = + t && + t.find( + (ti: TableInfo) => ti.name.toLowerCase() === ref.table.toLowerCase(), + ); + if (match) { + return { schema, table: match.name }; + } + } + return { schema: "public", table: ref.table }; +} + +export async function ensureColumns( + projectId: string, + schema: string, + table: string, +): Promise { + const colKey = `${projectId}::${schema}::${table}`; + const state = useProjectStore.getState(); + if (state.columns[colKey]) return state.columns[colKey]; + + const d = state.projects[projectId]; + if (!d) return []; + const driver = DriverFactory.getDriver(d.driver); + + try { + const cols = await driver.loadColumns(projectId, schema, table); + useProjectStore.setState((s) => { s.columns[colKey] = cols; }); + return cols; + } catch { + return []; + } +} + +export async function ensureTables( + projectId: string, + schema: string, +): Promise { + const key = `${projectId}::${schema}`; + const state = useProjectStore.getState(); + if (state.tables[key]) return state.tables[key]; + + const d = state.projects[projectId]; + if (!d) return []; + const driver = DriverFactory.getDriver(d.driver); + + try { + const rawRows = await driver.loadTables(projectId, schema); + const t = rawRows.map(([name, size]) => ({ name, size })); + useProjectStore.setState((s) => { s.tables[key] = t; }); + return t; + } catch { + return []; + } +} diff --git a/src/monaco/completion-provider/snippets.ts b/src/monaco/completion-provider/snippets.ts new file mode 100644 index 0000000..e85e243 --- /dev/null +++ b/src/monaco/completion-provider/snippets.ts @@ -0,0 +1,110 @@ +export const SQL_SNIPPETS = [ + { + label: "sel", + detail: "SELECT ... FROM ... WHERE", + insert: + "SELECT ${1:*}\nFROM ${2:table_name}\nWHERE ${3:condition}\nLIMIT ${4:100};", + }, + { + label: "selc", + detail: "SELECT COUNT(*)", + insert: "SELECT COUNT(*)\nFROM ${1:table_name}\nWHERE ${2:1=1};", + }, + { + label: "seld", + detail: "SELECT DISTINCT", + insert: "SELECT DISTINCT ${1:column}\nFROM ${2:table_name};", + }, + { + label: "ins", + detail: "INSERT INTO ... VALUES", + insert: "INSERT INTO ${1:table_name} (${2:columns})\nVALUES (${3:values});", + }, + { + label: "upd", + detail: "UPDATE ... SET ... WHERE", + insert: + "UPDATE ${1:table_name}\nSET ${2:column} = ${3:value}\nWHERE ${4:condition};", + }, + { + label: "del", + detail: "DELETE FROM ... WHERE", + insert: "DELETE FROM ${1:table_name}\nWHERE ${2:condition};", + }, + { + label: "crt", + detail: "CREATE TABLE", + insert: + "CREATE TABLE ${1:table_name} (\n ${2:id} SERIAL PRIMARY KEY,\n ${3:column} ${4:TEXT} NOT NULL\n);", + }, + { + label: "alt", + detail: "ALTER TABLE ADD COLUMN", + insert: + "ALTER TABLE ${1:table_name}\nADD COLUMN ${2:column_name} ${3:TEXT};", + }, + { + label: "idx", + detail: "CREATE INDEX", + insert: "CREATE INDEX ${1:idx_name}\nON ${2:table_name} (${3:column});", + }, + { + label: "jn", + detail: "SELECT ... JOIN ... ON", + insert: + "SELECT ${1:*}\nFROM ${2:table1} t1\nJOIN ${3:table2} t2 ON t1.${4:id} = t2.${5:t1_id}\nWHERE ${6:1=1};", + }, + { + label: "lj", + detail: "SELECT ... LEFT JOIN", + insert: + "SELECT ${1:*}\nFROM ${2:table1} t1\nLEFT JOIN ${3:table2} t2 ON t1.${4:id} = t2.${5:t1_id};", + }, + { + label: "grp", + detail: "SELECT ... GROUP BY ... ORDER BY", + insert: + "SELECT ${1:column}, COUNT(*)\nFROM ${2:table_name}\nGROUP BY ${1:column}\nORDER BY COUNT(*) DESC;", + }, + { + label: "cte", + detail: "WITH ... AS (...) SELECT", + insert: + "WITH ${1:cte_name} AS (\n SELECT ${2:*}\n FROM ${3:table_name}\n WHERE ${4:condition}\n)\nSELECT * FROM ${1:cte_name};", + }, + { + label: "exist", + detail: "SELECT ... WHERE EXISTS", + insert: + "SELECT *\nFROM ${1:table_name} t1\nWHERE EXISTS (\n SELECT 1\n FROM ${2:other_table} t2\n WHERE t2.${3:fk} = t1.${4:id}\n);", + }, + { + label: "upsert", + detail: "INSERT ... ON CONFLICT DO UPDATE", + insert: + "INSERT INTO ${1:table_name} (${2:columns})\nVALUES (${3:values})\nON CONFLICT (${4:constraint})\nDO UPDATE SET ${5:column} = EXCLUDED.${5:column};", + }, + { + label: "vw", + detail: "CREATE VIEW", + insert: + "CREATE OR REPLACE VIEW ${1:view_name} AS\nSELECT ${2:*}\nFROM ${3:table_name}\nWHERE ${4:condition};", + }, + { + label: "fn", + detail: "CREATE FUNCTION", + insert: + "CREATE OR REPLACE FUNCTION ${1:func_name}(${2:params})\nRETURNS ${3:return_type}\nLANGUAGE plpgsql\nAS \\$\\$\nBEGIN\n ${4:-- body}\nEND;\n\\$\\$;", + }, + { + label: "trg", + detail: "CREATE TRIGGER", + insert: + "CREATE TRIGGER ${1:trigger_name}\n${2:BEFORE} ${3:INSERT} ON ${4:table_name}\nFOR EACH ROW\nEXECUTE FUNCTION ${5:func_name}();", + }, + { + label: "txn", + detail: "BEGIN ... COMMIT", + insert: "BEGIN;\n ${1:-- statements}\nCOMMIT;", + }, +]; diff --git a/src/stores/project-store.ts b/src/stores/project-store.ts deleted file mode 100644 index c03ab0c..0000000 --- a/src/stores/project-store.ts +++ /dev/null @@ -1,526 +0,0 @@ -import { create } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import { toast } from "sonner"; -import { DriverFactory } from "@/lib/database-driver"; -import type { - ProjectMap, - ProjectDetails, - TableInfo, - ColumnDetail, - IndexDetail, - ConstraintDetail, - TriggerDetail, - RuleDetail, - PolicyDetail, - FunctionInfo, - TriggerFunctionInfo, - ProjectConnectionStatus, - DriverType, -} from "@/types"; -import { ProjectConnectionStatus as PCS } from "@/types"; -import { - getProjects, - insertProject, - deleteProject as deleteProjectApi, -} from "@/tauri"; - -interface ProjectState { - projects: ProjectMap; - status: Record; - connectionErrors: Record; - schemas: Record; - tables: Record; - columns: Record; - columnDetails: Record; - indexes: Record; - constraints: Record; - triggers: Record; - rules: Record; - policies: Record; - serverDatabases: Record; - serverTablespaces: Record; - views: Record; - materializedViews: Record; - functions: Record; - triggerFunctions: Record; - - loadProjects: () => Promise; - connect: (projectId: string) => Promise; - loadSchemas: (projectId: string) => Promise; - loadTables: (projectId: string, schema: string) => Promise; - loadColumns: ( - projectId: string, - schema: string, - table: string, - ) => Promise; - loadColumnDetails: ( - projectId: string, - schema: string, - table: string, - ) => Promise; - loadIndexes: ( - projectId: string, - schema: string, - table: string, - ) => Promise; - loadConstraints: ( - projectId: string, - schema: string, - table: string, - ) => Promise; - loadTableMetadata: ( - projectId: string, - schema: string, - table: string, - ) => Promise; - loadSchemaObjects: (projectId: string, schema: string) => Promise; - refreshConnection: (projectId: string) => Promise; - deleteProject: (projectId: string) => Promise; - saveConnection: (name: string, details: ProjectDetails) => Promise; - updateConnection: (name: string, details: ProjectDetails) => Promise; - addDatabaseToServer: ( - sourceProjectId: string, - name: string, - database: string, - ) => Promise; -} - -export const useProjectStore = create()( - immer((set, get) => ({ - projects: {}, - status: {}, - connectionErrors: {}, - schemas: {}, - tables: {}, - columns: {}, - columnDetails: {}, - indexes: {}, - constraints: {}, - triggers: {}, - rules: {}, - policies: {}, - serverDatabases: {}, - serverTablespaces: {}, - views: {}, - materializedViews: {}, - functions: {}, - triggerFunctions: {}, - - loadProjects: async () => { - try { - const raw = await getProjects(); - const projects: ProjectMap = {}; - for (const [id, arr] of Object.entries(raw)) { - projects[id] = parseProjectDetails(arr); - } - set({ projects }); - } catch (err) { - console.error("Failed to load projects, retrying in 500ms...", err); - // Retry once after a short delay — handles race where - // the Tauri backend hasn't finished setup yet. - await new Promise((r) => setTimeout(r, 500)); - try { - const raw = await getProjects(); - const projects: ProjectMap = {}; - for (const [id, arr] of Object.entries(raw)) { - projects[id] = parseProjectDetails(arr); - } - set({ projects }); - } catch (retryErr) { - console.error("Failed to load projects after retry:", retryErr); - } - } - }, - - connect: async (projectId: string) => { - const { projects } = get(); - const d = projects[projectId]; - if (!d) return; - - set((s) => { - s.status[projectId] = PCS.Connecting; - s.connectionErrors[projectId] = ""; - }); - - try { - const driver = DriverFactory.getDriver(d.driver); - const key: [string, string, string, string, string, string] = [ - d.username, - d.password, - d.database, - d.host, - d.port, - d.ssl, - ]; - const ssh = - d.sshEnabled === "true" - ? [ - d.sshHost, - d.sshPort || "22", - d.sshUser, - d.sshPassword, - d.sshKeyPath, - ] - : undefined; - const st = await driver.connect(projectId, key, ssh); - set((s) => { - s.status[projectId] = st; - }); - - if (st === PCS.Connected) { - const [sc, dbs, tsp] = await Promise.allSettled([ - driver.loadSchemas(projectId), - driver.loadDatabases?.(projectId), - driver.loadTablespaces?.(projectId), - ]); - set((s) => { - s.schemas[projectId] = sc.status === "fulfilled" ? sc.value : []; - s.serverDatabases[projectId] = - dbs.status === "fulfilled" && dbs.value ? dbs.value : []; - s.serverTablespaces[projectId] = - tsp.status === "fulfilled" && tsp.value ? tsp.value : []; - }); - } - } catch (err: unknown) { - const msg = - err instanceof Error - ? err.message - : typeof err === "string" - ? err - : "Connection failed"; - set((s) => { - s.status[projectId] = PCS.Failed; - s.connectionErrors[projectId] = msg; - }); - const d = projects[projectId]; - toast.error(`Connection failed: ${d?.database || projectId}`, { - description: msg, - duration: 10000, - }); - } - }, - - loadSchemas: async (projectId: string) => { - const { projects } = get(); - const d = projects[projectId]; - if (!d) return; - const driver = DriverFactory.getDriver(d.driver); - const sc = await driver.loadSchemas(projectId); - set((s) => { - s.schemas[projectId] = sc; - }); - }, - - loadTables: async (projectId: string, schema: string) => { - const key = `${projectId}::${schema}`; - const { tables, projects } = get(); - if (tables[key]) return; - - const d = projects[projectId]; - if (!d) return; - const driver = DriverFactory.getDriver(d.driver); - const rawRows = await driver.loadTables(projectId, schema); - const rows: TableInfo[] = rawRows.map(([name, size]) => ({ name, size })); - set((s) => { - s.tables[key] = rows; - }); - }, - - loadColumns: async (projectId: string, schema: string, table: string) => { - const colKey = `${projectId}::${schema}::${table}`; - const { columns, projects } = get(); - if (columns[colKey]) return columns[colKey]; - - const d = projects[projectId]; - if (!d) return []; - const driver = DriverFactory.getDriver(d.driver); - const cols = await driver.loadColumns(projectId, schema, table); - set((s) => { - s.columns[colKey] = cols; - }); - return cols; - }, - - loadColumnDetails: async ( - projectId: string, - schema: string, - table: string, - ) => { - const key = `${projectId}::${schema}::${table}`; - const { columnDetails, projects } = get(); - if (columnDetails[key]) return columnDetails[key]; - - const d = projects[projectId]; - if (!d) return []; - const driver = DriverFactory.getDriver(d.driver); - const details = await driver.loadColumnDetails(projectId, schema, table); - set((s) => { - s.columnDetails[key] = details; - }); - return details; - }, - - loadIndexes: async (projectId: string, schema: string, table: string) => { - const key = `${projectId}::${schema}::${table}`; - const { indexes, projects } = get(); - if (indexes[key]) return indexes[key]; - - const d = projects[projectId]; - if (!d) return []; - const driver = DriverFactory.getDriver(d.driver); - const idx = await driver.loadIndexes(projectId, schema, table); - set((s) => { - s.indexes[key] = idx; - }); - return idx; - }, - - loadConstraints: async ( - projectId: string, - schema: string, - table: string, - ) => { - const key = `${projectId}::${schema}::${table}`; - const { constraints, projects } = get(); - if (constraints[key]) return constraints[key]; - - const d = projects[projectId]; - if (!d) return []; - const driver = DriverFactory.getDriver(d.driver); - const c = await driver.loadConstraints(projectId, schema, table); - set((s) => { - s.constraints[key] = c; - }); - return c; - }, - - loadTableMetadata: async ( - projectId: string, - schema: string, - table: string, - ) => { - const key = `${projectId}::${schema}::${table}`; - const { columnDetails, projects } = get(); - if (columnDetails[key]) return; - - const d = projects[projectId]; - if (!d) return; - const driver = DriverFactory.getDriver(d.driver); - - const [colsR, idxsR, consR, trigsR, rlsR, polsR] = - await Promise.allSettled([ - driver.loadColumnDetails(projectId, schema, table), - driver.loadIndexes(projectId, schema, table), - driver.loadConstraints(projectId, schema, table), - driver.loadTriggers(projectId, schema, table), - driver.loadRules(projectId, schema, table), - driver.loadPolicies(projectId, schema, table), - ]); - - const val = (r: PromiseSettledResult, fallback: T): T => - r.status === "fulfilled" ? r.value : fallback; - - set((s) => { - s.columnDetails[key] = val(colsR, []); - s.indexes[key] = val(idxsR, []); - s.constraints[key] = val(consR, []); - s.triggers[key] = val(trigsR, []); - s.rules[key] = val(rlsR, []); - s.policies[key] = val(polsR, []); - }); - }, - - loadSchemaObjects: async (projectId: string, schema: string) => { - const key = `${projectId}::${schema}`; - const { views: existingViews, projects } = get(); - if (existingViews[key]) return; - - const d = projects[projectId]; - if (!d) return; - const driver = DriverFactory.getDriver(d.driver); - const [vR, mvR, fnR, tfnR] = await Promise.allSettled([ - driver.loadViews(projectId, schema), - driver.loadMaterializedViews(projectId, schema), - driver.loadFunctions(projectId, schema), - driver.loadTriggerFunctions(projectId, schema), - ]); - - const val = (r: PromiseSettledResult, fallback: T): T => - r.status === "fulfilled" ? r.value : fallback; - - set((s) => { - s.views[key] = val(vR, []); - s.materializedViews[key] = val(mvR, []); - s.functions[key] = val(fnR, []); - s.triggerFunctions[key] = val(tfnR, []); - }); - }, - - refreshConnection: async (projectId: string) => { - const { projects, status, tables } = get(); - const d = projects[projectId]; - if (!d || status[projectId] !== PCS.Connected) return; - - // Remember which schemas had tables loaded so we can reload them - const schemaPrefix = `${projectId}::`; - const expandedSchemas = Object.keys(tables) - .filter((k) => k.startsWith(schemaPrefix)) - .map((k) => k.slice(schemaPrefix.length)); - - // Clear all cached metadata for this project - set((s) => { - // Clear schema-level caches - for (const key of Object.keys(s.tables)) { - if (key.startsWith(schemaPrefix)) delete s.tables[key]; - } - for (const key of Object.keys(s.columns)) { - if (key.startsWith(schemaPrefix)) delete s.columns[key]; - } - for (const key of Object.keys(s.columnDetails)) { - if (key.startsWith(schemaPrefix)) delete s.columnDetails[key]; - } - for (const key of Object.keys(s.indexes)) { - if (key.startsWith(schemaPrefix)) delete s.indexes[key]; - } - for (const key of Object.keys(s.constraints)) { - if (key.startsWith(schemaPrefix)) delete s.constraints[key]; - } - for (const key of Object.keys(s.triggers)) { - if (key.startsWith(schemaPrefix)) delete s.triggers[key]; - } - for (const key of Object.keys(s.rules)) { - if (key.startsWith(schemaPrefix)) delete s.rules[key]; - } - for (const key of Object.keys(s.policies)) { - if (key.startsWith(schemaPrefix)) delete s.policies[key]; - } - for (const key of Object.keys(s.views)) { - if (key.startsWith(schemaPrefix)) delete s.views[key]; - } - for (const key of Object.keys(s.materializedViews)) { - if (key.startsWith(schemaPrefix)) delete s.materializedViews[key]; - } - for (const key of Object.keys(s.functions)) { - if (key.startsWith(schemaPrefix)) delete s.functions[key]; - } - for (const key of Object.keys(s.triggerFunctions)) { - if (key.startsWith(schemaPrefix)) delete s.triggerFunctions[key]; - } - - // Clear project-level caches - delete s.schemas[projectId]; - delete s.serverDatabases[projectId]; - delete s.serverTablespaces[projectId]; - }); - - // Reload schemas, databases, tablespaces - try { - const driver = DriverFactory.getDriver(d.driver); - const [sc, dbs, tsp] = await Promise.allSettled([ - driver.loadSchemas(projectId), - driver.loadDatabases?.(projectId), - driver.loadTablespaces?.(projectId), - ]); - set((s) => { - s.schemas[projectId] = sc.status === "fulfilled" ? sc.value : []; - s.serverDatabases[projectId] = - dbs.status === "fulfilled" && dbs.value ? dbs.value : []; - s.serverTablespaces[projectId] = - tsp.status === "fulfilled" && tsp.value ? tsp.value : []; - }); - - // Reload tables and schema objects for previously expanded schemas - await Promise.all( - expandedSchemas.map((schema) => - Promise.all([ - get().loadTables(projectId, schema), - get().loadSchemaObjects(projectId, schema), - ]), - ), - ); - - toast.success("Connection refreshed"); - } catch { - toast.error("Failed to refresh connection data"); - } - }, - - deleteProject: async (projectId: string) => { - await deleteProjectApi(projectId); - await get().loadProjects(); - set((s) => { - s.status[projectId] = PCS.Disconnected; - }); - }, - - saveConnection: async (name: string, details: ProjectDetails) => { - const arr = [ - details.driver, - details.username, - details.password, - details.database, - details.host, - details.port, - details.ssl, - details.sshEnabled ?? "false", - details.sshHost ?? "", - details.sshPort ?? "22", - details.sshUser ?? "", - details.sshPassword ?? "", - details.sshKeyPath ?? "", - ]; - await insertProject(name, arr); - await get().loadProjects(); - }, - - updateConnection: async (name: string, details: ProjectDetails) => { - const arr = [ - details.driver, - details.username, - details.password, - details.database, - details.host, - details.port, - details.ssl, - details.sshEnabled ?? "false", - details.sshHost ?? "", - details.sshPort ?? "22", - details.sshUser ?? "", - details.sshPassword ?? "", - details.sshKeyPath ?? "", - ]; - await insertProject(name, arr); - await get().loadProjects(); - }, - - addDatabaseToServer: async ( - sourceProjectId: string, - name: string, - database: string, - ) => { - const { projects } = get(); - const source = projects[sourceProjectId]; - if (!source) return; - const details = { ...source, database }; - await get().saveConnection(name, details); - }, - })), -); - -function parseProjectDetails(arr: string[]): ProjectDetails { - return { - driver: (arr[0] ?? "PGSQL") as DriverType, - username: arr[1] ?? "", - password: arr[2] ?? "", - database: arr[3] ?? "", - host: arr[4] ?? "", - port: arr[5] ?? "", - ssl: arr[6] ?? "false", - sshEnabled: arr[7] ?? "false", - sshHost: arr[8] ?? "", - sshPort: arr[9] ?? "22", - sshUser: arr[10] ?? "", - sshPassword: arr[11] ?? "", - sshKeyPath: arr[12] ?? "", - }; -} diff --git a/src/stores/project-store/connection.ts b/src/stores/project-store/connection.ts new file mode 100644 index 0000000..34ba755 --- /dev/null +++ b/src/stores/project-store/connection.ts @@ -0,0 +1,174 @@ +import type { StateCreator } from "zustand"; +import { toast } from "sonner"; +import { DriverFactory } from "@/lib/database-driver"; +import { ProjectConnectionStatus as PCS } from "@/types"; +import type { ProjectState } from "./index"; + +export type ConnectionSlice = { + connect: (projectId: string) => Promise; + refreshConnection: (projectId: string) => Promise; +}; + +export const createConnectionSlice: StateCreator< + ProjectState, + [["zustand/immer", never]], + [], + ConnectionSlice +> = (set, get) => ({ + connect: async (projectId: string) => { + const { projects } = get(); + const d = projects[projectId]; + if (!d) return; + + set((s) => { + s.status[projectId] = PCS.Connecting; + s.connectionErrors[projectId] = ""; + }); + + try { + const driver = DriverFactory.getDriver(d.driver); + const key: [string, string, string, string, string, string] = [ + d.username, + d.password, + d.database, + d.host, + d.port, + d.ssl, + ]; + const ssh = + d.sshEnabled === "true" + ? [ + d.sshHost, + d.sshPort || "22", + d.sshUser, + d.sshPassword, + d.sshKeyPath, + ] + : undefined; + const st = await driver.connect(projectId, key, ssh); + set((s) => { + s.status[projectId] = st; + }); + + if (st === PCS.Connected) { + const [sc, dbs, tsp] = await Promise.allSettled([ + driver.loadSchemas(projectId), + driver.loadDatabases?.(projectId), + driver.loadTablespaces?.(projectId), + ]); + set((s) => { + s.schemas[projectId] = sc.status === "fulfilled" ? sc.value : []; + s.serverDatabases[projectId] = + dbs.status === "fulfilled" && dbs.value ? dbs.value : []; + s.serverTablespaces[projectId] = + tsp.status === "fulfilled" && tsp.value ? tsp.value : []; + }); + } + } catch (err: unknown) { + const msg = + err instanceof Error + ? err.message + : typeof err === "string" + ? err + : "Connection failed"; + set((s) => { + s.status[projectId] = PCS.Failed; + s.connectionErrors[projectId] = msg; + }); + const d = projects[projectId]; + toast.error(`Connection failed: ${d?.database || projectId}`, { + description: msg, + duration: 10000, + }); + } + }, + + refreshConnection: async (projectId: string) => { + const { projects, status, tables } = get(); + const d = projects[projectId]; + if (!d || status[projectId] !== PCS.Connected) return; + + // Remember which schemas had tables loaded so we can reload them + const schemaPrefix = `${projectId}::`; + const expandedSchemas = Object.keys(tables) + .filter((k) => k.startsWith(schemaPrefix)) + .map((k) => k.slice(schemaPrefix.length)); + + // Clear all cached metadata for this project + set((s) => { + // Clear schema-level caches + for (const key of Object.keys(s.tables)) { + if (key.startsWith(schemaPrefix)) delete s.tables[key]; + } + for (const key of Object.keys(s.columns)) { + if (key.startsWith(schemaPrefix)) delete s.columns[key]; + } + for (const key of Object.keys(s.columnDetails)) { + if (key.startsWith(schemaPrefix)) delete s.columnDetails[key]; + } + for (const key of Object.keys(s.indexes)) { + if (key.startsWith(schemaPrefix)) delete s.indexes[key]; + } + for (const key of Object.keys(s.constraints)) { + if (key.startsWith(schemaPrefix)) delete s.constraints[key]; + } + for (const key of Object.keys(s.triggers)) { + if (key.startsWith(schemaPrefix)) delete s.triggers[key]; + } + for (const key of Object.keys(s.rules)) { + if (key.startsWith(schemaPrefix)) delete s.rules[key]; + } + for (const key of Object.keys(s.policies)) { + if (key.startsWith(schemaPrefix)) delete s.policies[key]; + } + for (const key of Object.keys(s.views)) { + if (key.startsWith(schemaPrefix)) delete s.views[key]; + } + for (const key of Object.keys(s.materializedViews)) { + if (key.startsWith(schemaPrefix)) delete s.materializedViews[key]; + } + for (const key of Object.keys(s.functions)) { + if (key.startsWith(schemaPrefix)) delete s.functions[key]; + } + for (const key of Object.keys(s.triggerFunctions)) { + if (key.startsWith(schemaPrefix)) delete s.triggerFunctions[key]; + } + + // Clear project-level caches + delete s.schemas[projectId]; + delete s.serverDatabases[projectId]; + delete s.serverTablespaces[projectId]; + }); + + // Reload schemas, databases, tablespaces + try { + const driver = DriverFactory.getDriver(d.driver); + const [sc, dbs, tsp] = await Promise.allSettled([ + driver.loadSchemas(projectId), + driver.loadDatabases?.(projectId), + driver.loadTablespaces?.(projectId), + ]); + set((s) => { + s.schemas[projectId] = sc.status === "fulfilled" ? sc.value : []; + s.serverDatabases[projectId] = + dbs.status === "fulfilled" && dbs.value ? dbs.value : []; + s.serverTablespaces[projectId] = + tsp.status === "fulfilled" && tsp.value ? tsp.value : []; + }); + + // Reload tables and schema objects for previously expanded schemas + await Promise.all( + expandedSchemas.map((schema) => + Promise.all([ + get().loadTables(projectId, schema), + get().loadSchemaObjects(projectId, schema), + ]), + ), + ); + + toast.success("Connection refreshed"); + } catch { + toast.error("Failed to refresh connection data"); + } + }, +}); diff --git a/src/stores/project-store/core.ts b/src/stores/project-store/core.ts new file mode 100644 index 0000000..f0d7b96 --- /dev/null +++ b/src/stores/project-store/core.ts @@ -0,0 +1,144 @@ +import type { StateCreator } from "zustand"; +import type { + ProjectMap, + ProjectDetails, + ProjectConnectionStatus, + DriverType, +} from "@/types"; +import { ProjectConnectionStatus as PCS } from "@/types"; +import { + getProjects, + insertProject, + deleteProject as deleteProjectApi, +} from "@/tauri"; +import type { ProjectState } from "./index"; + +export type CoreSlice = { + projects: ProjectMap; + status: Record; + connectionErrors: Record; + loadProjects: () => Promise; + deleteProject: (projectId: string) => Promise; + saveConnection: (name: string, details: ProjectDetails) => Promise; + updateConnection: (name: string, details: ProjectDetails) => Promise; + addDatabaseToServer: ( + sourceProjectId: string, + name: string, + database: string, + ) => Promise; +}; + +export function parseProjectDetails(arr: string[]): ProjectDetails { + return { + driver: (arr[0] ?? "PGSQL") as DriverType, + username: arr[1] ?? "", + password: arr[2] ?? "", + database: arr[3] ?? "", + host: arr[4] ?? "", + port: arr[5] ?? "", + ssl: arr[6] ?? "false", + sshEnabled: arr[7] ?? "false", + sshHost: arr[8] ?? "", + sshPort: arr[9] ?? "22", + sshUser: arr[10] ?? "", + sshPassword: arr[11] ?? "", + sshKeyPath: arr[12] ?? "", + }; +} + +export const createCoreSlice: StateCreator< + ProjectState, + [["zustand/immer", never]], + [], + CoreSlice +> = (set, get) => ({ + projects: {}, + status: {}, + connectionErrors: {}, + + loadProjects: async () => { + try { + const raw = await getProjects(); + const projects: ProjectMap = {}; + for (const [id, arr] of Object.entries(raw)) { + projects[id] = parseProjectDetails(arr); + } + set({ projects }); + } catch (err) { + console.error("Failed to load projects, retrying in 500ms...", err); + // Retry once after a short delay — handles race where + // the Tauri backend hasn't finished setup yet. + await new Promise((r) => setTimeout(r, 500)); + try { + const raw = await getProjects(); + const projects: ProjectMap = {}; + for (const [id, arr] of Object.entries(raw)) { + projects[id] = parseProjectDetails(arr); + } + set({ projects }); + } catch (retryErr) { + console.error("Failed to load projects after retry:", retryErr); + } + } + }, + + deleteProject: async (projectId: string) => { + await deleteProjectApi(projectId); + await get().loadProjects(); + set((s) => { + s.status[projectId] = PCS.Disconnected; + }); + }, + + saveConnection: async (name: string, details: ProjectDetails) => { + const arr = [ + details.driver, + details.username, + details.password, + details.database, + details.host, + details.port, + details.ssl, + details.sshEnabled ?? "false", + details.sshHost ?? "", + details.sshPort ?? "22", + details.sshUser ?? "", + details.sshPassword ?? "", + details.sshKeyPath ?? "", + ]; + await insertProject(name, arr); + await get().loadProjects(); + }, + + updateConnection: async (name: string, details: ProjectDetails) => { + const arr = [ + details.driver, + details.username, + details.password, + details.database, + details.host, + details.port, + details.ssl, + details.sshEnabled ?? "false", + details.sshHost ?? "", + details.sshPort ?? "22", + details.sshUser ?? "", + details.sshPassword ?? "", + details.sshKeyPath ?? "", + ]; + await insertProject(name, arr); + await get().loadProjects(); + }, + + addDatabaseToServer: async ( + sourceProjectId: string, + name: string, + database: string, + ) => { + const { projects } = get(); + const source = projects[sourceProjectId]; + if (!source) return; + const details = { ...source, database }; + await get().saveConnection(name, details); + }, +}); diff --git a/src/stores/project-store/index.ts b/src/stores/project-store/index.ts new file mode 100644 index 0000000..6b8ad0a --- /dev/null +++ b/src/stores/project-store/index.ts @@ -0,0 +1,23 @@ +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { createCoreSlice, type CoreSlice } from "./core"; +import { createConnectionSlice, type ConnectionSlice } from "./connection"; +import { createSchemaSlice, type SchemaSlice } from "./schema"; +import { createIndexesSlice, type IndexesSlice } from "./indexes"; +import { createViewsSlice, type ViewsSlice } from "./views"; + +export type ProjectState = CoreSlice & + ConnectionSlice & + SchemaSlice & + IndexesSlice & + ViewsSlice; + +export const useProjectStore = create()( + immer((...a) => ({ + ...createCoreSlice(...a), + ...createConnectionSlice(...a), + ...createSchemaSlice(...a), + ...createIndexesSlice(...a), + ...createViewsSlice(...a), + })), +); diff --git a/src/stores/project-store/indexes.ts b/src/stores/project-store/indexes.ts new file mode 100644 index 0000000..d534b65 --- /dev/null +++ b/src/stores/project-store/indexes.ts @@ -0,0 +1,75 @@ +import type { StateCreator } from "zustand"; +import { DriverFactory } from "@/lib/database-driver"; +import type { + IndexDetail, + ConstraintDetail, + TriggerDetail, + RuleDetail, + PolicyDetail, +} from "@/types"; +import type { ProjectState } from "./index"; + +export type IndexesSlice = { + indexes: Record; + constraints: Record; + triggers: Record; + rules: Record; + policies: Record; + loadIndexes: ( + projectId: string, + schema: string, + table: string, + ) => Promise; + loadConstraints: ( + projectId: string, + schema: string, + table: string, + ) => Promise; +}; + +export const createIndexesSlice: StateCreator< + ProjectState, + [["zustand/immer", never]], + [], + IndexesSlice +> = (set, get) => ({ + indexes: {}, + constraints: {}, + triggers: {}, + rules: {}, + policies: {}, + + loadIndexes: async (projectId: string, schema: string, table: string) => { + const key = `${projectId}::${schema}::${table}`; + const { indexes, projects } = get(); + if (indexes[key]) return indexes[key]; + + const d = projects[projectId]; + if (!d) return []; + const driver = DriverFactory.getDriver(d.driver); + const idx = await driver.loadIndexes(projectId, schema, table); + set((s) => { + s.indexes[key] = idx; + }); + return idx; + }, + + loadConstraints: async ( + projectId: string, + schema: string, + table: string, + ) => { + const key = `${projectId}::${schema}::${table}`; + const { constraints, projects } = get(); + if (constraints[key]) return constraints[key]; + + const d = projects[projectId]; + if (!d) return []; + const driver = DriverFactory.getDriver(d.driver); + const c = await driver.loadConstraints(projectId, schema, table); + set((s) => { + s.constraints[key] = c; + }); + return c; + }, +}); diff --git a/src/stores/project-store/schema.ts b/src/stores/project-store/schema.ts new file mode 100644 index 0000000..b16beb3 --- /dev/null +++ b/src/stores/project-store/schema.ts @@ -0,0 +1,122 @@ +import type { StateCreator } from "zustand"; +import { DriverFactory } from "@/lib/database-driver"; +import type { TableInfo, ColumnDetail } from "@/types"; +import type { ProjectState } from "./index"; + +export type SchemaSlice = { + schemas: Record; + tables: Record; + columns: Record; + columnDetails: Record; + loadSchemas: (projectId: string) => Promise; + loadTables: (projectId: string, schema: string) => Promise; + loadColumns: ( + projectId: string, + schema: string, + table: string, + ) => Promise; + loadColumnDetails: ( + projectId: string, + schema: string, + table: string, + ) => Promise; + loadSchemaObjects: (projectId: string, schema: string) => Promise; +}; + +export const createSchemaSlice: StateCreator< + ProjectState, + [["zustand/immer", never]], + [], + SchemaSlice +> = (set, get) => ({ + schemas: {}, + tables: {}, + columns: {}, + columnDetails: {}, + + loadSchemas: async (projectId: string) => { + const { projects } = get(); + const d = projects[projectId]; + if (!d) return; + const driver = DriverFactory.getDriver(d.driver); + const sc = await driver.loadSchemas(projectId); + set((s) => { + s.schemas[projectId] = sc; + }); + }, + + loadTables: async (projectId: string, schema: string) => { + const key = `${projectId}::${schema}`; + const { tables, projects } = get(); + if (tables[key]) return; + + const d = projects[projectId]; + if (!d) return; + const driver = DriverFactory.getDriver(d.driver); + const rawRows = await driver.loadTables(projectId, schema); + const rows: TableInfo[] = rawRows.map(([name, size]) => ({ name, size })); + set((s) => { + s.tables[key] = rows; + }); + }, + + loadColumns: async (projectId: string, schema: string, table: string) => { + const colKey = `${projectId}::${schema}::${table}`; + const { columns, projects } = get(); + if (columns[colKey]) return columns[colKey]; + + const d = projects[projectId]; + if (!d) return []; + const driver = DriverFactory.getDriver(d.driver); + const cols = await driver.loadColumns(projectId, schema, table); + set((s) => { + s.columns[colKey] = cols; + }); + return cols; + }, + + loadColumnDetails: async ( + projectId: string, + schema: string, + table: string, + ) => { + const key = `${projectId}::${schema}::${table}`; + const { columnDetails, projects } = get(); + if (columnDetails[key]) return columnDetails[key]; + + const d = projects[projectId]; + if (!d) return []; + const driver = DriverFactory.getDriver(d.driver); + const details = await driver.loadColumnDetails(projectId, schema, table); + set((s) => { + s.columnDetails[key] = details; + }); + return details; + }, + + loadSchemaObjects: async (projectId: string, schema: string) => { + const key = `${projectId}::${schema}`; + const { views: existingViews, projects } = get(); + if (existingViews[key]) return; + + const d = projects[projectId]; + if (!d) return; + const driver = DriverFactory.getDriver(d.driver); + const [vR, mvR, fnR, tfnR] = await Promise.allSettled([ + driver.loadViews(projectId, schema), + driver.loadMaterializedViews(projectId, schema), + driver.loadFunctions(projectId, schema), + driver.loadTriggerFunctions(projectId, schema), + ]); + + const val = (r: PromiseSettledResult, fallback: T): T => + r.status === "fulfilled" ? r.value : fallback; + + set((s) => { + s.views[key] = val(vR, []); + s.materializedViews[key] = val(mvR, []); + s.functions[key] = val(fnR, []); + s.triggerFunctions[key] = val(tfnR, []); + }); + }, +}); diff --git a/src/stores/project-store/views.ts b/src/stores/project-store/views.ts new file mode 100644 index 0000000..f7e2cd7 --- /dev/null +++ b/src/stores/project-store/views.ts @@ -0,0 +1,68 @@ +import type { StateCreator } from "zustand"; +import { DriverFactory } from "@/lib/database-driver"; +import type { FunctionInfo, TriggerFunctionInfo } from "@/types"; +import type { ProjectState } from "./index"; + +export type ViewsSlice = { + views: Record; + materializedViews: Record; + functions: Record; + triggerFunctions: Record; + serverDatabases: Record; + serverTablespaces: Record; + loadTableMetadata: ( + projectId: string, + schema: string, + table: string, + ) => Promise; +}; + +export const createViewsSlice: StateCreator< + ProjectState, + [["zustand/immer", never]], + [], + ViewsSlice +> = (set, get) => ({ + views: {}, + materializedViews: {}, + functions: {}, + triggerFunctions: {}, + serverDatabases: {}, + serverTablespaces: {}, + + loadTableMetadata: async ( + projectId: string, + schema: string, + table: string, + ) => { + const key = `${projectId}::${schema}::${table}`; + const { columnDetails, projects } = get(); + if (columnDetails[key]) return; + + const d = projects[projectId]; + if (!d) return; + const driver = DriverFactory.getDriver(d.driver); + + const [colsR, idxsR, consR, trigsR, rlsR, polsR] = + await Promise.allSettled([ + driver.loadColumnDetails(projectId, schema, table), + driver.loadIndexes(projectId, schema, table), + driver.loadConstraints(projectId, schema, table), + driver.loadTriggers(projectId, schema, table), + driver.loadRules(projectId, schema, table), + driver.loadPolicies(projectId, schema, table), + ]); + + const val = (r: PromiseSettledResult, fallback: T): T => + r.status === "fulfilled" ? r.value : fallback; + + set((s) => { + s.columnDetails[key] = val(colsR, []); + s.indexes[key] = val(idxsR, []); + s.constraints[key] = val(consR, []); + s.triggers[key] = val(trigsR, []); + s.rules[key] = val(rlsR, []); + s.policies[key] = val(polsR, []); + }); + }, +}); From bbfa6edebfccc315c004c1bc65b374e1e496c1ee Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Sat, 16 May 2026 13:52:43 +0200 Subject: [PATCH 4/7] chore: remove ugly comments --- src-tauri/src/app_setup.rs | 3 --- .../drivers/pgsql/commands/admin_commands.rs | 5 ----- .../drivers/pgsql/commands/pool_connection.rs | 4 ---- .../drivers/pgsql/commands/pubsub_commands.rs | 2 -- src-tauri/src/drivers/pgsql/ddl_generation.rs | 10 ---------- .../src/drivers/pgsql/metadata_schema.rs | 11 ----------- .../drivers/pgsql/metadata_views_functions.rs | 7 ------- src-tauri/src/drivers/pgsql/mod.rs | 1 - .../pgsql/query_execution/streaming.rs | 4 ---- .../pgsql/query_execution/virtual_cache.rs | 2 -- .../roles_schema_objects/schema_objects.rs | 5 ----- .../pgsql/statistics_activity/database.rs | 3 --- .../pgsql/statistics_activity/objects.rs | 3 --- src-tauri/src/ssh.rs | 2 -- src-tauri/src/terminal.rs | 1 - src/App.tsx | 1 - src/components/command-palette/index.tsx | 6 ------ src/components/csv-import-modal.tsx | 1 - src/components/editor-toolbar.tsx | 2 -- src/components/enums-panel.tsx | 1 - src/components/erd-diagram/index.tsx | 15 +-------------- src/components/erd-diagram/layout.ts | 3 --- src/components/erd-diagram/table-details.tsx | 9 +-------- .../object-properties-modal/index.tsx | 6 ------ .../structure-editor/columns-section.tsx | 3 --- .../structure-editor/fk-card.tsx | 6 ------ .../structure-editor/index.tsx | 12 +----------- .../structure-editor/initialization.ts | 4 ---- .../structure-editor/pk-section.tsx | 1 - .../use-object-data.ts | 7 ------- src/components/performance-monitor/index.tsx | 1 - src/components/pg-settings-panel.tsx | 1 - src/components/results-grid/index.tsx | 19 +++---------------- src/components/results-map.tsx | 11 ----------- src/components/results-panel/index.tsx | 4 ---- src/components/results-panel/use-edit-mode.ts | 11 ----------- .../results-panel/use-virtual-paging.ts | 1 - .../server-sidebar/add-database-dialog.tsx | 1 - src/components/server-sidebar/ddl-queries.ts | 1 - .../server-sidebar/indent-guides.tsx | 1 - src/components/server-sidebar/index.tsx | 3 --- .../server-sidebar/render-saved-queries.tsx | 1 - .../server-sidebar/render-server-group.tsx | 11 ++--------- .../server-sidebar/render-table-details.tsx | 10 ---------- .../server-sidebar/section-header.tsx | 1 - src/components/server-sidebar/tree-row.tsx | 1 - src/components/server-sidebar/types.ts | 5 ----- src/components/terminal-panel.tsx | 5 ----- src/components/ui/context-menu.tsx | 1 - src/hooks/use-query-lifecycle.ts | 11 ++--------- src/lib/alter-table-sql.ts | 17 ++--------------- src/lib/database-driver/index.ts | 3 --- src/lib/sql-utils.ts | 3 --- src/lib/virtual-cache.ts | 1 - src/monaco/completion-provider/index.ts | 7 ------- src/monaco/setup.ts | 1 - src/stores/project-store/connection.ts | 7 +------ src/stores/tab-store.ts | 1 - 58 files changed, 13 insertions(+), 267 deletions(-) diff --git a/src-tauri/src/app_setup.rs b/src-tauri/src/app_setup.rs index 11fd94f..4e940e6 100644 --- a/src-tauri/src/app_setup.rs +++ b/src-tauri/src/app_setup.rs @@ -35,7 +35,6 @@ pub fn setup_app(app: &mut tauri::App) -> Result<(), Box> .await .expect("Failed to open local database"); - // Create tables let conn = db.connect().expect("Failed to create connection"); conn.execute( "CREATE TABLE IF NOT EXISTS projects ( @@ -110,7 +109,6 @@ pub fn setup_app(app: &mut tauri::App) -> Result<(), Box> .await .ok(); - // SSH tunnel columns migration for col in [ "ssh_enabled", "ssh_host", @@ -149,7 +147,6 @@ pub fn setup_app(app: &mut tauri::App) -> Result<(), Box> app_handle.manage(terminal_state); }); - // Native menu let handle = app.handle(); let app_menu = SubmenuBuilder::new(handle, "RSQL") diff --git a/src-tauri/src/drivers/pgsql/commands/admin_commands.rs b/src-tauri/src/drivers/pgsql/commands/admin_commands.rs index ec61700..60889df 100644 --- a/src-tauri/src/drivers/pgsql/commands/admin_commands.rs +++ b/src-tauri/src/drivers/pgsql/commands/admin_commands.rs @@ -120,7 +120,6 @@ pub async fn pgsql_table_action( ) -> Result { let client = acquire_client(&app_state.clients, project_id).await?; - // Quote identifiers safely fn qi(name: &str) -> String { format!("\"{}\"", name.replace('"', "\"\"")) } @@ -128,23 +127,19 @@ pub async fn pgsql_table_action( let qualified = format!("{}.{}", qi(schema), qi(table)); let sql = match (object_type, action) { - // Table actions ("table", "ANALYZE") => format!("ANALYZE {qualified}"), ("table", "VACUUM") => format!("VACUUM {qualified}"), ("table", "VACUUM FULL") => format!("VACUUM FULL {qualified}"), ("table", "REINDEX") => format!("REINDEX TABLE {qualified}"), ("table", "TRUNCATE") => format!("TRUNCATE TABLE {qualified}"), ("table", "DROP TABLE") => format!("DROP TABLE {qualified}"), - // View actions ("view", "DROP VIEW") => format!("DROP VIEW {qualified}"), ("view", "DROP VIEW CASCADE") => format!("DROP VIEW {qualified} CASCADE"), - // Materialized view actions ("matview", "REFRESH") => format!("REFRESH MATERIALIZED VIEW {qualified}"), ("matview", "REFRESH CONCURRENTLY") => { format!("REFRESH MATERIALIZED VIEW CONCURRENTLY {qualified}") } ("matview", "DROP MATERIALIZED VIEW") => format!("DROP MATERIALIZED VIEW {qualified}"), - // Function actions ("function" | "trigger-function", "DROP FUNCTION") => format!("DROP FUNCTION {qualified}"), ("function" | "trigger-function", "DROP FUNCTION CASCADE") => { format!("DROP FUNCTION {qualified} CASCADE") diff --git a/src-tauri/src/drivers/pgsql/commands/pool_connection.rs b/src-tauri/src/drivers/pgsql/commands/pool_connection.rs index b3a4632..9974ea3 100644 --- a/src-tauri/src/drivers/pgsql/commands/pool_connection.rs +++ b/src-tauri/src/drivers/pgsql/commands/pool_connection.rs @@ -19,7 +19,6 @@ pub(crate) fn is_sqlite_lock_error(message: &str) -> bool { lower.contains("database is locked") || lower.contains("database busy") } -/// Walk the full std::error::Error source chain into a single string. pub(crate) fn full_error_chain(e: &dyn std::error::Error) -> String { let mut msg = e.to_string(); let mut src = e.source(); @@ -189,7 +188,6 @@ pub async fn pgsql_connector( } }; - // Determine effective host/port, potentially through an SSH tunnel let (effective_host, effective_port_str) = if let Some(ref ssh_params) = ssh { // ssh_params: [ssh_host, ssh_port, ssh_user, ssh_password, ssh_key_path] if ssh_params.len() >= 3 && !ssh_params[0].is_empty() { @@ -205,7 +203,6 @@ pub async fn pgsql_connector( .filter(|s| !s.is_empty()) .map(|s| s.as_str()); - // Stop any existing tunnel for this project app_state.ssh_tunnels.lock().await.remove(project_id); let tunnel = crate::ssh::start_tunnel( @@ -243,7 +240,6 @@ pub async fn pgsql_connector( .host(&effective_host) .port(port); - // Create two pools: one for user queries, one for metadata. let query_pool = match create_pg_pool(&cfg, use_ssl, 16) { Ok(p) => Arc::new(p), Err(e) => { diff --git a/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs b/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs index 263ceea..064b20d 100644 --- a/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs +++ b/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs @@ -23,7 +23,6 @@ pub async fn pgsql_listen_start(project_id: &str, channel: &str, app: AppHandle) } } - // Get connection config from local db let (cfg, use_ssl) = { let conn = app_state .local_db @@ -62,7 +61,6 @@ pub async fn pgsql_listen_start(project_id: &str, channel: &str, app: AppHandle) let event_name = format!("pg-notify-{}", project_id); let handle = tokio::spawn(async move { - // Helper: drive a connection, forwarding notifications as Tauri events async fn listen_loop( client: tokio_postgres::Client, mut connection: tokio_postgres::Connection, diff --git a/src-tauri/src/drivers/pgsql/ddl_generation.rs b/src-tauri/src/drivers/pgsql/ddl_generation.rs index 810a2dd..841d8b5 100644 --- a/src-tauri/src/drivers/pgsql/ddl_generation.rs +++ b/src-tauri/src/drivers/pgsql/ddl_generation.rs @@ -2,7 +2,6 @@ use tokio_postgres::{Client, SimpleQueryMessage}; use crate::common::enums::AppError; -/// Generate full DDL for an object. Returns lines of DDL as a single String. pub async fn generate_full_ddl( client: &Client, schema: &str, @@ -58,7 +57,6 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# let mut ddl = format!("CREATE TABLE \"{schema}\".\"{table}\" (\n{col_defs}\n);\n"); - // Helper: extract single-column text rows from simple_query results fn collect_lines(messages: &[SimpleQueryMessage]) -> Vec { let mut out = Vec::new(); for msg in messages { @@ -73,7 +71,6 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# out } - // Constraints (PK, FK, UNIQUE, CHECK) let con_sql = format!( r#"SELECT 'ALTER TABLE "{schema}"."{table}" ADD CONSTRAINT "' || con.conname || '" ' || pg_get_constraintdef(con.oid) || ';' FROM pg_constraint con @@ -92,7 +89,6 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# ddl.push('\n'); } - // Indexes (non-constraint) let idx_sql = format!( r#"SELECT pg_get_indexdef(i.indexrelid) || ';' FROM pg_index i @@ -112,7 +108,6 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# ddl.push('\n'); } - // Triggers let trig_sql = format!( r#"SELECT pg_get_triggerdef(t.oid) || ';' FROM pg_trigger t @@ -131,7 +126,6 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# ddl.push('\n'); } - // RLS let rls_sql = format!( r#"SELECT CASE WHEN c.relrowsecurity THEN 'ALTER TABLE "{schema}"."{table}" ENABLE ROW LEVEL SECURITY;' ELSE '' END FROM pg_class c @@ -148,7 +142,6 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# ddl.push('\n'); } - // Policies let pol_sql = format!( r#"SELECT 'CREATE POLICY "' || pol.polname || '" ON "{schema}"."{table}"' || CASE pol.polcmd WHEN 'r' THEN ' FOR SELECT' WHEN 'a' THEN ' FOR INSERT' WHEN 'w' THEN ' FOR UPDATE' WHEN 'd' THEN ' FOR DELETE' WHEN '*' THEN '' END || @@ -171,7 +164,6 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# ddl.push('\n'); } - // Table comment let cmt_sql = format!( r#"SELECT 'COMMENT ON TABLE "{schema}"."{table}" IS ' || quote_literal(d.description) || ';' FROM pg_description d @@ -189,7 +181,6 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# ddl.push('\n'); } - // Column comments let col_cmt_sql = format!( r#"SELECT 'COMMENT ON COLUMN "{schema}"."{table}"."' || a.attname || '" IS ' || quote_literal(d.description) || ';' FROM pg_description d @@ -249,7 +240,6 @@ async fn generate_matview_ddl( } } - // Indexes on matview let idx_sql = format!( r#"SELECT pg_get_indexdef(i.indexrelid) || ';' FROM pg_index i diff --git a/src-tauri/src/drivers/pgsql/metadata_schema.rs b/src-tauri/src/drivers/pgsql/metadata_schema.rs index f928036..7c47148 100644 --- a/src-tauri/src/drivers/pgsql/metadata_schema.rs +++ b/src-tauri/src/drivers/pgsql/metadata_schema.rs @@ -7,7 +7,6 @@ use crate::common::pgsql::{PgsqlLoadColumns, PgsqlLoadSchemas, PgsqlLoadTables}; use super::{ColumnDetail, ConstraintDetail, IndexDetail, PolicyDetail, RuleDetail, TriggerDetail}; -/// Load schemas with a timeout. The query string is driver-specific. pub async fn load_schemas(client: &Client, query_sql: &str) -> Result { let rows = tokio_time::timeout( tokio_time::Duration::from_secs(10), @@ -20,7 +19,6 @@ pub async fn load_schemas(client: &Client, query_sql: &str) -> Result Result, AppError> { let client = pool.get().await.map_err(|e| AppError::DatabaseError(e.to_string()))?; let rows = client @@ -33,7 +31,6 @@ pub async fn load_databases(pool: &Pool) -> Result, AppError> { Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) } -/// Load tablespaces from the server. pub async fn load_tablespaces(pool: &Pool) -> Result, AppError> { let client = pool.get().await.map_err(|e| AppError::DatabaseError(e.to_string()))?; let rows = client @@ -50,7 +47,6 @@ pub async fn load_tablespaces(pool: &Pool) -> Result(0)).collect()) } -/// Load detailed column info for a given schema and table. pub async fn load_column_details( client: &Client, schema: &str, @@ -113,7 +107,6 @@ pub async fn load_column_details( .collect()) } -/// Load indexes for a given schema and table. pub async fn load_indexes( client: &Client, schema: &str, @@ -150,7 +143,6 @@ pub async fn load_indexes( .collect()) } -/// Load triggers for a given schema and table. pub async fn load_triggers( client: &Client, schema: &str, @@ -178,7 +170,6 @@ pub async fn load_triggers( .collect()) } -/// Load rules for a given schema and table. pub async fn load_rules( client: &Client, schema: &str, @@ -205,7 +196,6 @@ pub async fn load_rules( .collect()) } -/// Load RLS policies for a given schema and table. pub async fn load_policies( client: &Client, schema: &str, @@ -244,7 +234,6 @@ pub async fn load_policies( .collect()) } -/// Load constraints for a given schema and table. pub async fn load_constraints( client: &Client, schema: &str, diff --git a/src-tauri/src/drivers/pgsql/metadata_views_functions.rs b/src-tauri/src/drivers/pgsql/metadata_views_functions.rs index f9f53a6..e3f6a88 100644 --- a/src-tauri/src/drivers/pgsql/metadata_views_functions.rs +++ b/src-tauri/src/drivers/pgsql/metadata_views_functions.rs @@ -4,7 +4,6 @@ use crate::common::enums::AppError; use super::{FunctionInfo, ObjectStats}; -/// View info: (view_name) pub async fn load_views(client: &Client, schema: &str) -> Result, AppError> { let rows = client .query( @@ -20,7 +19,6 @@ pub async fn load_views(client: &Client, schema: &str) -> Result, Ap Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) } -/// Load materialized views for a schema. pub async fn load_materialized_views( client: &Client, schema: &str, @@ -39,7 +37,6 @@ pub async fn load_materialized_views( Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) } -/// Load functions for a schema (excluding trigger functions and aggregates). pub async fn load_functions(client: &Client, schema: &str) -> Result, AppError> { let rows = client .query( @@ -68,7 +65,6 @@ pub async fn load_functions(client: &Client, schema: &str) -> Result; /// Column detail info: (name, data_type, nullable, default_value) diff --git a/src-tauri/src/drivers/pgsql/query_execution/streaming.rs b/src-tauri/src/drivers/pgsql/query_execution/streaming.rs index 1b0880d..9e362fb 100644 --- a/src-tauri/src/drivers/pgsql/query_execution/streaming.rs +++ b/src-tauri/src/drivers/pgsql/query_execution/streaming.rs @@ -20,7 +20,6 @@ pub enum QueryStreamEvent { /// Maximum rows to send to the frontend to prevent OOM in the webview. const MAX_STREAM_ROWS: usize = 500_000; -/// Rows fetched per cursor FETCH round-trip. const CURSOR_FETCH_SIZE: usize = 10_000; /// Stream query results using a PostgreSQL cursor. @@ -86,7 +85,6 @@ pub async fn execute_query_streamed( break; } - // Emit columns on first batch if !columns_sent && let Some(cols) = batch_columns { let header = join_sep(&cols, CELL_SEP); let _ = app.emit( @@ -109,7 +107,6 @@ pub async fn execute_query_streamed( } } - // No rows at all if !columns_sent { let _ = app.emit( &event_name, @@ -120,7 +117,6 @@ pub async fn execute_query_streamed( ); } - // Clean up cursor + transaction client.batch_execute("CLOSE _rsql_cur").await.ok(); client.batch_execute("COMMIT").await.ok(); diff --git a/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs b/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs index ccc006c..83caeec 100644 --- a/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs +++ b/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs @@ -28,7 +28,6 @@ pub async fn execute_virtual( let (columns, all_rows) = process_simple_messages(messages); - // Non-SELECT or empty result if columns.is_empty() { let elapsed = start.elapsed().as_millis() as f32; return Ok((String::new(), 0, String::new(), elapsed)); @@ -62,7 +61,6 @@ pub async fn execute_virtual( let columns_packed = join_sep(&columns, CELL_SEP); let first_page_packed = pages.first().cloned().unwrap_or_default(); - // Store pre-packed pages in cache { let mut c = cache.lock().await; c.insert(query_id.to_string(), CachedQuery { pages, page_size }); diff --git a/src-tauri/src/drivers/pgsql/roles_schema_objects/schema_objects.rs b/src-tauri/src/drivers/pgsql/roles_schema_objects/schema_objects.rs index 01332f8..0a3da18 100644 --- a/src-tauri/src/drivers/pgsql/roles_schema_objects/schema_objects.rs +++ b/src-tauri/src/drivers/pgsql/roles_schema_objects/schema_objects.rs @@ -13,7 +13,6 @@ pub async fn extract_schema_objects( ) -> Result, AppError> { let mut objects = Vec::new(); - // Tables with columns let rows = client .query( "SELECT c.relname, @@ -42,7 +41,6 @@ pub async fn extract_schema_objects( }); } - // Views let rows = client .query( "SELECT viewname, definition FROM pg_views WHERE schemaname = $1 ORDER BY viewname", @@ -59,7 +57,6 @@ pub async fn extract_schema_objects( }); } - // Materialized views let rows = client .query( "SELECT matviewname, definition FROM pg_matviews WHERE schemaname = $1 ORDER BY matviewname", @@ -76,7 +73,6 @@ pub async fn extract_schema_objects( }); } - // Functions let rows = client .query( "SELECT p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', @@ -97,7 +93,6 @@ pub async fn extract_schema_objects( }); } - // Indexes let rows = client .query( "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 ORDER BY indexname", diff --git a/src-tauri/src/drivers/pgsql/statistics_activity/database.rs b/src-tauri/src/drivers/pgsql/statistics_activity/database.rs index a592fd2..577b5f8 100644 --- a/src-tauri/src/drivers/pgsql/statistics_activity/database.rs +++ b/src-tauri/src/drivers/pgsql/statistics_activity/database.rs @@ -4,7 +4,6 @@ use crate::common::enums::AppError; use super::super::DbStat; -/// Load pg_stat_activity - active connections and queries. pub async fn load_activity(client: &Client) -> Result>, AppError> { let rows = client .query( @@ -33,7 +32,6 @@ pub async fn load_activity(client: &Client) -> Result>, AppError .collect()) } -/// Load pg_stat_database - database-level stats. pub async fn load_database_stats(client: &Client) -> Result, AppError> { let rows = client .query( @@ -87,7 +85,6 @@ pub async fn load_database_stats(client: &Client) -> Result, AppErro .collect()) } -/// Load pg_stat_user_tables - table-level stats. pub async fn load_table_stats(client: &Client) -> Result>, AppError> { let rows = client .query( diff --git a/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs b/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs index ce227fe..8f2606d 100644 --- a/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs +++ b/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs @@ -4,7 +4,6 @@ use crate::common::enums::AppError; use super::super::{FKDetail, ForeignKeyInfo, ObjectStats}; -/// Load live statistics for a table. pub async fn load_table_statistics( client: &Client, schema: &str, @@ -64,7 +63,6 @@ pub async fn load_table_statistics( } } -/// Load outgoing or incoming FK details for a table. pub async fn load_fk_details( client: &Client, schema: &str, @@ -128,7 +126,6 @@ pub async fn load_fk_details( .collect()) } -/// Load all foreign key relationships for a given schema. pub async fn load_foreign_keys( client: &Client, schema: &str, diff --git a/src-tauri/src/ssh.rs b/src-tauri/src/ssh.rs index b45d4e8..0e55135 100644 --- a/src-tauri/src/ssh.rs +++ b/src-tauri/src/ssh.rs @@ -47,7 +47,6 @@ async fn connect_ssh( .await .map_err(|e| format!("SSH connection to {}:{} failed: {}", ssh_host, ssh_port, e))?; - // Try key file first if let Some(key_path) = ssh_key_path { if !key_path.is_empty() { match keys::load_secret_key(key_path, ssh_password) { @@ -68,7 +67,6 @@ async fn connect_ssh( } } - // Then password if let Some(password) = ssh_password { if !password.is_empty() { let result = handle diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index b6ee19f..0ebe428 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -54,7 +54,6 @@ pub async fn terminal_spawn( .await .insert(id.clone(), session); - // Spawn reader thread to emit events let terminal_id = id.clone(); std::thread::spawn(move || { let mut buf = [0u8; 4096]; diff --git a/src/App.tsx b/src/App.tsx index 25b05ca..16eda41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,7 +45,6 @@ export default function App() { const activeTab = useActiveTab(); const updateContent = useTabStore((s) => s.updateContent); - // Edit connection state const [editingConnection, setEditingConnection] = useState<{ name: string; details: ProjectDetails } | null>(null); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); diff --git a/src/components/command-palette/index.tsx b/src/components/command-palette/index.tsx index 2cdc6e4..0e457f5 100644 --- a/src/components/command-palette/index.tsx +++ b/src/components/command-palette/index.tsx @@ -67,7 +67,6 @@ export function CommandPalette({ } }, [open, workspacesLoaded, loadWorkspaces]); - // Close on Escape at root page useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { @@ -87,7 +86,6 @@ export function CommandPalette({ return () => window.removeEventListener("keydown", handler); }, [open, page, onClose]); - // Click outside to close useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { @@ -207,7 +205,6 @@ export function CommandPalette({ No results found - {/* Actions */} - {/* Connections */} - {/* Workspaces */} - {/* Database objects */} = {}; headers.forEach((h, i) => { const match = tableColumns.find( diff --git a/src/components/editor-toolbar.tsx b/src/components/editor-toolbar.tsx index f4a3fa1..19fb410 100644 --- a/src/components/editor-toolbar.tsx +++ b/src/components/editor-toolbar.tsx @@ -65,7 +65,6 @@ export function EditorToolbar({ } }; - // Only show for query tabs if (!activeTab || activeTab.type !== "query") return null; const hasContent = !!activeTab.editorValue?.trim(); @@ -167,7 +166,6 @@ export function EditorToolbar({ )}
- {/* Save Query Dialog */} diff --git a/src/components/enums-panel.tsx b/src/components/enums-panel.tsx index 8b1f62f..c1f898b 100644 --- a/src/components/enums-panel.tsx +++ b/src/components/enums-panel.tsx @@ -40,7 +40,6 @@ export function EnumsPanel({ projectId }: { projectId: string }) { e.name.toLowerCase().includes(lowerFilter) || e.labels.toLowerCase().includes(lowerFilter) || e.schema.toLowerCase().includes(lowerFilter) ); - // Group by schema const grouped = new Map(); for (const e of filtered) { if (!grouped.has(e.schema)) grouped.set(e.schema, []); diff --git a/src/components/erd-diagram/index.tsx b/src/components/erd-diagram/index.tsx index a3aadbc..5cfce2a 100644 --- a/src/components/erd-diagram/index.tsx +++ b/src/components/erd-diagram/index.tsx @@ -44,7 +44,6 @@ export function ERDDiagram({ projectId, schema }: ERDProps) { const key = `${projectId}::${schema}`; const schemaTables = tables[key] ?? []; - // Load tables and FK data useEffect(() => { let cancelled = false; setLoading(true); @@ -60,7 +59,6 @@ export function ERDDiagram({ projectId, schema }: ERDProps) { const driver = DriverFactory.getDriver(d.driver); - // Step 1: load tables + FKs in parallel let loadedFks: ForeignKey[] = []; try { const [, fkResult] = await Promise.allSettled([ @@ -79,14 +77,13 @@ export function ERDDiagram({ projectId, schema }: ERDProps) { if (cancelled) return; setFks(loadedFks); - // Step 2: get the current tables from the store const currentTables = useProjectStore.getState().tables[`${projectId}::${schema}`] ?? []; if (currentTables.length === 0) { setLoading(false); return; } - // Step 3: load column details + indexes for all tables (fire-and-forget) + // Fire-and-forget: column details + indexes for each table const detailPromises = currentTables.map((t) => { const detailKey = `${projectId}::${schema}::${t.name}`; const state = useProjectStore.getState(); @@ -112,13 +109,11 @@ export function ERDDiagram({ projectId, schema }: ERDProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, schema]); - // Derive whether we have enough detail data to render const detailsReady = schemaTables.length === 0 || schemaTables.some((t) => { const detailKey = `${projectId}::${schema}::${t.name}`; return columnDetails[detailKey] != null; }); - // Build ERD table data with column types and PK/FK info const tableData = useMemo(() => { if (!detailsReady) return []; @@ -148,7 +143,6 @@ export function ERDDiagram({ projectId, schema }: ERDProps) { const initialBoxes = useMemo(() => layoutTables(tableData, fks), [tableData, fks]); - // Apply custom positions from dragging const boxes = useMemo(() => { if (tablePositions.size === 0) return initialBoxes; return initialBoxes.map((b) => { @@ -162,7 +156,6 @@ export function ERDDiagram({ projectId, schema }: ERDProps) { const totalWidth = Math.max(800, ...boxes.map((b) => b.x + b.width + 60)); const totalHeight = Math.max(600, ...boxes.map((b) => b.y + b.height + 60)); - // Get connected tables for highlighting const { connectedTables, connectedFKs } = useTableDetails(hoveredTable, fks); const handleWheel = useCallback(createHandleWheel(setZoom), []); @@ -222,13 +215,10 @@ export function ERDDiagram({ projectId, schema }: ERDProps) { return (
- {/* Zoom controls */} - {/* Status indicator */} - {/* SVG Canvas */}
- {/* Grid dots background */} - {/* FK relationship lines */} - {/* Table boxes */} >(); for (const t of tables) adj.set(t.name, new Set()); for (const fk of fks) { @@ -32,7 +31,6 @@ export function layoutTables( adj.get(fk.targetTable)?.add(fk.sourceTable); } - // Sort: most connected tables first, then alphabetically const sorted = [...tables].sort((a, b) => { const ac = adj.get(a.name)?.size ?? 0; const bc = adj.get(b.name)?.size ?? 0; @@ -50,7 +48,6 @@ export function layoutTables( for (let i = 0; i < gridCols; i++) { colXOffsets.push(currentX); - // Estimate width for this column based on tables that will go here const colTables = sorted.filter((_, idx) => idx % gridCols === i); const maxWidth = colTables.reduce((max, t) => { const w = measureTableWidth(t.name, t.columns); diff --git a/src/components/erd-diagram/table-details.tsx b/src/components/erd-diagram/table-details.tsx index ea257fa..13572be 100644 --- a/src/components/erd-diagram/table-details.tsx +++ b/src/components/erd-diagram/table-details.tsx @@ -1,14 +1,7 @@ import { useMemo } from "react"; import type { ForeignKey } from "./types"; -/** - * Derives connection metadata for the currently hovered table. - * - * Returns the set of table names connected to the hovered table by a - * foreign key (in either direction) and the set of FK indices that - * touch the hovered table — used by the renderer to highlight related - * tables and dim everything else. - */ +// FKs are matched in both directions so highlighting works for either endpoint. export function useTableDetails(hoveredTable: string | null, fks: ForeignKey[]) { const connectedTables = useMemo(() => { if (!hoveredTable) return new Set(); diff --git a/src/components/object-properties-modal/index.tsx b/src/components/object-properties-modal/index.tsx index f992179..8f9e8df 100644 --- a/src/components/object-properties-modal/index.tsx +++ b/src/components/object-properties-modal/index.tsx @@ -55,7 +55,6 @@ export function ObjectPropertiesModal({ fetchDDL, } = useObjectData(projectId, schema, name, objectType, open); - // Cached metadata from store const columnDetails = useProjectStore((s) => s.columnDetails); const indexes = useProjectStore((s) => s.indexes); const constraints = useProjectStore((s) => s.constraints); @@ -74,7 +73,6 @@ export function ObjectPropertiesModal({ (idxs ?? []).filter((i) => i.isPrimary).map((i) => i.columnName), ); - // Reset UI state on open useEffect(() => { if (!open) return; setActiveTab(objectType === "table" ? "overview" : "overview"); @@ -104,7 +102,6 @@ export function ObjectPropertiesModal({ try { const msg = await driver.tableAction(projectId, actionLabel, schema, name, objectType); setActionResult({ type: "success", message: msg }); - // Refresh stats void fetchLiveData(); } catch (err: any) { setActionResult({ @@ -119,7 +116,6 @@ export function ObjectPropertiesModal({ [getDriver, projectId, schema, name, objectType, fetchLiveData], ); - // Build available tabs based on object type const availableTabs: { key: Tab; label: string }[] = []; availableTabs.push({ key: "overview", label: "Overview" }); if (objectType === "table") { @@ -153,7 +149,6 @@ export function ObjectPropertiesModal({ setActiveTab={setActiveTab} /> - {/* Content */}
{ void fetchLiveData(); - // Invalidate cached metadata so it re-fetches useProjectStore.setState((s) => { delete s.columnDetails[metaKey]; delete s.indexes[metaKey]; diff --git a/src/components/object-properties-modal/structure-editor/columns-section.tsx b/src/components/object-properties-modal/structure-editor/columns-section.tsx index a9dc691..3c7396b 100644 --- a/src/components/object-properties-modal/structure-editor/columns-section.tsx +++ b/src/components/object-properties-modal/structure-editor/columns-section.tsx @@ -11,7 +11,6 @@ export function ColumnsSection({ draft: StructureEditorState; setDraft: React.Dispatch>; }) { - // Column helpers const updateColumn = (id: string, updates: Partial) => { setDraft((prev) => ({ ...prev, @@ -75,7 +74,6 @@ export function ColumnsSection({ return (
- {/* Header */}
Name Type @@ -166,7 +164,6 @@ export function ColumnsSection({ > Add Column - {/* HTML datalist for type suggestions */} {PG_COMMON_TYPES.map((t) => (
{csvHeaders.map((h, i) => ( - + ))} @@ -131,7 +152,12 @@ export function CSVImportModal({ open: isOpen, onOpenChange, projectId, schema, {previewRows.map((row, ri) => ( {row.map((cell, ci) => ( - + ))} ))} @@ -144,11 +170,15 @@ export function CSVImportModal({ open: isOpen, onOpenChange, projectId, schema, {/* Column mapping */} {csvHeaders.length > 0 && (
-
Column Mapping
+
+ Column Mapping +
{csvHeaders.map((h, i) => (
- {h} + + {h} +
@@ -168,18 +200,31 @@ export function CSVImportModal({ open: isOpen, onOpenChange, projectId, schema, {/* Result message */} {result && ( -
- {result.success ? : } +
+ {result.success ? ( + + ) : ( + + )} {result.message}
)} {/* Actions */}
- {!result?.success && ( diff --git a/src/components/editor-toolbar.tsx b/src/components/editor-toolbar.tsx index 19fb410..6767b26 100644 --- a/src/components/editor-toolbar.tsx +++ b/src/components/editor-toolbar.tsx @@ -1,12 +1,18 @@ -import { useState, useRef, useEffect } from "react"; +import { AlignLeft, Columns2, GitBranch, Play, Save, Square, Timer } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { format as formatSQL } from "sql-formatter"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { useProjectStore } from "@/stores/project-store"; -import { useTabStore, useActiveTab } from "@/stores/tab-store"; import { useQueryStore } from "@/stores/query-store"; -import { AlignLeft, Columns2, GitBranch, Play, Save, Square, Timer } from "lucide-react"; -import { format as formatSQL } from "sql-formatter"; +import { useActiveTab, useTabStore } from "@/stores/tab-store"; const TIMEOUT_OPTIONS = [ { label: "No limit", value: 0 }, @@ -49,7 +55,13 @@ export function EditorToolbar({ const handleSaveSubmit = async () => { if (!activeProject || !activeProjectDetails || !saveTitle.trim()) return; - await saveQueryAction(activeProject, activeProjectDetails.database, activeProjectDetails.driver, saveTitle.trim(), activeTab?.editorValue ?? ""); + await saveQueryAction( + activeProject, + activeProjectDetails.database, + activeProjectDetails.driver, + saveTitle.trim(), + activeTab?.editorValue ?? "", + ); setSaveTitle(""); setSaveDialogOpen(false); }; @@ -58,7 +70,11 @@ export function EditorToolbar({ const sql = activeTab?.editorValue; if (!sql?.trim()) return; try { - const formatted = formatSQL(sql, { language: "postgresql", tabWidth: 2, keywordCase: "upper" }); + const formatted = formatSQL(sql, { + language: "postgresql", + tabWidth: 2, + keywordCase: "upper", + }); updateContent(selectedTabIndex, formatted); } catch { // silently ignore formatting errors @@ -180,8 +196,11 @@ export function EditorToolbar({ className="space-y-4 mt-2" >
- + setSaveTitle(e.target.value)} @@ -190,13 +209,26 @@ export function EditorToolbar({ />
-
{activeTab?.editorValue?.slice(0, 300)}{(activeTab?.editorValue?.length ?? 0) > 300 ? "..." : ""}
+
+                {activeTab?.editorValue?.slice(0, 300)}
+                {(activeTab?.editorValue?.length ?? 0) > 300 ? "..." : ""}
+              
- - diff --git a/src/components/enums-panel.tsx b/src/components/enums-panel.tsx index c1f898b..337ea4b 100644 --- a/src/components/enums-panel.tsx +++ b/src/components/enums-panel.tsx @@ -1,9 +1,9 @@ -import { useState, useEffect, useCallback } from "react"; -import { DriverFactory } from "@/lib/database-driver"; -import { useProjectStore } from "@/stores/project-store"; import { List, Loader2, RefreshCw } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { DriverFactory } from "@/lib/database-driver"; +import { useProjectStore } from "@/stores/project-store"; interface EnumType { schema: string; @@ -33,17 +33,22 @@ export function EnumsPanel({ projectId }: { projectId: string }) { } }, [projectId, details]); - useEffect(() => { void refresh(); }, [refresh]); + useEffect(() => { + void refresh(); + }, [refresh]); const lowerFilter = filter.toLowerCase(); - const filtered = enums.filter((e) => - e.name.toLowerCase().includes(lowerFilter) || e.labels.toLowerCase().includes(lowerFilter) || e.schema.toLowerCase().includes(lowerFilter) + const filtered = enums.filter( + (e) => + e.name.toLowerCase().includes(lowerFilter) || + e.labels.toLowerCase().includes(lowerFilter) || + e.schema.toLowerCase().includes(lowerFilter), ); const grouped = new Map(); for (const e of filtered) { if (!grouped.has(e.schema)) grouped.set(e.schema, []); - grouped.get(e.schema)!.push(e); + grouped.get(e.schema)?.push(e); } return ( @@ -52,7 +57,9 @@ export function EnumsPanel({ projectId }: { projectId: string }) {
Enum Types - {details?.database ?? projectId} + + {details?.database ?? projectId} +
-
@@ -70,7 +87,9 @@ export function EnumsPanel({ projectId }: { projectId: string }) {
{Array.from(grouped.entries()).map(([schema, types]) => (
-
{schema}
+
+ {schema} +
{types.map((e) => (
diff --git a/src/components/erd-diagram/index.tsx b/src/components/erd-diagram/index.tsx index 5cfce2a..b3edf72 100644 --- a/src/components/erd-diagram/index.tsx +++ b/src/components/erd-diagram/index.tsx @@ -1,33 +1,32 @@ -import { useState, useEffect, useRef, useMemo, useCallback } from "react"; -import { useProjectStore } from "@/stores/project-store"; +import { Loader2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DriverFactory } from "@/lib/database-driver"; +import { useProjectStore } from "@/stores/project-store"; import type { ColumnDetail, IndexDetail } from "@/types"; -import { Loader2 } from "lucide-react"; -import type { ERDProps, ERDColumn, ForeignKey } from "./types"; -import { layoutTables } from "./layout"; import { - createHandleWheel, createHandleMouseDown, createHandleMouseMove, createHandleMouseUp, - ERDToolbar, + createHandleWheel, ERDStatusBar, + ERDToolbar, } from "./interactions"; +import { layoutTables } from "./layout"; +import { ERDDefs, ERDFKLines, ERDGridBackground, ERDTableBoxes } from "./rendering"; import { useTableDetails } from "./table-details"; -import { - ERDDefs, - ERDGridBackground, - ERDFKLines, - ERDTableBoxes, -} from "./rendering"; +import type { ERDColumn, ERDProps, ForeignKey } from "./types"; export function ERDDiagram({ projectId, schema }: ERDProps) { const [fks, setFks] = useState([]); const [loading, setLoading] = useState(true); - const [tablePositions, setTablePositions] = useState>(new Map()); + const [tablePositions, setTablePositions] = useState>( + new Map(), + ); const [pan, setPan] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); - const [dragging, setDragging] = useState<{ type: "pan" | "table"; tableName?: string } | null>(null); + const [dragging, setDragging] = useState<{ type: "pan" | "table"; tableName?: string } | null>( + null, + ); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [hoveredTable, setHoveredTable] = useState(null); const containerRef = useRef(null); @@ -105,14 +104,18 @@ export function ERDDiagram({ projectId, schema }: ERDProps) { } load(); - return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, schema]); - - const detailsReady = schemaTables.length === 0 || schemaTables.some((t) => { - const detailKey = `${projectId}::${schema}::${t.name}`; - return columnDetails[detailKey] != null; - }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId, schema, loadColumnDetails, loadTables, loadIndexes]); + + const detailsReady = + schemaTables.length === 0 || + schemaTables.some((t) => { + const detailKey = `${projectId}::${schema}::${t.name}`; + return columnDetails[detailKey] != null; + }); const tableData = useMemo(() => { if (!detailsReady) return []; @@ -162,12 +165,12 @@ export function ERDDiagram({ projectId, schema }: ERDProps) { const handleMouseDown = useCallback( createHandleMouseDown(pan, zoom, boxMap, setDragging, setDragStart), - [pan, zoom, boxMap], + [], ); const handleMouseMove = useCallback( createHandleMouseMove(dragging, dragStart, zoom, setPan, setTablePositions), - [dragging, dragStart, zoom], + [], ); const handleMouseUp = useCallback(createHandleMouseUp(setDragging), []); diff --git a/src/components/erd-diagram/interactions.tsx b/src/components/erd-diagram/interactions.tsx index c0be456..1eb9b5f 100644 --- a/src/components/erd-diagram/interactions.tsx +++ b/src/components/erd-diagram/interactions.tsx @@ -1,4 +1,4 @@ -import { ZoomIn, ZoomOut, Maximize, Download } from "lucide-react"; +import { Download, Maximize, ZoomIn, ZoomOut } from "lucide-react"; import type { TableBox } from "./types"; export type DragState = { type: "pan" | "table"; tableName?: string } | null; @@ -14,6 +14,7 @@ export function ERDToolbar({ setZoom, fitToView, exportSVG }: ERDToolbarProps) { return (
- -
@@ -155,11 +205,16 @@ export function ExtensionsPanel({ projectId }: { projectId: string }) { {tab === "installed" && (
{filteredInstalled.map((ext) => ( -
+
{ext.name} - {ext.installedVersion} + + {ext.installedVersion} + {ext.defaultVersion && ext.defaultVersion !== ext.installedVersion && ( )} - {ext.schema} + + {ext.schema} +
@@ -202,11 +267,16 @@ export function ExtensionsPanel({ projectId }: { projectId: string }) { {tab === "available" && (
{filteredAvailable.map((ext) => ( -
+
{ext.name} - {ext.version} + + {ext.version} +
@@ -232,39 +306,64 @@ export function ExtensionsPanel({ projectId }: { projectId: string }) { )}
- { if (!open) setConfirmInstall(null); }}> + { + if (!open) setConfirmInstall(null); + }} + > Install Extension - Install {confirmInstall} into the current database? + Install{" "} + {confirmInstall} into + the current database?
CREATE EXTENSION IF NOT EXISTS "{confirmInstall}";
- - + +
- { if (!open) setConfirmDrop(null); }}> + { + if (!open) setConfirmDrop(null); + }} + > Drop Extension - Are you sure you want to drop {confirmDrop}? - This will also drop all objects that depend on it (CASCADE). + Are you sure you want to drop{" "} + {confirmDrop}? This + will also drop all objects that depend on it (CASCADE).
DROP EXTENSION IF EXISTS "{confirmDrop}" CASCADE;
- - + +
diff --git a/src/components/notify-panel.tsx b/src/components/notify-panel.tsx index 307bffe..5994214 100644 --- a/src/components/notify-panel.tsx +++ b/src/components/notify-panel.tsx @@ -1,12 +1,11 @@ -import { useState, useEffect, useRef, useCallback } from "react"; import { listen } from "@tauri-apps/api/event"; +import { Bell, BellOff, Radio, Send, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { DriverFactory } from "@/lib/database-driver"; +import { cn } from "@/lib/utils"; import { useProjectStore } from "@/stores/project-store"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; -import { Bell, BellOff, Send, Trash2, Radio } from "lucide-react"; -import { cn } from "@/lib/utils"; - interface Notification { channel: string; @@ -35,7 +34,10 @@ export function NotifyPanel({ projectId }: NotifyPanelProps) { // Discover available channels from trigger functions useEffect(() => { if (!driver) return; - driver.discoverChannels?.(projectId).then(setKnownChannels).catch(() => {}); + driver + .discoverChannels?.(projectId) + .then(setKnownChannels) + .catch(() => {}); }, [driver, projectId]); useEffect(() => { @@ -64,7 +66,7 @@ export function NotifyPanel({ projectId }: NotifyPanelProps) { if (listRef.current) { listRef.current.scrollTop = listRef.current.scrollHeight; } - }, [notifications]); + }, []); const subscribe = useCallback(async () => { const ch = newChannel.trim(); @@ -95,7 +97,7 @@ export function NotifyPanel({ projectId }: NotifyPanelProps) { driver?.listenStop?.(projectId, ch).catch(() => {}); }); }; - }, []); // eslint-disable-line + }, [projectId, driver?.listenStop]); // eslint-disable-line return (
@@ -111,12 +113,19 @@ export function NotifyPanel({ projectId }: NotifyPanelProps) { /> {knownChannels.length > 0 && ( - {knownChannels.filter((c) => !channels.includes(c)).map((c) => ( - )} -
@@ -125,12 +134,15 @@ export function NotifyPanel({ projectId }: NotifyPanelProps) { {knownChannels.length > 0 && (
- Available: + + Available: + {knownChannels.map((ch) => { const isActive = channels.includes(ch); return ( -
diff --git a/src/components/object-properties-modal/actions-tab.tsx b/src/components/object-properties-modal/actions-tab.tsx index 3f07435..7e6a606 100644 --- a/src/components/object-properties-modal/actions-tab.tsx +++ b/src/components/object-properties-modal/actions-tab.tsx @@ -1,14 +1,6 @@ +import { AlertTriangle, Check, Key, Loader2, Play, RefreshCw, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { - AlertTriangle, - Check, - Key, - Loader2, - Play, - RefreshCw, - Trash2, -} from "lucide-react"; import type { ObjectType } from "./types"; export function ActionsContent({ @@ -52,28 +44,100 @@ export function ActionsContent({ if (objectType === "table") { actions.push( - { label: "ANALYZE", icon: , confirm: true, description: "Update table statistics for the query planner." }, - { label: "VACUUM", icon: , confirm: true, description: "Reclaim storage occupied by dead tuples." }, - { label: "VACUUM FULL", icon: , confirm: true, description: "Rewrite table to reclaim max space. Locks table exclusively." }, - { label: "REINDEX", icon: , confirm: true, description: "Rebuild all indexes on this table." }, - { label: "TRUNCATE", icon: , destructive: true, confirm: true, description: "Remove all rows. Cannot be rolled back." }, - { label: "DROP TABLE", icon: , destructive: true, confirm: true, description: "Permanently delete this table and all its data." }, + { + label: "ANALYZE", + icon: , + confirm: true, + description: "Update table statistics for the query planner.", + }, + { + label: "VACUUM", + icon: , + confirm: true, + description: "Reclaim storage occupied by dead tuples.", + }, + { + label: "VACUUM FULL", + icon: , + confirm: true, + description: "Rewrite table to reclaim max space. Locks table exclusively.", + }, + { + label: "REINDEX", + icon: , + confirm: true, + description: "Rebuild all indexes on this table.", + }, + { + label: "TRUNCATE", + icon: , + destructive: true, + confirm: true, + description: "Remove all rows. Cannot be rolled back.", + }, + { + label: "DROP TABLE", + icon: , + destructive: true, + confirm: true, + description: "Permanently delete this table and all its data.", + }, ); } else if (objectType === "view") { actions.push( - { label: "DROP VIEW", icon: , destructive: true, confirm: true, description: "Permanently delete this view." }, - { label: "DROP VIEW CASCADE", icon: , destructive: true, confirm: true, description: "Drop view and all dependent objects." }, + { + label: "DROP VIEW", + icon: , + destructive: true, + confirm: true, + description: "Permanently delete this view.", + }, + { + label: "DROP VIEW CASCADE", + icon: , + destructive: true, + confirm: true, + description: "Drop view and all dependent objects.", + }, ); } else if (objectType === "matview") { actions.push( - { label: "REFRESH", icon: , confirm: true, description: "Refresh data by re-executing the query." }, - { label: "REFRESH CONCURRENTLY", icon: , confirm: true, description: "Refresh without locking reads. Requires a unique index." }, - { label: "DROP MATERIALIZED VIEW", icon: , destructive: true, confirm: true, description: "Permanently delete this materialized view." }, + { + label: "REFRESH", + icon: , + confirm: true, + description: "Refresh data by re-executing the query.", + }, + { + label: "REFRESH CONCURRENTLY", + icon: , + confirm: true, + description: "Refresh without locking reads. Requires a unique index.", + }, + { + label: "DROP MATERIALIZED VIEW", + icon: , + destructive: true, + confirm: true, + description: "Permanently delete this materialized view.", + }, ); } else if (objectType === "function" || objectType === "trigger-function") { actions.push( - { label: "DROP FUNCTION", icon: , destructive: true, confirm: true, description: "Permanently delete this function." }, - { label: "DROP FUNCTION CASCADE", icon: , destructive: true, confirm: true, description: "Drop function and all dependent objects (triggers, etc.)." }, + { + label: "DROP FUNCTION", + icon: , + destructive: true, + confirm: true, + description: "Permanently delete this function.", + }, + { + label: "DROP FUNCTION CASCADE", + icon: , + destructive: true, + confirm: true, + description: "Drop function and all dependent objects (triggers, etc.).", + }, ); } @@ -113,10 +177,7 @@ export function ActionsContent({ size="sm" className="h-7 text-xs gap-1.5" onClick={() => { - openTab( - projectId, - `SELECT * FROM ${qualified} ORDER BY 1 DESC LIMIT 10;`, - ); + openTab(projectId, `SELECT * FROM ${qualified} ORDER BY 1 DESC LIMIT 10;`); onOpenChange(false); }} > @@ -164,9 +225,7 @@ export function ActionsContent({ {action.icon} @@ -180,9 +239,7 @@ export function ActionsContent({ > {action.label}
-
- {action.description} -
+
{action.description}
{confirmAction !== action.label && (
+ - + ))} diff --git a/src/components/object-properties-modal/ddl-tab.tsx b/src/components/object-properties-modal/ddl-tab.tsx index 6a95388..64c9c55 100644 --- a/src/components/object-properties-modal/ddl-tab.tsx +++ b/src/components/object-properties-modal/ddl-tab.tsx @@ -1,12 +1,5 @@ +import { AlertTriangle, Check, Copy, FileCode, Play, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - AlertTriangle, - Check, - Copy, - FileCode, - Play, - RefreshCw, -} from "lucide-react"; import { LoadingPlaceholder } from "./shared"; export function DDLContent({ @@ -37,12 +30,7 @@ export function DDLContent({

{ddlError}

- @@ -67,9 +55,7 @@ export function DDLContent({
- - DDL - + DDL
- + + - - + + ))} @@ -120,11 +113,10 @@ export function ForeignKeysContent({ key={i} className="border-t border-border/15 hover:bg-primary/[0.04] transition-colors" > - + - + ))} diff --git a/src/components/object-properties-modal/index.tsx b/src/components/object-properties-modal/index.tsx index 8f9e8df..e57b36d 100644 --- a/src/components/object-properties-modal/index.tsx +++ b/src/components/object-properties-modal/index.tsx @@ -1,8 +1,8 @@ +import { useCallback, useEffect, useState } from "react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import { useProjectStore } from "@/stores/project-store"; import { useTabStore } from "@/stores/tab-store"; -import { useCallback, useEffect, useState } from "react"; import { ActionsContent } from "./actions-tab"; import { ColumnsContent } from "./columns-tab"; import { DDLContent } from "./ddl-tab"; @@ -69,9 +69,7 @@ export function ObjectPropertiesModal({ const trigs = triggers[metaKey]; const rls = rules[metaKey]; const pols = policies[metaKey]; - const pkCols = new Set( - (idxs ?? []).filter((i) => i.isPrimary).map((i) => i.columnName), - ); + const pkCols = new Set((idxs ?? []).filter((i) => i.isPrimary).map((i) => i.columnName)); useEffect(() => { if (!open) return; @@ -79,7 +77,7 @@ export function ObjectPropertiesModal({ setCopied(null); setActionResult(null); setConfirmAction(null); - }, [open, objectType, projectId, schema, name]); + }, [open, objectType]); useEffect(() => { if (open && activeTab === "ddl" && !ddl && !ddlLoading) { @@ -194,9 +192,7 @@ export function ObjectPropertiesModal({ copied={copied} /> )} - {activeTab === "columns" && ( - - )} + {activeTab === "columns" && } {activeTab === "indexes" && } {activeTab === "fkeys" && ( ; } @@ -22,7 +18,7 @@ export function IndexesContent({ const grouped = new Map(); for (const idx of idxs) { if (!grouped.has(idx.indexName)) grouped.set(idx.indexName, []); - grouped.get(idx.indexName)!.push(idx); + grouped.get(idx.indexName)?.push(idx); } return ( @@ -60,9 +56,7 @@ export function IndexesContent({ )} - + diff --git a/src/components/object-properties-modal/modal-header.tsx b/src/components/object-properties-modal/modal-header.tsx index 581be4c..ece8284 100644 --- a/src/components/object-properties-modal/modal-header.tsx +++ b/src/components/object-properties-modal/modal-header.tsx @@ -1,9 +1,3 @@ -import { - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { cn } from "@/lib/utils"; import { Check, Columns3, @@ -19,6 +13,8 @@ import { Table, Zap, } from "lucide-react"; +import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; import type { ObjectType, Tab } from "./types"; const objectIcon: Record = { @@ -79,12 +75,7 @@ export function ModalHeader({ setActiveTab: (tab: Tab) => void; }) { return ( -
+
@@ -95,6 +86,7 @@ export function ModalHeader({ {name}
@@ -126,6 +116,7 @@ export function ModalHeader({ {availableTabs.map((tab) => (
{/* Scan stats */} - } - > + }>
Sequential Scans
-
- {Number(tableStats.seqScan).toLocaleString()} -
+
{Number(tableStats.seqScan).toLocaleString()}
-
- Index Scans -
-
- {Number(tableStats.idxScan).toLocaleString()} -
+
Index Scans
+
{Number(tableStats.idxScan).toLocaleString()}
{/* Maintenance */} - } - > + }>
- - - + + + 0 && ( - } - > + }>
- {Array.from(new Set(cons.map((c) => c.constraintName))).map( - (cName) => { - const f = cons.find((c) => c.constraintName === cName)!; - const entries = cons.filter( - (c) => c.constraintName === cName, - ); - return ( -
- - {cName} - - {f.constraintType} - - - ({entries.map((e) => e.columnName).join(", ")}) - -
- ); - }, - )} + {Array.from(new Set(cons.map((c) => c.constraintName))).map((cName) => { + const f = cons.find((c) => c.constraintName === cName)!; + const entries = cons.filter((c) => c.constraintName === cName); + return ( +
+ + {cName} + + {f.constraintType} + + + ({entries.map((e) => e.columnName).join(", ")}) + +
+ ); + })}
)} {/* Triggers */} {trigs && trigs.length > 0 && ( - } - > + }>
{trigs.map((t) => (
0 && ( - } - > + }>
{pols.map((p) => (
0 && ( - } - > + }>
{rls.map((r) => (
- ); + return ; } return ; diff --git a/src/components/object-properties-modal/overview-view.tsx b/src/components/object-properties-modal/overview-view.tsx index 0f6bd3d..febe4c2 100644 --- a/src/components/object-properties-modal/overview-view.tsx +++ b/src/components/object-properties-modal/overview-view.tsx @@ -1,16 +1,5 @@ -import { - Check, - Database, - Eye, - FileCode, - HardDrive, - Shield, -} from "lucide-react"; -import { - LoadingPlaceholder, - PropertySection, - StatCard, -} from "./shared"; +import { Check, Database, Eye, FileCode, HardDrive, Shield } from "lucide-react"; +import { LoadingPlaceholder, PropertySection, StatCard } from "./shared"; import type { MatViewStats, ViewInfo } from "./types"; export function ViewOverview({ @@ -36,10 +25,7 @@ export function ViewOverview({ icon={} />
- } - > + }>
             {viewInfo.definition}
           
@@ -69,10 +55,7 @@ export function ViewOverview({ icon={} />
- } - > + }>
             {matViewStats.definition}
           
diff --git a/src/components/object-properties-modal/shared.tsx b/src/components/object-properties-modal/shared.tsx index 08e74fc..72bf733 100644 --- a/src/components/object-properties-modal/shared.tsx +++ b/src/components/object-properties-modal/shared.tsx @@ -1,11 +1,5 @@ +import { Check, Key, Link2, Loader2, Shield } from "lucide-react"; import { cn } from "@/lib/utils"; -import { - Check, - Key, - Link2, - Loader2, - Shield, -} from "lucide-react"; export function StatCard({ label, @@ -47,9 +41,7 @@ export function InfoRow({ label, value }: { label: string; value: string }) { return (
{label} - - {value} - + {value}
); } @@ -80,14 +72,10 @@ export function PropertySection({ } export function ConstraintIcon({ type }: { type: string }) { - if (type === "PRIMARY KEY") - return ; - if (type === "FOREIGN KEY") - return ; - if (type === "UNIQUE") - return ; - if (type === "CHECK") - return ; + if (type === "PRIMARY KEY") return ; + if (type === "FOREIGN KEY") return ; + if (type === "UNIQUE") return ; + if (type === "CHECK") return ; return ; } @@ -107,7 +95,7 @@ export function formatTimestamp(ts: string): string { if (ts === "never") return "never"; try { const d = new Date(ts); - if (isNaN(d.getTime())) return ts; + if (Number.isNaN(d.getTime())) return ts; const now = Date.now(); const diff = now - d.getTime(); if (diff < 60000) return "just now"; diff --git a/src/components/object-properties-modal/structure-editor/columns-section.tsx b/src/components/object-properties-modal/structure-editor/columns-section.tsx index 3c7396b..4ad1dff 100644 --- a/src/components/object-properties-modal/structure-editor/columns-section.tsx +++ b/src/components/object-properties-modal/structure-editor/columns-section.tsx @@ -1,7 +1,7 @@ +import { Plus, RefreshCw, Trash2 } from "lucide-react"; import type { DraftColumn, StructureEditorState } from "@/lib/alter-table-sql"; import { PG_COMMON_TYPES } from "@/lib/alter-table-sql"; import { cn } from "@/lib/utils"; -import { Plus, RefreshCw, Trash2 } from "lucide-react"; import { uid } from "./initialization"; export function ColumnsSection({ @@ -53,11 +53,7 @@ export function ColumnsSection({ ...prev, columns: prev.columns .map((c) => - c._id === id - ? c._status === "added" - ? null - : { ...c, _status: "removed" as const } - : c, + c._id === id ? (c._status === "added" ? null : { ...c, _status: "removed" as const }) : c, ) .filter(Boolean) as DraftColumn[], })); @@ -66,9 +62,7 @@ export function ColumnsSection({ const restoreColumn = (id: string) => { setDraft((prev) => ({ ...prev, - columns: prev.columns.map((c) => - c._id === id ? { ...c, _status: "existing" as const } : c, - ), + columns: prev.columns.map((c) => (c._id === id ? { ...c, _status: "existing" as const } : c)), })); }; @@ -99,18 +93,14 @@ export function ColumnsSection({ type="text" value={col.name} disabled={col._status === "removed"} - onChange={(e) => - updateColumn(col._id, { name: e.target.value }) - } + onChange={(e) => updateColumn(col._id, { name: e.target.value })} className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 disabled:opacity-40" /> - updateColumn(col._id, { dataType: e.target.value }) - } + onChange={(e) => updateColumn(col._id, { dataType: e.target.value })} list="pg-types" className="h-7 px-2 text-xs font-mono bg-background border border-border/30 rounded-md outline-none focus:border-primary/50 disabled:opacity-40" /> @@ -119,9 +109,7 @@ export function ColumnsSection({ type="checkbox" checked={col.nullable} disabled={col._status === "removed"} - onChange={(e) => - updateColumn(col._id, { nullable: e.target.checked }) - } + onChange={(e) => updateColumn(col._id, { nullable: e.target.checked })} className="h-3.5 w-3.5 rounded border-border accent-primary" />
@@ -140,6 +128,7 @@ export function ColumnsSection({
{col._status === "removed" ? ( ) : (
))}
@@ -158,10 +161,13 @@ export function FKCard({ > {targetCols.map((c) => ( - + ))}
))}
))}
- {subTab === "columns" && ( - - )} + {subTab === "columns" && } {subTab === "pk" && ( 0 && (
- - SQL Preview - + SQL Preview
diff --git a/src/components/object-properties-modal/structure-editor/indexes-section.tsx b/src/components/object-properties-modal/structure-editor/indexes-section.tsx index 186ed45..fefd923 100644 --- a/src/components/object-properties-modal/structure-editor/indexes-section.tsx +++ b/src/components/object-properties-modal/structure-editor/indexes-section.tsx @@ -1,6 +1,6 @@ +import { Plus, Trash2 } from "lucide-react"; import type { DraftIndex, StructureEditorState } from "@/lib/alter-table-sql"; import { cn } from "@/lib/utils"; -import { Plus, Trash2 } from "lucide-react"; import { uid } from "./initialization"; export function IndexesSection({ @@ -36,9 +36,7 @@ export function IndexesSection({ setDraft((prev) => ({ ...prev, indexes: prev.indexes.map((i) => - i._id === idx._id - ? { ...i, indexName: e.target.value } - : i, + i._id === idx._id ? { ...i, indexName: e.target.value } : i, ), })); }} @@ -53,9 +51,7 @@ export function IndexesSection({ setDraft((prev) => ({ ...prev, indexes: prev.indexes.map((i) => - i._id === idx._id - ? { ...i, isUnique: e.target.checked } - : i, + i._id === idx._id ? { ...i, isUnique: e.target.checked } : i, ), })); }} @@ -64,6 +60,7 @@ export function IndexesSection({ Unique
))}
))}
{h} + {h} +
{cell} + {cell} +
- {i + 1} - {i + 1} {pkCols.has(c.name) ? ( @@ -52,9 +50,7 @@ export function ColumnsContent({ )} - {c.name} - {c.name} {c.dataType} @@ -64,18 +60,14 @@ export function ColumnsContent({ {c.nullable ? ( YES ) : ( - - NOT NULL - + NOT NULL )} - {c.defaultValue ?? ( - - - )} + {c.defaultValue ?? -}
- {fk.constraintName} - - {fk.sourceColumn} - {fk.constraintName}{fk.sourceColumn} - {fk.onDelete} - - {fk.onUpdate} - {fk.onDelete}{fk.onUpdate}
- {fk.constraintName} - {fk.constraintName} {fk.sourceColumn} → {fk.targetColumn} - {fk.onDelete} - {fk.onDelete}
- {idxName} - {idxName} {entries.map((e) => e.columnName).join(", ")}
- {["PID", "User", "State", "Duration", "Wait", "Backend", "Client", "Query"].map((h) => ( - - ))} + {["PID", "User", "State", "Duration", "Wait", "Backend", "Client", "Query"].map( + (h) => ( + + ), + )} @@ -23,25 +30,48 @@ export function ActivityTab({ activity }: ActivityTabProps) { - - - - - + + + + + ))} {activity.length === 0 && ( - + )} diff --git a/src/components/performance-monitor/bloat-tab.tsx b/src/components/performance-monitor/bloat-tab.tsx index e5615ab..ecf1309 100644 --- a/src/components/performance-monitor/bloat-tab.tsx +++ b/src/components/performance-monitor/bloat-tab.tsx @@ -12,7 +12,8 @@ export function BloatTab({ bloat, tablesNeedingVacuum }: BloatTabProps) { {tablesNeedingVacuum.length > 0 && (
- {tablesNeedingVacuum.length} table{tablesNeedingVacuum.length !== 1 ? "s" : ""} with {">"} 10% bloat -- consider running VACUUM + {tablesNeedingVacuum.length} table{tablesNeedingVacuum.length !== 1 ? "s" : ""} with{" "} + {">"} 10% bloat -- consider running VACUUM
)} @@ -21,21 +22,42 @@ export function BloatTab({ bloat, tablesNeedingVacuum }: BloatTabProps) {
{h} + {h} +
{row.pid} {row.user} - + {row.state} {parseFloat(row.durationSec).toFixed(1)}s{row.waitEvent || "-"}{row.backendType}{row.clientAddr}{row.query} + {parseFloat(row.durationSec).toFixed(1)}s + + {row.waitEvent || "-"} + + {row.backendType} + + {row.clientAddr} + + {row.query} +
No active connections + No active connections +
- {["Schema", "Table", "Live Tuples", "Dead Tuples", "Bloat %", "Total Size", "Last Vacuum", "Last Analyze"].map((h) => ( - + {[ + "Schema", + "Table", + "Live Tuples", + "Dead Tuples", + "Bloat %", + "Total Size", + "Last Vacuum", + "Last Analyze", + ].map((h) => ( + ))} {bloat.map((row, idx) => { const pct = parseFloat(row.bloatPct) || 0; - const barColor = pct > 30 ? "bg-red-500" : pct > 10 ? "bg-yellow-500" : "bg-green-500"; + const barColor = + pct > 30 ? "bg-red-500" : pct > 10 ? "bg-yellow-500" : "bg-green-500"; return ( - + - - + + - - + + ); })} {bloat.length === 0 && ( - + )} diff --git a/src/components/performance-monitor/history-tab.tsx b/src/components/performance-monitor/history-tab.tsx index 572bb5d..b1f2530 100644 --- a/src/components/performance-monitor/history-tab.tsx +++ b/src/components/performance-monitor/history-tab.tsx @@ -17,15 +17,27 @@ export function HistoryTab({ slowQueries }: HistoryTabProps) { {slowQueries.map((q) => (
- - {new Date(q.timestamp).toLocaleTimeString()} - {q.success ? `${q.rowCount} rows` : "FAILED"} + + {new Date(q.timestamp).toLocaleTimeString()} -{" "} + {q.success ? `${q.rowCount} rows` : "FAILED"} - 1000 && "text-destructive")}> + 1000 && "text-destructive", + )} + > {q.executionTime.toFixed(1)}ms
-                {q.sql.slice(0, 200)}{q.sql.length > 200 ? "..." : ""}
+                {q.sql.slice(0, 200)}
+                {q.sql.length > 200 ? "..." : ""}
               
{q.error && (
{q.error}
diff --git a/src/components/performance-monitor/index.tsx b/src/components/performance-monitor/index.tsx index 3dee57b..22a652a 100644 --- a/src/components/performance-monitor/index.tsx +++ b/src/components/performance-monitor/index.tsx @@ -1,8 +1,3 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { DriverFactory } from "@/lib/database-driver"; -import { useProjectStore } from "@/stores/project-store"; -import { useHistoryStore } from "@/stores/history-store"; -import { cn } from "@/lib/utils"; import { Activity, BarChart3, @@ -16,7 +11,19 @@ import { Search, Table, } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; +import { DriverFactory } from "@/lib/database-driver"; +import { cn } from "@/lib/utils"; +import { useHistoryStore } from "@/stores/history-store"; +import { useProjectStore } from "@/stores/project-store"; +import { ActivityTab } from "./activity-tab"; +import { BloatTab } from "./bloat-tab"; +import { HistoryTab } from "./history-tab"; +import { IndexesTab } from "./indexes-tab"; +import { LocksTab } from "./locks-tab"; +import { OverviewTab } from "./overview-tab"; +import { TableStatsTab } from "./table-stats-tab"; import type { ActivityRow, BloatRow, @@ -25,13 +32,6 @@ import type { MonitorTab, TableStatRow, } from "./types"; -import { OverviewTab } from "./overview-tab"; -import { ActivityTab } from "./activity-tab"; -import { TableStatsTab } from "./table-stats-tab"; -import { HistoryTab } from "./history-tab"; -import { LocksTab } from "./locks-tab"; -import { IndexesTab } from "./indexes-tab"; -import { BloatTab } from "./bloat-tab"; export function PerformanceMonitor({ projectId }: { projectId: string }) { const projects = useProjectStore((s) => s.projects); @@ -87,7 +87,7 @@ export function PerformanceMonitor({ projectId }: { projectId: string }) { durationSec: r[7], backendType: r[8], clientAddr: r[9], - })) + })), ); } if (tStats.status === "fulfilled") { @@ -107,7 +107,7 @@ export function PerformanceMonitor({ projectId }: { projectId: string }) { lastVacuum: r[11], lastAutovacuum: r[12], lastAnalyze: r[13], - })) + })), ); } if (lk.status === "fulfilled" && lk.value) { @@ -123,7 +123,7 @@ export function PerformanceMonitor({ projectId }: { projectId: string }) { query: r[7], duration: r[8], waitEvent: r[9], - })) + })), ); } if (iu.status === "fulfilled" && iu.value) { @@ -138,7 +138,7 @@ export function PerformanceMonitor({ projectId }: { projectId: string }) { tuplesFetched: r[6], status: r[7], definition: r[8], - })) + })), ); } if (bl.status === "fulfilled" && bl.value) { @@ -154,7 +154,7 @@ export function PerformanceMonitor({ projectId }: { projectId: string }) { lastAutovacuum: r[7], lastAnalyze: r[8], lastAutoanalyze: r[9], - })) + })), ); } setLastRefresh(new Date()); @@ -179,11 +179,14 @@ export function PerformanceMonitor({ projectId }: { projectId: string }) { }, [autoRefresh, refresh]); const projectHistory = historyEntries.filter((e) => e.projectId === projectId); - const avgTime = projectHistory.length > 0 - ? projectHistory.reduce((sum, e) => sum + e.executionTime, 0) / projectHistory.length - : 0; + const avgTime = + projectHistory.length > 0 + ? projectHistory.reduce((sum, e) => sum + e.executionTime, 0) / projectHistory.length + : 0; const failedQueries = projectHistory.filter((e) => !e.success).length; - const slowQueries = [...projectHistory].sort((a, b) => b.executionTime - a.executionTime).slice(0, 10); + const slowQueries = [...projectHistory] + .sort((a, b) => b.executionTime - a.executionTime) + .slice(0, 10); const unusedIndexCount = indexUsage.filter((i) => i.status === "unused").length; const tablesNeedingVacuum = bloat.filter((b) => parseFloat(b.bloatPct) > 10); @@ -225,8 +228,18 @@ export function PerformanceMonitor({ projectId }: { projectId: string }) { > {autoRefresh ? : } -
@@ -236,12 +249,13 @@ export function PerformanceMonitor({ projectId }: { projectId: string }) { {tabs.map((t) => ( {["Schema", "Table", "Index", "Size", "Scans", "Status", "Definition"].map((h) => ( - + ))} {indexUsage.map((row, idx) => ( - - + + - + - + ))} {indexUsage.length === 0 && ( - + )} diff --git a/src/components/performance-monitor/locks-tab.tsx b/src/components/performance-monitor/locks-tab.tsx index c92fc1e..b003594 100644 --- a/src/components/performance-monitor/locks-tab.tsx +++ b/src/components/performance-monitor/locks-tab.tsx @@ -21,8 +21,22 @@ export function LocksTab({ locks, waitingLocks }: LocksTabProps) {
{h} + {h} +
{row.schema} + {row.schema} + {row.table}{parseInt(row.liveTuples).toLocaleString()}{parseInt(row.deadTuples).toLocaleString()} + {parseInt(row.liveTuples, 10).toLocaleString()} + + {parseInt(row.deadTuples, 10).toLocaleString()} +
@@ -44,25 +66,40 @@ export function BloatTab({ bloat, tablesNeedingVacuum }: BloatTabProps) { style={{ width: `${Math.min(pct, 100)}%` }} />
- 30 && "text-red-600 dark:text-red-400 font-medium", - pct > 10 && pct <= 30 && "text-yellow-600 dark:text-yellow-400", - pct <= 10 && "text-muted-foreground", - )}> + 30 && "text-red-600 dark:text-red-400 font-medium", + pct > 10 && pct <= 30 && "text-yellow-600 dark:text-yellow-400", + pct <= 10 && "text-muted-foreground", + )} + > {pct.toFixed(1)}%
{row.totalSize}{row.lastVacuum === "never" ? "never" : new Date(row.lastVacuum).toLocaleDateString()}{row.lastAnalyze === "never" ? "never" : new Date(row.lastAnalyze).toLocaleDateString()} + {row.lastVacuum === "never" + ? "never" + : new Date(row.lastVacuum).toLocaleDateString()} + + {row.lastAnalyze === "never" + ? "never" + : new Date(row.lastAnalyze).toLocaleDateString()} +
No table bloat data available + No table bloat data available +
{h} + {h} +
{row.schema}
+ {row.schema} + {row.table} {row.index} {row.size}{parseInt(row.scans).toLocaleString()} + {parseInt(row.scans, 10).toLocaleString()} + - + {row.status === "rarely_used" ? "rarely used" : row.status} {row.definition} + {row.definition} +
No non-primary indexes found + No non-primary indexes found +
- {["PID", "User", "Mode", "Lock Type", "Status", "Relation", "Duration", "Query"].map((h) => ( - + {[ + "PID", + "User", + "Mode", + "Lock Type", + "Status", + "Relation", + "Duration", + "Query", + ].map((h) => ( + ))} @@ -32,30 +46,48 @@ export function LocksTab({ locks, waitingLocks }: LocksTabProps) { key={`${row.pid}-${row.mode}-${row.locktype}-${idx}`} className={cn( "hover:bg-muted/30", - row.status === "waiting" && "bg-yellow-50/50 dark:bg-yellow-900/10" + row.status === "waiting" && "bg-yellow-50/50 dark:bg-yellow-900/10", )} > - + - - + + ))} {locks.length === 0 && ( - + )} diff --git a/src/components/performance-monitor/overview-tab.tsx b/src/components/performance-monitor/overview-tab.tsx index c263373..fcb9796 100644 --- a/src/components/performance-monitor/overview-tab.tsx +++ b/src/components/performance-monitor/overview-tab.tsx @@ -1,5 +1,5 @@ -import { cn } from "@/lib/utils"; import { Database, HardDrive, Users, Zap } from "lucide-react"; +import { cn } from "@/lib/utils"; import type { HistoryEntry } from "@/stores/history-store"; interface OverviewTabProps { @@ -16,10 +16,26 @@ export function OverviewTab({ dbStats, projectHistory, avgTime, failedQueries }:
{/* Stat cards */}
- } label="Active Connections" value={statValue("Active Connections")} /> - } label="Database Size" value={statValue("Database Size")} /> - } label="Cache Hit Ratio" value={statValue("Cache Hit Ratio")} /> - } label="Deadlocks" value={statValue("Deadlocks")} /> + } + label="Active Connections" + value={statValue("Active Connections")} + /> + } + label="Database Size" + value={statValue("Database Size")} + /> + } + label="Cache Hit Ratio" + value={statValue("Cache Hit Ratio")} + /> + } + label="Deadlocks" + value={statValue("Deadlocks")} + />
{/* All stats table */} @@ -57,7 +73,11 @@ export function OverviewTab({ dbStats, projectHistory, avgTime, failedQueries }:
Avg Execution Time
-
0 && "text-destructive")}>{failedQueries}
+
0 && "text-destructive")} + > + {failedQueries} +
Failed Queries
diff --git a/src/components/performance-monitor/table-stats-tab.tsx b/src/components/performance-monitor/table-stats-tab.tsx index 48049d8..a15353d 100644 --- a/src/components/performance-monitor/table-stats-tab.tsx +++ b/src/components/performance-monitor/table-stats-tab.tsx @@ -12,48 +12,100 @@ export function TableStatsTab({ tableStats }: TableStatsTabProps) { Cumulative stats since server start or last pg_stat_reset(). Source: pg_stat_user_tables

-
-
{h} + {h} +
{row.pid} {row.user} {row.mode}{row.locktype} + {row.locktype} + - + {row.status} {row.relation || "-"}{parseFloat(row.duration || "0").toFixed(1)}s{row.query} + {parseFloat(row.duration || "0").toFixed(1)}s + + {row.query} +
No active locks + No active locks +
- - - {["Schema", "Table", "Seq Scan", "Idx Scan", "Live Tuples", "Dead Tuples", "Inserts", "Updates", "Deletes", "Last Vacuum", "Last Analyze"].map((h) => ( - - ))} - - - - {tableStats.map((row) => { - const deadRatio = parseInt(row.liveTuples) > 0 - ? (parseInt(row.deadTuples) / parseInt(row.liveTuples)) * 100 - : 0; - return ( - - - - - - - +
{h}
{row.schema}{row.table}{parseInt(row.seqScan).toLocaleString()}{parseInt(row.idxScan).toLocaleString()}{parseInt(row.liveTuples).toLocaleString()} 10 && "text-destructive font-medium")}> - {parseInt(row.deadTuples).toLocaleString()} - {deadRatio > 10 && ({deadRatio.toFixed(0)}%)} +
+ + + + {[ + "Schema", + "Table", + "Seq Scan", + "Idx Scan", + "Live Tuples", + "Dead Tuples", + "Inserts", + "Updates", + "Deletes", + "Last Vacuum", + "Last Analyze", + ].map((h) => ( + + ))} + + + + {tableStats.map((row) => { + const deadRatio = + parseInt(row.liveTuples, 10) > 0 + ? (parseInt(row.deadTuples, 10) / parseInt(row.liveTuples, 10)) * 100 + : 0; + return ( + + + + + + + + + + + + + + ); + })} + {tableStats.length === 0 && ( + + - - - - - - ); - })} - {tableStats.length === 0 && ( - - - - )} - -
+ {h} +
+ {row.schema} + {row.table} + {parseInt(row.seqScan, 10).toLocaleString()} + + {parseInt(row.idxScan, 10).toLocaleString()} + + {parseInt(row.liveTuples, 10).toLocaleString()} + 10 && "text-destructive font-medium", + )} + > + {parseInt(row.deadTuples, 10).toLocaleString()} + {deadRatio > 10 && ( + ({deadRatio.toFixed(0)}%) + )} + + {parseInt(row.inserts, 10).toLocaleString()} + + {parseInt(row.updates, 10).toLocaleString()} + + {parseInt(row.deletes, 10).toLocaleString()} + + {row.lastVacuum === "never" + ? "never" + : new Date(row.lastVacuum).toLocaleDateString()} + + {row.lastAnalyze === "never" + ? "never" + : new Date(row.lastAnalyze).toLocaleDateString()} +
+ No table stats available {parseInt(row.inserts).toLocaleString()}{parseInt(row.updates).toLocaleString()}{parseInt(row.deletes).toLocaleString()}{row.lastVacuum === "never" ? "never" : new Date(row.lastVacuum).toLocaleDateString()}{row.lastAnalyze === "never" ? "never" : new Date(row.lastAnalyze).toLocaleDateString()}
No table stats available
+ )} +
+
-
); } diff --git a/src/components/performance-monitor/types.ts b/src/components/performance-monitor/types.ts index 0046508..3f24139 100644 --- a/src/components/performance-monitor/types.ts +++ b/src/components/performance-monitor/types.ts @@ -66,4 +66,11 @@ export interface BloatRow { lastAutoanalyze: string; } -export type MonitorTab = "overview" | "activity" | "tables" | "history" | "locks" | "indexes" | "bloat"; +export type MonitorTab = + | "overview" + | "activity" + | "tables" + | "history" + | "locks" + | "indexes" + | "bloat"; diff --git a/src/components/pg-settings-panel.tsx b/src/components/pg-settings-panel.tsx index 3777594..086fec8 100644 --- a/src/components/pg-settings-panel.tsx +++ b/src/components/pg-settings-panel.tsx @@ -1,10 +1,10 @@ -import { useState, useEffect, useCallback, useMemo } from "react"; -import { DriverFactory } from "@/lib/database-driver"; -import { useProjectStore } from "@/stores/project-store"; -import { cn } from "@/lib/utils"; -import { Settings, Loader2, RefreshCw } from "lucide-react"; +import { Loader2, RefreshCw, Settings } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { DriverFactory } from "@/lib/database-driver"; +import { cn } from "@/lib/utils"; +import { useProjectStore } from "@/stores/project-store"; interface PgSetting { name: string; @@ -35,17 +35,28 @@ export function PgSettingsPanel({ projectId }: { projectId: string }) { const driver = DriverFactory.getDriver(details.driver); const result = await driver.loadPgSettings?.(projectId); if (result) { - setSettings(result.map((r) => ({ - name: r[0], setting: r[1], unit: r[2], category: r[3], - description: r[4], context: r[5], source: r[6], bootVal: r[7], resetVal: r[8], - }))); + setSettings( + result.map((r) => ({ + name: r[0], + setting: r[1], + unit: r[2], + category: r[3], + description: r[4], + context: r[5], + source: r[6], + bootVal: r[7], + resetVal: r[8], + })), + ); } } finally { setIsLoading(false); } }, [projectId, details]); - useEffect(() => { void refresh(); }, [refresh]); + useEffect(() => { + void refresh(); + }, [refresh]); const categories = useMemo(() => { const cats = new Set(settings.map((s) => s.category)); @@ -56,23 +67,33 @@ export function PgSettingsPanel({ projectId }: { projectId: string }) { const filtered = settings.filter((s) => { if (categoryFilter && s.category !== categoryFilter) return false; if (contextFilter && s.context !== contextFilter) return false; - if (lowerFilter && !s.name.toLowerCase().includes(lowerFilter) && !s.description.toLowerCase().includes(lowerFilter)) return false; + if ( + lowerFilter && + !s.name.toLowerCase().includes(lowerFilter) && + !s.description.toLowerCase().includes(lowerFilter) + ) + return false; return true; }); const grouped = new Map(); for (const s of filtered) { if (!grouped.has(s.category)) grouped.set(s.category, []); - grouped.get(s.category)!.push(s); + grouped.get(s.category)?.push(s); } const contextColor = (ctx: string) => { switch (ctx) { - case "user": return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"; - case "superuser": return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"; - case "postmaster": return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"; - case "sighup": return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"; - default: return "bg-muted text-muted-foreground"; + case "user": + return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"; + case "superuser": + return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"; + case "postmaster": + return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"; + case "sighup": + return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"; + default: + return "bg-muted text-muted-foreground"; } }; @@ -82,12 +103,26 @@ export function PgSettingsPanel({ projectId }: { projectId: string }) {
PostgreSQL Settings - {details?.database ?? projectId} + + {details?.database ?? projectId} +
- {filtered.length}/{settings.length} -
@@ -105,7 +140,11 @@ export function PgSettingsPanel({ projectId }: { projectId: string }) { className="h-7 rounded-md border bg-input/50 px-2 font-mono text-xs text-foreground" > - {categories.map((c) => )} + {categories.map((c) => ( + + ))} -
@@ -146,37 +203,67 @@ export function SchemaDiffPanel({ projectId }: SchemaDiffPanelProps) { {counts && (
{counts.modified > 0 && ( )} {counts.onlyLeft > 0 && ( )} {counts.onlyRight > 0 && ( @@ -189,15 +276,23 @@ export function SchemaDiffPanel({ projectId }: SchemaDiffPanelProps) { {/* List */}
{!diff ? ( -
Select schemas and compare
+
+ Select schemas and compare +
) : filtered && filtered.length === 0 ? ( -
No differences found
+
+ No differences found +
) : ( filtered?.map((entry, i) => ( )) )} @@ -240,13 +344,17 @@ export function SchemaDiffPanel({ projectId }: SchemaDiffPanelProps) { {selected.status === "modified" ? (
-
{leftSchema}
+
+ {leftSchema} +
                       {selected.leftDef}
                     
-
{rightSchema}
+
+ {rightSchema} +
                       {selected.rightDef}
                     
@@ -259,7 +367,9 @@ export function SchemaDiffPanel({ projectId }: SchemaDiffPanelProps) { )}
) : ( -
Select an object to view details
+
+ Select an object to view details +
)}
diff --git a/src/components/server-sidebar/add-database-dialog.tsx b/src/components/server-sidebar/add-database-dialog.tsx index 3742f4e..a5af896 100644 --- a/src/components/server-sidebar/add-database-dialog.tsx +++ b/src/components/server-sidebar/add-database-dialog.tsx @@ -1,12 +1,22 @@ import React from "react"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import type { ProjectDetails } from "@/types"; export function AddDatabaseDialog({ - open, onOpenChange, sourceProjectId, projects, onAdd, + open, + onOpenChange, + sourceProjectId, + projects, + onAdd, }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -19,7 +29,10 @@ export function AddDatabaseDialog({ const source = projects[sourceProjectId]; React.useEffect(() => { - if (open) { setDbName(""); setConnName(""); } + if (open) { + setDbName(""); + setConnName(""); + } }, [open]); const handleSubmit = (e: React.FormEvent) => { @@ -34,23 +47,33 @@ export function AddDatabaseDialog({ Add Database - Add a database to {source?.host}:{source?.port} + Add a database to{" "} + + {source?.host}:{source?.port} +
- + { setDbName(e.target.value); if (!connName) setConnName(""); }} + onChange={(e) => { + setDbName(e.target.value); + if (!connName) setConnName(""); + }} placeholder="analytics_db" autoFocus className="font-mono text-sm h-8" />
- +
- - + +
diff --git a/src/components/server-sidebar/constants.ts b/src/components/server-sidebar/constants.ts index 2952b91..379748f 100644 --- a/src/components/server-sidebar/constants.ts +++ b/src/components/server-sidebar/constants.ts @@ -1,2 +1,11 @@ // Indent levels (px) -export const I = { server: 4, cat: 14, db: 24, schema: 32, schemaObj: 40, table: 48, section: 56, item: 64 }; +export const I = { + server: 4, + cat: 14, + db: 24, + schema: 32, + schemaObj: 40, + table: 48, + section: 56, + item: 64, +}; diff --git a/src/components/server-sidebar/index.tsx b/src/components/server-sidebar/index.tsx index d9efe25..866b065 100644 --- a/src/components/server-sidebar/index.tsx +++ b/src/components/server-sidebar/index.tsx @@ -1,18 +1,18 @@ +import { Plus } from "lucide-react"; import React from "react"; +import { CSVImportModal } from "@/components/csv-import-modal"; +import { ObjectPropertiesModal } from "@/components/object-properties-modal"; import { Button } from "@/components/ui/button"; import { ContextMenu, useContextMenu } from "@/components/ui/context-menu"; -import { ObjectPropertiesModal } from "@/components/object-properties-modal"; -import { CSVImportModal } from "@/components/csv-import-modal"; import { useProjectStore } from "@/stores/project-store"; -import { useUIStore } from "@/stores/ui-store"; -import { useTabStore } from "@/stores/tab-store"; import { useQueryStore } from "@/stores/query-store"; +import { useTabStore } from "@/stores/tab-store"; +import { useUIStore } from "@/stores/ui-store"; import type { ProjectDetails } from "@/types"; -import { Plus } from "lucide-react"; import { AddDatabaseDialog } from "./add-database-dialog"; -import { renderServerGroup } from "./render-server-group"; import { renderSavedQueries } from "./render-saved-queries"; -import type { CsvImportTarget, PropsModalState, SidebarRenderCtx, ObjectKind } from "./types"; +import { renderServerGroup } from "./render-server-group"; +import type { CsvImportTarget, ObjectKind, PropsModalState, SidebarRenderCtx } from "./types"; export function ServerSidebar({ onEditConnection, @@ -67,7 +67,12 @@ export function ServerSidebar({ name: "", }); - const openProperties = (objectType: ObjectKind, projectId: string, schema: string, name: string) => { + const openProperties = ( + objectType: ObjectKind, + projectId: string, + schema: string, + name: string, + ) => { setPropsModal({ open: true, objectType, projectId, schema, name }); }; @@ -101,10 +106,7 @@ export function ServerSidebar({ if (!tables[tKey]) { setLoad(key, true); try { - await Promise.all([ - loadTables(projectId, schema), - loadSchemaObjects(projectId, schema), - ]); + await Promise.all([loadTables(projectId, schema), loadSchemaObjects(projectId, schema)]); } catch (e) { console.error("Failed to load schema objects:", e); } finally { @@ -137,22 +139,65 @@ export function ServerSidebar({ const copy = (text: string) => navigator.clipboard.writeText(text); const ctx: SidebarRenderCtx = { - projects, status, serverDatabases, serverTablespaces, schemas, tables, - columnDetails, indexes, constraints, triggers, rules, policies, - views, materializedViews, functions, triggerFunctions, - connect, loadColumns, refreshConnection, deleteProject, addDatabaseToServer, - openTab, openMonitorTab, openERDTab, openNotifyTab, openRolesTab, - openSchemaDiffTab, openExtensionsTab, openEnumsTab, openPgSettingsTab, - loading, selectedItem, setSelectedItem, setCsvImportTarget, setAddDbSource, - openProperties, toggle, isOpen, onConnect, onExpandSchema, onExpandTable, - onOpenTableQuery, copy, showMenu, onEditConnection, + projects, + status, + serverDatabases, + serverTablespaces, + schemas, + tables, + columnDetails, + indexes, + constraints, + triggers, + rules, + policies, + views, + materializedViews, + functions, + triggerFunctions, + connect, + loadColumns, + refreshConnection, + deleteProject, + addDatabaseToServer, + openTab, + openMonitorTab, + openERDTab, + openNotifyTab, + openRolesTab, + openSchemaDiffTab, + openExtensionsTab, + openEnumsTab, + openPgSettingsTab, + loading, + selectedItem, + setSelectedItem, + setCsvImportTarget, + setAddDbSource, + openProperties, + toggle, + isOpen, + onConnect, + onExpandSchema, + onExpandTable, + onOpenTableQuery, + copy, + showMenu, + onEditConnection, }; return (
- CONNECTIONS -
@@ -166,12 +211,14 @@ export function ServerSidebar({ for (const [pid, d] of entries) { const fp = serverFp(d); if (!serverGroups.has(fp)) serverGroups.set(fp, []); - serverGroups.get(fp)!.push(pid); + serverGroups.get(fp)?.push(pid); } return ( <> - {Array.from(serverGroups.entries()).map(([fp, pids]) => renderServerGroup(ctx, fp, pids))} + {Array.from(serverGroups.entries()).map(([fp, pids]) => + renderServerGroup(ctx, fp, pids), + )} ); })()} @@ -181,7 +228,9 @@ export function ServerSidebar({ { if (!open) setAddDbSource(null); }} + onOpenChange={(open) => { + if (!open) setAddDbSource(null); + }} sourceProjectId={addDbSource ?? ""} projects={projects} onAdd={async (name, database) => { @@ -204,7 +253,9 @@ export function ServerSidebar({ {csvImportTarget && ( { if (!open) setCsvImportTarget(null); }} + onOpenChange={(open) => { + if (!open) setCsvImportTarget(null); + }} projectId={csvImportTarget.projectId} schema={csvImportTarget.schema} table={csvImportTarget.table} diff --git a/src/components/server-sidebar/render-saved-queries.tsx b/src/components/server-sidebar/render-saved-queries.tsx index e3bc817..7125038 100644 --- a/src/components/server-sidebar/render-saved-queries.tsx +++ b/src/components/server-sidebar/render-saved-queries.tsx @@ -1,7 +1,7 @@ import { Copy, FileText, Trash2 } from "lucide-react"; +import type { SavedQuery } from "@/stores/query-store"; import { I } from "./constants"; import { TreeRow } from "./tree-row"; -import type { SavedQuery } from "@/stores/query-store"; import type { SidebarRenderCtx } from "./types"; export function renderSavedQueries( @@ -13,7 +13,9 @@ export function renderSavedQueries( return (
- SAVED QUERIES + + SAVED QUERIES + {savedQueries.length > 0 && ( {savedQueries.length} )} @@ -27,15 +29,36 @@ export function renderSavedQueries( icon={} label={q.title} selected={selectedItem === `query::${q.id}`} - onClick={() => { setSelectedItem(`query::${q.id}`); openTab(q.projectId, q.sql); }} - onContextMenu={(e) => { setSelectedItem(`query::${q.id}`); showMenu(e, [ - { label: "Open in Tab", icon: , onClick: () => openTab(q.projectId, q.sql) }, - { label: "Copy SQL", icon: , onClick: () => copy(q.sql) }, - { separator: true as const }, - { label: "Delete", icon: , onClick: () => void removeQuery(q.id), destructive: true }, - ]); }} + onClick={() => { + setSelectedItem(`query::${q.id}`); + openTab(q.projectId, q.sql); + }} + onContextMenu={(e) => { + setSelectedItem(`query::${q.id}`); + showMenu(e, [ + { + label: "Open in Tab", + icon: , + onClick: () => openTab(q.projectId, q.sql), + }, + { + label: "Copy SQL", + icon: , + onClick: () => copy(q.sql), + }, + { separator: true as const }, + { + label: "Delete", + icon: , + onClick: () => void removeQuery(q.id), + destructive: true, + }, + ]); + }} trailing={ - {q.projectId} + + {q.projectId} + } /> ))} diff --git a/src/components/server-sidebar/render-schema-objects.tsx b/src/components/server-sidebar/render-schema-objects.tsx index 6fbf77a..9024bd6 100644 --- a/src/components/server-sidebar/render-schema-objects.tsx +++ b/src/components/server-sidebar/render-schema-objects.tsx @@ -14,19 +14,37 @@ import { import { cn } from "@/lib/utils"; import { ProjectConnectionStatus } from "@/types"; import { I } from "./constants"; -import { TreeRow } from "./tree-row"; -import { SectionHeader } from "./section-header"; -import { ddlTableQuery, ddlViewQuery, ddlFunctionQuery } from "./ddl-queries"; +import { ddlFunctionQuery, ddlTableQuery, ddlViewQuery } from "./ddl-queries"; import { renderTableDetails } from "./render-table-details"; +import { SectionHeader } from "./section-header"; +import { TreeRow } from "./tree-row"; import type { SidebarRenderCtx } from "./types"; /** Render schemas + tables/views/functions for a connected database project */ export function renderSchemas(ctx: SidebarRenderCtx, pid: string) { const { - schemas, status, tables, views, materializedViews, functions, triggerFunctions, - loading, selectedItem, setSelectedItem, setCsvImportTarget, openProperties, - isOpen, toggle, onExpandSchema, onExpandTable, onOpenTableQuery, - openTab, openERDTab, loadColumns, showMenu, copy, + schemas, + status, + tables, + views, + materializedViews, + functions, + triggerFunctions, + loading, + selectedItem, + setSelectedItem, + setCsvImportTarget, + openProperties, + isOpen, + toggle, + onExpandSchema, + onExpandTable, + onOpenTableQuery, + openTab, + openERDTab, + loadColumns, + showMenu, + copy, } = ctx; const projectSchemas = schemas[pid] || []; @@ -45,171 +63,356 @@ export function renderSchemas(ctx: SidebarRenderCtx, pid: string) { return (
- } label={schema} expanded={isSchemaOpen} loading={loading[sKey]} onClick={() => void onExpandSchema(pid, schema)} - onContextMenu={(e) => showMenu(e, [ - { label: "ERD Diagram", icon: , onClick: () => openERDTab(pid, schema) }, - { label: "Copy Schema Name", icon: , onClick: () => copy(schema) }, - { label: "New Query", icon: , onClick: () => openTab(pid, `-- Schema: ${schema}\n`) }, - ])} + onContextMenu={(e) => + showMenu(e, [ + { + label: "ERD Diagram", + icon: , + onClick: () => openERDTab(pid, schema), + }, + { + label: "Copy Schema Name", + icon: , + onClick: () => copy(schema), + }, + { + label: "New Query", + icon: , + onClick: () => openTab(pid, `-- Schema: ${schema}\n`), + }, + ]) + } /> {isSchemaOpen && ( <> {/* Tables category */} - } sectionKey={`${sKey}::tables`} - expanded={isOpen(`${sKey}::tables`, true)} onClick={() => toggle(`${sKey}::tables`)} /> - {isOpen(`${sKey}::tables`, true) && schemaTables?.map((ti) => { - const tKey = `table::${pid}::${schema}::${ti.name}`; - const isTableOpen = isOpen(tKey); + } + sectionKey={`${sKey}::tables`} + expanded={isOpen(`${sKey}::tables`, true)} + onClick={() => toggle(`${sKey}::tables`)} + /> + {isOpen(`${sKey}::tables`, true) && + schemaTables?.map((ti) => { + const tKey = `table::${pid}::${schema}::${ti.name}`; + const isTableOpen = isOpen(tKey); - return ( -
- } - label={ti.name} - expanded={isTableOpen} - loading={loading[tKey]} - selected={selectedItem === tKey} - onClick={() => { setSelectedItem(tKey); void onExpandTable(pid, schema, ti.name); }} - onDoubleClick={() => onOpenTableQuery(pid, schema, ti.name)} - onContextMenu={(e) => { setSelectedItem(tKey); showMenu(e, [ - { header: "Query" }, - { label: "SELECT TOP 100", icon: , onClick: () => onOpenTableQuery(pid, schema, ti.name) }, - { label: "SELECT COUNT(*)", icon:
, onClick: () => openTab(pid, `SELECT COUNT(*) FROM "${schema}"."${ti.name}";`) }, - { separator: true as const }, - { label: "Import CSV", icon: , onClick: () => { - void loadColumns(pid, schema, ti.name).then((cols) => { - setCsvImportTarget({ projectId: pid, schema, table: ti.name, columns: cols }); - }); - }}, - { separator: true as const }, - { label: "Properties", icon: , onClick: () => openProperties("table", pid, schema, ti.name) }, - { label: "Show CREATE TABLE", icon: , onClick: () => openTab(pid, ddlTableQuery(schema, ti.name)) }, - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(`"${schema}"."${ti.name}"`), shortcut: navigator.platform.includes("Mac") ? "⌘C" : "Ctrl+C" }, - ]); }} - trailing={{ti.size}} - /> - {isTableOpen && renderTableDetails(ctx, pid, schema, ti.name)} - - ); - })} + return ( +
+ } + label={ti.name} + expanded={isTableOpen} + loading={loading[tKey]} + selected={selectedItem === tKey} + onClick={() => { + setSelectedItem(tKey); + void onExpandTable(pid, schema, ti.name); + }} + onDoubleClick={() => onOpenTableQuery(pid, schema, ti.name)} + onContextMenu={(e) => { + setSelectedItem(tKey); + showMenu(e, [ + { header: "Query" }, + { + label: "SELECT TOP 100", + icon:
, + onClick: () => onOpenTableQuery(pid, schema, ti.name), + }, + { + label: "SELECT COUNT(*)", + icon:
, + onClick: () => + openTab(pid, `SELECT COUNT(*) FROM "${schema}"."${ti.name}";`), + }, + { separator: true as const }, + { + label: "Import CSV", + icon: , + onClick: () => { + void loadColumns(pid, schema, ti.name).then((cols) => { + setCsvImportTarget({ + projectId: pid, + schema, + table: ti.name, + columns: cols, + }); + }); + }, + }, + { separator: true as const }, + { + label: "Properties", + icon: , + onClick: () => openProperties("table", pid, schema, ti.name), + }, + { + label: "Show CREATE TABLE", + icon: , + onClick: () => openTab(pid, ddlTableQuery(schema, ti.name)), + }, + { separator: true as const }, + { + label: "Copy Name", + icon: , + onClick: () => copy(`"${schema}"."${ti.name}"`), + shortcut: navigator.platform.includes("Mac") ? "⌘C" : "Ctrl+C", + }, + ]); + }} + trailing={ + + {ti.size} + + } + /> + {isTableOpen && renderTableDetails(ctx, pid, schema, ti.name)} + + ); + })} {/* Views category */} {schemaViews && schemaViews.length > 0 && ( <> - } sectionKey={`${sKey}::views`} - expanded={isOpen(`${sKey}::views`)} onClick={() => toggle(`${sKey}::views`)} /> - {isOpen(`${sKey}::views`) && schemaViews.map((v) => { - const vKey = `view::${pid}::${schema}::${v}`; - return ( - } - label={v} - selected={selectedItem === vKey} - onClick={() => { setSelectedItem(vKey); onOpenTableQuery(pid, schema, v); }} - onContextMenu={(e) => { setSelectedItem(vKey); showMenu(e, [ - { label: "SELECT TOP 100", icon: , onClick: () => onOpenTableQuery(pid, schema, v) }, - { separator: true as const }, - { label: "Properties", icon: , onClick: () => openProperties("view", pid, schema, v) }, - { label: "Show CREATE VIEW", icon: , onClick: () => openTab(pid, ddlViewQuery(schema, v)) }, - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(`"${schema}"."${v}"`) }, - ]); }} - /> - ); - })} + } + sectionKey={`${sKey}::views`} + expanded={isOpen(`${sKey}::views`)} + onClick={() => toggle(`${sKey}::views`)} + /> + {isOpen(`${sKey}::views`) && + schemaViews.map((v) => { + const vKey = `view::${pid}::${schema}::${v}`; + return ( + } + label={v} + selected={selectedItem === vKey} + onClick={() => { + setSelectedItem(vKey); + onOpenTableQuery(pid, schema, v); + }} + onContextMenu={(e) => { + setSelectedItem(vKey); + showMenu(e, [ + { + label: "SELECT TOP 100", + icon: , + onClick: () => onOpenTableQuery(pid, schema, v), + }, + { separator: true as const }, + { + label: "Properties", + icon: , + onClick: () => openProperties("view", pid, schema, v), + }, + { + label: "Show CREATE VIEW", + icon: , + onClick: () => openTab(pid, ddlViewQuery(schema, v)), + }, + { separator: true as const }, + { + label: "Copy Name", + icon: , + onClick: () => copy(`"${schema}"."${v}"`), + }, + ]); + }} + /> + ); + })} )} {/* Materialized Views category */} {schemaMatViews && schemaMatViews.length > 0 && ( <> - } sectionKey={`${sKey}::matviews`} - expanded={isOpen(`${sKey}::matviews`)} onClick={() => toggle(`${sKey}::matviews`)} /> - {isOpen(`${sKey}::matviews`) && schemaMatViews.map((mv) => { - const mvKey = `matview::${pid}::${schema}::${mv}`; - return ( - } - label={mv} - selected={selectedItem === mvKey} - onClick={() => { setSelectedItem(mvKey); onOpenTableQuery(pid, schema, mv); }} - onContextMenu={(e) => { setSelectedItem(mvKey); showMenu(e, [ - { label: "SELECT TOP 100", icon: , onClick: () => onOpenTableQuery(pid, schema, mv) }, - { label: "REFRESH", icon: , onClick: () => openTab(pid, `REFRESH MATERIALIZED VIEW "${schema}"."${mv}";`) }, - { separator: true as const }, - { label: "Properties", icon: , onClick: () => openProperties("matview", pid, schema, mv) }, - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(`"${schema}"."${mv}"`) }, - ]); }} - /> - ); - })} + } + sectionKey={`${sKey}::matviews`} + expanded={isOpen(`${sKey}::matviews`)} + onClick={() => toggle(`${sKey}::matviews`)} + /> + {isOpen(`${sKey}::matviews`) && + schemaMatViews.map((mv) => { + const mvKey = `matview::${pid}::${schema}::${mv}`; + return ( + } + label={mv} + selected={selectedItem === mvKey} + onClick={() => { + setSelectedItem(mvKey); + onOpenTableQuery(pid, schema, mv); + }} + onContextMenu={(e) => { + setSelectedItem(mvKey); + showMenu(e, [ + { + label: "SELECT TOP 100", + icon: , + onClick: () => onOpenTableQuery(pid, schema, mv), + }, + { + label: "REFRESH", + icon: , + onClick: () => + openTab(pid, `REFRESH MATERIALIZED VIEW "${schema}"."${mv}";`), + }, + { separator: true as const }, + { + label: "Properties", + icon: , + onClick: () => openProperties("matview", pid, schema, mv), + }, + { separator: true as const }, + { + label: "Copy Name", + icon: , + onClick: () => copy(`"${schema}"."${mv}"`), + }, + ]); + }} + /> + ); + })} )} {/* Functions category */} {schemaFns && schemaFns.length > 0 && ( <> - } sectionKey={`${sKey}::fns`} - expanded={isOpen(`${sKey}::fns`)} onClick={() => toggle(`${sKey}::fns`)} /> - {isOpen(`${sKey}::fns`) && schemaFns.map((fn, i) => { - const fnKey = `fn::${pid}::${schema}::${fn.name}::${i}`; - return ( -
setSelectedItem(fnKey)} - onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setSelectedItem(fnKey); showMenu(e, [ - { label: "Show Definition", icon: , onClick: () => openTab(pid, ddlFunctionQuery(schema, fn.name)) }, - { label: "Properties", icon: , onClick: () => openProperties("function", pid, schema, fn.name) }, - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(fn.name) }, - ]); }}> - - {fn.name}({fn.arguments ? "..." : ""}) - {fn.returnType} -
- ); - })} + } + sectionKey={`${sKey}::fns`} + expanded={isOpen(`${sKey}::fns`)} + onClick={() => toggle(`${sKey}::fns`)} + /> + {isOpen(`${sKey}::fns`) && + schemaFns.map((fn, i) => { + const fnKey = `fn::${pid}::${schema}::${fn.name}::${i}`; + return ( +
setSelectedItem(fnKey)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setSelectedItem(fnKey); + showMenu(e, [ + { + label: "Show Definition", + icon: , + onClick: () => openTab(pid, ddlFunctionQuery(schema, fn.name)), + }, + { + label: "Properties", + icon: , + onClick: () => openProperties("function", pid, schema, fn.name), + }, + { separator: true as const }, + { + label: "Copy Name", + icon: , + onClick: () => copy(fn.name), + }, + ]); + }} + > + + + {fn.name}({fn.arguments ? "..." : ""}) + + + {fn.returnType} + +
+ ); + })} )} {/* Trigger Functions category */} {schemaTrigFns && schemaTrigFns.length > 0 && ( <> - } sectionKey={`${sKey}::trigfns`} - expanded={isOpen(`${sKey}::trigfns`)} onClick={() => toggle(`${sKey}::trigfns`)} /> - {isOpen(`${sKey}::trigfns`) && schemaTrigFns.map((fn, i) => { - const tfKey = `trigfn::${pid}::${schema}::${fn.name}::${i}`; - return ( -
setSelectedItem(tfKey)} - onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setSelectedItem(tfKey); showMenu(e, [ - { label: "Show Definition", icon: , onClick: () => openTab(pid, ddlFunctionQuery(schema, fn.name)) }, - { label: "Properties", icon: , onClick: () => openProperties("trigger-function", pid, schema, fn.name) }, - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(fn.name) }, - ]); }}> - - {fn.name}() - trigger -
- ); - })} + } + sectionKey={`${sKey}::trigfns`} + expanded={isOpen(`${sKey}::trigfns`)} + onClick={() => toggle(`${sKey}::trigfns`)} + /> + {isOpen(`${sKey}::trigfns`) && + schemaTrigFns.map((fn, i) => { + const tfKey = `trigfn::${pid}::${schema}::${fn.name}::${i}`; + return ( +
setSelectedItem(tfKey)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setSelectedItem(tfKey); + showMenu(e, [ + { + label: "Show Definition", + icon: , + onClick: () => openTab(pid, ddlFunctionQuery(schema, fn.name)), + }, + { + label: "Properties", + icon: , + onClick: () => + openProperties("trigger-function", pid, schema, fn.name), + }, + { separator: true as const }, + { + label: "Copy Name", + icon: , + onClick: () => copy(fn.name), + }, + ]); + }} + > + + {fn.name}() + trigger +
+ ); + })} )} diff --git a/src/components/server-sidebar/render-server-group.tsx b/src/components/server-sidebar/render-server-group.tsx index dad0209..62cf04d 100644 --- a/src/components/server-sidebar/render-server-group.tsx +++ b/src/components/server-sidebar/render-server-group.tsx @@ -18,28 +18,41 @@ import { Zap, } from "lucide-react"; import { cn } from "@/lib/utils"; -import { ProjectConnectionStatus } from "@/types"; import type { ProjectDetails } from "@/types"; +import { ProjectConnectionStatus } from "@/types"; import { I } from "./constants"; -import { TreeRow } from "./tree-row"; import { renderSchemas } from "./render-schema-objects"; +import { TreeRow } from "./tree-row"; import type { SidebarRenderCtx } from "./types"; /** * Render a single server-fingerprint group with its databases, roles * and tablespaces. Auto-grouping by host:port:user:ssh fingerprint. */ -export function renderServerGroup( - ctx: SidebarRenderCtx, - fp: string, - pids: string[], -) { +export function renderServerGroup(ctx: SidebarRenderCtx, fp: string, pids: string[]) { const { - projects, status, serverDatabases, serverTablespaces, - isOpen, toggle, onConnect, addDatabaseToServer, deleteProject, refreshConnection, - openTab, openMonitorTab, openNotifyTab, openRolesTab, openSchemaDiffTab, - openExtensionsTab, openEnumsTab, openPgSettingsTab, - setAddDbSource, showMenu, copy, onEditConnection, + projects, + status, + serverDatabases, + serverTablespaces, + isOpen, + toggle, + onConnect, + addDatabaseToServer, + deleteProject, + refreshConnection, + openTab, + openMonitorTab, + openNotifyTab, + openRolesTab, + openSchemaDiffTab, + openExtensionsTab, + openEnumsTab, + openPgSettingsTab, + setAddDbSource, + showMenu, + copy, + onEditConnection, } = ctx; const primaryDetails: ProjectDetails | undefined = projects[pids[0]]; @@ -55,7 +68,10 @@ export function renderServerGroup( const discoveredDbs = new Set(); for (const pid of pids) { const dbs = serverDatabases[pid]; - if (dbs) dbs.forEach((db) => discoveredDbs.add(db)); + if (dbs) + dbs.forEach((db) => { + discoveredDbs.add(db); + }); const d = projects[pid]; if (d?.database) discoveredDbs.add(d.database); } @@ -78,26 +94,68 @@ export function renderServerGroup( bold expanded={isOpen(gKey, true)} onClick={() => toggle(gKey)} - onContextMenu={(e) => showMenu(e, [ - { header: "Server" }, - ...(connectedPid ? [ - { label: "New Query", icon: , onClick: () => openTab(connectedPid) }, - { label: "Performance Monitor", icon: , onClick: () => openMonitorTab(connectedPid) }, - { label: "PG Settings", icon: , onClick: () => openPgSettingsTab(connectedPid) }, - ] : []), - { label: "Add Database", icon: , onClick: () => setAddDbSource(pids[0]) }, - ...(onEditConnection ? [{ label: "Edit Connection", icon: , onClick: () => onEditConnection(pids[0]) }] : []), - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(serverLabel) }, - { separator: true as const }, - { label: "Delete", icon: , onClick: () => { for (const pid of pids) void deleteProject(pid); }, destructive: true }, - ])} + onContextMenu={(e) => + showMenu(e, [ + { header: "Server" }, + ...(connectedPid + ? [ + { + label: "New Query", + icon: , + onClick: () => openTab(connectedPid), + }, + { + label: "Performance Monitor", + icon: , + onClick: () => openMonitorTab(connectedPid), + }, + { + label: "PG Settings", + icon: , + onClick: () => openPgSettingsTab(connectedPid), + }, + ] + : []), + { + label: "Add Database", + icon: , + onClick: () => setAddDbSource(pids[0]), + }, + ...(onEditConnection + ? [ + { + label: "Edit Connection", + icon: , + onClick: () => onEditConnection(pids[0]), + }, + ] + : []), + { separator: true as const }, + { + label: "Copy Name", + icon: , + onClick: () => copy(serverLabel), + }, + { separator: true as const }, + { + label: "Delete", + icon: , + onClick: () => { + for (const pid of pids) void deleteProject(pid); + }, + destructive: true, + }, + ]) + } trailing={ -
+
} /> @@ -111,89 +169,163 @@ export function renderServerGroup( onClick={() => toggle(dbCatKey)} /> - {isOpen(dbCatKey, true) && allDbs.map((dbName) => { - const dbPid = dbToProject.get(dbName); - const dbKey = `db::${fp}::${dbName}`; + {isOpen(dbCatKey, true) && + allDbs.map((dbName) => { + const dbPid = dbToProject.get(dbName); + const dbKey = `db::${fp}::${dbName}`; + + if (dbPid) { + const dbConn = status[dbPid]; + const isDbConnected = dbConn === ProjectConnectionStatus.Connected; + const isDbConnecting = dbConn === ProjectConnectionStatus.Connecting; + const isDbFailed = dbConn === ProjectConnectionStatus.Failed; + return ( +
+ + ) : ( + + ) + } + label={dbName} + expanded={isDbConnected ? isOpen(dbKey, true) : undefined} + onClick={() => { + if (!isDbConnected && !isDbConnecting) void onConnect(dbPid); + else if (isDbConnected) toggle(dbKey); + }} + onContextMenu={(e) => + showMenu(e, [ + { header: "Database" }, + { + label: "New Query", + icon: , + onClick: () => openTab(dbPid), + }, + { + label: isDbConnected ? "Reconnect" : "Connect", + icon: , + onClick: () => void onConnect(dbPid), + }, + ...(isDbConnected + ? [ + { + label: "Refresh", + icon: , + onClick: () => void refreshConnection(dbPid), + }, + { + label: "LISTEN/NOTIFY", + icon: , + onClick: () => openNotifyTab(dbPid), + }, + { + label: "Schema Diff", + icon: , + onClick: () => openSchemaDiffTab(dbPid), + }, + { + label: "Extensions", + icon: , + onClick: () => openExtensionsTab(dbPid), + }, + { + label: "Enum Types", + icon: , + onClick: () => openEnumsTab(dbPid), + }, + ] + : []), + ...(onEditConnection + ? [ + { separator: true as const }, + { + label: "Edit Connection", + icon: , + onClick: () => onEditConnection(dbPid), + }, + ] + : []), + { separator: true as const }, + { + label: "Copy Name", + icon: , + onClick: () => copy(dbName), + }, + { separator: true as const }, + { + label: "Delete", + icon: , + onClick: () => void deleteProject(dbPid), + destructive: true, + }, + ]) + } + trailing={ +
+ } + /> - if (dbPid) { - const dbConn = status[dbPid]; - const isDbConnected = dbConn === ProjectConnectionStatus.Connected; - const isDbConnecting = dbConn === ProjectConnectionStatus.Connecting; - const isDbFailed = dbConn === ProjectConnectionStatus.Failed; - return ( -
+ {isDbConnected && isOpen(dbKey, true) && renderSchemas(ctx, dbPid)} +
+ ); + } else { + // No project entry yet — clicking auto-creates one + return ( - : } + icon={} label={dbName} - expanded={isDbConnected ? isOpen(dbKey, true) : undefined} - onClick={() => { - if (!isDbConnected && !isDbConnecting) void onConnect(dbPid); - else if (isDbConnected) toggle(dbKey); - }} - onContextMenu={(e) => showMenu(e, [ - { header: "Database" }, - { label: "New Query", icon: , onClick: () => openTab(dbPid) }, - { label: isDbConnected ? "Reconnect" : "Connect", icon: , onClick: () => void onConnect(dbPid) }, - ...(isDbConnected ? [ - { label: "Refresh", icon: , onClick: () => void refreshConnection(dbPid) }, - { label: "LISTEN/NOTIFY", icon: , onClick: () => openNotifyTab(dbPid) }, - { label: "Schema Diff", icon: , onClick: () => openSchemaDiffTab(dbPid) }, - { label: "Extensions", icon: , onClick: () => openExtensionsTab(dbPid) }, - { label: "Enum Types", icon: , onClick: () => openEnumsTab(dbPid) }, - ] : []), - ...(onEditConnection ? [{ separator: true as const }, { label: "Edit Connection", icon: , onClick: () => onEditConnection(dbPid) }] : []), - { separator: true as const }, - { label: "Copy Name", icon: , onClick: () => copy(dbName) }, - { separator: true as const }, - { label: "Delete", icon: , onClick: () => void deleteProject(dbPid), destructive: true }, - ])} - trailing={ -
+ onClick={() => void addDatabaseToServer(pids[0], dbName, dbName)} + onContextMenu={(e) => + showMenu(e, [ + { + label: "Connect", + icon: , + onClick: () => void addDatabaseToServer(pids[0], dbName, dbName), + }, + { + label: "Copy Name", + icon: , + onClick: () => copy(dbName), + }, + ]) } /> - - {isDbConnected && isOpen(dbKey, true) && renderSchemas(ctx, dbPid)} -
- ); - } else { - // No project entry yet — clicking auto-creates one - return ( - } - label={dbName} - onClick={() => void addDatabaseToServer(pids[0], dbName, dbName)} - onContextMenu={(e) => showMenu(e, [ - { label: "Connect", icon: , onClick: () => void addDatabaseToServer(pids[0], dbName, dbName) }, - { label: "Copy Name", icon: , onClick: () => copy(dbName) }, - ])} - /> - ); - } - })} + ); + } + })} } label="Login/Group Roles" onClick={() => { - if (connectedPid) { openRolesTab(connectedPid); } - else { void onConnect(pids[0]).then(() => { const p = pids.find((id) => status[id] === ProjectConnectionStatus.Connected); if (p) openRolesTab(p); }); } + if (connectedPid) { + openRolesTab(connectedPid); + } else { + void onConnect(pids[0]).then(() => { + const p = pids.find((id) => status[id] === ProjectConnectionStatus.Connected); + if (p) openRolesTab(p); + }); + } }} /> {(() => { const tspCatKey = `${gKey}::tablespaces`; - const tspData = connectedPid ? (serverTablespaces[connectedPid] || []) : []; + const tspData = connectedPid ? serverTablespaces[connectedPid] || [] : []; return ( <> 0 ? ` (${tspData.length})` : ""}`} expanded={isOpen(tspCatKey)} onClick={() => { - if (connectedPid) { toggle(tspCatKey); } - else { void onConnect(pids[0]); } + if (connectedPid) { + toggle(tspCatKey); + } else { + void onConnect(pids[0]); + } }} /> - {isOpen(tspCatKey) && tspData.map(([name, owner, location]) => ( -
{ e.preventDefault(); e.stopPropagation(); showMenu(e, [ - { label: "Copy Name", icon: , onClick: () => copy(name) }, - ]); }}> - - {name} - {owner} - {location && {location}} -
- ))} + {isOpen(tspCatKey) && + tspData.map(([name, owner, location]) => ( +
{ + e.preventDefault(); + e.stopPropagation(); + showMenu(e, [ + { + label: "Copy Name", + icon: , + onClick: () => copy(name), + }, + ]); + }} + > + + {name} + {owner} + {location && ( + + {location} + + )} +
+ ))} ); })()} diff --git a/src/components/server-sidebar/render-table-details.tsx b/src/components/server-sidebar/render-table-details.tsx index d31e931..d1fb645 100644 --- a/src/components/server-sidebar/render-table-details.tsx +++ b/src/components/server-sidebar/render-table-details.tsx @@ -1,13 +1,4 @@ -import { - Columns3, - Copy, - Key, - Link2, - Lock, - ScrollText, - Shield, - Zap, -} from "lucide-react"; +import { Columns3, Copy, Key, Link2, Lock, ScrollText, Shield, Zap } from "lucide-react"; import { I } from "./constants"; import { SectionHeader } from "./section-header"; import type { SidebarRenderCtx } from "./types"; @@ -18,7 +9,18 @@ export function renderTableDetails( schema: string, tableName: string, ) { - const { columnDetails, indexes, constraints, triggers, rules, policies, isOpen, toggle, showMenu, copy } = ctx; + const { + columnDetails, + indexes, + constraints, + triggers, + rules, + policies, + isOpen, + toggle, + showMenu, + copy, + } = ctx; const tKey = `table::${pid}::${schema}::${tableName}`; const metaKey = `${pid}::${schema}::${tableName}`; const cols = columnDetails[metaKey]; @@ -33,102 +35,191 @@ export function renderTableDetails( return ( <> - } sectionKey={`${tKey}::cols`} - expanded={isOpen(`${tKey}::cols`, true)} onClick={() => toggle(`${tKey}::cols`)} /> - {isOpen(`${tKey}::cols`, true) && cols.map((c) => ( -
{ e.preventDefault(); e.stopPropagation(); showMenu(e, [ - { label: "Copy Column Name", icon: , onClick: () => copy(c.name) }, - ]); }}> - {pkCols.has(c.name) ? : } - {c.name} - {c.dataType} - {c.nullable && NULL} -
- ))} + } + sectionKey={`${tKey}::cols`} + expanded={isOpen(`${tKey}::cols`, true)} + onClick={() => toggle(`${tKey}::cols`)} + /> + {isOpen(`${tKey}::cols`, true) && + cols.map((c) => ( +
{ + e.preventDefault(); + e.stopPropagation(); + showMenu(e, [ + { + label: "Copy Column Name", + icon: , + onClick: () => copy(c.name), + }, + ]); + }} + > + {pkCols.has(c.name) ? ( + + ) : ( + + )} + {c.name} + {c.dataType} + {c.nullable && ( + NULL + )} +
+ ))} {idxs && idxs.length > 0 && ( <> - i.indexName)).size})`} - icon={} sectionKey={`${tKey}::idx`} - expanded={isOpen(`${tKey}::idx`)} onClick={() => toggle(`${tKey}::idx`)} /> - {isOpen(`${tKey}::idx`) && Array.from(new Set(idxs.map((i) => i.indexName))).map((name) => { - const idxEntries = idxs.filter((i) => i.indexName === name); - const f = idxEntries[0]; - return ( -
- {f.isPrimary ? : f.isUnique ? : } - {name} - ({idxEntries.map((e) => e.columnName).join(", ")}) - {f.isUnique && UNIQUE} -
- ); - })} + i.indexName)).size})`} + icon={} + sectionKey={`${tKey}::idx`} + expanded={isOpen(`${tKey}::idx`)} + onClick={() => toggle(`${tKey}::idx`)} + /> + {isOpen(`${tKey}::idx`) && + Array.from(new Set(idxs.map((i) => i.indexName))).map((name) => { + const idxEntries = idxs.filter((i) => i.indexName === name); + const f = idxEntries[0]; + return ( +
+ {f.isPrimary ? ( + + ) : f.isUnique ? ( + + ) : ( + + )} + {name} + + ({idxEntries.map((e) => e.columnName).join(", ")}) + + {f.isUnique && ( + UNIQUE + )} +
+ ); + })} )} {cons && cons.length > 0 && ( <> - c.constraintName)).size})`} - icon={} sectionKey={`${tKey}::con`} - expanded={isOpen(`${tKey}::con`)} onClick={() => toggle(`${tKey}::con`)} /> - {isOpen(`${tKey}::con`) && Array.from(new Set(cons.map((c) => c.constraintName))).map((name) => { - const f = cons.find((c) => c.constraintName === name)!; - return ( -
- - {name} - {f.constraintType} -
- ); - })} + c.constraintName)).size})`} + icon={} + sectionKey={`${tKey}::con`} + expanded={isOpen(`${tKey}::con`)} + onClick={() => toggle(`${tKey}::con`)} + /> + {isOpen(`${tKey}::con`) && + Array.from(new Set(cons.map((c) => c.constraintName))).map((name) => { + const f = cons.find((c) => c.constraintName === name)!; + return ( +
+ + {name} + + {f.constraintType} + +
+ ); + })} )} {trigs && trigs.length > 0 && ( <> - } sectionKey={`${tKey}::trig`} - expanded={isOpen(`${tKey}::trig`)} onClick={() => toggle(`${tKey}::trig`)} /> - {isOpen(`${tKey}::trig`) && trigs.map((t) => ( -
- - {t.triggerName} - {t.timing} {t.event} -
- ))} + } + sectionKey={`${tKey}::trig`} + expanded={isOpen(`${tKey}::trig`)} + onClick={() => toggle(`${tKey}::trig`)} + /> + {isOpen(`${tKey}::trig`) && + trigs.map((t) => ( +
+ + {t.triggerName} + + {t.timing} {t.event} + +
+ ))} )} {rls && rls.length > 0 && ( <> - } sectionKey={`${tKey}::rules`} - expanded={isOpen(`${tKey}::rules`)} onClick={() => toggle(`${tKey}::rules`)} /> - {isOpen(`${tKey}::rules`) && rls.map((r) => ( -
- - {r.ruleName} - {r.event} -
- ))} + } + sectionKey={`${tKey}::rules`} + expanded={isOpen(`${tKey}::rules`)} + onClick={() => toggle(`${tKey}::rules`)} + /> + {isOpen(`${tKey}::rules`) && + rls.map((r) => ( +
+ + {r.ruleName} + {r.event} +
+ ))} )} {pols && pols.length > 0 && ( <> - } sectionKey={`${tKey}::pol`} - expanded={isOpen(`${tKey}::pol`)} onClick={() => toggle(`${tKey}::pol`)} /> - {isOpen(`${tKey}::pol`) && pols.map((p) => ( -
- - {p.policyName} - {p.permissive} {p.command} -
- ))} + } + sectionKey={`${tKey}::pol`} + expanded={isOpen(`${tKey}::pol`)} + onClick={() => toggle(`${tKey}::pol`)} + /> + {isOpen(`${tKey}::pol`) && + pols.map((p) => ( +
+ + {p.policyName} + + {p.permissive} {p.command} + +
+ ))} )} diff --git a/src/components/server-sidebar/section-header.tsx b/src/components/server-sidebar/section-header.tsx index 1f63e00..f8f5c76 100644 --- a/src/components/server-sidebar/section-header.tsx +++ b/src/components/server-sidebar/section-header.tsx @@ -1,9 +1,13 @@ -import React from "react"; import { ChevronDown, ChevronRight } from "lucide-react"; +import type React from "react"; import { IndentGuides } from "./indent-guides"; export function SectionHeader({ - indent, label, icon, expanded, onClick, + indent, + label, + icon, + expanded, + onClick, }: { indent: number; label: string; @@ -13,11 +17,18 @@ export function SectionHeader({ onClick: () => void; }) { return ( - diff --git a/src/components/server-sidebar/tree-row.tsx b/src/components/server-sidebar/tree-row.tsx index f9d3f27..580f052 100644 --- a/src/components/server-sidebar/tree-row.tsx +++ b/src/components/server-sidebar/tree-row.tsx @@ -1,11 +1,20 @@ -import React from "react"; import { ChevronDown, ChevronRight, Loader2 } from "lucide-react"; +import type React from "react"; import { cn } from "@/lib/utils"; import { IndentGuides } from "./indent-guides"; export function TreeRow({ - indent, icon, label, bold, expanded, loading: isLoading, trailing, selected, - onClick, onDoubleClick, onContextMenu, + indent, + icon, + label, + bold, + expanded, + loading: isLoading, + trailing, + selected, + onClick, + onDoubleClick, + onContextMenu, }: { indent: number; icon: React.ReactNode; @@ -21,6 +30,7 @@ export function TreeRow({ }) { return (
- {activeProject && activeProjectDetails && status[activeProject] === ProjectConnectionStatus.Connected ? ( + {activeProject && + activeProjectDetails && + status[activeProject] === ProjectConnectionStatus.Connected ? (
{activeProject} @@ -54,7 +56,8 @@ export function TopBar({ {Object.entries(projects).map(([id, details]) => ( ))} @@ -68,6 +71,7 @@ export function TopBar({
{/* Command palette trigger */} @@ -95,11 +94,7 @@ export function TopBar({ className="h-8 w-8 hover:rotate-12 transition-all duration-200" onClick={toggleTheme} > - {theme === "light" ? ( - - ) : ( - - )} + {theme === "light" ? : }
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 77ce1d4..ce70602 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,29 +1,32 @@ -import * as React from 'react' -import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from '../../lib/utils' +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { cn } from "../../lib/utils"; const buttonVariants = cva( - 'inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + "inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/90 border border-transparent', - outline: 'border border-border bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground', - ghost: 'bg-transparent text-foreground hover:bg-white/[0.06] dark:hover:bg-white/[0.06] hover:bg-black/[0.04] hover:text-accent-foreground', - gradient: 'gradient-accent text-white border-0 hover:shadow-[0_0_20px_rgba(120,80,220,0.3)] hover:brightness-110', + default: "bg-primary text-primary-foreground hover:bg-primary/90 border border-transparent", + outline: + "border border-border bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground", + ghost: + "bg-transparent text-foreground hover:bg-white/[0.06] dark:hover:bg-white/[0.06] hover:bg-black/[0.04] hover:text-accent-foreground", + gradient: + "gradient-accent text-white border-0 hover:shadow-[0_0_20px_rgba(120,80,220,0.3)] hover:brightness-110", }, size: { - default: 'h-9 px-3 py-2', - sm: 'h-8 px-2 py-1', - icon: 'h-9 w-9', + default: "h-9 px-3 py-2", + sm: "h-8 px-2 py-1", + icon: "h-9 w-9", }, }, defaultVariants: { - variant: 'default', - size: 'default', + variant: "default", + size: "default", }, - } -) + }, +); export interface ButtonProps extends React.ButtonHTMLAttributes, @@ -33,14 +36,14 @@ const Button = React.forwardRef( ({ className, variant, size, type, ...props }, ref) => { return (
-)) -DialogContent.displayName = DialogPrimitive.Content.displayName +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-) -DialogHeader.displayName = 'DialogHeader' +
+); +DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
-) -DialogFooter.displayName = 'DialogFooter' +
+); +DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< React.ElementRef, @@ -75,11 +75,11 @@ const DialogTitle = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< React.ElementRef, @@ -87,19 +87,19 @@ const DialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; export { Dialog, - DialogTrigger, DialogClose, DialogContent, - DialogHeader, + DialogDescription, DialogFooter, + DialogHeader, DialogTitle, - DialogDescription, -} + DialogTrigger, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 6213d47..58afa8d 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,8 +1,7 @@ -import * as React from "react" -import { cn } from "@/lib/utils" +import * as React from "react"; +import { cn } from "@/lib/utils"; -export interface InputProps - extends React.InputHTMLAttributes {} +export interface InputProps extends React.InputHTMLAttributes {} const Input = React.forwardRef( ({ className, type, ...props }, ref) => { @@ -11,14 +10,14 @@ const Input = React.forwardRef( type={type} className={cn( "flex h-9 w-full rounded-lg border border-border/50 bg-input px-3 py-1 text-sm shadow-sm transition-all duration-150 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-50", - className + className, )} ref={ref} {...props} /> - ) - } -) -Input.displayName = "Input" + ); + }, +); +Input.displayName = "Input"; -export { Input } +export { Input }; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx index 97dea45..0bc44ea 100644 --- a/src/components/ui/label.tsx +++ b/src/components/ui/label.tsx @@ -1,6 +1,6 @@ -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { cn } from "@/lib/utils" +import * as LabelPrimitive from "@radix-ui/react-label"; +import * as React from "react"; +import { cn } from "@/lib/utils"; const Label = React.forwardRef< React.ElementRef, @@ -10,11 +10,11 @@ const Label = React.forwardRef< ref={ref} className={cn( "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", - className + className, )} {...props} /> -)) -Label.displayName = LabelPrimitive.Root.displayName +)); +Label.displayName = LabelPrimitive.Root.displayName; -export { Label } +export { Label }; diff --git a/src/hooks/use-query-lifecycle.ts b/src/hooks/use-query-lifecycle.ts index f7d6a9f..5aaed12 100644 --- a/src/hooks/use-query-lifecycle.ts +++ b/src/hooks/use-query-lifecycle.ts @@ -1,17 +1,17 @@ -import { useEffect, useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { DriverFactory } from "@/lib/database-driver"; -import * as virtualCache from "@/lib/virtual-cache"; import { CELL_SEP, - PAGE_SIZE, - ROW_SEP, isQueryCancelledError, notifyQueryComplete, + PAGE_SIZE, + ROW_SEP, } from "@/lib/query-helpers"; +import * as virtualCache from "@/lib/virtual-cache"; +import { useHistoryStore } from "@/stores/history-store"; import { useProjectStore } from "@/stores/project-store"; import { useTabStore } from "@/stores/tab-store"; import { useUIStore } from "@/stores/ui-store"; -import { useHistoryStore } from "@/stores/history-store"; interface UseQueryLifecycleArgs { setCommandPaletteOpen: (updater: (v: boolean) => boolean) => void; @@ -60,8 +60,13 @@ export function useQueryLifecycle({ setCommandPaletteOpen }: UseQueryLifecycleAr if (driver.executeVirtual) { const sql = tab.editorValue; const queryId = crypto.randomUUID().replace(/-/g, "").slice(0, 12); - const [colsPacked, totalRows, pagePacked, elapsed] = - await driver.executeVirtual(tab.projectId, sql, queryId, PAGE_SIZE, timeoutMs); + const [colsPacked, totalRows, pagePacked, elapsed] = await driver.executeVirtual( + tab.projectId, + sql, + queryId, + PAGE_SIZE, + timeoutMs, + ); if (!colsPacked) { const parts = pagePacked ? pagePacked.split(ROW_SEP) : []; @@ -93,7 +98,14 @@ export function useQueryLifecycle({ setCommandPaletteOpen }: UseQueryLifecycleAr notifyQueryComplete(tab.editorValue, elapsed, true, firstPage.length); } else { virtualCache.setPage(queryId, 0, firstPage); - setVirtualQuery(idx, { queryId, columns, totalRows, pageSize: PAGE_SIZE, colCount: columns.length, time: elapsed }); + setVirtualQuery(idx, { + queryId, + columns, + totalRows, + pageSize: PAGE_SIZE, + colCount: columns.length, + time: elapsed, + }); updateResult(idx, { columns, rows: firstPage, time: elapsed }); notifyQueryComplete(tab.editorValue, elapsed, true, totalRows); } @@ -256,7 +268,10 @@ export function useQueryLifecycle({ setCommandPaletteOpen }: UseQueryLifecycleAr const closingTab = t[idx]; if (closingTab?.virtualQuery?.queryId && closingTab.projectId) { const dd = useProjectStore.getState().projects[closingTab.projectId]; - if (dd) DriverFactory.getDriver(dd.driver).closeVirtual?.(closingTab.projectId, closingTab.virtualQuery.queryId).catch(() => {}); + if (dd) + DriverFactory.getDriver(dd.driver) + .closeVirtual?.(closingTab.projectId, closingTab.virtualQuery.queryId) + .catch(() => {}); virtualCache.clearQuery(closingTab.virtualQuery.queryId); } closeTab(idx); @@ -281,7 +296,7 @@ export function useQueryLifecycle({ setCommandPaletteOpen }: UseQueryLifecycleAr }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [cancelQuery, closeTab, runExplain]); + }, [cancelQuery, closeTab, runExplain, setCommandPaletteOpen]); return { runQuery, runExplain, cancelQuery, runSplitQuery }; } diff --git a/src/index.css b/src/index.css index 92dc4fb..199accf 100644 --- a/src/index.css +++ b/src/index.css @@ -4,72 +4,72 @@ @custom-variant dark (&:is(.dark *)); :root { - --background: oklch(0.97 0.005 260); - --foreground: oklch(0.15 0.01 260); - --card: oklch(1 0 0); - --card-foreground: oklch(0.15 0.01 260); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.15 0.01 260); - --primary: oklch(0.5 0.2 265); - --primary-foreground: oklch(0.98 0 0); - --secondary: oklch(0.94 0.005 260); - --secondary-foreground: oklch(0.15 0.01 260); - --muted: oklch(0.93 0.005 260); - --muted-foreground: oklch(0.45 0.01 260); - --accent: oklch(0.95 0.005 260); - --accent-foreground: oklch(0.15 0.01 260); - --destructive: oklch(0.55 0.22 25); - --destructive-foreground: oklch(0.98 0 0); - --border: oklch(0.88 0.01 260); - --input: oklch(0.96 0.005 260); - --ring: oklch(0.5 0.2 280); - --radius: 0.5rem; - --sidebar: oklch(0.96 0.005 260); - --sidebar-foreground: oklch(0.2 0.01 260); - --sidebar-primary: oklch(0.5 0.2 265); - --sidebar-primary-foreground: oklch(0.98 0 0); - --sidebar-accent: oklch(0.92 0.005 260); - --sidebar-accent-foreground: oklch(0.15 0.01 260); - --sidebar-border: oklch(0.88 0.01 260); - --sidebar-ring: oklch(0.5 0.2 280); - --success: oklch(0.52 0.18 150); - --warning: oklch(0.6 0.18 85); - --editor-bg: #fefefe; - --editor-line: #f5f5f8; + --background: oklch(0.97 0.005 260); + --foreground: oklch(0.15 0.01 260); + --card: oklch(1 0 0); + --card-foreground: oklch(0.15 0.01 260); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.15 0.01 260); + --primary: oklch(0.5 0.2 265); + --primary-foreground: oklch(0.98 0 0); + --secondary: oklch(0.94 0.005 260); + --secondary-foreground: oklch(0.15 0.01 260); + --muted: oklch(0.93 0.005 260); + --muted-foreground: oklch(0.45 0.01 260); + --accent: oklch(0.95 0.005 260); + --accent-foreground: oklch(0.15 0.01 260); + --destructive: oklch(0.55 0.22 25); + --destructive-foreground: oklch(0.98 0 0); + --border: oklch(0.88 0.01 260); + --input: oklch(0.96 0.005 260); + --ring: oklch(0.5 0.2 280); + --radius: 0.5rem; + --sidebar: oklch(0.96 0.005 260); + --sidebar-foreground: oklch(0.2 0.01 260); + --sidebar-primary: oklch(0.5 0.2 265); + --sidebar-primary-foreground: oklch(0.98 0 0); + --sidebar-accent: oklch(0.92 0.005 260); + --sidebar-accent-foreground: oklch(0.15 0.01 260); + --sidebar-border: oklch(0.88 0.01 260); + --sidebar-ring: oklch(0.5 0.2 280); + --success: oklch(0.52 0.18 150); + --warning: oklch(0.6 0.18 85); + --editor-bg: #fefefe; + --editor-line: #f5f5f8; } .dark { - --background: oklch(0.13 0.015 260); - --foreground: oklch(0.93 0.01 260); - --card: oklch(0.16 0.015 260); - --card-foreground: oklch(0.93 0.01 260); - --popover: oklch(0.18 0.015 260); - --popover-foreground: oklch(0.93 0.01 260); - --primary: oklch(0.65 0.2 265); - --primary-foreground: oklch(0.98 0 0); - --secondary: oklch(0.2 0.02 260); - --secondary-foreground: oklch(0.93 0.01 260); - --muted: oklch(0.22 0.015 260); - --muted-foreground: oklch(0.55 0.01 260); - --accent: oklch(0.22 0.02 265); - --accent-foreground: oklch(0.93 0.01 260); - --destructive: oklch(0.55 0.22 25); - --destructive-foreground: oklch(0.93 0.01 260); - --border: oklch(0.24 0.015 260); - --input: oklch(0.19 0.015 260); - --ring: oklch(0.65 0.2 280); - --sidebar: oklch(0.11 0.015 260); - --sidebar-foreground: oklch(0.85 0.01 260); - --sidebar-primary: oklch(0.65 0.2 265); - --sidebar-primary-foreground: oklch(0.98 0 0); - --sidebar-accent: oklch(0.18 0.02 260); - --sidebar-accent-foreground: oklch(0.93 0.01 260); - --sidebar-border: oklch(0.2 0.015 260); - --sidebar-ring: oklch(0.65 0.2 280); - --success: oklch(0.65 0.18 150); - --warning: oklch(0.75 0.18 85); - --editor-bg: #1c1a2e; - --editor-line: #252340; + --background: oklch(0.13 0.015 260); + --foreground: oklch(0.93 0.01 260); + --card: oklch(0.16 0.015 260); + --card-foreground: oklch(0.93 0.01 260); + --popover: oklch(0.18 0.015 260); + --popover-foreground: oklch(0.93 0.01 260); + --primary: oklch(0.65 0.2 265); + --primary-foreground: oklch(0.98 0 0); + --secondary: oklch(0.2 0.02 260); + --secondary-foreground: oklch(0.93 0.01 260); + --muted: oklch(0.22 0.015 260); + --muted-foreground: oklch(0.55 0.01 260); + --accent: oklch(0.22 0.02 265); + --accent-foreground: oklch(0.93 0.01 260); + --destructive: oklch(0.55 0.22 25); + --destructive-foreground: oklch(0.93 0.01 260); + --border: oklch(0.24 0.015 260); + --input: oklch(0.19 0.015 260); + --ring: oklch(0.65 0.2 280); + --sidebar: oklch(0.11 0.015 260); + --sidebar-foreground: oklch(0.85 0.01 260); + --sidebar-primary: oklch(0.65 0.2 265); + --sidebar-primary-foreground: oklch(0.98 0 0); + --sidebar-accent: oklch(0.18 0.02 260); + --sidebar-accent-foreground: oklch(0.93 0.01 260); + --sidebar-border: oklch(0.2 0.015 260); + --sidebar-ring: oklch(0.65 0.2 280); + --success: oklch(0.65 0.18 150); + --warning: oklch(0.75 0.18 85); + --editor-bg: #1c1a2e; + --editor-line: #252340; } @theme inline { @@ -113,162 +113,163 @@ } @layer base { - * { - @apply border-border outline-ring/50; - } - html, body { - @apply bg-background text-foreground; - overflow: hidden; - height: 100%; - } - #root { - height: 100%; - overflow: hidden; - } + * { + @apply border-border outline-ring/50; + } + html, + body { + @apply bg-background text-foreground; + overflow: hidden; + height: 100%; + } + #root { + height: 100%; + overflow: hidden; + } } /* Gradient accent utility */ .gradient-accent { - background: linear-gradient(135deg, oklch(0.6 0.22 255), oklch(0.55 0.22 290)); + background: linear-gradient(135deg, oklch(0.6 0.22 255), oklch(0.55 0.22 290)); } /* Hide scrollbar utility */ .scrollbar-none::-webkit-scrollbar { - display: none; + display: none; } .scrollbar-none { - -ms-overflow-style: none; - scrollbar-width: none; + -ms-overflow-style: none; + scrollbar-width: none; } /* Custom thin scrollbar */ ::-webkit-scrollbar { - width: 6px; - height: 6px; + width: 6px; + height: 6px; } ::-webkit-scrollbar-track { - background: transparent; + background: transparent; } ::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 3px; + background: var(--border); + border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { - background: var(--muted-foreground); + background: var(--muted-foreground); } /* Slightly wider scrollbar for result grid only */ .results-grid-scroll .dvn-scroller::-webkit-scrollbar { - width: 10px; - height: 10px; + width: 10px; + height: 10px; } .results-grid-scroll .dvn-scroller::-webkit-scrollbar-thumb { - border-radius: 6px; + border-radius: 6px; } /* cmdk command palette */ .cmdk-overlay { - position: fixed; - inset: 0; - z-index: 9999; - background: rgba(0, 0, 0, 0.5); + position: fixed; + inset: 0; + z-index: 9999; + background: rgba(0, 0, 0, 0.5); } .cmdk-content { - position: fixed; - left: 50%; - top: 15%; - z-index: 10000; - width: 560px; - max-width: calc(100vw - 2rem); - transform: translateX(-50%); - border-radius: 0.5rem; - border: 1px solid var(--border); - background: var(--popover); - color: var(--popover-foreground); - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); - overflow: hidden; - padding: 0; + position: fixed; + left: 50%; + top: 15%; + z-index: 10000; + width: 560px; + max-width: calc(100vw - 2rem); + transform: translateX(-50%); + border-radius: 0.5rem; + border: 1px solid var(--border); + background: var(--popover); + color: var(--popover-foreground); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + overflow: hidden; + padding: 0; } [cmdk-input] { - width: 100%; - border: none; - border-bottom: 1px solid var(--border); - background: transparent; - padding: 0.75rem 1rem; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 0.875rem; - color: var(--foreground); - outline: none; - caret-color: var(--primary); + width: 100%; + border: none; + border-bottom: 1px solid var(--border); + background: transparent; + padding: 0.75rem 1rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.875rem; + color: var(--foreground); + outline: none; + caret-color: var(--primary); } [cmdk-input]::placeholder { - color: var(--muted-foreground); + color: var(--muted-foreground); } [cmdk-list] { - max-height: 400px; - overflow-y: auto; - padding: 0.25rem 0; + max-height: 400px; + overflow-y: auto; + padding: 0.25rem 0; } [cmdk-group-heading] { - padding: 0.375rem 1rem; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 0.625rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--muted-foreground); - opacity: 0.7; + padding: 0.375rem 1rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted-foreground); + opacity: 0.7; } [cmdk-item] { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.5rem 1rem; - cursor: pointer; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 0.875rem; - color: var(--foreground); - transition: background-color 0.1s; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + cursor: pointer; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.875rem; + color: var(--foreground); + transition: background-color 0.1s; } [cmdk-item][data-selected="true"] { - background: var(--accent); + background: var(--accent); } [cmdk-item]:hover { - background: var(--accent); - opacity: 0.8; + background: var(--accent); + opacity: 0.8; } [cmdk-item][data-disabled="true"] { - opacity: 0.5; - pointer-events: none; + opacity: 0.5; + pointer-events: none; } [cmdk-empty] { - padding: 2rem 1rem; - text-align: center; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 0.875rem; - color: var(--muted-foreground); + padding: 2rem 1rem; + text-align: center; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.875rem; + color: var(--muted-foreground); } .cmdk-meta { - margin-left: auto; - padding: 0.125rem 0.375rem; - border-radius: 0.25rem; - background: var(--muted); - font-size: 0.625rem; - color: var(--muted-foreground); - flex-shrink: 0; + margin-left: auto; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + background: var(--muted); + font-size: 0.625rem; + color: var(--muted-foreground); + flex-shrink: 0; } .cmdk-detail { - font-size: 0.75rem; - color: var(--muted-foreground); - flex-shrink: 0; + font-size: 0.75rem; + color: var(--muted-foreground); + flex-shrink: 0; } /* Sidebar indent guides */ .sidebar-indent-guide { - position: absolute; - top: 0; - bottom: 0; - width: 1px; - background: var(--sidebar-border); - pointer-events: none; - opacity: 0.3; + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--sidebar-border); + pointer-events: none; + opacity: 0.3; } diff --git a/src/lib/alter-table-sql.ts b/src/lib/alter-table-sql.ts index d0ececf..148d05e 100644 --- a/src/lib/alter-table-sql.ts +++ b/src/lib/alter-table-sql.ts @@ -96,13 +96,7 @@ export const PG_COMMON_TYPES = [ "jsonb[]", ]; -export const FK_ACTIONS = [ - "NO ACTION", - "RESTRICT", - "CASCADE", - "SET NULL", - "SET DEFAULT", -]; +export const FK_ACTIONS = ["NO ACTION", "RESTRICT", "CASCADE", "SET NULL", "SET DEFAULT"]; export function generateAlterTableSQL( schema: string, @@ -117,25 +111,19 @@ export function generateAlterTableSQL( // and drop everything before adding new objects, otherwise PG errors out. for (const fk of draft.foreignKeys) { if (fk._status === "removed") { - stmts.push( - `ALTER TABLE ${target} DROP CONSTRAINT ${quoteIdent(fk.constraintName)};`, - ); + stmts.push(`ALTER TABLE ${target} DROP CONSTRAINT ${quoteIdent(fk.constraintName)};`); } } for (const uc of draft.uniqueConstraints) { if (uc._status === "removed") { - stmts.push( - `ALTER TABLE ${target} DROP CONSTRAINT ${quoteIdent(uc.constraintName)};`, - ); + stmts.push(`ALTER TABLE ${target} DROP CONSTRAINT ${quoteIdent(uc.constraintName)};`); } } for (const idx of draft.indexes) { if (idx._status === "removed") { - stmts.push( - `DROP INDEX ${quoteIdent(schema)}.${quoteIdent(idx.indexName)};`, - ); + stmts.push(`DROP INDEX ${quoteIdent(schema)}.${quoteIdent(idx.indexName)};`); } } @@ -160,7 +148,7 @@ export function generateAlterTableSQL( let stmt = `ALTER TABLE ${target} ADD COLUMN ${quoteIdent(col.name)} ${col.dataType}`; if (!col.nullable) stmt += " NOT NULL"; if (col.defaultValue) stmt += ` DEFAULT ${col.defaultValue}`; - stmts.push(stmt + ";"); + stmts.push(`${stmt};`); } } @@ -180,10 +168,7 @@ export function generateAlterTableSQL( ); } - if ( - col.originalNullable !== undefined && - col.originalNullable !== col.nullable - ) { + if (col.originalNullable !== undefined && col.originalNullable !== col.nullable) { if (col.nullable) { stmts.push( `ALTER TABLE ${target} ALTER COLUMN ${quoteIdent(effectiveName)} DROP NOT NULL;`, @@ -195,10 +180,7 @@ export function generateAlterTableSQL( } } - if ( - col.originalDefault !== undefined && - col.originalDefault !== col.defaultValue - ) { + if (col.originalDefault !== undefined && col.originalDefault !== col.defaultValue) { if (col.defaultValue) { stmts.push( `ALTER TABLE ${target} ALTER COLUMN ${quoteIdent(effectiveName)} SET DEFAULT ${col.defaultValue};`, @@ -214,8 +196,7 @@ export function generateAlterTableSQL( if ( draft.primaryKey && - (draft.primaryKey._status === "added" || - draft.primaryKey._status === "modified") + (draft.primaryKey._status === "added" || draft.primaryKey._status === "modified") ) { const pkCols = draft.primaryKey.columns.map(quoteIdent).join(", "); stmts.push( @@ -236,9 +217,7 @@ export function generateAlterTableSQL( if (idx._status === "added") { const idxCols = idx.columns.map(quoteIdent).join(", "); const unique = idx.isUnique ? "UNIQUE " : ""; - stmts.push( - `CREATE ${unique}INDEX ${quoteIdent(idx.indexName)} ON ${target} (${idxCols});`, - ); + stmts.push(`CREATE ${unique}INDEX ${quoteIdent(idx.indexName)} ON ${target} (${idxCols});`); } } diff --git a/src/lib/database-driver/factory.ts b/src/lib/database-driver/factory.ts index b2ece62..6ec7564 100644 --- a/src/lib/database-driver/factory.ts +++ b/src/lib/database-driver/factory.ts @@ -8,7 +8,7 @@ export class DriverFactory { ]); static getDriver(driverType: DriverType): DatabaseDriver { - const driver = this.drivers.get(driverType); + const driver = DriverFactory.drivers.get(driverType); if (!driver) { throw new Error(`Driver ${driverType} not found`); } @@ -16,7 +16,7 @@ export class DriverFactory { } static getSupportedDrivers(): DriverType[] { - return Array.from(this.drivers.keys()); + return Array.from(DriverFactory.drivers.keys()); } } diff --git a/src/lib/database-driver/index.ts b/src/lib/database-driver/index.ts index 37a2222..bafb17d 100644 --- a/src/lib/database-driver/index.ts +++ b/src/lib/database-driver/index.ts @@ -1,8 +1,17 @@ -import { ProjectConnectionStatus } from "@/types"; import type { - ColumnDetail, IndexDetail, ConstraintDetail, - TriggerDetail, RuleDetail, PolicyDetail, FunctionInfo, TriggerFunctionInfo, - PgRole, TableGrant, DbGrant, SchemaObject, + ColumnDetail, + ConstraintDetail, + DbGrant, + FunctionInfo, + IndexDetail, + PgRole, + PolicyDetail, + ProjectConnectionStatus, + RuleDetail, + SchemaObject, + TableGrant, + TriggerDetail, + TriggerFunctionInfo, } from "@/types"; // Wire types from Rust (tuples) @@ -49,7 +58,11 @@ export interface StreamCallbacks { } export interface DatabaseDriver { - connect(projectId: string, key: [string, string, string, string, string, string], ssh?: string[]): Promise; + connect( + projectId: string, + key: [string, string, string, string, string, string], + ssh?: string[], + ): Promise; cancelQuery?(projectId: string): Promise; loadSchemas(projectId: string): Promise; loadTables(projectId: string, schema: string): Promise; @@ -65,22 +78,63 @@ export interface DatabaseDriver { loadFunctions(projectId: string, schema: string): Promise; loadTriggerFunctions(projectId: string, schema: string): Promise; runQuery(projectId: string, sql: string, timeoutMs?: number): Promise; - runQueryStreamed?(projectId: string, sql: string, streamId: string, callbacks: StreamCallbacks): Promise; - executeVirtual?(projectId: string, sql: string, queryId: string, pageSize: number, timeoutMs?: number): Promise<[string, number, string, number]>; - fetchPage?(projectId: string, queryId: string, colCount: number, offset: number, limit: number): Promise; + runQueryStreamed?( + projectId: string, + sql: string, + streamId: string, + callbacks: StreamCallbacks, + ): Promise; + executeVirtual?( + projectId: string, + sql: string, + queryId: string, + pageSize: number, + timeoutMs?: number, + ): Promise<[string, number, string, number]>; + fetchPage?( + projectId: string, + queryId: string, + colCount: number, + offset: number, + limit: number, + ): Promise; closeVirtual?(projectId: string, queryId: string): Promise; loadActivity(projectId: string): Promise; loadDatabaseStats(projectId: string): Promise<[string, string][]>; loadTableStats(projectId: string): Promise; loadForeignKeys(projectId: string, schema: string): Promise; - loadTableStatistics?(projectId: string, schema: string, table: string): Promise<[string, string][]>; - loadFKDetails?(projectId: string, schema: string, table: string, direction: string): Promise<[string, string, string, string, string, string, string, string, string][]>; + loadTableStatistics?( + projectId: string, + schema: string, + table: string, + ): Promise<[string, string][]>; + loadFKDetails?( + projectId: string, + schema: string, + table: string, + direction: string, + ): Promise<[string, string, string, string, string, string, string, string, string][]>; loadViewInfo?(projectId: string, schema: string, view: string): Promise<[string, string][]>; loadMatviewInfo?(projectId: string, schema: string, matview: string): Promise<[string, string][]>; - loadFunctionInfo?(projectId: string, schema: string, funcName: string): Promise<[string, string][]>; - generateDDL?(projectId: string, schema: string, name: string, objectType: string): Promise; + loadFunctionInfo?( + projectId: string, + schema: string, + funcName: string, + ): Promise<[string, string][]>; + generateDDL?( + projectId: string, + schema: string, + name: string, + objectType: string, + ): Promise; csvPreview?(filePath: string): Promise<[string[], string[][]]>; - csvImport?(projectId: string, filePath: string, schema: string, table: string, columnMapping: [number, string][]): Promise; + csvImport?( + projectId: string, + filePath: string, + schema: string, + table: string, + columnMapping: [number, string][], + ): Promise; listenStart?(projectId: string, channel: string): Promise; listenStop?(projectId: string, channel: string): Promise; notifySend?(projectId: string, channel: string, payload: string): Promise; @@ -98,30 +152,46 @@ export interface DatabaseDriver { loadAvailableExtensions?(projectId: string): Promise; loadEnumTypes?(projectId: string): Promise; loadPgSettings?(projectId: string): Promise; - tableAction?(projectId: string, action: string, schema: string, table: string, objectType: string): Promise; + tableAction?( + projectId: string, + action: string, + schema: string, + table: string, + objectType: string, + ): Promise; } export function parseColumnDetails(wire: WireColumnDetail[]): ColumnDetail[] { return wire.map(([name, dataType, nullable, defaultValue]) => ({ - name, dataType, nullable, defaultValue, + name, + dataType, + nullable, + defaultValue, })); } export function parseIndexDetails(wire: WireIndexDetail[]): IndexDetail[] { return wire.map(([indexName, columnName, isUnique, isPrimary]) => ({ - indexName, columnName, isUnique, isPrimary, + indexName, + columnName, + isUnique, + isPrimary, })); } export function parseConstraintDetails(wire: WireConstraintDetail[]): ConstraintDetail[] { return wire.map(([constraintName, constraintType, columnName]) => ({ - constraintName, constraintType, columnName, + constraintName, + constraintType, + columnName, })); } export function parseTriggerDetails(wire: WireTriggerDetail[]): TriggerDetail[] { return wire.map(([triggerName, event, timing]) => ({ - triggerName, event, timing, + triggerName, + event, + timing, })); } @@ -131,21 +201,26 @@ export function parseRuleDetails(wire: WireRuleDetail[]): RuleDetail[] { export function parsePolicyDetails(wire: WirePolicyDetail[]): PolicyDetail[] { return wire.map(([policyName, permissive, command]) => ({ - policyName, permissive, command, + policyName, + permissive, + command, })); } export function parseFunctionInfo(wire: WireFunctionInfo[]): FunctionInfo[] { return wire.map(([name, returnType, arguments_]) => ({ - name, returnType, arguments: arguments_, + name, + returnType, + arguments: arguments_, })); } export function parseTriggerFunctionInfo(wire: WireTriggerFunctionInfo[]): TriggerFunctionInfo[] { return wire.map(([name, arguments_]) => ({ - name, arguments: arguments_, + name, + arguments: arguments_, })); } -export { DriverFactory, DRIVER_CONFIGS } from "./factory"; export type { DriverConfig, DriverType } from "./factory"; +export { DRIVER_CONFIGS, DriverFactory } from "./factory"; diff --git a/src/lib/database-driver/pgsql.ts b/src/lib/database-driver/pgsql.ts index 62de7de..20bf797 100644 --- a/src/lib/database-driver/pgsql.ts +++ b/src/lib/database-driver/pgsql.ts @@ -1,42 +1,47 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { ProjectConnectionStatus } from "@/types"; -import type { - PgRole, TableGrant, DbGrant, SchemaObject, -} from "@/types"; -import { - CELL_SEP, - ROW_SEP, - unpackResult, - parseColumnDetails, - parseIndexDetails, - parseConstraintDetails, - parseTriggerDetails, - parseRuleDetails, - parsePolicyDetails, - parseFunctionInfo, - parseTriggerFunctionInfo, -} from "./index"; +import type { DbGrant, PgRole, ProjectConnectionStatus, SchemaObject, TableGrant } from "@/types"; import type { DatabaseDriver, - StreamCallbacks, QueryStreamEvent, - WireTableInfo, - WirePackedResult, + StreamCallbacks, WireColumnDetail, - WireIndexDetail, WireConstraintDetail, - WireTriggerDetail, - WireRuleDetail, - WirePolicyDetail, + WireForeignKeyInfo, WireFunctionInfo, + WireIndexDetail, + WirePackedResult, + WirePolicyDetail, + WireRuleDetail, + WireTableInfo, + WireTriggerDetail, WireTriggerFunctionInfo, - WireForeignKeyInfo, +} from "./index"; +import { + CELL_SEP, + parseColumnDetails, + parseConstraintDetails, + parseFunctionInfo, + parseIndexDetails, + parsePolicyDetails, + parseRuleDetails, + parseTriggerDetails, + parseTriggerFunctionInfo, + ROW_SEP, + unpackResult, } from "./index"; export class PostgreSQLDriver implements DatabaseDriver { - async connect(projectId: string, key: [string, string, string, string, string, string], ssh?: string[]) { - return invoke("pgsql_connector", { project_id: projectId, key, ssh: ssh ?? null }); + async connect( + projectId: string, + key: [string, string, string, string, string, string], + ssh?: string[], + ) { + return invoke("pgsql_connector", { + project_id: projectId, + key, + ssh: ssh ?? null, + }); } async cancelQuery(projectId: string) { return invoke("pgsql_cancel_query", { project_id: projectId }); @@ -51,27 +56,51 @@ export class PostgreSQLDriver implements DatabaseDriver { return invoke("pgsql_load_columns", { project_id: projectId, schema, table }); } async loadColumnDetails(projectId: string, schema: string, table: string) { - const wire = await invoke("pgsql_load_column_details", { project_id: projectId, schema, table }); + const wire = await invoke("pgsql_load_column_details", { + project_id: projectId, + schema, + table, + }); return parseColumnDetails(wire); } async loadIndexes(projectId: string, schema: string, table: string) { - const wire = await invoke("pgsql_load_indexes", { project_id: projectId, schema, table }); + const wire = await invoke("pgsql_load_indexes", { + project_id: projectId, + schema, + table, + }); return parseIndexDetails(wire); } async loadConstraints(projectId: string, schema: string, table: string) { - const wire = await invoke("pgsql_load_constraints", { project_id: projectId, schema, table }); + const wire = await invoke("pgsql_load_constraints", { + project_id: projectId, + schema, + table, + }); return parseConstraintDetails(wire); } async loadTriggers(projectId: string, schema: string, table: string) { - const wire = await invoke("pgsql_load_triggers", { project_id: projectId, schema, table }); + const wire = await invoke("pgsql_load_triggers", { + project_id: projectId, + schema, + table, + }); return parseTriggerDetails(wire); } async loadRules(projectId: string, schema: string, table: string) { - const wire = await invoke("pgsql_load_rules", { project_id: projectId, schema, table }); + const wire = await invoke("pgsql_load_rules", { + project_id: projectId, + schema, + table, + }); return parseRuleDetails(wire); } async loadPolicies(projectId: string, schema: string, table: string) { - const wire = await invoke("pgsql_load_policies", { project_id: projectId, schema, table }); + const wire = await invoke("pgsql_load_policies", { + project_id: projectId, + schema, + table, + }); return parsePolicyDetails(wire); } async loadViews(projectId: string, schema: string) { @@ -81,17 +110,25 @@ export class PostgreSQLDriver implements DatabaseDriver { return invoke("pgsql_load_materialized_views", { project_id: projectId, schema }); } async loadFunctions(projectId: string, schema: string) { - const wire = await invoke("pgsql_load_functions", { project_id: projectId, schema }); + const wire = await invoke("pgsql_load_functions", { + project_id: projectId, + schema, + }); return parseFunctionInfo(wire); } async loadTriggerFunctions(projectId: string, schema: string) { - const wire = await invoke("pgsql_load_trigger_functions", { project_id: projectId, schema }); + const wire = await invoke("pgsql_load_trigger_functions", { + project_id: projectId, + schema, + }); return parseTriggerFunctionInfo(wire); } async runQuery(projectId: string, sql: string, timeoutMs?: number) { // Use packed format for faster IPC (avoids JSON overhead of nested arrays) const [packed, time] = await invoke("pgsql_run_query_packed", { - project_id: projectId, sql, timeout_ms: timeoutMs ?? null, + project_id: projectId, + sql, + timeout_ms: timeoutMs ?? null, }); return unpackResult(packed, time); } @@ -108,32 +145,29 @@ export class PostgreSQLDriver implements DatabaseDriver { rejectStream = reject; }); - const unlisten = await listen( - `query-stream-${streamId}`, - (event) => { - const p = event.payload; - switch (p.type) { - case "columns": { - const cols = p.columns ? p.columns.split(CELL_SEP) : []; - onColumns(cols, p.total_rows); - break; - } - case "chunk": { - if (p.data) { - const rows = p.data.split(ROW_SEP).map((r) => r.split(CELL_SEP)); - onChunk(rows); - } - break; - } - case "done": { - onDone(p.elapsed, p.capped); - unlisten(); - resolveStream!(); - break; + const unlisten = await listen(`query-stream-${streamId}`, (event) => { + const p = event.payload; + switch (p.type) { + case "columns": { + const cols = p.columns ? p.columns.split(CELL_SEP) : []; + onColumns(cols, p.total_rows); + break; + } + case "chunk": { + if (p.data) { + const rows = p.data.split(ROW_SEP).map((r) => r.split(CELL_SEP)); + onChunk(rows); } + break; } - }, - ); + case "done": { + onDone(p.elapsed, p.capped); + unlisten(); + resolveStream?.(); + break; + } + } + }); invoke("pgsql_run_query_streamed", { project_id: projectId, @@ -141,19 +175,38 @@ export class PostgreSQLDriver implements DatabaseDriver { stream_id: streamId, }).catch((err) => { unlisten(); - rejectStream!(err); + rejectStream?.(err); }); return streamDone; } - async executeVirtual(projectId: string, sql: string, queryId: string, pageSize: number, timeoutMs?: number) { + async executeVirtual( + projectId: string, + sql: string, + queryId: string, + pageSize: number, + timeoutMs?: number, + ) { return invoke<[string, number, string, number]>("pgsql_execute_virtual", { - project_id: projectId, sql, query_id: queryId, page_size: pageSize, timeout_ms: timeoutMs ?? null, + project_id: projectId, + sql, + query_id: queryId, + page_size: pageSize, + timeout_ms: timeoutMs ?? null, }); } - async fetchPage(_projectId: string, queryId: string, colCount: number, offset: number, limit: number) { + async fetchPage( + _projectId: string, + queryId: string, + colCount: number, + offset: number, + limit: number, + ) { return invoke("pgsql_fetch_page", { - query_id: queryId, col_count: colCount, offset, limit, + query_id: queryId, + col_count: colCount, + offset, + limit, }); } async closeVirtual(_projectId: string, queryId: string) { @@ -171,34 +224,72 @@ export class PostgreSQLDriver implements DatabaseDriver { return invoke("pgsql_load_table_stats", { project_id: projectId }); } async loadForeignKeys(projectId: string, schema: string) { - const wire = await invoke("pgsql_load_foreign_keys", { project_id: projectId, schema }); + const wire = await invoke("pgsql_load_foreign_keys", { + project_id: projectId, + schema, + }); return wire.map(([sourceTable, sourceColumn, targetTable, targetColumn]) => ({ - sourceTable, sourceColumn, targetTable, targetColumn, + sourceTable, + sourceColumn, + targetTable, + targetColumn, })); } async loadTableStatistics(projectId: string, schema: string, table: string) { - return invoke<[string, string][]>("pgsql_table_statistics", { project_id: projectId, schema, table }); + return invoke<[string, string][]>("pgsql_table_statistics", { + project_id: projectId, + schema, + table, + }); } async loadFKDetails(projectId: string, schema: string, table: string, direction: string) { - return invoke<[string, string, string, string, string, string, string, string, string][]>("pgsql_fk_details", { project_id: projectId, schema, table, direction }); + return invoke<[string, string, string, string, string, string, string, string, string][]>( + "pgsql_fk_details", + { project_id: projectId, schema, table, direction }, + ); } async loadViewInfo(projectId: string, schema: string, view: string) { return invoke<[string, string][]>("pgsql_view_info", { project_id: projectId, schema, view }); } async loadMatviewInfo(projectId: string, schema: string, matview: string) { - return invoke<[string, string][]>("pgsql_matview_info", { project_id: projectId, schema, matview }); + return invoke<[string, string][]>("pgsql_matview_info", { + project_id: projectId, + schema, + matview, + }); } async loadFunctionInfo(projectId: string, schema: string, funcName: string) { - return invoke<[string, string][]>("pgsql_function_info", { project_id: projectId, schema, func_name: funcName }); + return invoke<[string, string][]>("pgsql_function_info", { + project_id: projectId, + schema, + func_name: funcName, + }); } async generateDDL(projectId: string, schema: string, name: string, objectType: string) { - return invoke("pgsql_generate_ddl", { project_id: projectId, schema, name, object_type: objectType }); + return invoke("pgsql_generate_ddl", { + project_id: projectId, + schema, + name, + object_type: objectType, + }); } async csvPreview(filePath: string) { return invoke<[string[], string[][]]>("pgsql_csv_preview", { file_path: filePath }); } - async csvImport(projectId: string, filePath: string, schema: string, table: string, columnMapping: [number, string][]) { - return invoke("pgsql_csv_import", { project_id: projectId, file_path: filePath, schema, table, column_mapping: columnMapping }); + async csvImport( + projectId: string, + filePath: string, + schema: string, + table: string, + columnMapping: [number, string][], + ) { + return invoke("pgsql_csv_import", { + project_id: projectId, + file_path: filePath, + schema, + table, + column_mapping: columnMapping, + }); } async listenStart(projectId: string, channel: string) { return invoke("pgsql_listen_start", { project_id: projectId, channel }); @@ -216,13 +307,22 @@ export class PostgreSQLDriver implements DatabaseDriver { return invoke("pgsql_load_roles", { project_id: projectId }); } async loadTableGrants(projectId: string, roleName: string) { - return invoke("pgsql_load_table_grants", { project_id: projectId, role_name: roleName }); + return invoke("pgsql_load_table_grants", { + project_id: projectId, + role_name: roleName, + }); } async loadDatabaseGrants(projectId: string, roleName: string) { - return invoke("pgsql_load_database_grants", { project_id: projectId, role_name: roleName }); + return invoke("pgsql_load_database_grants", { + project_id: projectId, + role_name: roleName, + }); } async extractSchemaObjects(projectId: string, schema: string) { - return invoke("pgsql_extract_schema_objects", { project_id: projectId, schema }); + return invoke("pgsql_extract_schema_objects", { + project_id: projectId, + schema, + }); } async loadLocks(projectId: string) { return invoke("pgsql_load_locks", { project_id: projectId }); @@ -251,7 +351,19 @@ export class PostgreSQLDriver implements DatabaseDriver { async loadPgSettings(projectId: string) { return invoke("pgsql_load_pg_settings", { project_id: projectId }); } - async tableAction(projectId: string, action: string, schema: string, table: string, objectType: string) { - return invoke("pgsql_table_action", { project_id: projectId, action, schema, table, object_type: objectType }); + async tableAction( + projectId: string, + action: string, + schema: string, + table: string, + objectType: string, + ) { + return invoke("pgsql_table_action", { + project_id: projectId, + action, + schema, + table, + object_type: objectType, + }); } } diff --git a/src/lib/export.ts b/src/lib/export.ts index 70b3009..5dd385c 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -54,7 +54,9 @@ export function toSQL(columns: string[], rows: string[][], tableName = "table_na export function toMarkdown(columns: string[], rows: string[][]): string { const header = `| ${columns.join(" | ")} |`; const separator = `| ${columns.map(() => "---").join(" | ")} |`; - const body = rows.map((r) => `| ${r.map((c) => c.replace(/\|/g, "\\|")).join(" | ")} |`).join("\n"); + const body = rows + .map((r) => `| ${r.map((c) => c.replace(/\|/g, "\\|")).join(" | ")} |`) + .join("\n"); return `${header}\n${separator}\n${body}`; } @@ -71,7 +73,10 @@ export function toXML(columns: string[], rows: string[][]): string { return lines.join("\n"); } -const formatters: Record string> = { +const formatters: Record< + ExportFormat, + (cols: string[], rows: string[][], table?: string) => string +> = { csv: toCSV, json: toJSON, sql: toSQL, diff --git a/src/lib/query-helpers.ts b/src/lib/query-helpers.ts index fed0070..d7de345 100644 --- a/src/lib/query-helpers.ts +++ b/src/lib/query-helpers.ts @@ -1,22 +1,30 @@ const NOTIFY_THRESHOLD_MS = 5000; const DEFAULT_PAGE_SIZE = 2_000; const PAGE_SIZE_RAW = Number(import.meta.env.VITE_PAGE_SIZE ?? DEFAULT_PAGE_SIZE); -export const PAGE_SIZE = Number.isFinite(PAGE_SIZE_RAW) && PAGE_SIZE_RAW >= 100 - ? Math.floor(PAGE_SIZE_RAW) - : DEFAULT_PAGE_SIZE; +export const PAGE_SIZE = + Number.isFinite(PAGE_SIZE_RAW) && PAGE_SIZE_RAW >= 100 + ? Math.floor(PAGE_SIZE_RAW) + : DEFAULT_PAGE_SIZE; export const CELL_SEP = "\x1F"; export const ROW_SEP = "\x1E"; export function isQueryCancelledError(message: string): boolean { const lower = message.toLowerCase(); - return lower.includes("canceling statement due to user request") - || lower.includes("cancelling statement due to user request") - || lower.includes("query canceled") - || lower.includes("query cancelled") - || lower.includes("statement timeout"); + return ( + lower.includes("canceling statement due to user request") || + lower.includes("cancelling statement due to user request") || + lower.includes("query canceled") || + lower.includes("query cancelled") || + lower.includes("statement timeout") + ); } -export function notifyQueryComplete(sql: string, time: number, success: boolean, rowCount?: number) { +export function notifyQueryComplete( + sql: string, + time: number, + success: boolean, + rowCount?: number, +) { if (document.hasFocus() || time < NOTIFY_THRESHOLD_MS) return; if (!("Notification" in window)) return; if (Notification.permission !== "granted") return; diff --git a/src/lib/sql-utils.ts b/src/lib/sql-utils.ts index cfec9c2..7b3e118 100644 --- a/src/lib/sql-utils.ts +++ b/src/lib/sql-utils.ts @@ -3,7 +3,10 @@ * Returns null for complex queries (JOINs, subqueries, UNIONs, CTEs). */ export function parseSelectTable(sql: string): { schema: string; table: string } | null { - const normalized = sql.trim().replace(/;+\s*$/, "").replace(/\s+/g, " "); + const normalized = sql + .trim() + .replace(/;+\s*$/, "") + .replace(/\s+/g, " "); if (/\b(join|union|intersect|except)\b/i.test(normalized)) return null; if (/\bwith\s+\w+\s+as\s*\(/i.test(normalized)) return null; diff --git a/src/lib/updater.ts b/src/lib/updater.ts index f9722ed..0234def 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -96,9 +96,10 @@ async function runUpdateFlow(silent: boolean): Promise { } }); - const sizeText = totalBytes > 0 - ? `\n\nDownloaded ${formatBytes(downloadedBytes)} of ${formatBytes(totalBytes)}.` - : ""; + const sizeText = + totalBytes > 0 + ? `\n\nDownloaded ${formatBytes(downloadedBytes)} of ${formatBytes(totalBytes)}.` + : ""; const restartNow = await confirm( `Version ${update.version} has been installed.${sizeText}\n\nRestart now to finish applying the update?`, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..365058c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/src/lib/virtual-cache.ts b/src/lib/virtual-cache.ts index f348a44..88f59ba 100644 --- a/src/lib/virtual-cache.ts +++ b/src/lib/virtual-cache.ts @@ -6,7 +6,7 @@ const cache = new Map>(); export function setPage(queryId: string, pageIndex: number, rows: string[][]): void { if (!cache.has(queryId)) cache.set(queryId, new Map()); - cache.get(queryId)!.set(pageIndex, rows); + cache.get(queryId)?.set(pageIndex, rows); } export function getRow(queryId: string, rowIndex: number, pageSize: number): string[] | null { @@ -30,7 +30,9 @@ export function clearQuery(queryId: string): void { export function evictDistant(queryId: string, currentPage: number, maxPages: number): void { const pages = cache.get(queryId); if (!pages || pages.size <= maxPages) return; - const indices = [...pages.keys()].sort((a, b) => Math.abs(a - currentPage) - Math.abs(b - currentPage)); + const indices = [...pages.keys()].sort( + (a, b) => Math.abs(a - currentPage) - Math.abs(b - currentPage), + ); const toKeep = new Set(indices.slice(0, maxPages)); for (const key of pages.keys()) { if (!toKeep.has(key)) pages.delete(key); diff --git a/src/monaco/completion-provider/alias-parser.ts b/src/monaco/completion-provider/alias-parser.ts index fc61cbf..091b8d8 100644 --- a/src/monaco/completion-provider/alias-parser.ts +++ b/src/monaco/completion-provider/alias-parser.ts @@ -8,12 +8,13 @@ export function extractAliasMap(sql: string): Record { const map: Record = {}; const re = /(from|join)\s+("?[A-Za-z0-9_]+"?)(?:\s*\.\s*("?[A-Za-z0-9_]+"?))?(?:\s+as)?\s+("?[A-Za-z0-9_]+"?)/gi; - let m: RegExpExecArray | null; - while ((m = re.exec(sql)) !== null) { + let m: RegExpExecArray | null = re.exec(sql); + while (m !== null) { const schemaMaybe = m[3] ? stripQuotes(m[2]) : undefined; const table = stripQuotes(m[3] ?? m[2]); const alias = stripQuotes(m[4]); map[alias] = { schema: schemaMaybe, table }; + m = re.exec(sql); } return map; } diff --git a/src/monaco/completion-provider/index.ts b/src/monaco/completion-provider/index.ts index d41d799..ae5230b 100644 --- a/src/monaco/completion-provider/index.ts +++ b/src/monaco/completion-provider/index.ts @@ -2,8 +2,8 @@ import type * as Monaco from "monaco-editor"; import { useProjectStore } from "@/stores/project-store"; import { useTabStore } from "@/stores/tab-store"; import { extractAliasMap, genAlias, stripQuotes } from "./alias-parser"; -import { ensureColumns, ensureTables, resolveTableRef } from "./resolver"; import { SQL_KEYWORDS } from "./keywords"; +import { ensureColumns, ensureTables, resolveTableRef } from "./resolver"; import { SQL_SNIPPETS } from "./snippets"; let registered = false; @@ -32,8 +32,7 @@ export function registerContextAwareCompletions(monaco: typeof Monaco) { range: undefined, }; if (snippet) { - item.insertTextRules = - monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet; + item.insertTextRules = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet; } suggestions.push(item); }; @@ -54,9 +53,7 @@ export function registerContextAwareCompletions(monaco: typeof Monaco) { if (projectId && d) { const aliasMap = extractAliasMap(context); - const tableCtx = /([A-Za-z0-9_"]+)\s*\.\s*([A-Za-z0-9_"]*)$/i.exec( - context, - ); + const tableCtx = /([A-Za-z0-9_"]+)\s*\.\s*([A-Za-z0-9_"]*)$/i.exec(context); if (tableCtx) { const left = stripQuotes(tableCtx[1]); @@ -66,25 +63,18 @@ export function registerContextAwareCompletions(monaco: typeof Monaco) { (k) => k.toLowerCase() === left.toLowerCase(), ); if (aliasKey && aliasMap[aliasKey]) { - const resolved = await resolveTableRef( - projectId, - aliasMap[aliasKey], - ); + const resolved = await resolveTableRef(projectId, aliasMap[aliasKey]); if (resolved) { - const cols = await ensureColumns( - projectId, - resolved.schema, - resolved.table, - ); - cols.forEach((c) => + const cols = await ensureColumns(projectId, resolved.schema, resolved.table); + cols.forEach((c) => { add( c, monaco.languages.CompletionItemKind.Property, `"${c}"`, false, `${resolved.table}.${c}`, - ), - ); + ); + }); return { suggestions }; } } @@ -105,15 +95,9 @@ export function registerContextAwareCompletions(monaco: typeof Monaco) { } const cols = await ensureColumns(projectId, left, right); - cols.forEach((c) => - add( - c, - monaco.languages.CompletionItemKind.Property, - `"${c}"`, - false, - `${right}.${c}`, - ), - ); + cols.forEach((c) => { + add(c, monaco.languages.CompletionItemKind.Property, `"${c}"`, false, `${right}.${c}`); + }); return { suggestions }; } @@ -137,15 +121,9 @@ export function registerContextAwareCompletions(monaco: typeof Monaco) { } const projSchemas = state.schemas[projectId] || []; - projSchemas.forEach((s) => - add( - s, - monaco.languages.CompletionItemKind.Module, - `"${s}"`, - false, - "schema", - ), - ); + projSchemas.forEach((s) => { + add(s, monaco.languages.CompletionItemKind.Module, `"${s}"`, false, "schema"); + }); } for (const kw of SQL_KEYWORDS) { diff --git a/src/monaco/completion-provider/resolver.ts b/src/monaco/completion-provider/resolver.ts index a90e41d..696131d 100644 --- a/src/monaco/completion-provider/resolver.ts +++ b/src/monaco/completion-provider/resolver.ts @@ -1,5 +1,5 @@ -import { useProjectStore } from "@/stores/project-store"; import { DriverFactory } from "@/lib/database-driver"; +import { useProjectStore } from "@/stores/project-store"; import type { TableInfo } from "@/types"; import type { TableRef } from "./alias-parser"; @@ -23,16 +23,14 @@ export async function resolveTableRef( try { const rawRows = await driver.loadTables(projectId, schema); t = rawRows.map(([name, size]) => ({ name, size })); - useProjectStore.setState((s) => { s.tables[key] = t!; }); + useProjectStore.setState((s) => { + s.tables[key] = t!; + }); } catch { continue; } } - const match = - t && - t.find( - (ti: TableInfo) => ti.name.toLowerCase() === ref.table.toLowerCase(), - ); + const match = t?.find((ti: TableInfo) => ti.name.toLowerCase() === ref.table.toLowerCase()); if (match) { return { schema, table: match.name }; } @@ -55,17 +53,16 @@ export async function ensureColumns( try { const cols = await driver.loadColumns(projectId, schema, table); - useProjectStore.setState((s) => { s.columns[colKey] = cols; }); + useProjectStore.setState((s) => { + s.columns[colKey] = cols; + }); return cols; } catch { return []; } } -export async function ensureTables( - projectId: string, - schema: string, -): Promise { +export async function ensureTables(projectId: string, schema: string): Promise { const key = `${projectId}::${schema}`; const state = useProjectStore.getState(); if (state.tables[key]) return state.tables[key]; @@ -77,7 +74,9 @@ export async function ensureTables( try { const rawRows = await driver.loadTables(projectId, schema); const t = rawRows.map(([name, size]) => ({ name, size })); - useProjectStore.setState((s) => { s.tables[key] = t; }); + useProjectStore.setState((s) => { + s.tables[key] = t; + }); return t; } catch { return []; diff --git a/src/monaco/completion-provider/snippets.ts b/src/monaco/completion-provider/snippets.ts index e85e243..6b245a0 100644 --- a/src/monaco/completion-provider/snippets.ts +++ b/src/monaco/completion-provider/snippets.ts @@ -2,8 +2,7 @@ export const SQL_SNIPPETS = [ { label: "sel", detail: "SELECT ... FROM ... WHERE", - insert: - "SELECT ${1:*}\nFROM ${2:table_name}\nWHERE ${3:condition}\nLIMIT ${4:100};", + insert: "SELECT ${1:*}\nFROM ${2:table_name}\nWHERE ${3:condition}\nLIMIT ${4:100};", }, { label: "selc", @@ -23,8 +22,7 @@ export const SQL_SNIPPETS = [ { label: "upd", detail: "UPDATE ... SET ... WHERE", - insert: - "UPDATE ${1:table_name}\nSET ${2:column} = ${3:value}\nWHERE ${4:condition};", + insert: "UPDATE ${1:table_name}\nSET ${2:column} = ${3:value}\nWHERE ${4:condition};", }, { label: "del", @@ -40,8 +38,7 @@ export const SQL_SNIPPETS = [ { label: "alt", detail: "ALTER TABLE ADD COLUMN", - insert: - "ALTER TABLE ${1:table_name}\nADD COLUMN ${2:column_name} ${3:TEXT};", + insert: "ALTER TABLE ${1:table_name}\nADD COLUMN ${2:column_name} ${3:TEXT};", }, { label: "idx", diff --git a/src/monaco/setup.ts b/src/monaco/setup.ts index 5085619..190f676 100644 --- a/src/monaco/setup.ts +++ b/src/monaco/setup.ts @@ -4,8 +4,8 @@ import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; import pgWorker from "monaco-sql-languages/esm/languages/pgsql/pgsql.worker?worker"; import "monaco-sql-languages/esm/languages/pgsql/pgsql.contribution"; -import { setupLanguageFeatures } from "monaco-sql-languages/esm/setupLanguageFeatures"; import { LanguageIdEnum } from "monaco-sql-languages/esm/common/constants"; +import { setupLanguageFeatures } from "monaco-sql-languages/esm/setupLanguageFeatures"; import { registerContextAwareCompletions } from "./completion-provider"; // @ts-expect-error MonacoEnvironment is attached to global scope at runtime diff --git a/src/stores/project-store/connection.ts b/src/stores/project-store/connection.ts index 8482874..f6a6aa0 100644 --- a/src/stores/project-store/connection.ts +++ b/src/stores/project-store/connection.ts @@ -1,5 +1,5 @@ -import type { StateCreator } from "zustand"; import { toast } from "sonner"; +import type { StateCreator } from "zustand"; import { DriverFactory } from "@/lib/database-driver"; import { ProjectConnectionStatus as PCS } from "@/types"; import type { ProjectState } from "./index"; @@ -37,13 +37,7 @@ export const createConnectionSlice: StateCreator< ]; const ssh = d.sshEnabled === "true" - ? [ - d.sshHost, - d.sshPort || "22", - d.sshUser, - d.sshPassword, - d.sshKeyPath, - ] + ? [d.sshHost, d.sshPort || "22", d.sshUser, d.sshPassword, d.sshKeyPath] : undefined; const st = await driver.connect(projectId, key, ssh); set((s) => { @@ -58,19 +52,13 @@ export const createConnectionSlice: StateCreator< ]); set((s) => { s.schemas[projectId] = sc.status === "fulfilled" ? sc.value : []; - s.serverDatabases[projectId] = - dbs.status === "fulfilled" && dbs.value ? dbs.value : []; - s.serverTablespaces[projectId] = - tsp.status === "fulfilled" && tsp.value ? tsp.value : []; + s.serverDatabases[projectId] = dbs.status === "fulfilled" && dbs.value ? dbs.value : []; + s.serverTablespaces[projectId] = tsp.status === "fulfilled" && tsp.value ? tsp.value : []; }); } } catch (err: unknown) { const msg = - err instanceof Error - ? err.message - : typeof err === "string" - ? err - : "Connection failed"; + err instanceof Error ? err.message : typeof err === "string" ? err : "Connection failed"; set((s) => { s.status[projectId] = PCS.Failed; s.connectionErrors[projectId] = msg; @@ -146,10 +134,8 @@ export const createConnectionSlice: StateCreator< ]); set((s) => { s.schemas[projectId] = sc.status === "fulfilled" ? sc.value : []; - s.serverDatabases[projectId] = - dbs.status === "fulfilled" && dbs.value ? dbs.value : []; - s.serverTablespaces[projectId] = - tsp.status === "fulfilled" && tsp.value ? tsp.value : []; + s.serverDatabases[projectId] = dbs.status === "fulfilled" && dbs.value ? dbs.value : []; + s.serverTablespaces[projectId] = tsp.status === "fulfilled" && tsp.value ? tsp.value : []; }); await Promise.all( diff --git a/src/stores/project-store/core.ts b/src/stores/project-store/core.ts index f0d7b96..799e96b 100644 --- a/src/stores/project-store/core.ts +++ b/src/stores/project-store/core.ts @@ -1,16 +1,7 @@ import type { StateCreator } from "zustand"; -import type { - ProjectMap, - ProjectDetails, - ProjectConnectionStatus, - DriverType, -} from "@/types"; +import { deleteProject as deleteProjectApi, getProjects, insertProject } from "@/tauri"; +import type { DriverType, ProjectConnectionStatus, ProjectDetails, ProjectMap } from "@/types"; import { ProjectConnectionStatus as PCS } from "@/types"; -import { - getProjects, - insertProject, - deleteProject as deleteProjectApi, -} from "@/tauri"; import type { ProjectState } from "./index"; export type CoreSlice = { @@ -21,11 +12,7 @@ export type CoreSlice = { deleteProject: (projectId: string) => Promise; saveConnection: (name: string, details: ProjectDetails) => Promise; updateConnection: (name: string, details: ProjectDetails) => Promise; - addDatabaseToServer: ( - sourceProjectId: string, - name: string, - database: string, - ) => Promise; + addDatabaseToServer: (sourceProjectId: string, name: string, database: string) => Promise; }; export function parseProjectDetails(arr: string[]): ProjectDetails { @@ -130,11 +117,7 @@ export const createCoreSlice: StateCreator< await get().loadProjects(); }, - addDatabaseToServer: async ( - sourceProjectId: string, - name: string, - database: string, - ) => { + addDatabaseToServer: async (sourceProjectId: string, name: string, database: string) => { const { projects } = get(); const source = projects[sourceProjectId]; if (!source) return; diff --git a/src/stores/project-store/index.ts b/src/stores/project-store/index.ts index 6b8ad0a..2a79bd8 100644 --- a/src/stores/project-store/index.ts +++ b/src/stores/project-store/index.ts @@ -1,16 +1,12 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; -import { createCoreSlice, type CoreSlice } from "./core"; -import { createConnectionSlice, type ConnectionSlice } from "./connection"; -import { createSchemaSlice, type SchemaSlice } from "./schema"; +import { type ConnectionSlice, createConnectionSlice } from "./connection"; +import { type CoreSlice, createCoreSlice } from "./core"; import { createIndexesSlice, type IndexesSlice } from "./indexes"; +import { createSchemaSlice, type SchemaSlice } from "./schema"; import { createViewsSlice, type ViewsSlice } from "./views"; -export type ProjectState = CoreSlice & - ConnectionSlice & - SchemaSlice & - IndexesSlice & - ViewsSlice; +export type ProjectState = CoreSlice & ConnectionSlice & SchemaSlice & IndexesSlice & ViewsSlice; export const useProjectStore = create()( immer((...a) => ({ diff --git a/src/stores/project-store/indexes.ts b/src/stores/project-store/indexes.ts index d534b65..64d0c04 100644 --- a/src/stores/project-store/indexes.ts +++ b/src/stores/project-store/indexes.ts @@ -1,11 +1,11 @@ import type { StateCreator } from "zustand"; import { DriverFactory } from "@/lib/database-driver"; import type { - IndexDetail, ConstraintDetail, - TriggerDetail, - RuleDetail, + IndexDetail, PolicyDetail, + RuleDetail, + TriggerDetail, } from "@/types"; import type { ProjectState } from "./index"; @@ -15,11 +15,7 @@ export type IndexesSlice = { triggers: Record; rules: Record; policies: Record; - loadIndexes: ( - projectId: string, - schema: string, - table: string, - ) => Promise; + loadIndexes: (projectId: string, schema: string, table: string) => Promise; loadConstraints: ( projectId: string, schema: string, @@ -54,11 +50,7 @@ export const createIndexesSlice: StateCreator< return idx; }, - loadConstraints: async ( - projectId: string, - schema: string, - table: string, - ) => { + loadConstraints: async (projectId: string, schema: string, table: string) => { const key = `${projectId}::${schema}::${table}`; const { constraints, projects } = get(); if (constraints[key]) return constraints[key]; diff --git a/src/stores/project-store/schema.ts b/src/stores/project-store/schema.ts index b16beb3..eb1d39a 100644 --- a/src/stores/project-store/schema.ts +++ b/src/stores/project-store/schema.ts @@ -1,6 +1,6 @@ import type { StateCreator } from "zustand"; import { DriverFactory } from "@/lib/database-driver"; -import type { TableInfo, ColumnDetail } from "@/types"; +import type { ColumnDetail, TableInfo } from "@/types"; import type { ProjectState } from "./index"; export type SchemaSlice = { @@ -10,16 +10,8 @@ export type SchemaSlice = { columnDetails: Record; loadSchemas: (projectId: string) => Promise; loadTables: (projectId: string, schema: string) => Promise; - loadColumns: ( - projectId: string, - schema: string, - table: string, - ) => Promise; - loadColumnDetails: ( - projectId: string, - schema: string, - table: string, - ) => Promise; + loadColumns: (projectId: string, schema: string, table: string) => Promise; + loadColumnDetails: (projectId: string, schema: string, table: string) => Promise; loadSchemaObjects: (projectId: string, schema: string) => Promise; }; @@ -75,11 +67,7 @@ export const createSchemaSlice: StateCreator< return cols; }, - loadColumnDetails: async ( - projectId: string, - schema: string, - table: string, - ) => { + loadColumnDetails: async (projectId: string, schema: string, table: string) => { const key = `${projectId}::${schema}::${table}`; const { columnDetails, projects } = get(); if (columnDetails[key]) return columnDetails[key]; diff --git a/src/stores/project-store/views.ts b/src/stores/project-store/views.ts index f7e2cd7..cd218b8 100644 --- a/src/stores/project-store/views.ts +++ b/src/stores/project-store/views.ts @@ -10,11 +10,7 @@ export type ViewsSlice = { triggerFunctions: Record; serverDatabases: Record; serverTablespaces: Record; - loadTableMetadata: ( - projectId: string, - schema: string, - table: string, - ) => Promise; + loadTableMetadata: (projectId: string, schema: string, table: string) => Promise; }; export const createViewsSlice: StateCreator< @@ -30,11 +26,7 @@ export const createViewsSlice: StateCreator< serverDatabases: {}, serverTablespaces: {}, - loadTableMetadata: async ( - projectId: string, - schema: string, - table: string, - ) => { + loadTableMetadata: async (projectId: string, schema: string, table: string) => { const key = `${projectId}::${schema}::${table}`; const { columnDetails, projects } = get(); if (columnDetails[key]) return; @@ -43,15 +35,14 @@ export const createViewsSlice: StateCreator< if (!d) return; const driver = DriverFactory.getDriver(d.driver); - const [colsR, idxsR, consR, trigsR, rlsR, polsR] = - await Promise.allSettled([ - driver.loadColumnDetails(projectId, schema, table), - driver.loadIndexes(projectId, schema, table), - driver.loadConstraints(projectId, schema, table), - driver.loadTriggers(projectId, schema, table), - driver.loadRules(projectId, schema, table), - driver.loadPolicies(projectId, schema, table), - ]); + const [colsR, idxsR, consR, trigsR, rlsR, polsR] = await Promise.allSettled([ + driver.loadColumnDetails(projectId, schema, table), + driver.loadIndexes(projectId, schema, table), + driver.loadConstraints(projectId, schema, table), + driver.loadTriggers(projectId, schema, table), + driver.loadRules(projectId, schema, table), + driver.loadPolicies(projectId, schema, table), + ]); const val = (r: PromiseSettledResult, fallback: T): T => r.status === "fulfilled" ? r.value : fallback; diff --git a/src/stores/query-store.ts b/src/stores/query-store.ts index 8005ba3..25c6237 100644 --- a/src/stores/query-store.ts +++ b/src/stores/query-store.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; -import { getQueries, insertQuery, deleteQuery } from "@/tauri"; +import { deleteQuery, getQueries, insertQuery } from "@/tauri"; export interface SavedQuery { id: string; diff --git a/src/stores/tab-store.ts b/src/stores/tab-store.ts index b43bf79..fde6d14 100644 --- a/src/stores/tab-store.ts +++ b/src/stores/tab-store.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; -import type { Tab, QueryResult, ExplainPlan, VirtualQuery } from "@/types"; +import type { ExplainPlan, QueryResult, Tab, VirtualQuery } from "@/types"; let nextId = 1; function genTabId(): string { @@ -48,10 +48,7 @@ function makeSingletonTab( ): (s: TabState) => void { return (s) => { const existing = s.tabs.findIndex( - (t) => - t.type === type && - t.projectId === projectId && - (!schema || t.schema === schema), + (t) => t.type === type && t.projectId === projectId && (!schema || t.schema === schema), ); if (existing >= 0) { s.selectedTabIndex = existing; @@ -99,8 +96,7 @@ export const useTabStore = create()( }); }, - openMonitorTab: (projectId) => - set(makeSingletonTab("monitor", projectId, "Monitor")), + openMonitorTab: (projectId) => set(makeSingletonTab("monitor", projectId, "Monitor")), openERDTab: (projectId, schema) => set(makeSingletonTab("erd", projectId, `ERD: ${schema}`, schema)), openTerminalTab: () => { @@ -115,16 +111,13 @@ export const useTabStore = create()( s.selectedTabIndex = s.tabs.length - 1; }); }, - openNotifyTab: (projectId) => - set(makeSingletonTab("notify", projectId, "LISTEN/NOTIFY")), - openRolesTab: (projectId) => - set(makeSingletonTab("roles", projectId, "Roles")), + openNotifyTab: (projectId) => set(makeSingletonTab("notify", projectId, "LISTEN/NOTIFY")), + openRolesTab: (projectId) => set(makeSingletonTab("roles", projectId, "Roles")), openSchemaDiffTab: (projectId) => set(makeSingletonTab("schema-diff", projectId, "Schema Diff")), openExtensionsTab: (projectId) => set(makeSingletonTab("extensions", projectId, "Extensions")), - openEnumsTab: (projectId) => - set(makeSingletonTab("enums", projectId, "Enum Types")), + openEnumsTab: (projectId) => set(makeSingletonTab("enums", projectId, "Enum Types")), openPgSettingsTab: (projectId) => set(makeSingletonTab("pg-settings", projectId, "PG Settings")), @@ -248,22 +241,13 @@ export const useTabStore = create()( merge: (persisted: unknown, current: TabState) => { const p = persisted as Partial | undefined; if (!p?.tabs || !Array.isArray(p.tabs)) return current; - if (p.tabs.length === 0) - return { ...current, tabs: [], selectedTabIndex: -1 }; + if (p.tabs.length === 0) return { ...current, tabs: [], selectedTabIndex: -1 }; const validTabs = p.tabs.filter( (t): t is Tab => - t != null && - typeof t === "object" && - "id" in t && - "type" in t && - "title" in t, - ); - if (validTabs.length === 0) - return { ...current, tabs: [], selectedTabIndex: -1 }; - const idx = Math.min( - Math.max(0, p.selectedTabIndex ?? 0), - validTabs.length - 1, + t != null && typeof t === "object" && "id" in t && "type" in t && "title" in t, ); + if (validTabs.length === 0) return { ...current, tabs: [], selectedTabIndex: -1 }; + const idx = Math.min(Math.max(0, p.selectedTabIndex ?? 0), validTabs.length - 1); return { ...current, tabs: validTabs, selectedTabIndex: idx }; }, }, diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index 1a077c7..ff9e695 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -68,10 +68,7 @@ export const useUIStore = create()( const containerHeight = window.innerHeight - 48 - 24; const deltaPercent = (delta / containerHeight) * 100; set((s) => { - s.editorHeight = Math.max( - 20, - Math.min(80, s.editorHeight + deltaPercent), - ); + s.editorHeight = Math.max(20, Math.min(80, s.editorHeight + deltaPercent)); }); }, diff --git a/src/stores/workspace-store.ts b/src/stores/workspace-store.ts index 399fe72..0081750 100644 --- a/src/stores/workspace-store.ts +++ b/src/stores/workspace-store.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; -import { workspaceSave, workspaceLoadAll, workspaceDelete } from "@/tauri"; +import { workspaceDelete, workspaceLoadAll, workspaceSave } from "@/tauri"; interface WorkspaceEntry { name: string; diff --git a/src/tauri.ts b/src/tauri.ts index add9338..4fe2b9b 100644 --- a/src/tauri.ts +++ b/src/tauri.ts @@ -19,10 +19,7 @@ export async function getProjects(): Promise { return await invoke("project_db_select"); } -export async function insertProject( - project_id: string, - project_details: string[], -): Promise { +export async function insertProject(project_id: string, project_details: string[]): Promise { await invoke("project_db_insert", { project_id, project_details }); } @@ -34,10 +31,7 @@ export async function getQueries(): Promise> { return await invoke>("query_db_select"); } -export async function insertQuery( - query_id: string, - sql: string, -): Promise { +export async function insertQuery(query_id: string, sql: string): Promise { await invoke("query_db_insert", { query_id, sql }); } @@ -61,6 +55,8 @@ export async function workspaceDelete(name: string): Promise { await invoke("workspace_delete", { name }); } -export async function pgsqlTestConnection(key: [string, string, string, string, string, string]): Promise { +export async function pgsqlTestConnection( + key: [string, string, string, string, string, string], +): Promise { return await invoke("pgsql_test_connection", { key }); } diff --git a/src/types/index.ts b/src/types/index.ts index 04dba99..800d3a8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,7 +18,17 @@ export type DriverType = "PGSQL"; export type ProjectMap = Record; -export type TabType = "query" | "monitor" | "erd" | "terminal" | "notify" | "roles" | "schema-diff" | "extensions" | "enums" | "pg-settings"; +export type TabType = + | "query" + | "monitor" + | "erd" + | "terminal" + | "notify" + | "roles" + | "schema-diff" + | "extensions" + | "enums" + | "pg-settings"; export interface Tab { id: string; @@ -41,15 +51,15 @@ export interface Tab { export interface ExplainNode { "Node Type": string; "Relation Name"?: string; - "Alias"?: string; + Alias?: string; "Join Type"?: string; "Index Name"?: string; "Index Cond"?: string; - "Filter"?: string; + Filter?: string; "Hash Cond"?: string; "Merge Cond"?: string; "Sort Key"?: string[]; - "Strategy"?: string; + Strategy?: string; "Startup Cost": number; "Total Cost": number; "Plan Rows": number; @@ -65,10 +75,10 @@ export interface ExplainNode { } export interface ExplainPlan { - "Plan": ExplainNode; + Plan: ExplainNode; "Planning Time"?: number; "Execution Time"?: number; - "Triggers"?: unknown[]; + Triggers?: unknown[]; } export interface QueryResult { diff --git a/vite.config.ts b/vite.config.ts index a0c655f..ae4d118 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; +import path from "node:path"; import tailwindcss from "@tailwindcss/vite"; -import path from "path"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; // @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; diff --git a/website/package.json b/website/package.json index efa8da0..1196eed 100644 --- a/website/package.json +++ b/website/package.json @@ -6,7 +6,11 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "format": "biome format --write .", + "lint": "biome lint .", + "check": "biome check --write .", + "check:ci": "biome check ." }, "dependencies": { "@electric-sql/pglite": "^0.2.17", @@ -16,6 +20,7 @@ "react-dom": "^19.1.0" }, "devDependencies": { + "@biomejs/biome": "2.4.15", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.4.1", diff --git a/website/src/App.tsx b/website/src/App.tsx index 84b83f4..7af5ea6 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -1,11 +1,11 @@ +import { Download, Github, Menu, Moon, Sun, X } from "lucide-react"; import { useEffect, useState } from "react"; -import { Hero } from "@/components/Hero"; -import { Features } from "@/components/Features"; -import { Comparison } from "@/components/Comparison"; import { Benchmarks } from "@/components/Benchmarks"; -import { Demo } from "@/components/Demo"; +import { Comparison } from "@/components/Comparison"; import { CTA } from "@/components/CTA"; -import { Github, Download, Moon, Sun, Menu, X } from "lucide-react"; +import { Demo } from "@/components/Demo"; +import { Features } from "@/components/Features"; +import { Hero } from "@/components/Hero"; const THEME_KEY = "rsql-theme"; type Theme = "dark" | "light"; @@ -86,6 +86,7 @@ export function App() {
diff --git a/website/src/components/Benchmarks.tsx b/website/src/components/Benchmarks.tsx index ba844b6..c3a784f 100644 --- a/website/src/components/Benchmarks.tsx +++ b/website/src/components/Benchmarks.tsx @@ -8,7 +8,8 @@ const benchmarks = [ { name: "DBeaver (JVM)", value: "~500 MB–1 GB", bar: 70 }, { name: "DataGrip (JVM)", value: "~700 MB–2 GB", bar: 90 }, ], - explanation: "Tauri v2 uses the system WebView instead of bundling Chromium. No separate browser process, no JVM heap overhead.", + explanation: + "Tauri v2 uses the system WebView instead of bundling Chromium. No separate browser process, no JVM heap overhead.", }, { category: "Binary Size", @@ -27,7 +28,8 @@ const benchmarks = [ metric: "Rendering approach", rsql: { value: "HTML5 Canvas", bar: 0 }, others: [], - explanation: "Glide Data Grid renders to a single element. O(1) DOM complexity regardless of dataset size — no layout thrashing, smooth 60fps scroll across 100K+ rows.", + explanation: + "Glide Data Grid renders to a single element. O(1) DOM complexity regardless of dataset size — no layout thrashing, smooth 60fps scroll across 100K+ rows.", comparison: [ { label: "RSQL (Canvas)", detail: "1 DOM node, GPU-accelerated paint", highlight: true }, { label: "DOM table (pgAdmin)", detail: "rows × cols DOM nodes, CPU layout" }, @@ -44,16 +46,16 @@ const benchmarks = [ { name: "Java Jackson", value: "~600 MB/s", bar: 22 }, { name: "Python json", value: "~300 MB/s", bar: 11 }, ], - explanation: "sonic-rs uses SIMD instructions (AVX2/SSE4/NEON). Additionally, RSQL packs result data with flat ASCII separators (\\x1F cell, \\x1E row) bypassing JSON array overhead entirely.", + explanation: + "sonic-rs uses SIMD instructions (AVX2/SSE4/NEON). Additionally, RSQL packs result data with flat ASCII separators (\\x1F cell, \\x1E row) bypassing JSON array overhead entirely.", }, { category: "Large Results", metric: "Memory for multi-million-row results", rsql: { value: "O(page_size)", bar: 5 }, - others: [ - { name: "Typical client", value: "O(total rows)", bar: 95 }, - ], - explanation: "Virtual pagination pre-packs all rows into 2,000-row pages cached on the Rust backend. The frontend only holds ~24 pages in memory at any time — distant pages are LRU-evicted. Browsing 5 million rows uses the same frontend memory as 1,000 rows. No row limit on virtual pagination.", + others: [{ name: "Typical client", value: "O(total rows)", bar: 95 }], + explanation: + "Virtual pagination pre-packs all rows into 2,000-row pages cached on the Rust backend. The frontend only holds ~24 pages in memory at any time — distant pages are LRU-evicted. Browsing 5 million rows uses the same frontend memory as 1,000 rows. No row limit on virtual pagination.", }, ]; @@ -65,56 +67,64 @@ const archNumbers = [ value: "10,000", unit: "rows/round-trip", source: "CURSOR_FETCH_SIZE in common.rs", - detail: "Server-side DECLARE CURSOR + FETCH FORWARD. Streams results in 10K-row chunks without loading entire result set into backend memory.", + detail: + "Server-side DECLARE CURSOR + FETCH FORWARD. Streams results in 10K-row chunks without loading entire result set into backend memory.", }, { label: "Page size", value: "2,000", unit: "rows/page", source: "VITE_PAGE_SIZE default", - detail: "Each virtual page contains 2,000 rows, pre-packed with flat separators. Pages are served from cache with zero packing overhead at request time.", + detail: + "Each virtual page contains 2,000 rows, pre-packed with flat separators. Pages are served from cache with zero packing overhead at request time.", }, { label: "Cache window", value: "24", unit: "pages in memory", source: "results-panel.tsx", - detail: "Frontend keeps 24 pages around the current viewport. Distant pages are LRU-evicted, keeping memory constant regardless of total result size.", + detail: + "Frontend keeps 24 pages around the current viewport. Distant pages are LRU-evicted, keeping memory constant regardless of total result size.", }, { label: "Concurrent fetches", value: "6", unit: "parallel page requests", source: "results-panel.tsx", - detail: "Up to 6 pages fetched in parallel with a queue depth of 32. Pre-fetches pages ahead of scroll direction for seamless navigation.", + detail: + "Up to 6 pages fetched in parallel with a queue depth of 32. Pre-fetches pages ahead of scroll direction for seamless navigation.", }, { label: "Query connections", value: "16", unit: "pooled connections", source: "deadpool-postgres config", - detail: "Dual connection pool: 16 for queries, 8 for metadata (schema loading, autocomplete). Query and metadata traffic never block each other.", + detail: + "Dual connection pool: 16 for queries, 8 for metadata (schema loading, autocomplete). Query and metadata traffic never block each other.", }, { label: "Parallel packing", value: "50K+", unit: "row threshold", source: "rayon in common.rs", - detail: "Results over 50,000 rows are packed into pages using rayon parallel iterators. Smaller datasets use sequential packing to avoid thread overhead.", + detail: + "Results over 50,000 rows are packed into pages using rayon parallel iterators. Smaller datasets use sequential packing to avoid thread overhead.", }, { label: "Virtual pagination", value: "No limit", unit: "on row count", source: "execute_virtual in common.rs", - detail: "All rows are pre-packed into pages on the Rust backend. The frontend requests pages on demand — 5M+ rows work seamlessly. Streaming mode (real-time push) has a separate 500K safety cap.", + detail: + "All rows are pre-packed into pages on the Rust backend. The frontend requests pages on demand — 5M+ rows work seamlessly. Streaming mode (real-time push) has a separate 500K safety cap.", }, { label: "IPC format", value: "\\x1F / \\x1E", unit: "cell / row separator", source: "common.rs packed format", - detail: "Results packed as flat strings with ASCII Unit Separator (\\x1F) between cells and Record Separator (\\x1E) between rows. No JSON array nesting, no per-cell quotes.", + detail: + "Results packed as flat strings with ASCII Unit Separator (\\x1F) between cells and Record Separator (\\x1E) between rows. No JSON array nesting, no per-cell quotes.", }, ]; @@ -126,9 +136,7 @@ export function Benchmarks() {
Performance -

- Built to be fast -

+

Built to be fast

Every layer is optimized — from SIMD serialization to canvas rendering.

@@ -143,19 +151,22 @@ export function Benchmarks() {

{b.category}

{b.metric}

-
- {b.rsql.value} -
+
{b.rsql.value}
{b.others.length > 0 && (
- RSQL + + RSQL +
{b.rsql.value}
@@ -163,7 +174,9 @@ export function Benchmarks() {
{b.others.map((o) => (
- {o.name} + + {o.name} +
-
+
{c.label}
-
{c.detail}
+
+ {c.detail} +
))}
)} -

{b.explanation}

+

+ {b.explanation} +

))}
@@ -216,7 +235,10 @@ export function Benchmarks() {
{archNumbers.map((n) => ( -
+
{n.value} {n.unit} @@ -246,15 +268,21 @@ export function Benchmarks() { { label: "Canvas Grid", sub: "WebGL render" }, ].map((step, i, arr) => (
-
-
+
+
{step.label}
-
{step.sub}
+
+ {step.sub} +
{i < arr.length - 1 && (
@@ -266,15 +294,18 @@ export function Benchmarks() {

- End-to-end: SQL text → Rust IPC → PostgreSQL cursor → parallel page packing → SIMD serialization → canvas paint. - Each page (2,000 rows) is pre-packed and cached — subsequent page requests are served with zero processing overhead. + End-to-end: SQL text → Rust IPC → PostgreSQL cursor → parallel page packing → SIMD + serialization → canvas paint. Each page (2,000 rows) is pre-packed and cached — + subsequent page requests are served with zero processing overhead.

- All numbers verified from source: src-tauri/src/drivers/common.rs, pgsql.rs, src/components/results-panel.tsx. + All numbers verified from source: src-tauri/src/drivers/common.rs, pgsql.rs, + src/components/results-panel.tsx.
- Serialization throughput from sonic-rs published benchmarks. Memory figures are typical ranges on Apple M1. + Serialization throughput from sonic-rs published benchmarks. Memory figures are typical + ranges on Apple M1.

diff --git a/website/src/components/CTA.tsx b/website/src/components/CTA.tsx index 0782b42..540d1a5 100644 --- a/website/src/components/CTA.tsx +++ b/website/src/components/CTA.tsx @@ -8,9 +8,7 @@ export function CTA() { Get started -

- Try RSQL today -

+

Try RSQL today

Free, open source, no account needed. Available on macOS, Windows, and Linux. diff --git a/website/src/components/Comparison.tsx b/website/src/components/Comparison.tsx index 40d55b3..534c948 100644 --- a/website/src/components/Comparison.tsx +++ b/website/src/components/Comparison.tsx @@ -1,28 +1,132 @@ -import { Check, X, Minus } from "lucide-react"; +import { Check, Minus, X } from "lucide-react"; type S = "yes" | "no" | "partial"; interface Row { feature: string; - rsql: S; pgadmin: S; dbeaver: S; datagrip: S; tableplus: S; + rsql: S; + pgadmin: S; + dbeaver: S; + datagrip: S; + tableplus: S; note?: string; } const rows: Row[] = [ - { feature: "Monaco editor + autocomplete", rsql: "yes", pgadmin: "partial", dbeaver: "partial", datagrip: "yes", tableplus: "partial" }, - { feature: "Canvas-based result grid", rsql: "yes", pgadmin: "no", dbeaver: "no", datagrip: "no", tableplus: "no", note: "Zero DOM nodes per cell" }, - { feature: "EXPLAIN plan visualizer", rsql: "yes", pgadmin: "yes", dbeaver: "yes", datagrip: "partial", tableplus: "no" }, - { feature: "ERD diagrams", rsql: "yes", pgadmin: "yes", dbeaver: "yes", datagrip: "yes", tableplus: "no" }, - { feature: "Schema navigator", rsql: "yes", pgadmin: "yes", dbeaver: "yes", datagrip: "yes", tableplus: "yes" }, - { feature: "PostGIS map view", rsql: "yes", pgadmin: "no", dbeaver: "yes", datagrip: "no", tableplus: "no" }, - { feature: "FK click-navigation", rsql: "yes", pgadmin: "no", dbeaver: "partial", datagrip: "yes", tableplus: "no" }, - { feature: "Built-in terminal", rsql: "yes", pgadmin: "no", dbeaver: "no", datagrip: "yes", tableplus: "no" }, - { feature: "Schema diff tool", rsql: "yes", pgadmin: "no", dbeaver: "partial", datagrip: "yes", tableplus: "no", note: "DBeaver: Pro only" }, - { feature: "Command palette", rsql: "yes", pgadmin: "no", dbeaver: "no", datagrip: "yes", tableplus: "yes" }, - { feature: "SSH tunnels", rsql: "yes", pgadmin: "yes", dbeaver: "yes", datagrip: "yes", tableplus: "yes" }, - { feature: "CSV/JSON export", rsql: "yes", pgadmin: "yes", dbeaver: "yes", datagrip: "yes", tableplus: "partial" }, - { feature: "Dark mode", rsql: "yes", pgadmin: "yes", dbeaver: "yes", datagrip: "yes", tableplus: "yes" }, - { feature: "Cross-platform", rsql: "yes", pgadmin: "yes", dbeaver: "yes", datagrip: "yes", tableplus: "yes" }, + { + feature: "Monaco editor + autocomplete", + rsql: "yes", + pgadmin: "partial", + dbeaver: "partial", + datagrip: "yes", + tableplus: "partial", + }, + { + feature: "Canvas-based result grid", + rsql: "yes", + pgadmin: "no", + dbeaver: "no", + datagrip: "no", + tableplus: "no", + note: "Zero DOM nodes per cell", + }, + { + feature: "EXPLAIN plan visualizer", + rsql: "yes", + pgadmin: "yes", + dbeaver: "yes", + datagrip: "partial", + tableplus: "no", + }, + { + feature: "ERD diagrams", + rsql: "yes", + pgadmin: "yes", + dbeaver: "yes", + datagrip: "yes", + tableplus: "no", + }, + { + feature: "Schema navigator", + rsql: "yes", + pgadmin: "yes", + dbeaver: "yes", + datagrip: "yes", + tableplus: "yes", + }, + { + feature: "PostGIS map view", + rsql: "yes", + pgadmin: "no", + dbeaver: "yes", + datagrip: "no", + tableplus: "no", + }, + { + feature: "FK click-navigation", + rsql: "yes", + pgadmin: "no", + dbeaver: "partial", + datagrip: "yes", + tableplus: "no", + }, + { + feature: "Built-in terminal", + rsql: "yes", + pgadmin: "no", + dbeaver: "no", + datagrip: "yes", + tableplus: "no", + }, + { + feature: "Schema diff tool", + rsql: "yes", + pgadmin: "no", + dbeaver: "partial", + datagrip: "yes", + tableplus: "no", + note: "DBeaver: Pro only", + }, + { + feature: "Command palette", + rsql: "yes", + pgadmin: "no", + dbeaver: "no", + datagrip: "yes", + tableplus: "yes", + }, + { + feature: "SSH tunnels", + rsql: "yes", + pgadmin: "yes", + dbeaver: "yes", + datagrip: "yes", + tableplus: "yes", + }, + { + feature: "CSV/JSON export", + rsql: "yes", + pgadmin: "yes", + dbeaver: "yes", + datagrip: "yes", + tableplus: "partial", + }, + { + feature: "Dark mode", + rsql: "yes", + pgadmin: "yes", + dbeaver: "yes", + datagrip: "yes", + tableplus: "yes", + }, + { + feature: "Cross-platform", + rsql: "yes", + pgadmin: "yes", + dbeaver: "yes", + datagrip: "yes", + tableplus: "yes", + }, ]; const tools = [ @@ -42,9 +146,30 @@ const pricing = [ ]; const scoreCard = [ - { label: "Binary size", rsql: "~20 MB", pgadmin: "~180 MB", dbeaver: "~200 MB", datagrip: "~600 MB", tableplus: "~40 MB" }, - { label: "Grid tech", rsql: "Canvas", pgadmin: "DOM", dbeaver: "SWT", datagrip: "Swing", tableplus: "Native" }, - { label: "Runtime", rsql: "System WebView", pgadmin: "Python + browser", dbeaver: "JVM (Java 21)", datagrip: "JVM", tableplus: "Native" }, + { + label: "Binary size", + rsql: "~20 MB", + pgadmin: "~180 MB", + dbeaver: "~200 MB", + datagrip: "~600 MB", + tableplus: "~40 MB", + }, + { + label: "Grid tech", + rsql: "Canvas", + pgadmin: "DOM", + dbeaver: "SWT", + datagrip: "Swing", + tableplus: "Native", + }, + { + label: "Runtime", + rsql: "System WebView", + pgadmin: "Python + browser", + dbeaver: "JVM (Java 21)", + datagrip: "JVM", + tableplus: "Native", + }, ]; function Icon({ s }: { s: S }) { @@ -61,9 +186,7 @@ export function Comparison() {

Comparison -

- How RSQL stacks up -

+

How RSQL stacks up

Feature-by-feature against the most popular PostgreSQL tools.

@@ -74,7 +197,9 @@ export function Comparison() {
- + {tools.map((t) => ( {rows.map((row) => ( - +
Feature + Feature +
{row.feature} {row.note && ( @@ -148,10 +276,14 @@ export function Comparison() {
{tools.map((t) => (
- + {t.name} - + {s[t.key]}
diff --git a/website/src/components/Demo.tsx b/website/src/components/Demo.tsx index 0ea4d6e..d074caa 100644 --- a/website/src/components/Demo.tsx +++ b/website/src/components/Demo.tsx @@ -1,25 +1,19 @@ -import { useState, useRef, useEffect, useCallback } from "react"; import { - Play, - Loader2, ChevronDown, ChevronRight, - Database, - Table, Columns3, + Database, + FolderOpen, Key, + Loader2, + Play, RotateCcw, Server, - FolderOpen, + Table, } from "lucide-react"; -import { - useDBReady, - useTables, - useColumns, - useExecuteSQL, - useResetDB, -} from "@/hooks/use-sql"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { QueryResult } from "@/hooks/use-sql"; +import { useColumns, useDBReady, useExecuteSQL, useResetDB, useTables } from "@/hooks/use-sql"; const SAMPLE_QUERIES: { label: string; sql: string }[] = [ { @@ -93,7 +87,7 @@ export function Demo() { }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ready]); + }, [ready, result, executeMutation.mutate]); if (!ready) { return ( @@ -101,7 +95,9 @@ export function Demo() {

Starting PostgreSQL…

-

PGlite WebAssembly engine

+

+ PGlite WebAssembly engine +

); @@ -114,9 +110,7 @@ export function Demo() {
Sandbox -

- Try it right here -

+

Try it right here

Full PostgreSQL running in WebAssembly. Real SQL, seeded data, no install.

@@ -127,6 +121,7 @@ export function Demo() { {SAMPLE_QUERIES.map((q) => (
- - + + {done ? "5 rows · 1.8ms" : ""} @@ -203,12 +308,17 @@ export function Hero() {
{/* Results */} -
+
{["name", "department", "projects", "avg_salary"].map((h) => ( - ))} @@ -218,7 +328,10 @@ export function Hero() { {RESULT_ROWS.map((row, i) => ( {row.map((cell, j) => ( - ))} @@ -242,7 +355,17 @@ export function Hero() { Works with any PostgreSQL host

- {["PostgreSQL", "Supabase", "Neon", "AWS RDS", "Railway", "Render", "Fly.io", "DigitalOcean", "TimescaleDB"].map((name) => ( + {[ + "PostgreSQL", + "Supabase", + "Neon", + "AWS RDS", + "Railway", + "Render", + "Fly.io", + "DigitalOcean", + "TimescaleDB", + ].map((name) => ( {name} diff --git a/website/src/hooks/use-sql.ts b/website/src/hooks/use-sql.ts index 60071e4..0e24dbe 100644 --- a/website/src/hooks/use-sql.ts +++ b/website/src/hooks/use-sql.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getDB, resetDB } from "@/lib/pglite"; export interface QueryResult { @@ -31,7 +31,7 @@ export function useTables() { const res = await db.query<{ table_name: string }>( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' - ORDER BY table_name` + ORDER BY table_name`, ); return res.rows.map((r) => r.table_name); }, @@ -51,7 +51,7 @@ export function useColumns(table: string | null) { FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`, - [table] + [table], ); return res.rows; }, diff --git a/website/src/index.css b/website/src/index.css index 7f5390b..c50b567 100644 --- a/website/src/index.css +++ b/website/src/index.css @@ -26,9 +26,9 @@ --success: #22c55e; --destructive: #ef4444; - --shadow-sm: 0 1px 2px rgba(0,0,0,0.4); - --shadow-md: 0 4px 16px rgba(0,0,0,0.4); - --shadow-lg: 0 16px 64px rgba(0,0,0,0.5); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 16px 64px rgba(0, 0, 0, 0.5); --shadow-glow: 0 0 60px -12px rgba(167, 139, 250, 0.15); } @@ -47,15 +47,18 @@ --accent-muted: rgba(124, 58, 237, 0.08); --success: #16a34a; --destructive: #dc2626; - --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); - --shadow-md: 0 4px 16px rgba(0,0,0,0.06); - --shadow-lg: 0 16px 64px rgba(0,0,0,0.08); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 16px 64px rgba(0, 0, 0, 0.08); --shadow-glow: 0 0 60px -12px rgba(124, 58, 237, 0.08); } /* ─── Reset ─── */ -html { scroll-behavior: smooth; background: var(--bg); } +html { + scroll-behavior: smooth; + background: var(--bg); +} body { margin: 0; @@ -80,13 +83,28 @@ body::before { background-size: 128px; } -#root { min-height: 100vh; position: relative; z-index: 1; } +#root { + min-height: 100vh; + position: relative; + z-index: 1; +} -*, *::before, *::after { border-color: var(--border); } +*, +*::before, +*::after { + border-color: var(--border); +} -::-webkit-scrollbar { width: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--fg-subtle); border-radius: 3px; } +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--fg-subtle); + border-radius: 3px; +} /* ─── Typography ─── */ @@ -97,12 +115,18 @@ body::before { line-height: 0.95; } -.text-muted { color: var(--fg-muted); } -.text-subtle { color: var(--fg-subtle); } +.text-muted { + color: var(--fg-muted); +} +.text-subtle { + color: var(--fg-subtle); +} /* ─── Accent ─── */ -.accent-text { color: var(--accent); } +.accent-text { + color: var(--accent); +} .accent-glow { color: var(--accent); @@ -170,9 +194,7 @@ body::before { overflow: hidden; border: 1px solid var(--border); background: var(--surface); - box-shadow: - var(--shadow-lg), - var(--shadow-glow); + box-shadow: var(--shadow-lg), var(--shadow-glow); } .product-frame-titlebar { @@ -234,18 +256,34 @@ body::before { /* ─── Animations ─── */ @keyframes fade-in-up { - from { opacity: 0; transform: translateY(24px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(24px); + } + to { + opacity: 1; + transform: translateY(0); + } } @keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes scale-in { - from { opacity: 0; transform: scale(0.96) translateY(16px); } - to { opacity: 1; transform: scale(1) translateY(0); } + from { + opacity: 0; + transform: scale(0.96) translateY(16px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } } .animate-in { @@ -260,20 +298,35 @@ body::before { animation: fade-in 0.5s ease-out forwards; } -.d1 { animation-delay: 0.1s; } -.d2 { animation-delay: 0.2s; } -.d3 { animation-delay: 0.3s; } -.d4 { animation-delay: 0.4s; } -.d5 { animation-delay: 0.5s; } +.d1 { + animation-delay: 0.1s; +} +.d2 { + animation-delay: 0.2s; +} +.d3 { + animation-delay: 0.3s; +} +.d4 { + animation-delay: 0.4s; +} +.d5 { + animation-delay: 0.5s; +} /* ─── Editor theme (for demo) ─── */ -.editor-bg { background: var(--surface); } -[data-theme="dark"] .editor-bg { background: #0c0c14; } +.editor-bg { + background: var(--surface); +} +[data-theme="dark"] .editor-bg { + background: #0c0c14; +} /* ─── Showcase window reuse for Demo ─── */ -.showcase-window { } +.showcase-window { +} .showcase-titlebar { display: flex; @@ -304,7 +357,9 @@ body::before { color: var(--accent-fg); transition: all 0.2s ease; } -.gradient-btn:hover { opacity: 0.9; } +.gradient-btn:hover { + opacity: 0.9; +} .link-chip { border: 1px solid var(--border); diff --git a/website/src/main.tsx b/website/src/main.tsx index eaf7e6b..b3ed3f3 100644 --- a/website/src/main.tsx +++ b/website/src/main.tsx @@ -1,6 +1,6 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React from "react"; import ReactDOM from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { App } from "./App"; import "./index.css"; @@ -15,5 +15,5 @@ ReactDOM.createRoot(document.getElementById("root")!).render( - + , ); diff --git a/website/vite.config.ts b/website/vite.config.ts index 6f8a092..823f7b3 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from "vite"; +import path from "node:path"; import react from "@vitejs/plugin-react"; -import path from "path"; +import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], diff --git a/website/yarn.lock b/website/yarn.lock index ebf1cdd..ed69304 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -165,6 +165,60 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@biomejs/biome@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz#cb84ad6eb4235e7230b3c105a825e9bc03399944" + integrity sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw== + optionalDependencies: + "@biomejs/cli-darwin-arm64" "2.4.15" + "@biomejs/cli-darwin-x64" "2.4.15" + "@biomejs/cli-linux-arm64" "2.4.15" + "@biomejs/cli-linux-arm64-musl" "2.4.15" + "@biomejs/cli-linux-x64" "2.4.15" + "@biomejs/cli-linux-x64-musl" "2.4.15" + "@biomejs/cli-win32-arm64" "2.4.15" + "@biomejs/cli-win32-x64" "2.4.15" + +"@biomejs/cli-darwin-arm64@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz#3469daa56ac3ff4f16588a120df706381a96f65c" + integrity sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg== + +"@biomejs/cli-darwin-x64@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz#0697b81089409635da16682ac1e539165c262006" + integrity sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ== + +"@biomejs/cli-linux-arm64-musl@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz#c6af054e3732c361e9ad8c44070f909666b5616f" + integrity sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ== + +"@biomejs/cli-linux-arm64@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz#527cef60339649a442d51a9cd129ae9dfe9da926" + integrity sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug== + +"@biomejs/cli-linux-x64-musl@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz#b39292ad106c3d5a612bf3c61ba3119f66833013" + integrity sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w== + +"@biomejs/cli-linux-x64@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz#7360b7f81ff03ec6d9350bedc76b89f783b0945d" + integrity sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g== + +"@biomejs/cli-win32-arm64@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz#9542aac679174892a9379267e0c0048f8eee4d9f" + integrity sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w== + +"@biomejs/cli-win32-x64@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz#80288e4eea8f916fc5c876e9a486baadb8de537d" + integrity sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ== + "@electric-sql/pglite@^0.2.17": version "0.2.17" resolved "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.2.17.tgz#23d53a9b7ddd1590d59d7c701aba23b037f08108" diff --git a/yarn.lock b/yarn.lock index 88f21d8..6a69c3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -319,6 +319,60 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@biomejs/biome@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz#cb84ad6eb4235e7230b3c105a825e9bc03399944" + integrity sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw== + optionalDependencies: + "@biomejs/cli-darwin-arm64" "2.4.15" + "@biomejs/cli-darwin-x64" "2.4.15" + "@biomejs/cli-linux-arm64" "2.4.15" + "@biomejs/cli-linux-arm64-musl" "2.4.15" + "@biomejs/cli-linux-x64" "2.4.15" + "@biomejs/cli-linux-x64-musl" "2.4.15" + "@biomejs/cli-win32-arm64" "2.4.15" + "@biomejs/cli-win32-x64" "2.4.15" + +"@biomejs/cli-darwin-arm64@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz#3469daa56ac3ff4f16588a120df706381a96f65c" + integrity sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg== + +"@biomejs/cli-darwin-x64@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz#0697b81089409635da16682ac1e539165c262006" + integrity sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ== + +"@biomejs/cli-linux-arm64-musl@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz#c6af054e3732c361e9ad8c44070f909666b5616f" + integrity sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ== + +"@biomejs/cli-linux-arm64@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz#527cef60339649a442d51a9cd129ae9dfe9da926" + integrity sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug== + +"@biomejs/cli-linux-x64-musl@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz#b39292ad106c3d5a612bf3c61ba3119f66833013" + integrity sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w== + +"@biomejs/cli-linux-x64@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz#7360b7f81ff03ec6d9350bedc76b89f783b0945d" + integrity sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g== + +"@biomejs/cli-win32-arm64@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz#9542aac679174892a9379267e0c0048f8eee4d9f" + integrity sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w== + +"@biomejs/cli-win32-x64@2.4.15": + version "2.4.15" + resolved "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz#80288e4eea8f916fc5c876e9a486baadb8de537d" + integrity sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ== + "@emnapi/core@^1.4.3", "@emnapi/core@^1.4.5": version "1.10.0" resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467"
+ {h}
+ {cell}