Editor settings, SSH/SSL runtime, data import + table-menu loose ends (#7)
* feat: editor settings model + typed clamped defaults (Task 1) * feat: carry SSH config + ssh_password to backend DbConfig (Task 2) * feat: Change enum bulk/drop/empty + type-specific change payload builder (Task 3) * feat: csv parser + shared export util (Task 4) * feat: rustls TLS connector factory with modes + client auth (Task 5) * feat: real SSH tunnel manager (testable backend) + pool eviction hook (Task 6) * feat: table DDL fetch (sqlite + pg_dump arg builder) (Task 7) * feat: keychain SSH secrets + connection delete purge + tunnel lifecycle (Task 8) * feat: real SSH tunnel + TLS connect path for postgres/mysql (Task 9) * feat: execute_change bulk/drop/empty + get_table_ddl command (Task 10) * feat: fetch SSH secrets into dbConnect + save on connection form (Task 11) * feat: Editor settings tab UI (Task 12) * feat: QueryEditor applies editor settings live (Task 13) * feat: ImportDialog with CSV/JSON preview + column mapping (Task 14) * feat: table-menu export/empty/delete/import + queue labels + payload builder (Task 15) * fix: error sanitization, encrypted-key guard, row-indexed import errors, caps (Task 16) * docs: mark Editor Settings, SSH/SSL runtime, Data Import, table-menu loose ends shipped (Task 17) * feat: auto-refresh schema tree after schema-modifying SQL (query + queue drop) * fix: use theme-consistent red classes for danger menu items (text-error was undefined) * feat: changes queue as tab-bar popover + amber pending border * feat: redesign changes popover (visual/SQL toggle, cards, footer actions, Cmd+S) * refactor: drop per-card status label from changes popover cards * feat: green completion indicator on committed cards + auto-close tabs of dropped tables * docs: changes queue popover UX + auto schema refresh statuses
This commit is contained in:
@@ -193,9 +193,9 @@ cargo test # Rust tests
|
|||||||
| DB Viewer: Redis browse | ❌ | Test connection works; browsing not wired |
|
| DB Viewer: Redis browse | ❌ | Test connection works; browsing not wired |
|
||||||
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
|
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
|
||||||
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
||||||
| SSH tunnel runtime | 🟡 | UI exists; backend is a **placeholder** (TODO: ssh2 crate integration) |
|
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
||||||
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths |
|
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths |
|
||||||
| SSL/TLS runtime | 🟡 | Config persisted; **not yet passed to sqlx/tokio-postgres** |
|
| SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity) |
|
||||||
|
|
||||||
### Home Screen & Organization
|
### Home Screen & Organization
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
@@ -237,7 +237,10 @@ cargo test # Rust tests
|
|||||||
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
|
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
|
||||||
| Export toolbar (JSON, CSV, SQL, Markdown) | ✅ | Client-side Blob download of visible rows |
|
| Export toolbar (JSON, CSV, SQL, Markdown) | ✅ | Client-side Blob download of visible rows |
|
||||||
| Auto-refresh timer | ✅ | Configurable interval in settings |
|
| Auto-refresh timer | ✅ | Configurable interval in settings |
|
||||||
| Changes queue (INSERT, UPDATE, DELETE) | ✅ | Queue changes → Commit All; cancel individual changes. Tab bar shows a **Changes** icon button with a pending-count badge that toggles the bottom panel (the queue dropdown was removed from the table toolbar — one entry point only) |
|
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table |
|
||||||
|
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing `drop_table` via the queue — no manual refresh needed |
|
||||||
|
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
|
||||||
|
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
|
||||||
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
||||||
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
|
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
|
||||||
| Inline cell editing | ❌ | Cells are read-only; changes via queue Insert button only |
|
| Inline cell editing | ❌ | Cells are read-only; changes via queue Insert button only |
|
||||||
@@ -273,7 +276,7 @@ cargo test # Rust tests
|
|||||||
| Saved queries (named, organized) | ✅ | v6 `queries` table (nullable `connection_id` for global queries, `folder` field, `ON DELETE CASCADE`); `save_query`/`get_saved_queries`/`update_saved_query`/`delete_saved_query` commands with validation (name ≤200, folder ≤100, text ≤1MB); **SaveQueryDialog** (name + folder, empty-name guard); managed in the Queries view Saved tab |
|
| Saved queries (named, organized) | ✅ | v6 `queries` table (nullable `connection_id` for global queries, `folder` field, `ON DELETE CASCADE`); `save_query`/`get_saved_queries`/`update_saved_query`/`delete_saved_query` commands with validation (name ≤200, folder ≤100, text ≤1MB); **SaveQueryDialog** (name + folder, empty-name guard); managed in the Queries view Saved tab |
|
||||||
| Query favorites / pinning | ✅ | Star toggle per history entry via `set_history_favorite`; favorites-only filter in the Queries view History tab |
|
| Query favorites / pinning | ✅ | Star toggle per history entry via `set_history_favorite`; favorites-only filter in the Queries view History tab |
|
||||||
| Queries view | ✅ | Two-pane layout following Explorer: left sidebar (Explorer-styled header — Queries title, History/Saved dropdown, favorites/clear/search icons, animated search) scoped to the **current connection**, right side reuses the shared tabbed query workspace (TabBar + toolbar + editor + results); clicking a history/saved row loads it into the editor |
|
| Queries view | ✅ | Two-pane layout following Explorer: left sidebar (Explorer-styled header — Queries title, History/Saved dropdown, favorites/clear/search icons, animated search) scoped to the **current connection**, right side reuses the shared tabbed query workspace (TabBar + toolbar + editor + results); clicking a history/saved row loads it into the editor |
|
||||||
| Editor settings (font, tab size, word wrap, minimap) | ❌ | Settings page has "Editor" tab with "coming soon" placeholder |
|
| Editor settings (font, tab size, word wrap, minimap) | ✅ | Font size (8–24), font family (allowlist), word wrap, minimap, tab size (2–8) — applied live via Monaco `updateOptions`, no remount |
|
||||||
|
|
||||||
### Backup & Restore
|
### Backup & Restore
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
@@ -302,7 +305,7 @@ cargo test # Rust tests
|
|||||||
| Confirm-before-delete toggle | ✅ | When off, folder/bulk deletes execute without a confirmation dialog |
|
| Confirm-before-delete toggle | ✅ | When off, folder/bulk deletes execute without a confirmation dialog |
|
||||||
| Default ports per DB type | ✅ | New-connection forms prefill the port from `default_ports` per DB type (custom ports in pasted URLs still win) |
|
| Default ports per DB type | ✅ | New-connection forms prefill the port from `default_ports` per DB type (custom ports in pasted URLs still win) |
|
||||||
| More keyboard shortcuts | ❌ | Only 2 configurable actions |
|
| More keyboard shortcuts | ❌ | Only 2 configurable actions |
|
||||||
| Editor settings | ❌ | Placeholder tab |
|
| Editor settings | ✅ | Five options wired to the settings store + live Monaco `updateOptions` |
|
||||||
| SSH key management | ❌ | Only path inputs, no key file reading |
|
| SSH key management | ❌ | Only path inputs, no key file reading |
|
||||||
| Settings export/import | ❌ | |
|
| Settings export/import | ❌ | |
|
||||||
|
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ Most database GUI clients either lock essential productivity features behind pay
|
|||||||
| Saved connections | 2 | Unlimited | **Unlimited** |
|
| Saved connections | 2 | Unlimited | **Unlimited** |
|
||||||
| Saved queries | 5 | Unlimited | **Unlimited** |
|
| Saved queries | 5 | Unlimited | **Unlimited** |
|
||||||
| Data export (CSV, JSON, SQL) | ❌ (paid only) | Basic only | **JSON, CSV, SQL, Markdown** |
|
| Data export (CSV, JSON, SQL) | ❌ (paid only) | Basic only | **JSON, CSV, SQL, Markdown** |
|
||||||
| Data import (CSV, JSON) | ❌ (paid only) | ✅ | 🟡 *Upcoming* |
|
| Data import (CSV, JSON) | ❌ (paid only) | ✅ | **✅ CSV/JSON + column mapping** |
|
||||||
| pg_dump / pg_restore GUI | ❌ | ❌ (paid only) | **First-class UI** |
|
| pg_dump / pg_restore GUI | ❌ | ❌ (paid only) | **First-class UI** |
|
||||||
| DB-to-DB sync | ❌ | ❌ | **Built-in pipe sync** |
|
| DB-to-DB sync | ❌ | ❌ | **Built-in pipe sync** |
|
||||||
| Object explorer depth | Tables, views | Tables, views | **Functions, Triggers, Enums, Sequences, Extensions** |
|
| Object explorer depth | Tables, views | Tables, views | **Functions, Triggers, Enums, Sequences, Extensions** |
|
||||||
| ER diagram / schema visualizer | ❌ (planned) | ❌ (paid only) | **✅ Interactive React Flow** |
|
| ER diagram / schema visualizer | ❌ (planned) | ❌ (paid only) | **✅ Interactive React Flow** |
|
||||||
| Inline cell editing | ✅ | ✅ | 🟡 *Upcoming* |
|
| Inline cell editing | ✅ | ✅ | 🟡 *Upcoming* |
|
||||||
| SSH tunneling | 🟡 (likely paid) | ✅ | 🟡 *Config UI done* |
|
| SSH tunneling | 🟡 (likely paid) | ✅ | **✅ Full tunnel (password + key auth, keychain)** |
|
||||||
| OS credential vault | ✅ | ✅ | **Keychain / Secret Service** |
|
| OS credential vault | ✅ | ✅ | **Keychain / Secret Service** |
|
||||||
| Workspace / folder hierarchy | ❌ | ❌ | **Multi-level tree + tags** |
|
| Workspace / folder hierarchy | ❌ | ❌ | **Multi-level tree + tags** |
|
||||||
| Changes queue (stage & commit) | ❌ | ❌ | **✅ Queue → Commit All** (tab-bar **Changes** button with count badge toggles the commit panel) |
|
| Changes queue (stage & commit) | ❌ | ❌ | **✅ Queue → Commit All** (tab-bar **Changes** button with count badge toggles a popover: Visual/SQL preview, per-change revert, Clear All, ⌘S commit) |
|
||||||
| Query history | ✅ (auto-saved) | ✅ | 🟡 *Backend done, UI pending* |
|
| Query history | ✅ (auto-saved) | ✅ | **✅ Toolbar dropdown, favorites, Queries view** |
|
||||||
| AI assistant | ✅ (BYO key) | ❌ (paid only) | 🔮 *Planned — BYOK* |
|
| AI assistant | ✅ (BYO key) | ❌ (paid only) | 🔮 *Planned — BYOK* |
|
||||||
| Open source | ❌ | ✅ (GPLv3) | **✅ (MIT)** |
|
| Open source | ❌ | ✅ (GPLv3) | **✅ (MIT)** |
|
||||||
| Desktop shell | Native webview | Electron (~250MB) | **Tauri 2.0 (~40MB)** |
|
| Desktop shell | Native webview | Electron (~250MB) | **Tauri 2.0 (~40MB)** |
|
||||||
@@ -103,7 +103,7 @@ Full tree-view navigation of all native PostgreSQL schema objects:
|
|||||||
- **Query Tabs** — dedicated query tabs alongside table tabs, results rendered in the same virtualized data grid, close with Cmd/Ctrl+W
|
- **Query Tabs** — dedicated query tabs alongside table tabs, results rendered in the same virtualized data grid, close with Cmd/Ctrl+W
|
||||||
- **SQL Autocomplete** — keyword + table suggestions from the active schema; typing `table.` suggests that table's columns (schema introspection, cached per schema)
|
- **SQL Autocomplete** — keyword + table suggestions from the active schema; typing `table.` suggests that table's columns (schema introspection, cached per schema)
|
||||||
- **Multi-Tab Workspace** — unlimited named tabs, session persistence across restarts
|
- **Multi-Tab Workspace** — unlimited named tabs, session persistence across restarts
|
||||||
- **Changes Queue** — queue INSERT/UPDATE/DELETE changes; preview before committing all. The tab bar's **Changes** button (checklist icon + pending-count badge) toggles the bottom Commit All panel — the single entry point
|
- **Changes Queue** — queue INSERT/UPDATE/DELETE/import/drop changes; preview before committing all. The tab bar's **Changes** button (checklist icon + amber pending border + count badge) toggles a popover with a **Visual/SQL** preview toggle, per-change revert, **Clear All** / **Commit All (N)** footer and a **⌘S** shortcut — the single entry point
|
||||||
- **Smart Default Sort** — auto-detects `updated_at`, `created_at`, `_id` columns for logical initial sorting
|
- **Smart Default Sort** — auto-detects `updated_at`, `created_at`, `_id` columns for logical initial sorting
|
||||||
- **Query History** — recent queries per connection in a toolbar dropdown (load / run / favorite / clear), consecutive-identical dedup, retention pruned to 500 per connection
|
- **Query History** — recent queries per connection in a toolbar dropdown (load / run / favorite / clear), consecutive-identical dedup, retention pruned to 500 per connection
|
||||||
- **Saved Queries** — save the current query with a name + folder from the toolbar; manage them in the Queries view
|
- **Saved Queries** — save the current query with a name + folder from the toolbar; manage them in the Queries view
|
||||||
@@ -117,7 +117,7 @@ Full tree-view navigation of all native PostgreSQL schema objects:
|
|||||||
- **FK Preview** — click a foreign key cell to preview the referenced row
|
- **FK Preview** — click a foreign key cell to preview the referenced row
|
||||||
- **JSON/JSONB Viewer** — popover with formatted/raw tabs and copy button
|
- **JSON/JSONB Viewer** — popover with formatted/raw tabs and copy button
|
||||||
- **Auto-Refresh** — configurable interval timer
|
- **Auto-Refresh** — configurable interval timer
|
||||||
- *(Inline cell editing, visual filter builder, and data import — upcoming)*
|
- *(Inline cell editing and visual filter builder — upcoming)*
|
||||||
|
|
||||||
### PostgreSQL Administrative Tools
|
### PostgreSQL Administrative Tools
|
||||||
- **Visual Backup** — `pg_dump` wrapper with format selector (Plain SQL, Custom, Tar, Directory), file browser, schema filter, no-owner toggle, real-time progress bar
|
- **Visual Backup** — `pg_dump` wrapper with format selector (Plain SQL, Custom, Tar, Directory), file browser, schema filter, no-owner toggle, real-time progress bar
|
||||||
@@ -239,12 +239,11 @@ gridline/
|
|||||||
- **Query History & Saved Queries** — toolbar history dropdown (load / run / favorite / clear), favorites, consecutive-identical dedup + 500-retention pruning, SaveQueryDialog, and a two-pane Queries view (History / Saved Queries sidebar scoped per connection + tabbed query workspace)
|
- **Query History & Saved Queries** — toolbar history dropdown (load / run / favorite / clear), favorites, consecutive-identical dedup + 500-retention pruning, SaveQueryDialog, and a two-pane Queries view (History / Saved Queries sidebar scoped per connection + tabbed query workspace)
|
||||||
- **Consolidated Navigation** — merged Functions/Triggers/Sequences/Enums/Extensions into a single Objects view (object-type dropdown) and Backup/Restore/DB Sync into a single Tools view (operation dropdown)
|
- **Consolidated Navigation** — merged Functions/Triggers/Sequences/Enums/Extensions into a single Objects view (object-type dropdown) and Backup/Restore/DB Sync into a single Tools view (operation dropdown)
|
||||||
- **Settings (Redesigned & Fully Wired)** — DB-viewer-styled settings screen (icon+text sidebar, tab-titled header, border-sharp no-card sections, Back returns to origin view); all settings functional: theme (light/dark/system, applied live + native macOS Overlay titlebar sync), font size, **accent color** (circle palette), default folder on startup, confirm-before-delete toggle, default ports prefill; drag-and-drop tag reorder
|
- **Settings (Redesigned & Fully Wired)** — DB-viewer-styled settings screen (icon+text sidebar, tab-titled header, border-sharp no-card sections, Back returns to origin view); all settings functional: theme (light/dark/system, applied live + native macOS Overlay titlebar sync), font size, **accent color** (circle palette), default folder on startup, confirm-before-delete toggle, default ports prefill; drag-and-drop tag reorder
|
||||||
|
- **Editor Settings, SSH/SSL Runtime, Data Import** — Monaco editor options (font size/family, word wrap, minimap, tab size) applied live; real SSH tunnel (`ssh2`, password + key auth, keychain secrets, full lifecycle) and TLS (`rustls`, all modes + client certs) for PostgreSQL/MySQL; CSV/JSON import with preview + column mapping through the changes queue; table-menu loose ends (Copy table schema DDL, Empty/Delete Table via queue, export stubs wired)
|
||||||
|
|
||||||
### 🟡 In Progress / Upcoming
|
### 🟡 In Progress / Upcoming
|
||||||
- **Editor Settings** — font, tab size, word wrap, minimap options
|
|
||||||
- **SSH/SSL Runtime** — SSH tunnel via `ssh2` crate, SSL/TLS config passed to `sqlx`/`tokio-postgres`
|
|
||||||
- **Inline Cell Editing** — Edit cells directly in the data grid
|
- **Inline Cell Editing** — Edit cells directly in the data grid
|
||||||
- **Data Import** — CSV, JSON import with column mapping
|
- **Visual Filter Builder** — drag-and-drop filter construction
|
||||||
|
|
||||||
### 🔮 Future
|
### 🔮 Future
|
||||||
- **Multi-DB Support** — MySQL browsing, Redis key browser, full MySQL/SQLite/Redis parity with PostgreSQL
|
- **Multi-DB Support** — MySQL browsing, Redis key browser, full MySQL/SQLite/Redis parity with PostgreSQL
|
||||||
|
|||||||
Generated
+171
-8
@@ -112,7 +112,7 @@ checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
"log",
|
"log",
|
||||||
"security-framework",
|
"security-framework 3.7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -329,6 +329,16 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcder"
|
||||||
|
version = "0.7.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b593e5aeaf7992d388c08a9831c921cd703718064b3e50ba8e6d666d6cf86ca7"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -704,6 +714,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -727,7 +747,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -740,7 +760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1770,6 +1790,10 @@ dependencies = [
|
|||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"redis",
|
"redis",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"rustls 0.23.43",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"rustls-pemfile 2.2.0",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -1782,6 +1806,7 @@ dependencies = [
|
|||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-postgres",
|
"tokio-postgres",
|
||||||
|
"tokio-postgres-rustls",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -3016,6 +3041,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.117"
|
version = "0.9.117"
|
||||||
@@ -3115,6 +3146,16 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -3748,10 +3789,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-webpki",
|
"rustls-webpki 0.101.7",
|
||||||
"sct",
|
"sct",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.43"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki 0.103.13",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-native-certs"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
|
||||||
|
dependencies = [
|
||||||
|
"openssl-probe",
|
||||||
|
"rustls-pemfile 2.2.0",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"schannel",
|
||||||
|
"security-framework 2.11.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pemfile"
|
name = "rustls-pemfile"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3761,6 +3830,24 @@ dependencies = [
|
|||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pemfile"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.101.7"
|
version = "0.101.7"
|
||||||
@@ -3771,6 +3858,17 @@ dependencies = [
|
|||||||
"untrusted",
|
"untrusted",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -3792,6 +3890,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3859,6 +3966,19 @@ dependencies = [
|
|||||||
"untrusted",
|
"untrusted",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -3866,7 +3986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
@@ -4310,8 +4430,8 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"paste",
|
"paste",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rustls",
|
"rustls 0.21.12",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile 1.0.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
@@ -4616,7 +4736,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
@@ -5156,6 +5276,30 @@ dependencies = [
|
|||||||
"whoami 2.1.2",
|
"whoami 2.1.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-postgres-rustls"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04fb792ccd6bbcd4bba408eb8a292f70fc4a3589e5d793626f45190e6454b6ab"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls 0.23.43",
|
||||||
|
"tokio",
|
||||||
|
"tokio-postgres",
|
||||||
|
"tokio-rustls",
|
||||||
|
"x509-certificate",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||||
|
dependencies = [
|
||||||
|
"rustls 0.23.43",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
@@ -6493,6 +6637,25 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x509-certificate"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "66534846dec7a11d7c50a74b7cdb208b9a581cad890b7866430d438455847c85"
|
||||||
|
dependencies = [
|
||||||
|
"bcder",
|
||||||
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"der",
|
||||||
|
"hex",
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"signature",
|
||||||
|
"spki",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
@@ -38,4 +38,10 @@ ssh2 = { version = "0.9" }
|
|||||||
deadpool-postgres = { version = "0.14" }
|
deadpool-postgres = { version = "0.14" }
|
||||||
indexmap = { version = "2", features = ["serde"] }
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
tauri-plugin-keyring-store = { version = "0.2.0", default-features = false }
|
tauri-plugin-keyring-store = { version = "0.2.0", default-features = false }
|
||||||
|
tokio-postgres-rustls = "0.12"
|
||||||
|
# rustls: ring-only crypto provider (no aws-lc-rs / cmake C build); tls12 needed for Postgres
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||||
|
rustls-pemfile = "2"
|
||||||
|
rustls-pki-types = "1"
|
||||||
|
rustls-native-certs = "0.7"
|
||||||
|
|
||||||
|
|||||||
@@ -96,8 +96,21 @@ pub fn update_connection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_connection(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
|
pub fn delete_connection(
|
||||||
delete_connection_inner(&state.db_store, &id)
|
state: tauri::State<crate::AppState>,
|
||||||
|
id: String,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
delete_connection_inner(&state.db_store, &id)?;
|
||||||
|
// Purge keychain secrets (missing entries are no-ops) and close any
|
||||||
|
// SSH tunnel associated with the deleted connection.
|
||||||
|
let _ = crate::commands::keychain::delete_connection_password_internal(&app, &id);
|
||||||
|
let _ = crate::commands::keychain::delete_connection_ssh_password_internal(&app, &id);
|
||||||
|
let _ = crate::commands::keychain::delete_connection_ssh_passphrase_internal(&app, &id);
|
||||||
|
if let Ok(mut mgr) = state.ssh_manager.lock() {
|
||||||
|
mgr.close_tunnel(&id);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -147,6 +160,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -177,6 +191,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -205,6 +220,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
|
|||||||
@@ -90,6 +90,75 @@ pub fn offset(page: i64, page_size: i64) -> i64 {
|
|||||||
(page - 1) * page_size
|
(page - 1) * page_size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Table DDL helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Fetch the stored `CREATE TABLE` statement for a SQLite table from
|
||||||
|
/// `sqlite_master`. Errors when the table does not exist.
|
||||||
|
pub fn get_sqlite_ddl(conn: &rusqlite::Connection, table: &str) -> Result<String, String> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
|
||||||
|
rusqlite::params![table],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("table DDL not found for {table}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the `pg_dump` argument vector for schema-only DDL extraction of a
|
||||||
|
/// single table. The password is intentionally NOT part of these args — it is
|
||||||
|
/// passed via the `PGPASSWORD` environment variable so it never appears on
|
||||||
|
/// the command line.
|
||||||
|
pub fn build_pg_dump_ddl_args(schema: &str, table: &str) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"--schema-only".into(),
|
||||||
|
"--no-owner".into(),
|
||||||
|
format!("--schema={schema}"),
|
||||||
|
format!("--table={table}"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the system `pg_dump` binary is on PATH.
|
||||||
|
pub fn pg_dump_available() -> bool {
|
||||||
|
std::process::Command::new("pg_dump")
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a single table's DDL from a PostgreSQL database by shelling out to
|
||||||
|
/// the system `pg_dump` with `--schema-only`. Credentials are supplied via the
|
||||||
|
/// `PGPASSWORD` environment variable only — never as argv — and are never
|
||||||
|
/// logged. Execution requires a reachable PostgreSQL server plus an installed
|
||||||
|
/// `pg_dump`; unit tests cover the argument construction instead.
|
||||||
|
pub fn get_pg_ddl_via_dump(
|
||||||
|
schema: &str,
|
||||||
|
table: &str,
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
user: &str,
|
||||||
|
db: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if !pg_dump_available() {
|
||||||
|
return Err("pg_dump not found. Install PostgreSQL client tools to copy table schema.".into());
|
||||||
|
}
|
||||||
|
let mut cmd = std::process::Command::new("pg_dump");
|
||||||
|
cmd.args([
|
||||||
|
format!("--host={host}"),
|
||||||
|
format!("--port={port}"),
|
||||||
|
format!("--username={user}"),
|
||||||
|
format!("--dbname={db}"),
|
||||||
|
]);
|
||||||
|
cmd.args(build_pg_dump_ddl_args(schema, table));
|
||||||
|
cmd.env("PGPASSWORD", password);
|
||||||
|
let out = cmd.output().map_err(|e| format!("pg_dump spawn failed: {e}"))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(String::from_utf8_lossy(&out.stderr).to_string());
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Filter / Sort → SQL helpers
|
// Filter / Sort → SQL helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -420,6 +489,112 @@ fn json_to_sqlite_value(v: &serde_json::Value) -> rusqlite::types::Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the SQL skeleton for a bulk INSERT into PostgreSQL.
|
||||||
|
///
|
||||||
|
/// Emits `$N` placeholders; callers bind one row of values per execution so
|
||||||
|
/// the same statement can be reused for every row in the batch.
|
||||||
|
pub fn build_pg_bulk_insert_sql(schema: &str, table: &str, columns: &[String]) -> String {
|
||||||
|
let cols: Vec<String> = columns.iter().map(|c| format!("\"{}\"", c)).collect();
|
||||||
|
let placeholders: Vec<String> = (1..=columns.len()).map(|i| format!("${i}")).collect();
|
||||||
|
format!(
|
||||||
|
"INSERT INTO \"{}\".\"{}\" ({}) VALUES ({})",
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
cols.join(", "),
|
||||||
|
placeholders.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `DROP TABLE` statement (schema-qualified). SQLite accepts the same
|
||||||
|
/// qualified form against the `main` schema.
|
||||||
|
pub fn build_drop_table_sql(schema: &str, table: &str) -> String {
|
||||||
|
format!("DROP TABLE \"{}\".\"{}\"", schema, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `DELETE FROM` (empty-table) statement (schema-qualified). SQLite
|
||||||
|
/// accepts the same qualified form against the `main` schema.
|
||||||
|
pub fn build_empty_table_sql(schema: &str, table: &str) -> String {
|
||||||
|
format!("DELETE FROM \"{}\".\"{}\"", schema, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a batch of rows to a SQLite table inside a single transaction.
|
||||||
|
///
|
||||||
|
/// Every row is inserted with its own parameterized statement; on the first
|
||||||
|
/// error the whole transaction is rolled back so no partial batch survives.
|
||||||
|
pub fn apply_bulk_insert_sqlite(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
table: &str,
|
||||||
|
columns: &[String],
|
||||||
|
rows: &[Vec<serde_json::Value>],
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let sql = build_insert_sql("main", table, columns);
|
||||||
|
conn.execute_batch("BEGIN").map_err(|e| e.to_string())?;
|
||||||
|
let result = (|| {
|
||||||
|
let mut count = 0;
|
||||||
|
for (i, row) in rows.iter().enumerate() {
|
||||||
|
let params: Vec<rusqlite::types::Value> =
|
||||||
|
row.iter().map(json_to_sqlite_value).collect();
|
||||||
|
conn.execute(&sql, rusqlite::params_from_iter(params))
|
||||||
|
.map_err(|e| format!("row {}: {}", i + 1, e))?;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
Ok::<usize, String>(count)
|
||||||
|
})();
|
||||||
|
match result {
|
||||||
|
Ok(count) => {
|
||||||
|
conn.execute_batch("COMMIT").map_err(|e| e.to_string())?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Best-effort rollback so a failed batch never persists partially.
|
||||||
|
let _ = conn.execute_batch("ROLLBACK");
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a batch of rows to a PostgreSQL table inside a single transaction.
|
||||||
|
///
|
||||||
|
/// The same `$N`-placeholder statement is reused per row with natively bound
|
||||||
|
/// values; on the first error the transaction is rolled back.
|
||||||
|
pub async fn apply_bulk_insert_pg(
|
||||||
|
client: &tokio_postgres::Client,
|
||||||
|
schema: &str,
|
||||||
|
table: &str,
|
||||||
|
columns: &[String],
|
||||||
|
rows: &[Vec<serde_json::Value>],
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let sql = build_pg_bulk_insert_sql(schema, table, columns);
|
||||||
|
client.batch_execute("BEGIN").await.map_err(|e| e.to_string())?;
|
||||||
|
let mut count = 0;
|
||||||
|
for (i, row) in rows.iter().enumerate() {
|
||||||
|
let boxed: Vec<Box<dyn ToSql + Send + Sync>> = row.iter().map(pg_box_value).collect();
|
||||||
|
let refs: Vec<&(dyn ToSql + Sync)> = boxed
|
||||||
|
.iter()
|
||||||
|
.map(|b| {
|
||||||
|
let r: &(dyn ToSql + Sync) = &**b;
|
||||||
|
r
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Err(e) = client.execute(&sql, &refs).await {
|
||||||
|
let _ = client.batch_execute("ROLLBACK").await;
|
||||||
|
return Err(format!("row {}: {}", i + 1, e));
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
client
|
||||||
|
.batch_execute("COMMIT")
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize a raw error string before it crosses the IPC boundary: redact
|
||||||
|
/// credential-like fragments (connection URLs, `password=...`) and cap length.
|
||||||
|
fn sanitize_error(e: &str) -> String {
|
||||||
|
truncate(&redact_secrets(e), 400)
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a JSON object string (e.g. `{"id": 1}`) into ordered (column, value)
|
/// Parse a JSON object string (e.g. `{"id": 1}`) into ordered (column, value)
|
||||||
/// pairs. Insertion order of the JSON object is preserved by `serde_json`.
|
/// pairs. Insertion order of the JSON object is preserved by `serde_json`.
|
||||||
fn parse_json_pairs(json: &str) -> Result<Vec<(String, serde_json::Value)>, String> {
|
fn parse_json_pairs(json: &str) -> Result<Vec<(String, serde_json::Value)>, String> {
|
||||||
@@ -538,6 +713,29 @@ pub(crate) fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_jso
|
|||||||
serde_json::Value::Null
|
serde_json::Value::Null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Establish a PostgreSQL connection with the given TLS connector and spawn
|
||||||
|
/// the background connection driver task.
|
||||||
|
///
|
||||||
|
/// This helper keeps the two TLS branches of `db_connect` unified: without it
|
||||||
|
/// the `Connection<Socket, NoTlsStream>` vs `Connection<Socket, TlsStream>`
|
||||||
|
/// types would force duplicated spawn/register blocks.
|
||||||
|
pub(crate) async fn connect_pg_with<T>(
|
||||||
|
pgconfig: &tokio_postgres::Config,
|
||||||
|
tls: T,
|
||||||
|
) -> Result<(tokio_postgres::Client, tokio::task::JoinHandle<()>), tokio_postgres::Error>
|
||||||
|
where
|
||||||
|
T: tokio_postgres::tls::MakeTlsConnect<tokio_postgres::Socket>,
|
||||||
|
T::Stream: Send + 'static,
|
||||||
|
{
|
||||||
|
let (client, connection) = pgconfig.connect(tls).await?;
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
if let Err(e) = connection.await {
|
||||||
|
eprintln!("PostgreSQL connection error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok((client, handle))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn db_connect(
|
pub async fn db_connect(
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -545,35 +743,87 @@ pub async fn db_connect(
|
|||||||
state: State<'_, crate::AppState>,
|
state: State<'_, crate::AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if config.db_type == "postgresql" {
|
if config.db_type == "postgresql" {
|
||||||
use tokio_postgres::NoTls;
|
|
||||||
|
|
||||||
let host = &config.host;
|
|
||||||
let port = config.port.unwrap_or(5432) as u16;
|
|
||||||
let user = config.username.as_deref().unwrap_or("postgres");
|
let user = config.username.as_deref().unwrap_or("postgres");
|
||||||
let dbname = config.database.as_deref().unwrap_or("postgres");
|
let dbname = config.database.as_deref().unwrap_or("postgres");
|
||||||
let password = config.password.as_deref().unwrap_or("");
|
let password = config.password.as_deref().unwrap_or("");
|
||||||
|
let default_port = config.port.unwrap_or(5432) as u16;
|
||||||
|
|
||||||
// Build a postgres URL connection string rather than the fragile
|
let ssh_cfg = config.ssh_config();
|
||||||
// libpq key=value format. tokio-postgres parses URLs reliably and
|
let will_tunnel = ssh_cfg.is_some();
|
||||||
// urlencoding handles special characters in user/password/dbname.
|
|
||||||
use urlencoding::encode as enc;
|
// TLS first: through a tunnel the peer is loopback, so
|
||||||
let conn_str = format!(
|
// verify-ca/verify-full degrade to encrypt-only `require`; direct
|
||||||
"postgresql://{}:{}@{}:{}/{}?connect_timeout=10",
|
// connections honor the user's mode. Building this before opening the
|
||||||
enc(user),
|
// tunnel means a config error can't leak the tunnel.
|
||||||
enc(password),
|
let decision = crate::commands::ssh::effective_tls_decision(
|
||||||
host,
|
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||||
port,
|
will_tunnel,
|
||||||
enc(dbname),
|
|
||||||
);
|
);
|
||||||
|
let tls = crate::db::tls::build_tls_config(
|
||||||
|
decision,
|
||||||
|
config.ssl_ca_path.as_deref(),
|
||||||
|
config.ssl_cert_path.as_deref(),
|
||||||
|
config.ssl_key_path.as_deref(),
|
||||||
|
)
|
||||||
|
.map_err(|e| sanitize_error(&e))?;
|
||||||
|
|
||||||
match tokio_postgres::connect(&conn_str, NoTls).await {
|
// SSH tunnel: if configured, open a loopback tunnel to the remote DB
|
||||||
Ok((client, connection)) => {
|
// and connect through it. The blocking ssh2 handshake runs in
|
||||||
let handle = tokio::spawn(async move {
|
// `spawn_blocking` so it never blocks the async runtime.
|
||||||
if let Err(e) = connection.await {
|
let (connect_host, connect_port, via_tunnel) = match ssh_cfg {
|
||||||
eprintln!("PostgreSQL connection error: {}", e);
|
Some(ssh) => {
|
||||||
}
|
let key = connection_id.clone();
|
||||||
});
|
let remote_host = config.host.clone();
|
||||||
|
let remote_port = config.port.unwrap_or(5432) as u16;
|
||||||
|
let pw = config.ssh_password.clone();
|
||||||
|
let pp = config.ssh_passphrase.clone();
|
||||||
|
let backend = state.ssh_manager.lock().unwrap().backend_clone();
|
||||||
|
let tunnel = tokio::task::spawn_blocking(move || {
|
||||||
|
backend.open(
|
||||||
|
&key,
|
||||||
|
&ssh,
|
||||||
|
&remote_host,
|
||||||
|
remote_port,
|
||||||
|
pw.as_deref(),
|
||||||
|
pp.as_deref(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Connection failed: {e}"))?
|
||||||
|
.map_err(|e| sanitize_error(&e))?;
|
||||||
|
let lp = tunnel.local_port;
|
||||||
|
state
|
||||||
|
.ssh_manager
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert_tunnel(connection_id.clone(), tunnel);
|
||||||
|
("127.0.0.1".to_string(), lp, true)
|
||||||
|
}
|
||||||
|
None => (config.host.clone(), default_port, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Config builder: user/password/dbname are sent as-is (no URL
|
||||||
|
// percent-encoding needed), and the TLS connector is chosen explicitly.
|
||||||
|
let mut pgconfig = tokio_postgres::Config::new();
|
||||||
|
pgconfig
|
||||||
|
.host(connect_host.clone())
|
||||||
|
.port(connect_port)
|
||||||
|
.user(user)
|
||||||
|
.password(password)
|
||||||
|
.dbname(dbname)
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10));
|
||||||
|
|
||||||
|
let result = match tls {
|
||||||
|
None => connect_pg_with(&pgconfig, tokio_postgres::NoTls).await,
|
||||||
|
Some(cc) => {
|
||||||
|
let connector =
|
||||||
|
tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone());
|
||||||
|
connect_pg_with(&pgconfig, connector).await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((client, handle)) => {
|
||||||
let mut pm = state.pool_manager.lock().await;
|
let mut pm = state.pool_manager.lock().await;
|
||||||
pm.register(
|
pm.register(
|
||||||
&connection_id,
|
&connection_id,
|
||||||
@@ -581,7 +831,16 @@ pub async fn db_connect(
|
|||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(format!("Connection failed: {}", pg_error_message(&e))),
|
Err(e) => {
|
||||||
|
if via_tunnel {
|
||||||
|
state
|
||||||
|
.ssh_manager
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.close_tunnel(&connection_id);
|
||||||
|
}
|
||||||
|
Err(format!("Connection failed: {}", pg_error_message(&e)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if config.db_type == "sqlite" {
|
} else if config.db_type == "sqlite" {
|
||||||
match rusqlite::Connection::open(&config.host) {
|
match rusqlite::Connection::open(&config.host) {
|
||||||
@@ -1233,6 +1492,33 @@ pub async fn execute_change(
|
|||||||
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
Change::BulkInsert {
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// Single transaction for the whole batch; rolls back on the
|
||||||
|
// first failed row so no partial batch persists.
|
||||||
|
return apply_bulk_insert_pg(client, schema, table, columns, rows)
|
||||||
|
.await
|
||||||
|
.map(|_| ());
|
||||||
|
}
|
||||||
|
Change::DropTable { schema, table, .. } => {
|
||||||
|
client
|
||||||
|
.execute(&build_drop_table_sql(schema, table), &[])
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Change::EmptyTable { schema, table, .. } => {
|
||||||
|
client
|
||||||
|
.execute(&build_empty_table_sql(schema, table), &[])
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Box each value for trait-object binding (`$N` placeholders). The
|
// Box each value for trait-object binding (`$N` placeholders). The
|
||||||
@@ -1302,6 +1588,26 @@ pub async fn execute_change(
|
|||||||
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
Change::BulkInsert {
|
||||||
|
table,
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// Single transaction for the whole batch; rolls back on the
|
||||||
|
// first failed row so no partial batch persists.
|
||||||
|
return apply_bulk_insert_sqlite(conn, table, columns, rows).map(|_| ());
|
||||||
|
}
|
||||||
|
Change::DropTable { schema, table, .. } => {
|
||||||
|
conn.execute(&build_drop_table_sql(schema, table), [])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Change::EmptyTable { schema, table, .. } => {
|
||||||
|
conn.execute(&build_empty_table_sql(schema, table), [])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let sqlite_params: Vec<rusqlite::types::Value> =
|
let sqlite_params: Vec<rusqlite::types::Value> =
|
||||||
@@ -1500,6 +1806,68 @@ pub async fn get_extensions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch a table's `CREATE TABLE` DDL for display/copy.
|
||||||
|
///
|
||||||
|
/// SQLite reads the stored statement from `sqlite_master` directly; PostgreSQL
|
||||||
|
/// shells out to the system `pg_dump --schema-only` so the output matches what
|
||||||
|
/// `pg_dump` would emit, scoped to the requested schema + table.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_table_ddl(
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut pm = state.pool_manager.lock().await;
|
||||||
|
match pm.get(&connection_id) {
|
||||||
|
Some(DbHandle::Sqlite(conn)) => get_sqlite_ddl(conn, &table),
|
||||||
|
Some(DbHandle::Postgresql(_client, _)) => {
|
||||||
|
// Pull connection metadata so pg_dump reaches the same server the
|
||||||
|
// pool is connected to (host/port/user/dbname + keychain password).
|
||||||
|
let conn_row = state
|
||||||
|
.db_store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.get_connections()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| c.id == connection_id)
|
||||||
|
.ok_or_else(|| format!("connection {connection_id} not found"))?;
|
||||||
|
let host = conn_row.host.clone();
|
||||||
|
let port = conn_row.port.unwrap_or(5432) as u16;
|
||||||
|
let user = conn_row.username.unwrap_or_else(|| "postgres".into());
|
||||||
|
let db = conn_row.database.unwrap_or_else(|| "postgres".into());
|
||||||
|
let password =
|
||||||
|
crate::commands::keychain::get_connection_password_internal(&app, &connection_id)?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// SSH-tunneled connections: pg_dump must reach the DB through the
|
||||||
|
// same local loopback listener the app uses, not the remote host.
|
||||||
|
let tunnel_port = state
|
||||||
|
.ssh_manager
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.get_local_port(&connection_id);
|
||||||
|
let (dump_host, dump_port) = match tunnel_port {
|
||||||
|
Some(lp) => ("127.0.0.1".to_string(), lp),
|
||||||
|
None => (host, port),
|
||||||
|
};
|
||||||
|
|
||||||
|
// pg_dump is blocking I/O; run it off the async runtime. Credentials
|
||||||
|
// travel via PGPASSWORD, never argv.
|
||||||
|
let ddl = tokio::task::spawn_blocking(move || {
|
||||||
|
get_pg_ddl_via_dump(&schema, &table, &dump_host, dump_port, &user, &db, &password)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("pg_dump task failed: {e}"))??;
|
||||||
|
Ok(sanitize_error(&ddl))
|
||||||
|
}
|
||||||
|
None => Err("Connection not found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1620,4 +1988,132 @@ mod tests {
|
|||||||
sql
|
sql
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement
|
||||||
|
/// from `sqlite_master`.
|
||||||
|
#[test]
|
||||||
|
fn sqlite_ddl_returns_create_table() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute("CREATE TABLE foo (id INTEGER PRIMARY KEY, name TEXT)", [])
|
||||||
|
.unwrap();
|
||||||
|
let ddl = get_sqlite_ddl(&conn, "foo").unwrap();
|
||||||
|
assert!(ddl.contains("CREATE TABLE foo"), "got: {ddl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that `get_sqlite_ddl` errors for a table that does not exist.
|
||||||
|
#[test]
|
||||||
|
fn sqlite_ddl_missing_table_errors() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
assert!(get_sqlite_ddl(&conn, "nope").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that the pg_dump argument builder emits schema-only DDL flags
|
||||||
|
/// scoped to the requested schema and table.
|
||||||
|
#[test]
|
||||||
|
fn pg_dump_ddl_args_built() {
|
||||||
|
let args = build_pg_dump_ddl_args("public", "users");
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec![
|
||||||
|
"--schema-only".to_string(),
|
||||||
|
"--no-owner".to_string(),
|
||||||
|
"--schema=public".to_string(),
|
||||||
|
"--table=users".to_string()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that a SQLite bulk insert applies every row in a single batch.
|
||||||
|
#[test]
|
||||||
|
fn apply_bulk_insert_sqlite_success() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY, b TEXT)", [])
|
||||||
|
.unwrap();
|
||||||
|
let rows = vec![
|
||||||
|
vec![serde_json::json!(1), serde_json::json!("y")],
|
||||||
|
vec![serde_json::json!(2), serde_json::json!("z")],
|
||||||
|
];
|
||||||
|
apply_bulk_insert_sqlite(
|
||||||
|
&conn,
|
||||||
|
"t",
|
||||||
|
&["a".to_string(), "b".to_string()],
|
||||||
|
&rows,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that a SQLite bulk insert rolls back the whole batch when any
|
||||||
|
/// row fails (a non-integer value bound to the INTEGER PRIMARY KEY column
|
||||||
|
/// raises a datatype mismatch).
|
||||||
|
#[test]
|
||||||
|
fn apply_bulk_insert_sqlite_inserts_and_rolls_back() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY, b TEXT)", [])
|
||||||
|
.unwrap();
|
||||||
|
let rows = vec![
|
||||||
|
vec![serde_json::json!(1), serde_json::json!("y")],
|
||||||
|
vec![serde_json::json!("bad"), serde_json::json!("z")],
|
||||||
|
];
|
||||||
|
let res = apply_bulk_insert_sqlite(
|
||||||
|
&conn,
|
||||||
|
"t",
|
||||||
|
&["a".to_string(), "b".to_string()],
|
||||||
|
&rows,
|
||||||
|
);
|
||||||
|
assert!(res.is_err(), "non-integer PK value should fail");
|
||||||
|
// Rollback: no rows persisted.
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 0, "failed batch must roll back all rows");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that a SQLite bulk insert reports the failing row index (1-based)
|
||||||
|
/// when a row cannot be inserted.
|
||||||
|
#[test]
|
||||||
|
fn apply_bulk_insert_sqlite_error_includes_row_index() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
// INTEGER PRIMARY KEY rejects non-integer values (datatype mismatch),
|
||||||
|
// guaranteeing row 2 fails.
|
||||||
|
conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY)", []).unwrap();
|
||||||
|
let rows = vec![vec![serde_json::json!(1)], vec![serde_json::json!("x")]];
|
||||||
|
let err = apply_bulk_insert_sqlite(&conn, "t", &["a".to_string()], &rows)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("row 2"),
|
||||||
|
"error should name the failing row index (1-based): {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that the PostgreSQL bulk-insert skeleton uses `$N` placeholders
|
||||||
|
/// and quotes schema, table, and columns.
|
||||||
|
#[test]
|
||||||
|
fn build_pg_bulk_insert_sql_shape() {
|
||||||
|
let sql = build_pg_bulk_insert_sql(
|
||||||
|
"public",
|
||||||
|
"users",
|
||||||
|
&["id".to_string(), "name".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sql,
|
||||||
|
r#"INSERT INTO "public"."users" ("id", "name") VALUES ($1, $2)"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that the DROP TABLE / DELETE-all SQL helpers quote schema + table.
|
||||||
|
#[test]
|
||||||
|
fn drop_and_empty_table_sql_shapes() {
|
||||||
|
assert_eq!(
|
||||||
|
build_drop_table_sql("public", "users"),
|
||||||
|
r#"DROP TABLE "public"."users""#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_empty_table_sql("public", "users"),
|
||||||
|
r#"DELETE FROM "public"."users""#
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,7 @@ pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Re
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -108,6 +109,7 @@ fn ensure_demo_db_inner(store: &Mutex<Store>) -> Result<(), String> {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -143,6 +145,7 @@ fn ensure_demo_db_inner(store: &Mutex<Store>) -> Result<(), String> {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ pub fn import_connections_inner(state: &Mutex<Store>, json: String) -> Result<Im
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -172,7 +173,7 @@ mod tests {
|
|||||||
port: Some(5432), username: None, folder_id: None,
|
port: Some(5432), username: None, folder_id: None,
|
||||||
password: None, database: None,
|
password: None, database: None,
|
||||||
ssh_host: None, ssh_port: None, ssh_user: None, ssh_auth_method: None,
|
ssh_host: None, ssh_port: None, ssh_user: None, ssh_auth_method: None,
|
||||||
ssh_private_key_path: None, ssh_passphrase: None,
|
ssh_private_key_path: None, ssh_password: None, ssh_passphrase: None,
|
||||||
ssl_mode: None, ssl_ca_path: None, ssl_cert_path: None, ssl_key_path: None,
|
ssl_mode: None, ssl_ca_path: None, ssl_cert_path: None, ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
|||||||
@@ -39,6 +39,124 @@ pub fn get_connection_password_internal(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the keyring account name for an SSH secret (password or passphrase).
|
||||||
|
/// The `connection_id` namespaces each secret per connection.
|
||||||
|
pub fn ssh_account(kind: &str, connection_id: &str) -> String {
|
||||||
|
format!("ssh_{kind}:{connection_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an SSH tunnel password in the OS keychain.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_connection_ssh_password(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
connection_id: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
app.keyring()
|
||||||
|
.store
|
||||||
|
.set_password(&ssh_account("password", &connection_id), &password)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve an SSH tunnel password from the OS keychain.
|
||||||
|
/// Returns None if no SSH password was stored for this connection.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_connection_ssh_password(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
connection_id: String,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
app.keyring()
|
||||||
|
.store
|
||||||
|
.get_password(&ssh_account("password", &connection_id))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an SSH tunnel password from the OS keychain.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_connection_ssh_password(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
connection_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
app.keyring()
|
||||||
|
.store
|
||||||
|
.delete(&ssh_account("password", &connection_id))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an SSH private-key passphrase in the OS keychain.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_connection_ssh_passphrase(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
connection_id: String,
|
||||||
|
passphrase: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
app.keyring()
|
||||||
|
.store
|
||||||
|
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve an SSH private-key passphrase from the OS keychain.
|
||||||
|
/// Returns None if no passphrase was stored for this connection.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_connection_ssh_passphrase(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
connection_id: String,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
app.keyring()
|
||||||
|
.store
|
||||||
|
.get_password(&ssh_account("passphrase", &connection_id))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an SSH private-key passphrase from the OS keychain.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_connection_ssh_passphrase(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
connection_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
app.keyring()
|
||||||
|
.store
|
||||||
|
.delete(&ssh_account("passphrase", &connection_id))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve an SSH tunnel password from the OS keychain (internal helper).
|
||||||
|
/// Returns None if no SSH password was stored for this connection.
|
||||||
|
pub fn get_connection_ssh_password_internal(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
connection_id: &str,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
app.keyring()
|
||||||
|
.store
|
||||||
|
.get_password(&ssh_account("password", connection_id))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an SSH tunnel password from the OS keychain (internal helper).
|
||||||
|
/// Errors are ignored by callers (deleting an absent key is a no-op).
|
||||||
|
pub fn delete_connection_ssh_password_internal(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
connection_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
app.keyring()
|
||||||
|
.store
|
||||||
|
.delete(&ssh_account("password", connection_id))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an SSH private-key passphrase from the OS keychain (internal helper).
|
||||||
|
/// Errors are ignored by callers (deleting an absent key is a no-op).
|
||||||
|
pub fn delete_connection_ssh_passphrase_internal(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
connection_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
app.keyring()
|
||||||
|
.store
|
||||||
|
.delete(&ssh_account("passphrase", connection_id))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete a connection password from the OS keychain.
|
/// Delete a connection password from the OS keychain.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_connection_password(
|
pub fn delete_connection_password(
|
||||||
@@ -50,3 +168,26 @@ pub fn delete_connection_password(
|
|||||||
.delete(&connection_id)
|
.delete(&connection_id)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete a connection password from the OS keychain (internal helper).
|
||||||
|
/// Errors are ignored by callers (deleting an absent key is a no-op).
|
||||||
|
pub fn delete_connection_password_internal(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
connection_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
app.keyring()
|
||||||
|
.store
|
||||||
|
.delete(connection_id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssh_account_namespaces_password() {
|
||||||
|
assert_eq!(ssh_account("password", "c1"), "ssh_password:c1");
|
||||||
|
assert_eq!(ssh_account("passphrase", "c1"), "ssh_passphrase:c1");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -906,6 +906,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
|
|||||||
+318
-74
@@ -1,110 +1,112 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use crate::db::tls::TlsDecision;
|
||||||
|
use crate::models::SshConfig;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// SSH tunnel configuration.
|
/// Through a tunnel the TLS peer is loopback (`127.0.0.1`), so certificate
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
/// verification is meaningless: `verify-ca`/`verify-full` degrade to
|
||||||
pub struct SshConfig {
|
/// encrypt-only `require`. A direct (non-tunneled) connection honors the
|
||||||
pub host: String,
|
/// user's mode unchanged.
|
||||||
pub port: u16,
|
pub fn effective_tls_decision(d: TlsDecision, via_tunnel: bool) -> TlsDecision {
|
||||||
pub user: String,
|
if via_tunnel && matches!(d, TlsDecision::Verify) {
|
||||||
/// "password" or "key"
|
TlsDecision::Require
|
||||||
pub auth_method: String,
|
} else {
|
||||||
pub password: Option<String>,
|
d
|
||||||
pub private_key_path: Option<String>,
|
}
|
||||||
pub passphrase: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SshConfig {
|
/// A live tunnel handle. `closer` drops the listener + ssh session when called.
|
||||||
/// Create a new `SshConfig` with the required fields.
|
pub struct Tunnel {
|
||||||
pub fn new(
|
pub local_port: u16,
|
||||||
host: String,
|
closer: Option<Box<dyn FnOnce() + Send>>,
|
||||||
port: u16,
|
}
|
||||||
user: String,
|
|
||||||
auth_method: String,
|
impl Tunnel {
|
||||||
) -> Self {
|
/// Create a tunnel handle with no resources to clean up (test backend).
|
||||||
SshConfig {
|
pub fn fake(port: u16) -> Self {
|
||||||
host,
|
Tunnel {
|
||||||
port,
|
local_port: port,
|
||||||
user,
|
closer: None,
|
||||||
auth_method,
|
|
||||||
password: None,
|
|
||||||
private_key_path: None,
|
|
||||||
passphrase: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate SSH configuration.
|
|
||||||
///
|
|
||||||
/// Returns `true` if:
|
|
||||||
/// - `host` is not empty
|
|
||||||
/// - `port` is in range 1..=65535 (u16 guarantees <= 65535)
|
|
||||||
/// - `user` is not empty
|
|
||||||
pub fn is_valid(&self) -> bool {
|
|
||||||
!self.host.is_empty() && self.port >= 1 && !self.user.is_empty()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents an active SSH tunnel connection.
|
/// Backend that actually establishes SSH tunnels.
|
||||||
#[derive(Debug)]
|
///
|
||||||
struct SshTunnel {
|
/// The manager only does bookkeeping; opening/closing the OS-level tunnel is
|
||||||
local_port: u16,
|
/// delegated here so it can be faked in tests.
|
||||||
remote_host: String,
|
pub trait TunnelBackend: Send + Sync {
|
||||||
remote_port: u16,
|
/// Open a tunnel to `remote_host:remote_port` via `cfg` and return a
|
||||||
|
/// handle exposing the bound local port.
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
cfg: &SshConfig,
|
||||||
|
remote_host: &str,
|
||||||
|
remote_port: u16,
|
||||||
|
password: Option<&str>,
|
||||||
|
passphrase: Option<&str>,
|
||||||
|
) -> Result<Tunnel, String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manages SSH tunnels, mapping connection keys to active tunnels.
|
/// Manages SSH tunnels, mapping connection keys to active tunnels.
|
||||||
///
|
///
|
||||||
/// This is a placeholder implementation. Real SSH connectivity (via `ssh2`
|
/// Bookkeeping only: validation, key->tunnel map, and lifecycle hooks.
|
||||||
/// or `async-ssh2`) will be added in a later task. Currently the manager
|
/// The actual SSH connectivity is delegated to a `TunnelBackend` so the
|
||||||
/// stores mock entries when validation passes.
|
/// manager's behavior is unit-testable with a fake backend.
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct SshTunnelManager {
|
pub struct SshTunnelManager {
|
||||||
tunnels: HashMap<String, SshTunnel>,
|
tunnels: HashMap<String, Tunnel>,
|
||||||
|
backend: Arc<dyn TunnelBackend>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SshTunnelManager {
|
impl SshTunnelManager {
|
||||||
/// Create a new empty tunnel manager.
|
/// Create a new tunnel manager backed by `backend`.
|
||||||
pub fn new() -> Self {
|
pub fn new(backend: Arc<dyn TunnelBackend>) -> Self {
|
||||||
SshTunnelManager {
|
SshTunnelManager {
|
||||||
tunnels: HashMap::new(),
|
tunnels: HashMap::new(),
|
||||||
|
backend,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open an SSH tunnel for the given config.
|
/// Open an SSH tunnel for the given config.
|
||||||
///
|
///
|
||||||
/// Returns the local port on success.
|
/// Returns the local port on success. Replaces any existing tunnel for
|
||||||
///
|
/// the same key (closing the old one).
|
||||||
/// TODO: Replace placeholder with a real SSH connection via `ssh2` or
|
pub fn open_tunnel(
|
||||||
/// `async-ssh2`. Currently stores a mock entry (`local_port = 15432`)
|
&mut self,
|
||||||
/// when `config.is_valid()` passes.
|
key: &str,
|
||||||
pub fn open_tunnel(&mut self, key: &str, config: &SshConfig) -> Result<u16, String> {
|
cfg: &SshConfig,
|
||||||
if !config.is_valid() {
|
remote_host: &str,
|
||||||
|
remote_port: u16,
|
||||||
|
password: Option<&str>,
|
||||||
|
passphrase: Option<&str>,
|
||||||
|
) -> Result<u16, String> {
|
||||||
|
if !cfg.is_valid() {
|
||||||
return Err("invalid SSH configuration".to_string());
|
return Err("invalid SSH configuration".to_string());
|
||||||
}
|
}
|
||||||
// TODO: Replace with real SSH tunnel via ssh2::Session + port forwarding.
|
let tunnel = self
|
||||||
// For now, store a mock entry with local_port = 15432.
|
.backend
|
||||||
self.tunnels.insert(
|
.open(key, cfg, remote_host, remote_port, password, passphrase)?;
|
||||||
key.to_string(),
|
let port = tunnel.local_port;
|
||||||
SshTunnel {
|
if let Some(old) = self.tunnels.insert(key.to_string(), tunnel) {
|
||||||
local_port: 15432,
|
drop(old.closer);
|
||||||
remote_host: config.host.clone(),
|
}
|
||||||
remote_port: config.port,
|
Ok(port)
|
||||||
},
|
|
||||||
);
|
|
||||||
Ok(15432)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close and remove the SSH tunnel for the given key.
|
/// Close and remove the SSH tunnel for the given key.
|
||||||
///
|
|
||||||
/// TODO: When real SSH is implemented, this should disconnect the
|
|
||||||
/// session and free the local port.
|
|
||||||
pub fn close_tunnel(&mut self, key: &str) {
|
pub fn close_tunnel(&mut self, key: &str) {
|
||||||
self.tunnels.remove(key);
|
if let Some(t) = self.tunnels.remove(key) {
|
||||||
|
drop(t.closer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close all active SSH tunnels.
|
/// Close all active SSH tunnels.
|
||||||
pub fn close_all(&mut self) {
|
pub fn close_all(&mut self) {
|
||||||
self.tunnels.clear();
|
let tunnels = std::mem::take(&mut self.tunnels);
|
||||||
|
for (_, t) in tunnels {
|
||||||
|
drop(t.closer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the local port for an active tunnel, if any.
|
/// Get the local port for an active tunnel, if any.
|
||||||
@@ -112,12 +114,122 @@ impl SshTunnelManager {
|
|||||||
self.tunnels.get(key).map(|t| t.local_port)
|
self.tunnels.get(key).map(|t| t.local_port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clone of the active backend, for handing into `spawn_blocking` so the
|
||||||
|
/// blocking ssh2 work never blocks an async runtime thread.
|
||||||
|
pub fn backend_clone(&self) -> Arc<dyn TunnelBackend> {
|
||||||
|
self.backend.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert an already-opened tunnel under `key`, closing any previous one.
|
||||||
|
pub fn insert_tunnel(&mut self, key: String, tunnel: Tunnel) {
|
||||||
|
if let Some(old) = self.tunnels.insert(key, tunnel) {
|
||||||
|
drop(old.closer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Return the number of active tunnels.
|
/// Return the number of active tunnels.
|
||||||
pub fn active_count(&self) -> usize {
|
pub fn active_count(&self) -> usize {
|
||||||
self.tunnels.len()
|
self.tunnels.len()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Real ssh2 backend: binds a loopback listener, authenticates to the SSH
|
||||||
|
/// host over a blocking socket, and pumps data between the local client and
|
||||||
|
/// the remote DB over an SSH direct-tcpip channel.
|
||||||
|
///
|
||||||
|
/// The whole `open` runs inside `tokio::task::spawn_blocking` at the call
|
||||||
|
/// sites because `ssh2::Session` is purely blocking.
|
||||||
|
pub struct Ssh2Backend;
|
||||||
|
|
||||||
|
impl TunnelBackend for Ssh2Backend {
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
_key: &str,
|
||||||
|
cfg: &SshConfig,
|
||||||
|
remote_host: &str,
|
||||||
|
remote_port: u16,
|
||||||
|
password: Option<&str>,
|
||||||
|
passphrase: Option<&str>,
|
||||||
|
) -> Result<Tunnel, String> {
|
||||||
|
use ssh2::Session;
|
||||||
|
|
||||||
|
// Loopback-only listener with an ephemeral port.
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.map_err(|e| format!("bind local tunnel port: {e}"))?;
|
||||||
|
let local_port = listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|e| format!("local tunnel address: {e}"))?
|
||||||
|
.port();
|
||||||
|
|
||||||
|
let tcp = std::net::TcpStream::connect((cfg.host.as_str(), cfg.port))
|
||||||
|
.map_err(|e| format!("connect ssh host: {e}"))?;
|
||||||
|
let mut session = Session::new().map_err(|e| format!("ssh session: {e}"))?;
|
||||||
|
session.set_tcp_stream(tcp);
|
||||||
|
session.handshake().map_err(|e| format!("ssh handshake: {e}"))?;
|
||||||
|
|
||||||
|
match cfg.auth_method.as_str() {
|
||||||
|
"key" => {
|
||||||
|
let path = cfg.private_key_path.as_deref().ok_or_else(|| {
|
||||||
|
"private_key_path required for key auth".to_string()
|
||||||
|
})?;
|
||||||
|
session
|
||||||
|
.userauth_pubkey_file(&cfg.user, None, std::path::Path::new(path), passphrase)
|
||||||
|
.map_err(|e| format!("ssh key auth: {e}"))?;
|
||||||
|
}
|
||||||
|
_ => session
|
||||||
|
.userauth_password(&cfg.user, password.unwrap_or(""))
|
||||||
|
.map_err(|e| format!("ssh password auth: {e}"))?,
|
||||||
|
}
|
||||||
|
if !session.authenticated() {
|
||||||
|
return Err("SSH authentication failed".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let remote_host = remote_host.to_string();
|
||||||
|
let session = Arc::new(std::sync::Mutex::new(session));
|
||||||
|
let (closer_tx, closer_rx) = std::sync::mpsc::channel::<()>();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Ok((mut local, _)) = listener.accept() {
|
||||||
|
// Open the direct-tcpip channel to the remote DB. `Channel` is
|
||||||
|
// cloneable (Arc-shared inner), so one clone per direction
|
||||||
|
// lets two pump threads copy data in parallel.
|
||||||
|
let mut channel = match session.lock().unwrap().channel_direct_tcpip(
|
||||||
|
&remote_host,
|
||||||
|
remote_port as u16,
|
||||||
|
None,
|
||||||
|
) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
// The accepted socket stays owned by this thread; when the
|
||||||
|
// tunnel is closed the closer wakes us, we drop `local` and
|
||||||
|
// the pumps end on EOF/broken pipe.
|
||||||
|
let mut upstream = channel.clone();
|
||||||
|
let down = local.try_clone();
|
||||||
|
let pump = match down {
|
||||||
|
Ok(down) => Some(std::thread::spawn(move || {
|
||||||
|
let mut down = down;
|
||||||
|
// client -> remote DB
|
||||||
|
let _ = std::io::copy(&mut down, &mut upstream);
|
||||||
|
})),
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
// remote DB -> client (this thread)
|
||||||
|
let _ = std::io::copy(&mut channel, &mut local);
|
||||||
|
if let Some(p) = pump {
|
||||||
|
let _ = p.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = closer_rx.recv();
|
||||||
|
});
|
||||||
|
Ok(Tunnel {
|
||||||
|
local_port,
|
||||||
|
closer: Some(Box::new(move || {
|
||||||
|
let _ = closer_tx.send(());
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -176,4 +288,136 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(config.is_valid(), "port 1 should be valid");
|
assert!(config.is_valid(), "port 1 should be valid");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Tunnel manager tests (fake backend)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct FakeBackend {
|
||||||
|
opens: std::sync::Mutex<Vec<String>>,
|
||||||
|
next_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for FakeBackend {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
opens: std::sync::Mutex::new(self.opens.lock().unwrap().clone()),
|
||||||
|
next_port: self.next_port,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TunnelBackend for FakeBackend {
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
_cfg: &crate::models::SshConfig,
|
||||||
|
_remote_host: &str,
|
||||||
|
_remote_port: u16,
|
||||||
|
_pw: Option<&str>,
|
||||||
|
_pp: Option<&str>,
|
||||||
|
) -> Result<Tunnel, String> {
|
||||||
|
self.opens.lock().unwrap().push(key.to_string());
|
||||||
|
let p = self.next_port;
|
||||||
|
Ok(Tunnel::fake(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manager_open_and_get_port() {
|
||||||
|
let backend = Arc::new(FakeBackend {
|
||||||
|
next_port: 22222,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut mgr = SshTunnelManager::new(backend.clone());
|
||||||
|
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||||
|
let port = mgr
|
||||||
|
.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(port, 22222);
|
||||||
|
assert_eq!(mgr.get_local_port("c1"), Some(22222));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manager_invalid_config_errors() {
|
||||||
|
let backend = Arc::new(FakeBackend::default());
|
||||||
|
let mut mgr = SshTunnelManager::new(backend);
|
||||||
|
let cfg = crate::models::SshConfig::new("".into(), 22, "u".into(), "password".into());
|
||||||
|
assert!(mgr
|
||||||
|
.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manager_close_removes_tunnel() {
|
||||||
|
let backend = Arc::new(FakeBackend {
|
||||||
|
next_port: 1,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut mgr = SshTunnelManager::new(backend);
|
||||||
|
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||||
|
mgr.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
|
||||||
|
.unwrap();
|
||||||
|
mgr.close_tunnel("c1");
|
||||||
|
assert_eq!(mgr.get_local_port("c1"), None);
|
||||||
|
assert_eq!(mgr.active_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tunneled_tls_is_downgraded_to_require() {
|
||||||
|
// verify-full through a tunnel degrades to encrypt-only `require`
|
||||||
|
assert_eq!(
|
||||||
|
effective_tls_decision(crate::db::tls::tls_decision(Some("verify-full")), true),
|
||||||
|
crate::db::tls::TlsDecision::Require
|
||||||
|
);
|
||||||
|
// direct (non-tunneled) connection keeps the user's mode
|
||||||
|
assert_eq!(
|
||||||
|
effective_tls_decision(crate::db::tls::tls_decision(Some("verify-full")), false),
|
||||||
|
crate::db::tls::TlsDecision::Verify
|
||||||
|
);
|
||||||
|
// disable stays disabled regardless of tunneling
|
||||||
|
assert_eq!(
|
||||||
|
effective_tls_decision(crate::db::tls::tls_decision(Some("disable")), true),
|
||||||
|
crate::db::tls::TlsDecision::Disable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manager_backend_clone_returns_backend() {
|
||||||
|
let backend = Arc::new(FakeBackend {
|
||||||
|
next_port: 7,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mgr = SshTunnelManager::new(backend.clone());
|
||||||
|
// The cloned Arc points at the same fake backend.
|
||||||
|
let cloned = mgr.backend_clone();
|
||||||
|
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||||
|
let tunnel = cloned
|
||||||
|
.open("c1", &cfg, "db.host", 5432, None, None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tunnel.local_port, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manager_insert_tunnel_replaces_and_closes_old() {
|
||||||
|
let mut mgr = SshTunnelManager::new(Arc::new(FakeBackend::default()));
|
||||||
|
mgr.insert_tunnel("c1".to_string(), Tunnel::fake(1111));
|
||||||
|
assert_eq!(mgr.get_local_port("c1"), Some(1111));
|
||||||
|
// Re-inserting under the same key replaces the old tunnel.
|
||||||
|
mgr.insert_tunnel("c1".to_string(), Tunnel::fake(2222));
|
||||||
|
assert_eq!(mgr.get_local_port("c1"), Some(2222));
|
||||||
|
assert_eq!(mgr.active_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manager_close_all() {
|
||||||
|
let backend = Arc::new(FakeBackend::default());
|
||||||
|
let mut mgr = SshTunnelManager::new(backend);
|
||||||
|
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||||
|
mgr.open_tunnel("a", &cfg, "db", 5432, None, None).ok();
|
||||||
|
mgr.open_tunnel("b", &cfg, "db", 5432, None, None).ok();
|
||||||
|
mgr.close_all();
|
||||||
|
assert_eq!(mgr.active_count(), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::commands::ssh::SshTunnelManager;
|
||||||
use crate::db::pool::DbConfig;
|
use crate::db::pool::DbConfig;
|
||||||
|
|
||||||
|
/// The SSH tunnel manager behind a mutex (as stored in `AppState`).
|
||||||
|
type SshManager = std::sync::Mutex<SshTunnelManager>;
|
||||||
|
|
||||||
/// Result of a test database connection attempt.
|
/// Result of a test database connection attempt.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TestConnectionResult {
|
pub struct TestConnectionResult {
|
||||||
@@ -23,9 +28,8 @@ pub fn sanitize_error(msg: &str) -> String {
|
|||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < bytes.len() {
|
while i < bytes.len() {
|
||||||
let lower = msg[i..].to_lowercase();
|
let lower = msg[i..].to_lowercase();
|
||||||
if lower.starts_with("postgres://") || lower.starts_with("postgresql://") {
|
if let Some(scheme_end) = url_scheme_end(msg, i) {
|
||||||
out.push_str("[redacted-url://");
|
out.push_str("[redacted-url://");
|
||||||
let scheme_end = i + msg[i..].find("://").unwrap_or(0) + 3;
|
|
||||||
let rest = &msg[scheme_end..];
|
let rest = &msg[scheme_end..];
|
||||||
let end = match rest.find(['/', '?']) {
|
let end = match rest.find(['/', '?']) {
|
||||||
Some(pos) => scheme_end + pos,
|
Some(pos) => scheme_end + pos,
|
||||||
@@ -61,6 +65,32 @@ pub fn sanitize_error(msg: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If `msg[i..]` begins a `scheme://authority` URL — a 1-16 char scheme of
|
||||||
|
/// alphanumerics/`+`/`-`/`.` preceded by a non-word boundary — return the byte
|
||||||
|
/// index just past the `://`. This redacts embedded credentials for any scheme
|
||||||
|
/// (`postgres://`, `redis://`, `mysql://`, ...) without matching non-URL text.
|
||||||
|
fn url_scheme_end(msg: &str, i: usize) -> Option<usize> {
|
||||||
|
let rest = &msg[i..];
|
||||||
|
let colon = rest.find("://")?;
|
||||||
|
if colon == 0 || colon > 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let scheme = &rest[..colon];
|
||||||
|
if !scheme
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Require a boundary before the scheme so mid-word text is not a URL.
|
||||||
|
if let Some(c) = msg[..i].chars().next_back() {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '_' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(i + colon + 3)
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate `DbConfig` before attempting a connection test.
|
/// Validate `DbConfig` before attempting a connection test.
|
||||||
///
|
///
|
||||||
/// Returns `Some(error_message)` if the config is invalid, or `None` if valid.
|
/// Returns `Some(error_message)` if the config is invalid, or `None` if valid.
|
||||||
@@ -102,12 +132,81 @@ pub fn validate_test_input(config: &DbConfig) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Effective connect target after optional SSH tunnel resolution.
|
||||||
|
struct ConnectTarget {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
via_tunnel: bool,
|
||||||
|
/// Key of the opened tunnel, if any — must be closed after the probe.
|
||||||
|
tunnel_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an SSH tunnel if `config` has one configured, returning the loopback
|
||||||
|
/// target to connect through. The blocking ssh2 handshake runs in
|
||||||
|
/// `spawn_blocking` so it never blocks the async runtime.
|
||||||
|
///
|
||||||
|
/// The caller must close the tunnel (via `tunnel_key`) after the probe, on
|
||||||
|
/// every path.
|
||||||
|
async fn resolve_connect_target(
|
||||||
|
config: &DbConfig,
|
||||||
|
ssh: &SshManager,
|
||||||
|
default_port: u16,
|
||||||
|
) -> Result<ConnectTarget, String> {
|
||||||
|
match config.ssh_config() {
|
||||||
|
Some(ssh_cfg) => {
|
||||||
|
let key = format!("test-{}", uuid::Uuid::new_v4());
|
||||||
|
let open_key = key.clone();
|
||||||
|
let remote_host = config.host.clone();
|
||||||
|
let remote_port = config.port.unwrap_or(default_port as i64) as u16;
|
||||||
|
let pw = config.ssh_password.clone();
|
||||||
|
let pp = config.ssh_passphrase.clone();
|
||||||
|
let backend = ssh.lock().unwrap().backend_clone();
|
||||||
|
let tunnel = tokio::task::spawn_blocking(move || {
|
||||||
|
backend.open(
|
||||||
|
&open_key,
|
||||||
|
&ssh_cfg,
|
||||||
|
&remote_host,
|
||||||
|
remote_port,
|
||||||
|
pw.as_deref(),
|
||||||
|
pp.as_deref(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())??;
|
||||||
|
let lp = tunnel.local_port;
|
||||||
|
ssh.lock().unwrap().insert_tunnel(key.clone(), tunnel);
|
||||||
|
Ok(ConnectTarget {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: lp,
|
||||||
|
via_tunnel: true,
|
||||||
|
tunnel_key: Some(key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => Ok(ConnectTarget {
|
||||||
|
host: config.host.clone(),
|
||||||
|
port: config.port.unwrap_or(default_port as i64) as u16,
|
||||||
|
via_tunnel: false,
|
||||||
|
tunnel_key: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the probe tunnel if one was opened.
|
||||||
|
fn close_probe_tunnel(ssh: &SshManager, key: Option<&str>) {
|
||||||
|
if let Some(k) = key {
|
||||||
|
ssh.lock().unwrap().close_tunnel(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Test a database connection for the given configuration.
|
/// Test a database connection for the given configuration.
|
||||||
///
|
///
|
||||||
/// Dispatches to the appropriate type-specific connection test based on
|
/// Dispatches to the appropriate type-specific connection test based on
|
||||||
/// `config.db_type`. Returns a `TestConnectionResult` indicating success
|
/// `config.db_type`. Returns a `TestConnectionResult` indicating success
|
||||||
/// or failure with a sanitized error message.
|
/// or failure with a sanitized error message.
|
||||||
pub async fn test_database_connection(config: &DbConfig) -> TestConnectionResult {
|
pub async fn test_database_connection(
|
||||||
|
config: &DbConfig,
|
||||||
|
ssh: &SshManager,
|
||||||
|
) -> TestConnectionResult {
|
||||||
// Validate input first
|
// Validate input first
|
||||||
if let Some(err) = validate_test_input(config) {
|
if let Some(err) = validate_test_input(config) {
|
||||||
return TestConnectionResult {
|
return TestConnectionResult {
|
||||||
@@ -117,10 +216,10 @@ pub async fn test_database_connection(config: &DbConfig) -> TestConnectionResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result = match config.db_type.to_lowercase().as_str() {
|
let result = match config.db_type.to_lowercase().as_str() {
|
||||||
"postgresql" => test_pg_connection(config).await,
|
"postgresql" => test_pg_connection(config, ssh).await,
|
||||||
"mysql" => test_mysql_connection(config).await,
|
"mysql" => test_mysql_connection(config, ssh).await,
|
||||||
"sqlite" => test_sqlite_connection(config),
|
"sqlite" => test_sqlite_connection(config),
|
||||||
"redis" => test_redis_connection(config).await,
|
"redis" => test_redis_connection(config, ssh).await,
|
||||||
other => TestConnectionResult {
|
other => TestConnectionResult {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some(format!("unsupported database type: {other}")),
|
error: Some(format!("unsupported database type: {other}")),
|
||||||
@@ -135,78 +234,141 @@ pub async fn test_database_connection(config: &DbConfig) -> TestConnectionResult
|
|||||||
|
|
||||||
/// Test a PostgreSQL connection using `tokio-postgres`.
|
/// Test a PostgreSQL connection using `tokio-postgres`.
|
||||||
///
|
///
|
||||||
/// Connects without TLS. The connection handler is spawned and immediately
|
/// Connects through an SSH tunnel when configured, with TLS selected from
|
||||||
/// dropped after confirming the connection is alive.
|
/// `ssl_mode` (downgraded to encrypt-only through a tunnel). The connection
|
||||||
async fn test_pg_connection(config: &DbConfig) -> TestConnectionResult {
|
/// handler is spawned and immediately dropped after confirming the
|
||||||
use tokio_postgres::NoTls;
|
/// connection is alive; any probe tunnel is closed in every path.
|
||||||
|
async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
|
||||||
let host = &config.host;
|
|
||||||
let port = config.port.unwrap_or(5432) as u16;
|
|
||||||
let user = config.username.as_deref().unwrap_or("postgres");
|
let user = config.username.as_deref().unwrap_or("postgres");
|
||||||
let dbname = config.database.as_deref().unwrap_or("postgres");
|
let dbname = config.database.as_deref().unwrap_or("postgres");
|
||||||
let password = config.password.as_deref().unwrap_or("");
|
let password = config.password.as_deref().unwrap_or("");
|
||||||
|
|
||||||
// Use a postgres URL rather than libpq key=value format: tokio-postgres
|
let target = match resolve_connect_target(config, ssh, 5432).await {
|
||||||
// parses URLs reliably and urlencoding handles special chars safely.
|
Ok(t) => t,
|
||||||
use urlencoding::encode as enc;
|
Err(e) => return TestConnectionResult { ok: false, error: Some(e) },
|
||||||
let conn_str = format!(
|
};
|
||||||
"postgresql://{}:{}@{}:{}/{}?connect_timeout=10",
|
|
||||||
enc(user),
|
|
||||||
enc(password),
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
enc(dbname),
|
|
||||||
);
|
|
||||||
|
|
||||||
match tokio_postgres::connect(&conn_str, NoTls).await {
|
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||||
Ok((_client, connection)) => {
|
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||||
// Spawn the connection handler so it keeps running while we test
|
let decision = crate::commands::ssh::effective_tls_decision(
|
||||||
tokio::spawn(async move {
|
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||||
if let Err(e) = connection.await {
|
target.via_tunnel,
|
||||||
eprintln!("connection error: {}", e);
|
);
|
||||||
}
|
let tls = match crate::db::tls::build_tls_config(
|
||||||
});
|
decision,
|
||||||
|
config.ssl_ca_path.as_deref(),
|
||||||
|
config.ssl_cert_path.as_deref(),
|
||||||
|
config.ssl_key_path.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
|
return TestConnectionResult { ok: false, error: Some(e) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Config builder: user/password/dbname are sent as-is (no URL
|
||||||
|
// percent-encoding needed), and the TLS connector is chosen explicitly.
|
||||||
|
let mut pgconfig = tokio_postgres::Config::new();
|
||||||
|
pgconfig
|
||||||
|
.host(target.host)
|
||||||
|
.port(target.port)
|
||||||
|
.user(user)
|
||||||
|
.password(password)
|
||||||
|
.dbname(dbname)
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10));
|
||||||
|
|
||||||
|
let result = match tls {
|
||||||
|
None => crate::commands::db_viewer::connect_pg_with(&pgconfig, tokio_postgres::NoTls).await,
|
||||||
|
Some(cc) => {
|
||||||
|
let connector =
|
||||||
|
tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone());
|
||||||
|
crate::commands::db_viewer::connect_pg_with(&pgconfig, connector).await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((_client, _handle)) => {
|
||||||
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
|
// Spawn the connection handler so it keeps running while we test.
|
||||||
|
// (Already spawned inside `connect_pg_with`.)
|
||||||
TestConnectionResult { ok: true, error: None }
|
TestConnectionResult { ok: true, error: None }
|
||||||
}
|
}
|
||||||
Err(e) => TestConnectionResult {
|
Err(e) => {
|
||||||
ok: false,
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
error: Some(e.to_string()),
|
TestConnectionResult {
|
||||||
},
|
ok: false,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test a MySQL connection using `sqlx`.
|
/// Test a MySQL connection using `sqlx`.
|
||||||
///
|
///
|
||||||
/// Uses `MySqlPoolOptions` with a pool size of 1 and a 10-second
|
/// Uses `MySqlPoolOptions` with a pool size of 1 and a 10-second
|
||||||
/// `acquire_timeout`.
|
/// `acquire_timeout`, connecting through an SSH tunnel when configured and
|
||||||
async fn test_mysql_connection(config: &DbConfig) -> TestConnectionResult {
|
/// mapping `ssl_mode` onto `MySqlSslMode` (downgraded to encrypt-only
|
||||||
use sqlx::mysql::MySqlPoolOptions;
|
/// through a tunnel). Any probe tunnel is closed in every path.
|
||||||
|
async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
|
||||||
|
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
||||||
|
|
||||||
let host = &config.host;
|
let target = match resolve_connect_target(config, ssh, 3306).await {
|
||||||
let port = config.port.unwrap_or(3306);
|
Ok(t) => t,
|
||||||
let user = config.username.as_deref().unwrap_or("root");
|
Err(e) => return TestConnectionResult { ok: false, error: Some(e) },
|
||||||
let password = config.password.as_deref().unwrap_or("");
|
};
|
||||||
let dbname = config.database.as_deref().unwrap_or("mysql");
|
|
||||||
|
|
||||||
let conn_str = format!(
|
let mut opts = MySqlConnectOptions::new()
|
||||||
"mysql://{}:{}@{}:{}/{}",
|
.host(&target.host)
|
||||||
user, password, host, port, dbname
|
.port(target.port)
|
||||||
|
.username(config.username.as_deref().unwrap_or("root"))
|
||||||
|
.password(config.password.as_deref().unwrap_or(""))
|
||||||
|
.database(config.database.as_deref().unwrap_or("mysql"));
|
||||||
|
|
||||||
|
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||||
|
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||||
|
let decision = crate::commands::ssh::effective_tls_decision(
|
||||||
|
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||||
|
target.via_tunnel,
|
||||||
);
|
);
|
||||||
|
match decision {
|
||||||
|
crate::db::tls::TlsDecision::Disable => {
|
||||||
|
opts = opts.ssl_mode(MySqlSslMode::Disabled);
|
||||||
|
}
|
||||||
|
crate::db::tls::TlsDecision::Require => {
|
||||||
|
opts = opts.ssl_mode(MySqlSslMode::Required);
|
||||||
|
}
|
||||||
|
crate::db::tls::TlsDecision::Verify => {
|
||||||
|
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
|
||||||
|
// verify-full -> VerifyIdentity (chain + hostname).
|
||||||
|
match config.ssl_mode.as_deref() {
|
||||||
|
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
|
||||||
|
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
|
||||||
|
}
|
||||||
|
if let Some(ca) = config.ssl_ca_path.as_deref() {
|
||||||
|
opts = opts.ssl_ca(ca);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match MySqlPoolOptions::new()
|
match MySqlPoolOptions::new()
|
||||||
.max_connections(1)
|
.max_connections(1)
|
||||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||||
.connect(&conn_str)
|
.connect_with(opts)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(pool) => {
|
Ok(pool) => {
|
||||||
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
pool.close().await;
|
pool.close().await;
|
||||||
TestConnectionResult { ok: true, error: None }
|
TestConnectionResult { ok: true, error: None }
|
||||||
}
|
}
|
||||||
Err(e) => TestConnectionResult {
|
Err(e) => {
|
||||||
ok: false,
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
error: Some(e.to_string()),
|
TestConnectionResult {
|
||||||
},
|
ok: false,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,18 +389,21 @@ fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
|
|||||||
/// Test a Redis connection using the `redis` crate.
|
/// Test a Redis connection using the `redis` crate.
|
||||||
///
|
///
|
||||||
/// Uses `redis::Client::open` followed by `get_async_connection` with a
|
/// Uses `redis::Client::open` followed by `get_async_connection` with a
|
||||||
/// 10-second timeout via `tokio::time::timeout`.
|
/// 10-second timeout via `tokio::time::timeout`. Connects through an SSH
|
||||||
async fn test_redis_connection(config: &DbConfig) -> TestConnectionResult {
|
/// tunnel when configured; any probe tunnel is closed in every path.
|
||||||
|
async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
let host = &config.host;
|
let target = match resolve_connect_target(config, ssh, 6379).await {
|
||||||
let port = config.port.unwrap_or(6379);
|
Ok(t) => t,
|
||||||
|
Err(e) => return TestConnectionResult { ok: false, error: Some(e) },
|
||||||
|
};
|
||||||
let password = config.password.as_deref();
|
let password = config.password.as_deref();
|
||||||
|
|
||||||
let conn_str = if let Some(pwd) = password {
|
let conn_str = if let Some(pwd) = password {
|
||||||
format!("redis://:{}@{}:{}/", pwd, host, port)
|
format!("redis://:{}@{}:{}/", pwd, target.host, target.port)
|
||||||
} else {
|
} else {
|
||||||
format!("redis://{}:{}/", host, port)
|
format!("redis://{}:{}/", target.host, target.port)
|
||||||
};
|
};
|
||||||
|
|
||||||
match redis::Client::open(conn_str.as_str()) {
|
match redis::Client::open(conn_str.as_str()) {
|
||||||
@@ -249,30 +414,46 @@ async fn test_redis_connection(config: &DbConfig) -> TestConnectionResult {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(_conn)) => TestConnectionResult { ok: true, error: None },
|
Ok(Ok(_conn)) => {
|
||||||
Ok(Err(e)) => TestConnectionResult {
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
ok: false,
|
TestConnectionResult { ok: true, error: None }
|
||||||
error: Some(e.to_string()),
|
}
|
||||||
},
|
Ok(Err(e)) => {
|
||||||
Err(_) => TestConnectionResult {
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
ok: false,
|
TestConnectionResult {
|
||||||
error: Some("connection timed out after 10 seconds".to_string()),
|
ok: false,
|
||||||
},
|
error: Some(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
|
TestConnectionResult {
|
||||||
|
ok: false,
|
||||||
|
error: Some("connection timed out after 10 seconds".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
|
TestConnectionResult {
|
||||||
|
ok: false,
|
||||||
|
error: Some(e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => TestConnectionResult {
|
|
||||||
ok: false,
|
|
||||||
error: Some(e.to_string()),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tauri command to test a database connection.
|
/// Tauri command to test a database connection.
|
||||||
///
|
///
|
||||||
/// Calls `test_database_connection` and returns the result.
|
/// Calls `test_database_connection` and returns the result. `state` is
|
||||||
|
/// auto-injected; the frontend only passes `config`.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn test_connection(config: DbConfig) -> Result<TestConnectionResult, String> {
|
pub async fn test_connection(
|
||||||
Ok(test_database_connection(&config).await)
|
config: DbConfig,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
) -> Result<TestConnectionResult, String> {
|
||||||
|
Ok(test_database_connection(&config, &state.ssh_manager).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -313,6 +494,22 @@ mod tests {
|
|||||||
assert!(!sanitized.contains("user="), "should remove user= pattern");
|
assert!(!sanitized.contains("user="), "should remove user= pattern");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_error_redacts_tunnel_style_urls() {
|
||||||
|
// Tunnel connect errors can carry a URL with embedded credentials, e.g.
|
||||||
|
// the redis:// string built for SSH-tunneled connections.
|
||||||
|
let msg = "SSH tunnel connect failed: redis://:hunter2@127.0.0.1:6379/";
|
||||||
|
let sanitized = sanitize_error(msg);
|
||||||
|
assert!(
|
||||||
|
!sanitized.contains("hunter2"),
|
||||||
|
"must redact URL password: {sanitized}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sanitized.contains("SSH tunnel connect failed"),
|
||||||
|
"must keep the diagnostic prefix: {sanitized}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// validate_test_input rejection
|
// validate_test_input rejection
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -324,13 +521,7 @@ mod tests {
|
|||||||
db_type: "mongodb".to_string(),
|
db_type: "mongodb".to_string(),
|
||||||
host: "localhost".to_string(),
|
host: "localhost".to_string(),
|
||||||
port: Some(27017),
|
port: Some(27017),
|
||||||
username: None,
|
..Default::default()
|
||||||
password: None,
|
|
||||||
database: None,
|
|
||||||
ssl_mode: None,
|
|
||||||
ssl_ca_path: None,
|
|
||||||
ssl_cert_path: None,
|
|
||||||
ssl_key_path: None,
|
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
validate_test_input(&config).is_some(),
|
validate_test_input(&config).is_some(),
|
||||||
@@ -342,13 +533,7 @@ mod tests {
|
|||||||
db_type: "postgresql".to_string(),
|
db_type: "postgresql".to_string(),
|
||||||
host: "".to_string(),
|
host: "".to_string(),
|
||||||
port: Some(5432),
|
port: Some(5432),
|
||||||
username: None,
|
..Default::default()
|
||||||
password: None,
|
|
||||||
database: None,
|
|
||||||
ssl_mode: None,
|
|
||||||
ssl_ca_path: None,
|
|
||||||
ssl_cert_path: None,
|
|
||||||
ssl_key_path: None,
|
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
validate_test_input(&config).is_some(),
|
validate_test_input(&config).is_some(),
|
||||||
@@ -360,13 +545,7 @@ mod tests {
|
|||||||
db_type: "postgresql".to_string(),
|
db_type: "postgresql".to_string(),
|
||||||
host: "localhost".to_string(),
|
host: "localhost".to_string(),
|
||||||
port: Some(0),
|
port: Some(0),
|
||||||
username: None,
|
..Default::default()
|
||||||
password: None,
|
|
||||||
database: None,
|
|
||||||
ssl_mode: None,
|
|
||||||
ssl_ca_path: None,
|
|
||||||
ssl_cert_path: None,
|
|
||||||
ssl_key_path: None,
|
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
validate_test_input(&config).is_some(),
|
validate_test_input(&config).is_some(),
|
||||||
@@ -385,12 +564,8 @@ mod tests {
|
|||||||
host: "localhost".to_string(),
|
host: "localhost".to_string(),
|
||||||
port: Some(5432),
|
port: Some(5432),
|
||||||
username: Some("user".to_string()),
|
username: Some("user".to_string()),
|
||||||
password: None,
|
|
||||||
database: Some("mydb".to_string()),
|
database: Some("mydb".to_string()),
|
||||||
ssl_mode: None,
|
..Default::default()
|
||||||
ssl_ca_path: None,
|
|
||||||
ssl_cert_path: None,
|
|
||||||
ssl_key_path: None,
|
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
validate_test_input(&config).is_none(),
|
validate_test_input(&config).is_none(),
|
||||||
@@ -405,13 +580,7 @@ mod tests {
|
|||||||
db_type: "sqlite".to_string(),
|
db_type: "sqlite".to_string(),
|
||||||
host: "/tmp/test.db".to_string(),
|
host: "/tmp/test.db".to_string(),
|
||||||
port: None,
|
port: None,
|
||||||
username: None,
|
..Default::default()
|
||||||
password: None,
|
|
||||||
database: None,
|
|
||||||
ssl_mode: None,
|
|
||||||
ssl_ca_path: None,
|
|
||||||
ssl_cert_path: None,
|
|
||||||
ssl_key_path: None,
|
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
validate_test_input(&config).is_none(),
|
validate_test_input(&config).is_none(),
|
||||||
@@ -423,13 +592,7 @@ mod tests {
|
|||||||
db_type: "sqlite".to_string(),
|
db_type: "sqlite".to_string(),
|
||||||
host: "/tmp/test.db".to_string(),
|
host: "/tmp/test.db".to_string(),
|
||||||
port: Some(9999),
|
port: Some(9999),
|
||||||
username: None,
|
..Default::default()
|
||||||
password: None,
|
|
||||||
database: None,
|
|
||||||
ssl_mode: None,
|
|
||||||
ssl_ca_path: None,
|
|
||||||
ssl_cert_path: None,
|
|
||||||
ssl_key_path: None,
|
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
validate_test_input(&config).is_none(),
|
validate_test_input(&config).is_none(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod pool;
|
pub mod pool;
|
||||||
pub mod introspection;
|
pub mod introspection;
|
||||||
|
pub mod tls;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||||
+131
-7
@@ -5,7 +5,7 @@ use std::time::Instant;
|
|||||||
///
|
///
|
||||||
/// Fields map to connection parameters. For SQLite, `host` stores the
|
/// Fields map to connection parameters. For SQLite, `host` stores the
|
||||||
/// file path and `port` is always `None`.
|
/// file path and `port` is always `None`.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct DbConfig {
|
pub struct DbConfig {
|
||||||
pub db_type: String,
|
pub db_type: String,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -17,6 +17,20 @@ pub struct DbConfig {
|
|||||||
pub ssl_ca_path: Option<String>,
|
pub ssl_ca_path: Option<String>,
|
||||||
pub ssl_cert_path: Option<String>,
|
pub ssl_cert_path: Option<String>,
|
||||||
pub ssl_key_path: Option<String>,
|
pub ssl_key_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ssh_host: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ssh_port: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ssh_user: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ssh_auth_method: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ssh_password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ssh_private_key_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ssh_passphrase: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DbConfig {
|
impl DbConfig {
|
||||||
@@ -35,8 +49,35 @@ impl DbConfig {
|
|||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
|
ssh_host: None,
|
||||||
|
ssh_port: None,
|
||||||
|
ssh_user: None,
|
||||||
|
ssh_auth_method: None,
|
||||||
|
ssh_password: None,
|
||||||
|
ssh_private_key_path: None,
|
||||||
|
ssh_passphrase: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build an `SshConfig` from the flat SSH fields, or `None` if no SSH host is set.
|
||||||
|
pub fn ssh_config(&self) -> Option<crate::models::SshConfig> {
|
||||||
|
let host = self.ssh_host.clone()?;
|
||||||
|
if host.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(crate::models::SshConfig {
|
||||||
|
host,
|
||||||
|
port: self.ssh_port.unwrap_or(22) as u16,
|
||||||
|
user: self.ssh_user.clone().unwrap_or_default(),
|
||||||
|
auth_method: self
|
||||||
|
.ssh_auth_method
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "password".to_string()),
|
||||||
|
password: self.ssh_password.clone(),
|
||||||
|
private_key_path: self.ssh_private_key_path.clone(),
|
||||||
|
passphrase: self.ssh_passphrase.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A handle to an active database connection.
|
/// A handle to an active database connection.
|
||||||
@@ -74,6 +115,10 @@ pub(crate) struct DbPoolEntry {
|
|||||||
pub struct ConnectionPoolManager {
|
pub struct ConnectionPoolManager {
|
||||||
pools: indexmap::IndexMap<String, DbPoolEntry>,
|
pools: indexmap::IndexMap<String, DbPoolEntry>,
|
||||||
max_pools: usize,
|
max_pools: usize,
|
||||||
|
/// Invoked with the id of every pool that gets evicted (LRU overflow in
|
||||||
|
/// `register` or shrinkage in `set_max_pools`). Lets callers free
|
||||||
|
/// associated resources (e.g. SSH tunnels).
|
||||||
|
on_evict: Option<Box<dyn Fn(&str) + Send + Sync>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectionPoolManager {
|
impl ConnectionPoolManager {
|
||||||
@@ -82,9 +127,15 @@ impl ConnectionPoolManager {
|
|||||||
Self {
|
Self {
|
||||||
pools: indexmap::IndexMap::new(),
|
pools: indexmap::IndexMap::new(),
|
||||||
max_pools: 5,
|
max_pools: 5,
|
||||||
|
on_evict: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register a callback invoked with the id of every evicted pool.
|
||||||
|
pub fn set_on_evict(&mut self, cb: Box<dyn Fn(&str) + Send + Sync>) {
|
||||||
|
self.on_evict = Some(cb);
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the maximum number of pools before LRU eviction kicks in.
|
/// Set the maximum number of pools before LRU eviction kicks in.
|
||||||
///
|
///
|
||||||
/// If the current pool count exceeds the new maximum, the oldest
|
/// If the current pool count exceeds the new maximum, the oldest
|
||||||
@@ -92,7 +143,11 @@ impl ConnectionPoolManager {
|
|||||||
pub fn set_max_pools(&mut self, max: usize) {
|
pub fn set_max_pools(&mut self, max: usize) {
|
||||||
self.max_pools = max;
|
self.max_pools = max;
|
||||||
while self.pools.len() > self.max_pools {
|
while self.pools.len() > self.max_pools {
|
||||||
self.pools.shift_remove_index(0);
|
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
|
||||||
|
if let Some(cb) = &self.on_evict {
|
||||||
|
cb(&evicted_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +169,11 @@ impl ConnectionPoolManager {
|
|||||||
|
|
||||||
// LRU eviction: remove oldest (front) entries until within capacity
|
// LRU eviction: remove oldest (front) entries until within capacity
|
||||||
while self.pools.len() > self.max_pools {
|
while self.pools.len() > self.max_pools {
|
||||||
self.pools.shift_remove_index(0);
|
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
|
||||||
|
if let Some(cb) = &self.on_evict {
|
||||||
|
cb(&evicted_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,10 +225,7 @@ mod tests {
|
|||||||
username: Some("admin".into()),
|
username: Some("admin".into()),
|
||||||
password: Some("secret".into()),
|
password: Some("secret".into()),
|
||||||
database: Some("mydb".into()),
|
database: Some("mydb".into()),
|
||||||
ssl_mode: None,
|
..Default::default()
|
||||||
ssl_ca_path: None,
|
|
||||||
ssl_cert_path: None,
|
|
||||||
ssl_key_path: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(cfg.db_type, "PostgreSQL");
|
assert_eq!(cfg.db_type, "PostgreSQL");
|
||||||
@@ -192,6 +248,33 @@ mod tests {
|
|||||||
assert!(cfg.database.is_none());
|
assert!(cfg.database.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn db_config_ssh_config_is_none_when_no_host() {
|
||||||
|
let cfg = DbConfig { db_type: "PostgreSQL".into(), host: "h".into(), port: Some(5432),
|
||||||
|
username: None, password: None, database: None, ssl_mode: None, ssl_ca_path: None,
|
||||||
|
ssl_cert_path: None, ssl_key_path: None, ssh_host: None, ssh_port: None, ssh_user: None,
|
||||||
|
ssh_auth_method: None, ssh_password: None, ssh_private_key_path: None, ssh_passphrase: None,
|
||||||
|
};
|
||||||
|
assert!(cfg.ssh_config().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn db_config_ssh_config_builds_from_flat_fields() {
|
||||||
|
let cfg = DbConfig { db_type: "PostgreSQL".into(), host: "db".into(), port: Some(5432),
|
||||||
|
username: None, password: None, database: None, ssl_mode: None, ssl_ca_path: None,
|
||||||
|
ssl_cert_path: None, ssl_key_path: None,
|
||||||
|
ssh_host: Some("jump".into()), ssh_port: Some(2222), ssh_user: Some("u".into()),
|
||||||
|
ssh_auth_method: Some("password".into()), ssh_password: Some("pw".into()),
|
||||||
|
ssh_private_key_path: None, ssh_passphrase: None,
|
||||||
|
};
|
||||||
|
let s = cfg.ssh_config().expect("ssh config present");
|
||||||
|
assert_eq!(s.host, "jump");
|
||||||
|
assert_eq!(s.port, 2222);
|
||||||
|
assert_eq!(s.user, "u");
|
||||||
|
assert_eq!(s.auth_method, "password");
|
||||||
|
assert_eq!(s.password.as_deref(), Some("pw"));
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// ConnectionPoolManager tests
|
// ConnectionPoolManager tests
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -261,4 +344,45 @@ mod tests {
|
|||||||
assert!(manager.contains("c"));
|
assert!(manager.contains("c"));
|
||||||
assert!(manager.contains("d"));
|
assert!(manager.contains("d"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_invokes_on_evict_with_evicted_id() {
|
||||||
|
let mut manager = ConnectionPoolManager::new();
|
||||||
|
manager.set_max_pools(1);
|
||||||
|
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
|
||||||
|
let evicted_cb = evicted.clone();
|
||||||
|
manager.set_on_evict(Box::new(move |id: &str| {
|
||||||
|
evicted_cb.lock().unwrap().push(id.to_string());
|
||||||
|
}));
|
||||||
|
manager.register(
|
||||||
|
"a",
|
||||||
|
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||||
|
);
|
||||||
|
manager.register(
|
||||||
|
"b",
|
||||||
|
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||||
|
);
|
||||||
|
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_invokes_on_evict_on_max_pools_shrink() {
|
||||||
|
let mut manager = ConnectionPoolManager::new();
|
||||||
|
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
|
||||||
|
let evicted_cb = evicted.clone();
|
||||||
|
manager.set_on_evict(Box::new(move |id: &str| {
|
||||||
|
evicted_cb.lock().unwrap().push(id.to_string());
|
||||||
|
}));
|
||||||
|
manager.register(
|
||||||
|
"a",
|
||||||
|
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||||
|
);
|
||||||
|
manager.register(
|
||||||
|
"b",
|
||||||
|
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||||
|
);
|
||||||
|
// Shrinking max_pools below the current count evicts oldest first.
|
||||||
|
manager.set_max_pools(1);
|
||||||
|
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
//! TLS connector factory for tokio-postgres.
|
||||||
|
//!
|
||||||
|
//! Maps the user-facing SSL modes to rustls `ClientConfig` values:
|
||||||
|
//! - `disable` -> no TLS (returns `None`)
|
||||||
|
//! - `require` -> encrypt without verifying the server certificate (custom `NoVerifier`)
|
||||||
|
//! - `verify-ca` / `verify-full` -> standard rustls webpki verification (chain AND
|
||||||
|
//! hostname; `verify-ca` is intentionally identical to `verify-full` in v1)
|
||||||
|
//!
|
||||||
|
//! Client certificates are supported via optional `cert_path` / `key_path` pair.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||||
|
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
|
||||||
|
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TlsDecision {
|
||||||
|
Disable,
|
||||||
|
Require,
|
||||||
|
Verify,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision {
|
||||||
|
match ssl_mode {
|
||||||
|
Some("require") => TlsDecision::Require,
|
||||||
|
Some("verify-ca") | Some("verify-full") => TlsDecision::Verify,
|
||||||
|
_ => TlsDecision::Disable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable.
|
||||||
|
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
|
||||||
|
pub fn build_tls_config(
|
||||||
|
decision: TlsDecision,
|
||||||
|
ca_path: Option<&str>,
|
||||||
|
cert_path: Option<&str>,
|
||||||
|
key_path: Option<&str>,
|
||||||
|
) -> Result<Option<Arc<ClientConfig>>, String> {
|
||||||
|
if matches!(decision, TlsDecision::Disable) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client_auth = match (cert_path, key_path) {
|
||||||
|
(Some(c), Some(k)) => Some(load_client_identity(c, k)?),
|
||||||
|
(Some(_), None) | (None, Some(_)) => {
|
||||||
|
return Err("both ssl_cert_path and ssl_key_path must be set for client auth".into())
|
||||||
|
}
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = match decision {
|
||||||
|
TlsDecision::Require => {
|
||||||
|
// Encrypt without verifying the server certificate.
|
||||||
|
let builder = ClientConfig::builder()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(NoVerifier));
|
||||||
|
match client_auth {
|
||||||
|
Some((certs, key)) => builder
|
||||||
|
.with_client_auth_cert(certs, key)
|
||||||
|
.map_err(|e| format!("client cert: {e}"))?,
|
||||||
|
None => builder.with_no_client_auth(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TlsDecision::Verify => {
|
||||||
|
let mut roots = RootCertStore::empty();
|
||||||
|
if let Some(ca) = ca_path {
|
||||||
|
add_ca_file(&mut roots, ca)?;
|
||||||
|
} else {
|
||||||
|
return Err("ssl_ca_path is required for verify-ca / verify-full".into());
|
||||||
|
}
|
||||||
|
for ta in rustls_native_certs::load_native_certs()
|
||||||
|
.map_err(|e| format!("native certs: {e}"))?
|
||||||
|
{
|
||||||
|
let _ = roots.add(ta);
|
||||||
|
}
|
||||||
|
let builder = ClientConfig::builder().with_root_certificates(roots);
|
||||||
|
match client_auth {
|
||||||
|
Some((certs, key)) => builder
|
||||||
|
.with_client_auth_cert(certs, key)
|
||||||
|
.map_err(|e| format!("client cert: {e}"))?,
|
||||||
|
None => builder.with_no_client_auth(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TlsDecision::Disable => unreachable!(),
|
||||||
|
};
|
||||||
|
Ok(Some(Arc::new(config)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_ca_file(roots: &mut RootCertStore, path: &str) -> Result<(), String> {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| format!("failed to read CA file {path}: {e}"))?;
|
||||||
|
let mut reader = std::io::BufReader::new(bytes.as_slice());
|
||||||
|
let parsed = rustls_pemfile::certs(&mut reader)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| format!("parse CA: {e}"))?;
|
||||||
|
let added = parsed.into_iter().filter_map(|c| roots.add(c).ok()).count();
|
||||||
|
if added == 0 {
|
||||||
|
return Err("no usable CA certificates found".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_client_identity(
|
||||||
|
cert_path: &str,
|
||||||
|
key_path: &str,
|
||||||
|
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), String> {
|
||||||
|
// rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so reject them up
|
||||||
|
// front with a clear message before touching the certificate file.
|
||||||
|
let kb = std::fs::read(key_path).map_err(|e| format!("read key: {e}"))?;
|
||||||
|
if String::from_utf8_lossy(&kb).contains("ENCRYPTED PRIVATE KEY") {
|
||||||
|
return Err(
|
||||||
|
"encrypted client keys are not supported in v1; use an unencrypted PEM key".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let cb = std::fs::read(cert_path).map_err(|e| format!("read cert: {e}"))?;
|
||||||
|
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut std::io::BufReader::new(
|
||||||
|
cb.as_slice(),
|
||||||
|
))
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| format!("parse cert: {e}"))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| c.into_owned())
|
||||||
|
.collect();
|
||||||
|
if certs.is_empty() {
|
||||||
|
return Err("no client certificates parsed".into());
|
||||||
|
}
|
||||||
|
let key = rustls_pemfile::private_key(&mut std::io::BufReader::new(kb.as_slice()))
|
||||||
|
.map_err(|e| format!("parse key: {e}"))?
|
||||||
|
.ok_or_else(|| "no private key parsed".to_string())?
|
||||||
|
.clone_key();
|
||||||
|
Ok((certs, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts every certificate: TLS encryption without authentication (`require` mode).
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct NoVerifier;
|
||||||
|
|
||||||
|
impl ServerCertVerifier for NoVerifier {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
_ee: &CertificateDer<'_>,
|
||||||
|
_ic: &[CertificateDer<'_>],
|
||||||
|
_n: &ServerName<'_>,
|
||||||
|
_ocsp: &[u8],
|
||||||
|
_now: UnixTime,
|
||||||
|
) -> Result<ServerCertVerified, rustls::Error> {
|
||||||
|
Ok(ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &CertificateDer<'_>,
|
||||||
|
_dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &CertificateDer<'_>,
|
||||||
|
_dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_decision_maps_modes() {
|
||||||
|
assert!(matches!(tls_decision(None), TlsDecision::Disable));
|
||||||
|
assert!(matches!(tls_decision(Some("disable")), TlsDecision::Disable));
|
||||||
|
assert!(matches!(tls_decision(Some("require")), TlsDecision::Require));
|
||||||
|
assert!(matches!(tls_decision(Some("verify-ca")), TlsDecision::Verify));
|
||||||
|
assert!(matches!(tls_decision(Some("verify-full")), TlsDecision::Verify));
|
||||||
|
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_tls_disable_returns_none() {
|
||||||
|
assert!(
|
||||||
|
build_tls_config(TlsDecision::Disable, None, None, None)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_tls_require_returns_some_without_files() {
|
||||||
|
assert!(
|
||||||
|
build_tls_config(TlsDecision::Require, None, None, None)
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_tls_verify_missing_ca_errors() {
|
||||||
|
let err = build_tls_config(TlsDecision::Verify, Some("/nonexistent/ca.pem"), None, None)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_lowercase().contains("ca"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_tls_client_cert_missing_key_errors() {
|
||||||
|
// cert set without key
|
||||||
|
let err = build_tls_config(TlsDecision::Require, None, Some("/nonexistent/cert.pem"), None)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_lowercase().contains("cert") || err.to_lowercase().contains("key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_tls_rejects_encrypted_key_marker() {
|
||||||
|
// rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so an ENCRYPTED
|
||||||
|
// PRIVATE KEY header must be rejected with a clear error. The cert file
|
||||||
|
// is a dummy: the key check fires before the cert is read.
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let cert_path = dir.join("gl_tls_cert_dummy.pem");
|
||||||
|
let key_path = dir.join("gl_tls_enc_key.pem");
|
||||||
|
std::fs::write(
|
||||||
|
&cert_path,
|
||||||
|
"-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
&key_path,
|
||||||
|
"-----BEGIN ENCRYPTED PRIVATE KEY-----\nabc\n-----END ENCRYPTED PRIVATE KEY-----\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = build_tls_config(
|
||||||
|
TlsDecision::Require,
|
||||||
|
None,
|
||||||
|
Some(cert_path.to_str().unwrap()),
|
||||||
|
Some(key_path.to_str().unwrap()),
|
||||||
|
);
|
||||||
|
assert!(r.is_err());
|
||||||
|
assert!(
|
||||||
|
r.unwrap_err().to_lowercase().contains("encrypt"),
|
||||||
|
"must mention encryption"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-5
@@ -7,10 +7,10 @@ mod models;
|
|||||||
mod store;
|
mod store;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
|
||||||
use std::sync::Mutex as StdMutex;
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use store::Store;
|
use store::Store;
|
||||||
use commands::ssh::SshTunnelManager;
|
use commands::ssh::{Ssh2Backend, SshTunnelManager};
|
||||||
use db::pool::ConnectionPoolManager;
|
use db::pool::ConnectionPoolManager;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -29,6 +29,10 @@ fn greet(name: &str) -> String {
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
// Install the ring crypto provider so rustls `ClientConfig::builder()` works (no-op if
|
||||||
|
// another provider is already installed).
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
let store = Store::open("gridline.db").expect("failed to open db");
|
let store = Store::open("gridline.db").expect("failed to open db");
|
||||||
let store_ref = StdMutex::new(store);
|
let store_ref = StdMutex::new(store);
|
||||||
|
|
||||||
@@ -40,7 +44,7 @@ pub fn run() {
|
|||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
db_store: store_ref,
|
db_store: store_ref,
|
||||||
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
||||||
ssh_manager: StdMutex::new(SshTunnelManager::new()),
|
ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
|
||||||
})
|
})
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
@@ -49,6 +53,22 @@ pub fn run() {
|
|||||||
eprintln!("Failed to set up demo DB: {e}");
|
eprintln!("Failed to set up demo DB: {e}");
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
|
// Close the SSH tunnel for a connection when its pool is evicted
|
||||||
|
// (LRU overflow or max-pool shrink). The hook captures a clone of
|
||||||
|
// the app handle and resolves AppState through the manager.
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
state
|
||||||
|
.pool_manager
|
||||||
|
.blocking_lock()
|
||||||
|
.set_on_evict(Box::new(move |id: &str| {
|
||||||
|
if let Some(s) = handle.try_state::<AppState>() {
|
||||||
|
if let Ok(mut mgr) = s.ssh_manager.lock() {
|
||||||
|
mgr.close_tunnel(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -80,6 +100,7 @@ pub fn run() {
|
|||||||
db_viewer::get_table_data,
|
db_viewer::get_table_data,
|
||||||
db_viewer::get_fk_preview,
|
db_viewer::get_fk_preview,
|
||||||
db_viewer::execute_change,
|
db_viewer::execute_change,
|
||||||
|
db_viewer::get_table_ddl,
|
||||||
db_viewer::refresh_connection,
|
db_viewer::refresh_connection,
|
||||||
db_viewer::get_functions,
|
db_viewer::get_functions,
|
||||||
db_viewer::get_triggers,
|
db_viewer::get_triggers,
|
||||||
@@ -89,6 +110,12 @@ pub fn run() {
|
|||||||
keychain::save_connection_password,
|
keychain::save_connection_password,
|
||||||
keychain::get_connection_password,
|
keychain::get_connection_password,
|
||||||
keychain::delete_connection_password,
|
keychain::delete_connection_password,
|
||||||
|
keychain::save_connection_ssh_password,
|
||||||
|
keychain::get_connection_ssh_password,
|
||||||
|
keychain::delete_connection_ssh_password,
|
||||||
|
keychain::save_connection_ssh_passphrase,
|
||||||
|
keychain::get_connection_ssh_passphrase,
|
||||||
|
keychain::delete_connection_ssh_passphrase,
|
||||||
demo::recreate_demo_db,
|
demo::recreate_demo_db,
|
||||||
backup::detect_pg_tools,
|
backup::detect_pg_tools,
|
||||||
backup::pg_dump,
|
backup::pg_dump,
|
||||||
@@ -104,6 +131,18 @@ pub fn run() {
|
|||||||
query::update_saved_query,
|
query::update_saved_query,
|
||||||
query::delete_saved_query,
|
query::delete_saved_query,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while building tauri application")
|
||||||
|
.run(|app_handle, event| {
|
||||||
|
// Close all SSH tunnels on exit: ExitRequested fires before the
|
||||||
|
// event loop ends, Exit fires after it has.
|
||||||
|
if matches!(
|
||||||
|
event,
|
||||||
|
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit
|
||||||
|
) {
|
||||||
|
if let Ok(mut mgr) = app_handle.state::<AppState>().ssh_manager.lock() {
|
||||||
|
mgr.close_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub struct ConnectionInput {
|
|||||||
pub ssh_user: Option<String>,
|
pub ssh_user: Option<String>,
|
||||||
pub ssh_auth_method: Option<String>,
|
pub ssh_auth_method: Option<String>,
|
||||||
pub ssh_private_key_path: Option<String>,
|
pub ssh_private_key_path: Option<String>,
|
||||||
|
pub ssh_password: Option<String>,
|
||||||
pub ssh_passphrase: Option<String>,
|
pub ssh_passphrase: Option<String>,
|
||||||
pub ssl_mode: Option<String>,
|
pub ssl_mode: Option<String>,
|
||||||
pub ssl_ca_path: Option<String>,
|
pub ssl_ca_path: Option<String>,
|
||||||
@@ -71,6 +72,7 @@ mod tests {
|
|||||||
ssh_user: Some("tunnel".to_string()),
|
ssh_user: Some("tunnel".to_string()),
|
||||||
ssh_auth_method: Some("Key".to_string()),
|
ssh_auth_method: Some("Key".to_string()),
|
||||||
ssh_private_key_path: Some("/path/to/key".to_string()),
|
ssh_private_key_path: Some("/path/to/key".to_string()),
|
||||||
|
ssh_password: Some("ssh-pw".to_string()),
|
||||||
ssh_passphrase: Some("passphrase".to_string()),
|
ssh_passphrase: Some("passphrase".to_string()),
|
||||||
ssl_mode: Some("require".to_string()),
|
ssl_mode: Some("require".to_string()),
|
||||||
ssl_ca_path: Some("/path/to/ca".to_string()),
|
ssl_ca_path: Some("/path/to/ca".to_string()),
|
||||||
@@ -96,6 +98,7 @@ mod tests {
|
|||||||
assert_eq!(deserialized.ssh_user, Some("tunnel".to_string()));
|
assert_eq!(deserialized.ssh_user, Some("tunnel".to_string()));
|
||||||
assert_eq!(deserialized.ssh_auth_method, Some("Key".to_string()));
|
assert_eq!(deserialized.ssh_auth_method, Some("Key".to_string()));
|
||||||
assert_eq!(deserialized.ssh_private_key_path, Some("/path/to/key".to_string()));
|
assert_eq!(deserialized.ssh_private_key_path, Some("/path/to/key".to_string()));
|
||||||
|
assert_eq!(deserialized.ssh_password, Some("ssh-pw".to_string()));
|
||||||
assert_eq!(deserialized.ssh_passphrase, Some("passphrase".to_string()));
|
assert_eq!(deserialized.ssh_passphrase, Some("passphrase".to_string()));
|
||||||
assert_eq!(deserialized.ssl_mode, Some("require".to_string()));
|
assert_eq!(deserialized.ssl_mode, Some("require".to_string()));
|
||||||
assert_eq!(deserialized.ssl_ca_path, Some("/path/to/ca".to_string()));
|
assert_eq!(deserialized.ssl_ca_path, Some("/path/to/ca".to_string()));
|
||||||
|
|||||||
@@ -135,6 +135,23 @@ pub enum Change {
|
|||||||
sql: String,
|
sql: String,
|
||||||
rollback_sql: String,
|
rollback_sql: String,
|
||||||
},
|
},
|
||||||
|
BulkInsert {
|
||||||
|
id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
rows: Vec<Vec<serde_json::Value>>,
|
||||||
|
},
|
||||||
|
DropTable {
|
||||||
|
id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
},
|
||||||
|
EmptyTable {
|
||||||
|
id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Change {
|
impl Change {
|
||||||
@@ -143,7 +160,10 @@ impl Change {
|
|||||||
Change::Update { id, .. }
|
Change::Update { id, .. }
|
||||||
| Change::Insert { id, .. }
|
| Change::Insert { id, .. }
|
||||||
| Change::Delete { id, .. }
|
| Change::Delete { id, .. }
|
||||||
| Change::AlterTable { id, .. } => id,
|
| Change::AlterTable { id, .. }
|
||||||
|
| Change::BulkInsert { id, .. }
|
||||||
|
| Change::DropTable { id, .. }
|
||||||
|
| Change::EmptyTable { id, .. } => id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,6 +299,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_bulk_insert_roundtrip() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"type": "bulk_insert", "id": "x", "schema": "public", "table": "t",
|
||||||
|
"columns": ["a", "b"],
|
||||||
|
"rows": [[1, "y"], [2, "z"]]
|
||||||
|
});
|
||||||
|
let c: Change = serde_json::from_value(json).unwrap();
|
||||||
|
match c {
|
||||||
|
Change::BulkInsert { columns, rows, .. } => {
|
||||||
|
assert_eq!(columns, vec!["a".to_string(), "b".to_string()]);
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
}
|
||||||
|
_ => panic!("expected BulkInsert"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_drop_and_empty_roundtrip() {
|
||||||
|
let drop: Change = serde_json::from_value(serde_json::json!({
|
||||||
|
"type": "drop_table", "id": "d", "schema": "public", "table": "t"
|
||||||
|
})).unwrap();
|
||||||
|
assert_eq!(drop.id(), "d");
|
||||||
|
let empty: Change = serde_json::from_value(serde_json::json!({
|
||||||
|
"type": "empty_table", "id": "e", "schema": "public", "table": "t"
|
||||||
|
})).unwrap();
|
||||||
|
assert_eq!(empty.id(), "e");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn column_info_fk_ref() {
|
fn column_info_fk_ref() {
|
||||||
let col = ColumnInfo {
|
let col = ColumnInfo {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod backup;
|
|||||||
pub mod connection;
|
pub mod connection;
|
||||||
pub mod db_viewer;
|
pub mod db_viewer;
|
||||||
pub mod folder;
|
pub mod folder;
|
||||||
|
pub mod ssh;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
||||||
@@ -10,4 +11,5 @@ pub use connection::{Connection, ConnectionInput};
|
|||||||
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
|
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
|
||||||
pub use folder::{Folder, FolderInput};
|
pub use folder::{Folder, FolderInput};
|
||||||
pub use settings::Settings;
|
pub use settings::Settings;
|
||||||
|
pub use ssh::SshConfig;
|
||||||
pub use tag::{Tag, TagInput};
|
pub use tag::{Tag, TagInput};
|
||||||
@@ -13,4 +13,10 @@ pub struct Settings {
|
|||||||
pub table_page_size: i64,
|
pub table_page_size: i64,
|
||||||
pub shortcuts: HashMap<String, String>,
|
pub shortcuts: HashMap<String, String>,
|
||||||
pub accent_color: String,
|
pub accent_color: String,
|
||||||
|
// Editor (Plan A)
|
||||||
|
pub editor_font_size: i64,
|
||||||
|
pub editor_font_family: String,
|
||||||
|
pub editor_word_wrap: String,
|
||||||
|
pub editor_minimap: bool,
|
||||||
|
pub editor_tab_size: i64,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// SSH tunnel configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SshConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub user: String,
|
||||||
|
/// "password" or "key"
|
||||||
|
pub auth_method: String,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub private_key_path: Option<String>,
|
||||||
|
pub passphrase: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshConfig {
|
||||||
|
/// Create a new `SshConfig` with the required fields.
|
||||||
|
pub fn new(
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
user: String,
|
||||||
|
auth_method: String,
|
||||||
|
) -> Self {
|
||||||
|
SshConfig {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user,
|
||||||
|
auth_method,
|
||||||
|
password: None,
|
||||||
|
private_key_path: None,
|
||||||
|
passphrase: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate SSH configuration.
|
||||||
|
///
|
||||||
|
/// Returns `true` if:
|
||||||
|
/// - `host` is not empty
|
||||||
|
/// - `port` is in range 1..=65535 (u16 guarantees <= 65535)
|
||||||
|
/// - `user` is not empty
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
!self.host.is_empty() && self.port >= 1 && !self.user.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ const MAX_NAME_LEN: usize = 200;
|
|||||||
const MAX_FOLDER_LEN: usize = 100;
|
const MAX_FOLDER_LEN: usize = 100;
|
||||||
const MAX_QUERY_TEXT_LEN: usize = 1_048_576; // 1 MB
|
const MAX_QUERY_TEXT_LEN: usize = 1_048_576; // 1 MB
|
||||||
|
|
||||||
|
const ALLOWED_EDITOR_FONTS: &[&str] =
|
||||||
|
&["Space Mono", "Fira Code", "Menlo", "Monaco", "Consolas", "JetBrains Mono", "monospace"];
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
pub fn from_connection(conn: SqliteConnection) -> Self {
|
pub fn from_connection(conn: SqliteConnection) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -432,6 +435,29 @@ impl Store {
|
|||||||
default_ports = parsed;
|
default_ports = parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let editor_font_size = map
|
||||||
|
.get("editor_font_size")
|
||||||
|
.and_then(|v| v.parse::<i64>().ok())
|
||||||
|
.map(|v| v.clamp(8, 24))
|
||||||
|
.unwrap_or(13);
|
||||||
|
let editor_font_family = map
|
||||||
|
.get("editor_font_family")
|
||||||
|
.cloned()
|
||||||
|
.filter(|v| ALLOWED_EDITOR_FONTS.contains(&v.as_str()))
|
||||||
|
.unwrap_or_else(|| "Space Mono".to_string());
|
||||||
|
let editor_word_wrap = match map.get("editor_word_wrap").map(|v| v.as_str()) {
|
||||||
|
Some("on") => "on".to_string(),
|
||||||
|
_ => "off".to_string(),
|
||||||
|
};
|
||||||
|
let editor_minimap = map
|
||||||
|
.get("editor_minimap")
|
||||||
|
.map(|v| v == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
let editor_tab_size = map
|
||||||
|
.get("editor_tab_size")
|
||||||
|
.and_then(|v| v.parse::<i64>().ok())
|
||||||
|
.map(|v| v.clamp(2, 8))
|
||||||
|
.unwrap_or(4);
|
||||||
Ok(Settings {
|
Ok(Settings {
|
||||||
confirm_before_delete: confirm,
|
confirm_before_delete: confirm,
|
||||||
default_folder_id,
|
default_folder_id,
|
||||||
@@ -455,6 +481,11 @@ impl Store {
|
|||||||
.get("accent_color")
|
.get("accent_color")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| "#2563EB".to_string()),
|
.unwrap_or_else(|| "#2563EB".to_string()),
|
||||||
|
editor_font_size,
|
||||||
|
editor_font_family,
|
||||||
|
editor_word_wrap,
|
||||||
|
editor_minimap,
|
||||||
|
editor_tab_size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,6 +867,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -883,6 +915,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -922,6 +955,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -960,6 +994,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -1022,6 +1057,7 @@ mod tests {
|
|||||||
ssh_user: Some("tunneluser".into()),
|
ssh_user: Some("tunneluser".into()),
|
||||||
ssh_auth_method: Some("Key".into()),
|
ssh_auth_method: Some("Key".into()),
|
||||||
ssh_private_key_path: Some("/home/user/.ssh/id_rsa".into()),
|
ssh_private_key_path: Some("/home/user/.ssh/id_rsa".into()),
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: Some("verify-full".into()),
|
ssl_mode: Some("verify-full".into()),
|
||||||
ssl_ca_path: Some("/etc/ssl/certs/ca.pem".into()),
|
ssl_ca_path: Some("/etc/ssl/certs/ca.pem".into()),
|
||||||
@@ -1076,6 +1112,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -1127,6 +1164,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -1176,6 +1214,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -1211,6 +1250,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -1258,6 +1298,7 @@ mod tests {
|
|||||||
ssh_user: None,
|
ssh_user: None,
|
||||||
ssh_auth_method: None,
|
ssh_auth_method: None,
|
||||||
ssh_private_key_path: None,
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
ssh_passphrase: None,
|
ssh_passphrase: None,
|
||||||
ssl_mode: None,
|
ssl_mode: None,
|
||||||
ssl_ca_path: None,
|
ssl_ca_path: None,
|
||||||
@@ -1350,4 +1391,45 @@ mod tests {
|
|||||||
let result = store.save_query(None, "ok", &huge_text, "");
|
let result = store.save_query(None, "ok", &huge_text, "");
|
||||||
assert!(result.is_err(), "Over-size query text should be rejected");
|
assert!(result.is_err(), "Over-size query text should be rejected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_settings_defaults_when_unset() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||||
|
let store = Store::from_connection(conn);
|
||||||
|
let s = store.get_settings().unwrap();
|
||||||
|
assert_eq!(s.editor_font_size, 13);
|
||||||
|
assert_eq!(s.editor_font_family, "Space Mono");
|
||||||
|
assert_eq!(s.editor_word_wrap, "off");
|
||||||
|
assert!(!s.editor_minimap);
|
||||||
|
assert_eq!(s.editor_tab_size, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_settings_clamp_out_of_range() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||||
|
let store = Store::from_connection(conn);
|
||||||
|
store.update_setting("editor_font_size", "999").unwrap();
|
||||||
|
store.update_setting("editor_tab_size", "1").unwrap();
|
||||||
|
store.update_setting("editor_minimap", "true").unwrap();
|
||||||
|
let s = store.get_settings().unwrap();
|
||||||
|
assert_eq!(s.editor_font_size, 24, "font_size clamps to 24");
|
||||||
|
assert_eq!(s.editor_tab_size, 2, "tab_size clamps to 2");
|
||||||
|
assert!(s.editor_minimap);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_settings_garbage_and_disallowed_fall_back() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||||
|
let store = Store::from_connection(conn);
|
||||||
|
store.update_setting("editor_font_size", "abc").unwrap();
|
||||||
|
store.update_setting("editor_font_family", "Comic Sans").unwrap();
|
||||||
|
store.update_setting("editor_word_wrap", "weird").unwrap();
|
||||||
|
let s = store.get_settings().unwrap();
|
||||||
|
assert_eq!(s.editor_font_size, 13, "garbage -> default");
|
||||||
|
assert_eq!(s.editor_font_family, "Space Mono", "disallowed font -> default");
|
||||||
|
assert_eq!(s.editor_word_wrap, "off", "invalid wrap -> default");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,11 @@ vi.mock("./lib/commands", () => ({
|
|||||||
table_page_size: 50,
|
table_page_size: 50,
|
||||||
shortcuts: {},
|
shortcuts: {},
|
||||||
accent_color: "#2563EB",
|
accent_color: "#2563EB",
|
||||||
|
editor_font_size: 13,
|
||||||
|
editor_font_family: "Space Mono",
|
||||||
|
editor_word_wrap: "off",
|
||||||
|
editor_minimap: false,
|
||||||
|
editor_tab_size: 4,
|
||||||
} satisfies Settings),
|
} satisfies Settings),
|
||||||
testConnection: vi.fn().mockResolvedValue({ ok: true }),
|
testConnection: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
}));
|
}));
|
||||||
@@ -112,6 +117,11 @@ describe("App", () => {
|
|||||||
table_page_size: 50,
|
table_page_size: 50,
|
||||||
shortcuts: {},
|
shortcuts: {},
|
||||||
accent_color: "#2563EB",
|
accent_color: "#2563EB",
|
||||||
|
editor_font_size: 13,
|
||||||
|
editor_font_family: "Space Mono",
|
||||||
|
editor_word_wrap: "off",
|
||||||
|
editor_minimap: false,
|
||||||
|
editor_tab_size: 4,
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(useUiStore.getState().activeFolderId).toBe("folder-1");
|
expect(useUiStore.getState().activeFolderId).toBe("folder-1");
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const BASE_FORM: ConnectionFormData = {
|
|||||||
password: null,
|
password: null,
|
||||||
database: null,
|
database: null,
|
||||||
use_keychain: false,
|
use_keychain: false,
|
||||||
|
ssh_password: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatefulForm(
|
function StatefulForm(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const BASE_FORM: ConnectionFormData = {
|
|||||||
password: "secret",
|
password: "secret",
|
||||||
database: "mydb",
|
database: "mydb",
|
||||||
use_keychain: true,
|
use_keychain: true,
|
||||||
|
ssh_password: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("GeneralTab", () => {
|
describe("GeneralTab", () => {
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ describe("NewConnectionScreen", () => {
|
|||||||
table_page_size: 50,
|
table_page_size: 50,
|
||||||
shortcuts: {},
|
shortcuts: {},
|
||||||
accent_color: "#2563EB",
|
accent_color: "#2563EB",
|
||||||
|
editor_font_size: 13,
|
||||||
|
editor_font_family: "Space Mono",
|
||||||
|
editor_word_wrap: "off",
|
||||||
|
editor_minimap: false,
|
||||||
|
editor_tab_size: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
render(<NewConnectionScreen folders={[]} tags={[]} />);
|
render(<NewConnectionScreen folders={[]} tags={[]} />);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function createEmptyForm(
|
|||||||
password: null,
|
password: null,
|
||||||
database: null,
|
database: null,
|
||||||
use_keychain: false,
|
use_keychain: false,
|
||||||
|
ssh_password: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +113,13 @@ export function NewConnectionScreen({
|
|||||||
password: form.password,
|
password: form.password,
|
||||||
database: form.database,
|
database: form.database,
|
||||||
use_keychain: form.use_keychain,
|
use_keychain: form.use_keychain,
|
||||||
|
ssh_host: form.ssh_host ?? null,
|
||||||
|
ssh_port: form.ssh_port ?? null,
|
||||||
|
ssh_user: form.ssh_user ?? null,
|
||||||
|
ssh_auth_method: form.ssh_auth_method ?? null,
|
||||||
|
ssh_private_key_path: form.ssh_private_key ?? null,
|
||||||
|
ssh_password: form.ssh_password ?? null,
|
||||||
|
ssh_passphrase: form.ssh_passphrase ?? null,
|
||||||
};
|
};
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const BASE_FORM: ConnectionFormData = {
|
|||||||
password: null,
|
password: null,
|
||||||
database: null,
|
database: null,
|
||||||
use_keychain: false,
|
use_keychain: false,
|
||||||
|
ssh_password: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatefulForm(
|
function StatefulForm(
|
||||||
|
|||||||
@@ -14,4 +14,14 @@ export interface ConnectionFormData {
|
|||||||
password: string | null;
|
password: string | null;
|
||||||
database: string | null;
|
database: string | null;
|
||||||
use_keychain: boolean;
|
use_keychain: boolean;
|
||||||
|
// SSH tunnel fields (non-secret flat fields are optional; SshFields writes
|
||||||
|
// the key path under ssh_private_key, which submit handlers map to
|
||||||
|
// ssh_private_key_path on ConnectionInput)
|
||||||
|
ssh_host?: string | null;
|
||||||
|
ssh_port?: number | null;
|
||||||
|
ssh_user?: string | null;
|
||||||
|
ssh_auth_method?: "password" | "key" | null;
|
||||||
|
ssh_private_key?: string | null;
|
||||||
|
ssh_password: string | null;
|
||||||
|
ssh_passphrase?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
|
import * as commands from "../../lib/commands";
|
||||||
|
|
||||||
describe("ChangesQueuePanel", () => {
|
describe("ChangesQueuePanel", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useDbViewerStore.setState({ changesQueue: [] });
|
useDbViewerStore.getState().reset();
|
||||||
|
useUiStore.setState({ activeConnectionId: "c1" });
|
||||||
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows nothing when queue is empty", () => {
|
it("shows nothing when queue is empty", () => {
|
||||||
@@ -14,7 +18,7 @@ describe("ChangesQueuePanel", () => {
|
|||||||
expect(container.textContent).toBe("");
|
expect(container.textContent).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows pending changes", () => {
|
it("shows the pending-changes header and a change card", () => {
|
||||||
useDbViewerStore.getState().addChange({
|
useDbViewerStore.getState().addChange({
|
||||||
type: "update",
|
type: "update",
|
||||||
schema: "public",
|
schema: "public",
|
||||||
@@ -22,13 +26,15 @@ describe("ChangesQueuePanel", () => {
|
|||||||
primaryKey: { id: 1 },
|
primaryKey: { id: 1 },
|
||||||
oldData: { name: "Bob" },
|
oldData: { name: "Bob" },
|
||||||
newData: { name: "Alice" },
|
newData: { name: "Alice" },
|
||||||
});
|
description: "Update row in users",
|
||||||
|
} as any);
|
||||||
render(<ChangesQueuePanel />);
|
render(<ChangesQueuePanel />);
|
||||||
expect(screen.getByText(/1 pending change/i)).toBeInTheDocument();
|
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/users/i)).toBeInTheDocument();
|
expect(screen.getByText(/update/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/public.users/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggle button flips the store expanded state", async () => {
|
it("revert removes the change from the queue", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
useDbViewerStore.getState().addChange({
|
useDbViewerStore.getState().addChange({
|
||||||
type: "update",
|
type: "update",
|
||||||
@@ -37,27 +43,138 @@ describe("ChangesQueuePanel", () => {
|
|||||||
primaryKey: { id: 1 },
|
primaryKey: { id: 1 },
|
||||||
oldData: { name: "Bob" },
|
oldData: { name: "Bob" },
|
||||||
newData: { name: "Alice" },
|
newData: { name: "Alice" },
|
||||||
});
|
description: "Update row in users",
|
||||||
|
} as any);
|
||||||
render(<ChangesQueuePanel />);
|
render(<ChangesQueuePanel />);
|
||||||
await user.click(screen.getByText(/1 pending change/i));
|
await user.click(screen.getByRole("button", { name: /revert/i }));
|
||||||
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(false);
|
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||||
await user.click(screen.getByText(/1 pending change/i));
|
|
||||||
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cancel button changes status", async () => {
|
it("labels bulk_insert / empty_table / drop_table cards", () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
useDbViewerStore.getState().addChange({
|
useDbViewerStore.getState().addChange({
|
||||||
type: "update",
|
type: "bulk_insert",
|
||||||
schema: "public",
|
schema: "public",
|
||||||
table: "users",
|
table: "t",
|
||||||
primaryKey: { id: 1 },
|
columns: ["a"],
|
||||||
oldData: { name: "Bob" },
|
rows: [[1]],
|
||||||
newData: { name: "Alice" },
|
description: "Import 2 rows into public.t",
|
||||||
|
} as any);
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "empty_table",
|
||||||
|
schema: "public",
|
||||||
|
table: "t",
|
||||||
|
description: "Empty Table: public.t",
|
||||||
|
} as any);
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "drop_table",
|
||||||
|
schema: "public",
|
||||||
|
table: "t",
|
||||||
|
description: "Drop Table: public.t",
|
||||||
|
} as any);
|
||||||
|
render(<ChangesQueuePanel />);
|
||||||
|
expect(screen.getByText(/import 2 rows into public.t/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/empty table: public.t/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/drop table: public.t/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commit calls executeChange with buildChangePayload output for insert", async () => {
|
||||||
|
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "insert",
|
||||||
|
schema: "public",
|
||||||
|
table: "t",
|
||||||
|
newData: { id: 1, name: "Alice" },
|
||||||
|
description: "Insert row into t",
|
||||||
});
|
});
|
||||||
render(<ChangesQueuePanel />);
|
render(<ChangesQueuePanel />);
|
||||||
const cancelBtn = screen.getByRole("button", { name: /cancel/i });
|
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||||
await user.click(cancelBtn);
|
await waitFor(() => expect(exec).toHaveBeenCalled());
|
||||||
expect(screen.getByText(/cancelled/i)).toBeInTheDocument();
|
expect(exec.mock.calls[0][0]).toBe("c1");
|
||||||
|
expect(exec.mock.calls[0][1]).toEqual(expect.objectContaining({
|
||||||
|
type: "insert",
|
||||||
|
schema: "public",
|
||||||
|
table: "t",
|
||||||
|
data: expect.any(String),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes the schema tree after committing a drop_table change", async () => {
|
||||||
|
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||||
|
const getSchemas = vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||||
|
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
|
||||||
|
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "drop_table",
|
||||||
|
schema: "public",
|
||||||
|
table: "t",
|
||||||
|
description: "Drop Table: public.t",
|
||||||
|
});
|
||||||
|
render(<ChangesQueuePanel />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||||
|
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SQL toggle shows the generated SQL", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "insert",
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
newData: { name: "Alice" },
|
||||||
|
description: "Insert row into users",
|
||||||
|
} as any);
|
||||||
|
render(<ChangesQueuePanel />);
|
||||||
|
await user.click(screen.getByRole("button", { name: /sql/i }));
|
||||||
|
expect(screen.getByText(/insert into "public"."users"/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Cmd+S commits all pending changes", async () => {
|
||||||
|
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "insert",
|
||||||
|
schema: "public",
|
||||||
|
table: "t",
|
||||||
|
newData: { a: 1 },
|
||||||
|
description: "Insert row into t",
|
||||||
|
} as any);
|
||||||
|
render(<ChangesQueuePanel />);
|
||||||
|
fireEvent.keyDown(document, { key: "s", metaKey: true });
|
||||||
|
await waitFor(() => expect(exec).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Clear All empties the queue", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "insert",
|
||||||
|
schema: "public",
|
||||||
|
table: "t",
|
||||||
|
newData: { a: 1 },
|
||||||
|
description: "Insert row into t",
|
||||||
|
} as any);
|
||||||
|
render(<ChangesQueuePanel />);
|
||||||
|
await user.click(screen.getByRole("button", { name: /clear all/i }));
|
||||||
|
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a green check on committed changes after Commit All", async () => {
|
||||||
|
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||||
|
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "Insert row into t" } as any);
|
||||||
|
render(<ChangesQueuePanel />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||||
|
await waitFor(() => expect(screen.getByTitle("Committed")).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-closes tabs for a table dropped via Commit All", async () => {
|
||||||
|
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||||
|
useDbViewerStore.getState().openTab("public", "users");
|
||||||
|
useDbViewerStore.getState().openTab("public", "posts", true);
|
||||||
|
useDbViewerStore.getState().addChange({ type: "drop_table", schema: "public", table: "users", description: "Drop Table: public.users" } as any);
|
||||||
|
render(<ChangesQueuePanel />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const tabs = useDbViewerStore.getState().tabs;
|
||||||
|
expect(tabs.some((t) => t.table === "users")).toBe(false);
|
||||||
|
expect(tabs.some((t) => t.table === "posts")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { X, Check, ChevronUp, ChevronDown } from "lucide-react";
|
import { Check, X, RotateCcw } from "lucide-react";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import * as cmd from "../../lib/commands";
|
import * as cmd from "../../lib/commands";
|
||||||
|
import { buildChangePayload, buildChangeSql } from "../../lib/changePayload";
|
||||||
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
|
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
|
||||||
import type { ChangeItem } from "../../lib/types";
|
|
||||||
|
|
||||||
const statusBg: Record<QueueStatus, string> = {
|
const statusBg: Record<QueueStatus, string> = {
|
||||||
pending: "bg-accent/5",
|
pending: "bg-accent/5",
|
||||||
@@ -14,54 +14,40 @@ const statusBg: Record<QueueStatus, string> = {
|
|||||||
cancelled: "bg-surface-raised/50",
|
cancelled: "bg-surface-raised/50",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatChangeLabel(change: QueueItem): string {
|
||||||
|
const schema = change.schema ?? "";
|
||||||
|
const table = change.table ?? "";
|
||||||
|
const fullName = schema ? `${schema}.${table}` : table;
|
||||||
|
switch (change.type) {
|
||||||
|
case "bulk_insert":
|
||||||
|
return change.description ?? `Import ${change.rows?.length ?? 0} rows into ${fullName}`;
|
||||||
|
case "empty_table":
|
||||||
|
return `Empty Table: ${fullName}`;
|
||||||
|
case "drop_table":
|
||||||
|
return `Drop Table: ${fullName}`;
|
||||||
|
default:
|
||||||
|
return change.table ?? "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function capitalizeType(type: string) {
|
function capitalizeType(type: string) {
|
||||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusIndicator({ status }: { status: QueueStatus }) {
|
function tableRef(change: QueueItem): string {
|
||||||
switch (status) {
|
if (change.schema && change.table) return `${change.schema}.${change.table}`;
|
||||||
case "pending":
|
return change.table ?? "-";
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 text-amber-400">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-amber-400" />
|
|
||||||
<span>Pending</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "committed":
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 text-green-500">
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
<span>Committed</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "failed":
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 text-red-500">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span>Failed</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "cancelled":
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 text-text-muted">
|
|
||||||
<span>Cancelled</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChangesQueuePanel() {
|
export function ChangesQueuePanel() {
|
||||||
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
||||||
const cancelChange = useDbViewerStore((state) => state.cancelChange);
|
const removeChange = useDbViewerStore((state) => state.removeChange);
|
||||||
|
const clearChanges = useDbViewerStore((state) => state.clearChanges);
|
||||||
const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted);
|
const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted);
|
||||||
const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed);
|
const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed);
|
||||||
const notify = useNotificationStore((state) => state.notify);
|
const notify = useNotificationStore((state) => state.notify);
|
||||||
const expanded = useDbViewerStore((state) => state.changesPanelExpanded);
|
|
||||||
const toggleChangesPanel = useDbViewerStore(
|
const [view, setView] = useState<"visual" | "sql">("visual");
|
||||||
(state) => state.toggleChangesPanel,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCommitAll = useCallback(async () => {
|
const handleCommitAll = useCallback(async () => {
|
||||||
const connectionId = useUiStore.getState().activeConnectionId;
|
const connectionId = useUiStore.getState().activeConnectionId;
|
||||||
@@ -76,19 +62,19 @@ export function ChangesQueuePanel() {
|
|||||||
if (pending.length === 0) return;
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
let committedCount = 0;
|
let committedCount = 0;
|
||||||
|
let treeDirty = false;
|
||||||
|
|
||||||
for (const change of pending) {
|
for (const change of pending) {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = buildChangePayload(change);
|
||||||
id: change.id,
|
|
||||||
type: change.type,
|
|
||||||
sql: change.sql,
|
|
||||||
status: "pending" as const,
|
|
||||||
description: change.description ?? null,
|
|
||||||
} satisfies ChangeItem;
|
|
||||||
await cmd.executeChange(connectionId, payload);
|
await cmd.executeChange(connectionId, payload);
|
||||||
markChangeCommitted(change.id);
|
markChangeCommitted(change.id);
|
||||||
committedCount++;
|
committedCount++;
|
||||||
|
if (change.type === "drop_table") {
|
||||||
|
treeDirty = true;
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
st.closeTabsForTable(change.schema ?? "", change.table ?? "");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
markChangeFailed(change.id, msg);
|
markChangeFailed(change.id, msg);
|
||||||
@@ -100,102 +86,134 @@ export function ChangesQueuePanel() {
|
|||||||
if (committedCount > 0) {
|
if (committedCount > 0) {
|
||||||
notify(`${committedCount} change(s) committed`, "success");
|
notify(`${committedCount} change(s) committed`, "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (treeDirty) {
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
void st.refreshTree(connectionId, st.currentSchema ?? undefined);
|
||||||
|
}
|
||||||
}, [markChangeCommitted, markChangeFailed, notify]);
|
}, [markChangeCommitted, markChangeFailed, notify]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleCommitAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [handleCommitAll]);
|
||||||
|
|
||||||
if (changesQueue.length === 0) {
|
if (changesQueue.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingCount = changesQueue.filter((c) => c.status === "pending").length;
|
const pendingCount = changesQueue.filter((c) => c.status === "pending").length;
|
||||||
const processedCount = changesQueue.filter(
|
|
||||||
(c) => c.status === "committed" || c.status === "failed",
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const changeWord = pendingCount === 1 ? "change" : "changes";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border bg-surface">
|
<div className="flex flex-col">
|
||||||
<button
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||||
type="button"
|
<span className="font-medium text-sm text-text">Pending Changes</span>
|
||||||
onClick={() => toggleChangesPanel()}
|
<div className="flex rounded-md border border-border overflow-hidden">
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm text-text hover:bg-surface-raised/50 cursor-pointer"
|
<button
|
||||||
>
|
type="button"
|
||||||
<div className="flex items-center gap-2">
|
aria-label="Visual"
|
||||||
{expanded ? (
|
onClick={() => setView("visual")}
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
className={[
|
||||||
) : (
|
"px-2 py-0.5 text-xs transition-colors",
|
||||||
<ChevronUp className="h-4 w-4 text-text-muted" />
|
view === "visual"
|
||||||
)}
|
? "bg-surface-raised text-text"
|
||||||
<span className="font-medium">
|
: "text-text-muted hover:text-text",
|
||||||
Changes Queue ({pendingCount} pending {changeWord}, {processedCount}{" "}
|
].join(" ")}
|
||||||
processed)
|
>
|
||||||
</span>
|
Visual
|
||||||
{pendingCount > 0 && (
|
</button>
|
||||||
<span className="rounded-full bg-accent/20 px-2 py-0.5 text-xs text-accent-muted">
|
<button
|
||||||
{pendingCount}
|
type="button"
|
||||||
</span>
|
aria-label="SQL"
|
||||||
)}
|
onClick={() => setView("sql")}
|
||||||
|
className={[
|
||||||
|
"px-2 py-0.5 text-xs transition-colors",
|
||||||
|
view === "sql"
|
||||||
|
? "bg-surface-raised text-text"
|
||||||
|
: "text-text-muted hover:text-text",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
SQL
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-64 overflow-y-auto px-2 py-2 space-y-2">
|
||||||
|
{view === "visual" ? (
|
||||||
|
changesQueue.map((change) => (
|
||||||
|
<div
|
||||||
|
key={change.id}
|
||||||
|
className={`rounded-lg border border-border bg-surface-raised/40 px-3 py-2 ${statusBg[change.status]}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-xs font-medium text-text-muted">
|
||||||
|
{capitalizeType(change.type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text truncate">
|
||||||
|
{tableRef(change)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{change.status === "pending" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Revert change"
|
||||||
|
title="Revert change"
|
||||||
|
onClick={() => removeChange(change.id)}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-red-500/10 hover:text-red-500 cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : change.status === "committed" ? (
|
||||||
|
<span title="Committed" className="shrink-0 text-green-500">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
) : change.status === "failed" ? (
|
||||||
|
<span title="Failed" className="shrink-0 text-red-500">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-text-muted truncate">
|
||||||
|
{formatChangeLabel(change)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
changesQueue.map((change) => (
|
||||||
|
<pre
|
||||||
|
key={change.id}
|
||||||
|
className="text-xs text-text-muted whitespace-pre-wrap rounded-md bg-canvas px-3 py-2 font-mono border border-border"
|
||||||
|
>
|
||||||
|
{buildChangeSql(change)}
|
||||||
|
</pre>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-border px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearChanges}
|
||||||
|
className="text-xs text-text-muted hover:text-text hover:bg-surface-raised rounded-md px-2 py-1 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={pendingCount === 0}
|
disabled={pendingCount === 0}
|
||||||
onClick={(e) => {
|
onClick={handleCommitAll}
|
||||||
e.stopPropagation();
|
className="inline-flex items-center rounded-md bg-accent px-3 py-1 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||||
handleCommitAll();
|
|
||||||
}}
|
|
||||||
className="rounded-md bg-accent px-3 py-1 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
|
||||||
>
|
>
|
||||||
Commit All
|
Commit All ({pendingCount})
|
||||||
|
<kbd className="ml-1.5 rounded bg-surface-raised px-1 text-[10px]">⌘S</kbd>
|
||||||
</button>
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div className="max-h-48 overflow-y-auto">
|
|
||||||
{changesQueue.map((change) => (
|
|
||||||
<ChangeRow
|
|
||||||
key={change.id}
|
|
||||||
change={change}
|
|
||||||
onCancel={() => cancelChange(change.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChangeRow({
|
|
||||||
change,
|
|
||||||
onCancel,
|
|
||||||
}: {
|
|
||||||
change: QueueItem;
|
|
||||||
onCancel: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex items-center justify-between px-4 py-2 text-sm ${statusBg[change.status]}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="rounded-md bg-surface-raised px-2 py-0.5 text-xs font-medium text-text-muted">
|
|
||||||
{capitalizeType(change.type)}
|
|
||||||
</span>
|
|
||||||
<span className="text-text">
|
|
||||||
{change.table ? change.table : "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<StatusIndicator status={change.status} />
|
|
||||||
{change.status === "pending" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Cancel"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded p-1 text-text-muted hover:bg-red-500/10 hover:text-red-500 cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -202,6 +202,62 @@ describe("DbViewerScreen", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refreshes the schema tree after a DDL query runs", async () => {
|
||||||
|
vi.spyOn(commands, "executeQuery").mockResolvedValue(
|
||||||
|
mockQueryResult as any,
|
||||||
|
);
|
||||||
|
const getSchemas = vi
|
||||||
|
.spyOn(commands, "getSchemas")
|
||||||
|
.mockResolvedValue(["public"]);
|
||||||
|
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
|
||||||
|
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
|
||||||
|
render(
|
||||||
|
<DbViewerScreen
|
||||||
|
connectionId="c1"
|
||||||
|
onHome={() => {}}
|
||||||
|
onSettings={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
|
||||||
|
const textarea = await waitFor(() =>
|
||||||
|
screen.getByTestId("monaco-textarea"),
|
||||||
|
);
|
||||||
|
fireEvent.change(textarea, {
|
||||||
|
target: { value: "CREATE TABLE users_new (id INTEGER)" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
|
||||||
|
// CREATE is destructive -> confirm dialog appears -> click Execute
|
||||||
|
await waitFor(() =>
|
||||||
|
screen.getByRole("button", { name: /execute/i }),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /execute/i }));
|
||||||
|
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not refresh the schema tree after a SELECT", async () => {
|
||||||
|
vi.spyOn(commands, "executeQuery").mockResolvedValue(
|
||||||
|
mockQueryResult as any,
|
||||||
|
);
|
||||||
|
const getSchemas = vi
|
||||||
|
.spyOn(commands, "getSchemas")
|
||||||
|
.mockResolvedValue(["public"]);
|
||||||
|
render(
|
||||||
|
<DbViewerScreen
|
||||||
|
connectionId="c1"
|
||||||
|
onHome={() => {}}
|
||||||
|
onSettings={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
|
||||||
|
const textarea = await waitFor(() =>
|
||||||
|
screen.getByTestId("monaco-textarea"),
|
||||||
|
);
|
||||||
|
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
|
||||||
|
await waitFor(() => expect(commands.executeQuery).toHaveBeenCalled());
|
||||||
|
expect(getSchemas).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("formats the query SQL when Auto format is clicked", async () => {
|
it("formats the query SQL when Auto format is clicked", async () => {
|
||||||
render(
|
render(
|
||||||
<DbViewerScreen
|
<DbViewerScreen
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { format as formatSql } from "sql-formatter";
|
|||||||
import { TooltipProvider } from "../ui/Tooltip";
|
import { TooltipProvider } from "../ui/Tooltip";
|
||||||
import { DbViewerSidebar } from "./DbViewerSidebar";
|
import { DbViewerSidebar } from "./DbViewerSidebar";
|
||||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||||
import { isDestructiveQuery } from "../../lib/utils";
|
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
|
||||||
import { executeQuery } from "../../lib/commands";
|
import { executeQuery } from "../../lib/commands";
|
||||||
|
|
||||||
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
|
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
|
||||||
@@ -16,7 +16,6 @@ import { TableTree } from "./TableTree";
|
|||||||
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
||||||
import { TabBar } from "./TabBar";
|
import { TabBar } from "./TabBar";
|
||||||
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
||||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
|
||||||
import { TableControls } from "./TableControls";
|
import { TableControls } from "./TableControls";
|
||||||
import { EditConnectionModal } from "./EditConnectionModal";
|
import { EditConnectionModal } from "./EditConnectionModal";
|
||||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||||
@@ -130,6 +129,10 @@ export function DbViewerScreen({
|
|||||||
const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize);
|
const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize);
|
||||||
setTabData(tabId, result);
|
setTabData(tabId, result);
|
||||||
useQueryStore.getState().invalidateHistory(connectionId);
|
useQueryStore.getState().invalidateHistory(connectionId);
|
||||||
|
if (isSchemaModifyingQuery(sql)) {
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
void st.refreshTree(connectionId, st.currentSchema ?? undefined);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setTabError(tabId, e instanceof Error ? e.message : String(e));
|
setTabError(tabId, e instanceof Error ? e.message : String(e));
|
||||||
useQueryStore.getState().invalidateHistory(connectionId);
|
useQueryStore.getState().invalidateHistory(connectionId);
|
||||||
@@ -1056,7 +1059,6 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{(currentView === "db-viewer" || currentView === "queries") && <ChangesQueuePanel />}
|
|
||||||
</div>
|
</div>
|
||||||
{currentConnection && (
|
{currentConnection && (
|
||||||
<EditConnectionModal
|
<EditConnectionModal
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Button } from "../ui/Button";
|
|||||||
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { updateConnection, testConnection, saveConnectionPassword } from "../../lib/commands";
|
import { updateConnection, testConnection, saveConnectionPassword, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
|
||||||
import type { Connection, ConnectionInput } from "../../lib/types";
|
import type { Connection, ConnectionInput } from "../../lib/types";
|
||||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||||
|
|
||||||
@@ -34,6 +34,15 @@ export function EditConnectionModal({
|
|||||||
password: null,
|
password: null,
|
||||||
database: connection.database ?? null,
|
database: connection.database ?? null,
|
||||||
use_keychain: false,
|
use_keychain: false,
|
||||||
|
ssh_host: connection.ssh_host ?? null,
|
||||||
|
ssh_port: connection.ssh_port ?? null,
|
||||||
|
ssh_user: connection.ssh_user ?? null,
|
||||||
|
ssh_auth_method:
|
||||||
|
(connection.ssh_auth_method as "password" | "key" | null | undefined) ??
|
||||||
|
null,
|
||||||
|
ssh_private_key: connection.ssh_private_key_path ?? null,
|
||||||
|
ssh_password: null,
|
||||||
|
ssh_passphrase: null,
|
||||||
}));
|
}));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
@@ -55,11 +64,25 @@ export function EditConnectionModal({
|
|||||||
folder_id: form.folder_id,
|
folder_id: form.folder_id,
|
||||||
environment: form.environment,
|
environment: form.environment,
|
||||||
tag_ids: form.tag_ids,
|
tag_ids: form.tag_ids,
|
||||||
|
ssh_host: form.ssh_host ?? null,
|
||||||
|
ssh_port: form.ssh_port ?? null,
|
||||||
|
ssh_user: form.ssh_user ?? null,
|
||||||
|
ssh_auth_method: form.ssh_auth_method ?? null,
|
||||||
|
ssh_private_key_path: form.ssh_private_key ?? null,
|
||||||
|
ssh_password: form.ssh_password ?? null,
|
||||||
|
ssh_passphrase: form.ssh_passphrase ?? null,
|
||||||
};
|
};
|
||||||
const updated = await updateConnection(connection.id, input);
|
const updated = await updateConnection(connection.id, input);
|
||||||
if (form.password) {
|
if (form.password) {
|
||||||
await saveConnectionPassword(connection.id, form.password).catch(() => {});
|
await saveConnectionPassword(connection.id, form.password).catch(() => {});
|
||||||
}
|
}
|
||||||
|
// Persist SSH secrets to the OS keychain (not SQLite)
|
||||||
|
if (form.ssh_host && (form.ssh_auth_method ?? "password") === "password" && form.ssh_password) {
|
||||||
|
await saveConnectionSshPassword(connection.id, form.ssh_password).catch(() => {});
|
||||||
|
}
|
||||||
|
if (form.ssh_host && form.ssh_passphrase) {
|
||||||
|
await saveConnectionSshPassphrase(connection.id, form.ssh_passphrase).catch(() => {});
|
||||||
|
}
|
||||||
notify("Connection updated", "success");
|
notify("Connection updated", "success");
|
||||||
onSaved(updated);
|
onSaved(updated);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -91,6 +114,7 @@ export function EditConnectionModal({
|
|||||||
folder_id: form.folder_id,
|
folder_id: form.folder_id,
|
||||||
environment: form.environment,
|
environment: form.environment,
|
||||||
tag_ids: form.tag_ids,
|
tag_ids: form.tag_ids,
|
||||||
|
ssh_password: form.ssh_password ?? null,
|
||||||
});
|
});
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
notify("Connection successful", "success");
|
notify("Connection successful", "success");
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
|
import { ImportDialog } from "./ImportDialog";
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn().mockResolvedValue("/tmp/f.csv") }));
|
||||||
|
vi.mock("@tauri-apps/plugin-fs", () => ({ readTextFile: vi.fn().mockResolvedValue("a,b\n1,2\n3,4") }));
|
||||||
|
|
||||||
|
describe("ImportDialog", () => {
|
||||||
|
it("parses CSV and stages a bulk_insert change", async () => {
|
||||||
|
const addChange = vi.fn();
|
||||||
|
render(<ImportDialog open schema="public" table="t" columns={["a", "b"]} onStage={addChange} onClose={() => {}} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||||
|
await waitFor(() => expect(screen.getByText(/preview/i)).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /stage import/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: "bulk_insert", schema: "public", table: "t",
|
||||||
|
columns: ["a", "b"],
|
||||||
|
}));
|
||||||
|
expect(addChange.mock.calls[0][0].rows.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects files over the row cap", async () => {
|
||||||
|
vi.mocked(readTextFile).mockResolvedValue("a\n" + "1\n".repeat(100_001));
|
||||||
|
const onStage = vi.fn();
|
||||||
|
render(<ImportDialog open schema="public" table="t" columns={["a"]} onStage={onStage} onClose={() => {}} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||||
|
await waitFor(() => expect(screen.getByText(/limit/i)).toBeInTheDocument());
|
||||||
|
expect(onStage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
|
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||||
|
import { Button } from "../ui/Button";
|
||||||
|
import { Select } from "../ui/Select";
|
||||||
|
import { normalizeImport, coerceRow } from "../../lib/importNormalize";
|
||||||
|
|
||||||
|
const MAX_ROWS = 100_000;
|
||||||
|
const MAX_BYTES = 100 * 1024 * 1024;
|
||||||
|
const SKIP = "<skip>";
|
||||||
|
|
||||||
|
export interface ImportDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
columns: string[];
|
||||||
|
onStage: (change: {
|
||||||
|
type: "bulk_insert";
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
columns: string[];
|
||||||
|
rows: unknown[][];
|
||||||
|
description: string;
|
||||||
|
}) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportDialog({
|
||||||
|
open: isOpen,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
columns,
|
||||||
|
onStage,
|
||||||
|
onClose,
|
||||||
|
}: ImportDialogProps) {
|
||||||
|
const [parsed, setParsed] = useState<{ headers: string[]; rows: string[][] } | null>(null);
|
||||||
|
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const chooseFile = async () => {
|
||||||
|
try {
|
||||||
|
const path = await open({
|
||||||
|
filters: [{ name: "Data", extensions: ["csv", "json"] }],
|
||||||
|
});
|
||||||
|
if (!path || Array.isArray(path)) return;
|
||||||
|
|
||||||
|
const text = await readTextFile(path as string);
|
||||||
|
if (text.length > MAX_BYTES) {
|
||||||
|
setError("File exceeds 100 MB limit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { headers, rows } = normalizeImport(text);
|
||||||
|
|
||||||
|
if (rows.length > MAX_ROWS) {
|
||||||
|
setError(`File has ${rows.length.toLocaleString()} rows; limit is ${MAX_ROWS.toLocaleString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setParsed({ headers, rows });
|
||||||
|
setMapping(
|
||||||
|
Object.fromEntries(
|
||||||
|
headers.map((h, i) => [h, columns[i] ?? SKIP]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stage = () => {
|
||||||
|
if (!parsed) return;
|
||||||
|
|
||||||
|
const selected = parsed.headers
|
||||||
|
.map((header) => ({ header, col: mapping[header] }))
|
||||||
|
.filter(({ col }) => col && col !== SKIP);
|
||||||
|
|
||||||
|
const targetColumns = selected.map(({ col }) => col);
|
||||||
|
const dataRows = parsed.rows.map((row) =>
|
||||||
|
selected.map(({ header }) => {
|
||||||
|
const idx = parsed.headers.indexOf(header);
|
||||||
|
return coerceRow(row[idx]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
onStage({
|
||||||
|
type: "bulk_insert",
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
columns: targetColumns,
|
||||||
|
rows: dataRows,
|
||||||
|
description: `Import ${dataRows.length.toLocaleString()} rows into ${schema}.${table}`,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const mappingOptions = [
|
||||||
|
{ value: SKIP, label: "<skip>" },
|
||||||
|
...columns.map((c) => ({ value: c, label: c })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const previewRows = parsed ? parsed.rows.slice(0, 100) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedModal open={isOpen} onClose={onClose}>
|
||||||
|
<div className="w-full min-w-md max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<h3 className="font-heading text-text text-lg mb-4">
|
||||||
|
Import into {schema}.{table}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button onClick={chooseFile}>Choose file</Button>
|
||||||
|
<span className="text-xs text-text-muted">CSV or JSON, up to 100 MB / 100,000 rows</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/30 rounded-md px-4 py-3">
|
||||||
|
<span className="text-red-300 text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parsed && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
Preview ({parsed.rows.length.toLocaleString()} rows × {parsed.headers.length} columns)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{parsed.headers.map((header) => (
|
||||||
|
<div key={header} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-text w-24 truncate" title={header}>{header}</span>
|
||||||
|
<span className="text-xs text-text-muted">→</span>
|
||||||
|
<Select
|
||||||
|
value={mapping[header] ?? SKIP}
|
||||||
|
onChange={(v) =>
|
||||||
|
setMapping((prev) => ({ ...prev, [header]: v }))
|
||||||
|
}
|
||||||
|
options={mappingOptions}
|
||||||
|
label={`Map ${header}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto max-h-64 rounded-lg border border-border">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-surface sticky top-0">
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
{parsed.headers.map((h) => (
|
||||||
|
<th key={h} className="px-3 py-2 text-left text-text-muted font-heading whitespace-nowrap">
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{previewRows.map((row, ri) => (
|
||||||
|
<tr key={ri} className="border-b border-border last:border-0 hover:bg-surface/30">
|
||||||
|
{row.map((cell, ci) => (
|
||||||
|
<td key={ci} className="px-3 py-1.5 text-text whitespace-nowrap">
|
||||||
|
{cell === "" ? (
|
||||||
|
<span className="italic text-text-muted/50">null</span>
|
||||||
|
) : (
|
||||||
|
String(cell)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button onClick={stage} disabled={!parsed}>Stage import</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,8 +14,7 @@ describe("TabBar", () => {
|
|||||||
it("renders the fixed Query and Changes actions when no tabs are open", () => {
|
it("renders the fixed Query and Changes actions when no tabs are open", () => {
|
||||||
render(<TabBar />);
|
render(<TabBar />);
|
||||||
expect(screen.getByRole("button", { name: /new query/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /new query/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /changes queue/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Changes queue" })).toBeInTheDocument();
|
||||||
expect(screen.queryAllByRole("tab")).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders open tab names", () => {
|
it("renders open tab names", () => {
|
||||||
@@ -61,8 +60,7 @@ describe("TabBar", () => {
|
|||||||
useDbViewerStore.setState({ changesPanelExpanded: false });
|
useDbViewerStore.setState({ changesPanelExpanded: false });
|
||||||
|
|
||||||
render(<TabBar />);
|
render(<TabBar />);
|
||||||
const changesButton = screen.getByRole("button", { name: /changes queue/i });
|
const changesButton = screen.getByRole("button", { name: "Changes queue" });
|
||||||
expect(within(changesButton).getByText("1")).toBeInTheDocument();
|
|
||||||
|
|
||||||
await user.click(changesButton);
|
await user.click(changesButton);
|
||||||
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true);
|
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true);
|
||||||
@@ -102,7 +100,7 @@ describe("TabBar", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(<TabBar />);
|
render(<TabBar />);
|
||||||
const button = screen.getByRole("button", { name: /changes queue/i });
|
const button = screen.getByRole("button", { name: "Changes queue" });
|
||||||
expect(button.querySelector("svg")).not.toBeNull();
|
expect(button.querySelector("svg")).not.toBeNull();
|
||||||
expect(within(button).getByText("2")).toBeInTheDocument();
|
expect(within(button).getByText("2")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Changes")).toBeNull();
|
expect(screen.queryByText("Changes")).toBeNull();
|
||||||
@@ -110,7 +108,7 @@ describe("TabBar", () => {
|
|||||||
|
|
||||||
it("hides the count badge when there are no pending changes", () => {
|
it("hides the count badge when there are no pending changes", () => {
|
||||||
render(<TabBar />);
|
render(<TabBar />);
|
||||||
const button = screen.getByRole("button", { name: /changes queue/i });
|
const button = screen.getByRole("button", { name: "Changes queue" });
|
||||||
expect(within(button).queryByText(/\d/)).toBeNull();
|
expect(within(button).queryByText(/\d/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,4 +128,51 @@ describe("TabBar", () => {
|
|||||||
useDbViewerStore.getState().tabs.find((t) => t.id === firstTabId),
|
useDbViewerStore.getState().tabs.find((t) => t.id === firstTabId),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the changes popover when the button is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "update",
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
primaryKey: { id: 1 },
|
||||||
|
oldData: { name: "Bob" },
|
||||||
|
newData: { name: "Alice" },
|
||||||
|
});
|
||||||
|
useDbViewerStore.setState({ changesPanelExpanded: false });
|
||||||
|
render(<TabBar />);
|
||||||
|
expect(screen.queryByText(/pending changes/i)).toBeNull();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Changes queue" }));
|
||||||
|
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /commit all/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the changes popover on Escape", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "insert",
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
newData: { id: 1 },
|
||||||
|
description: "Insert row into users",
|
||||||
|
});
|
||||||
|
useDbViewerStore.setState({ changesPanelExpanded: true });
|
||||||
|
render(<TabBar />);
|
||||||
|
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(screen.queryByText(/pending changes/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns the button border amber when there are pending changes", () => {
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "insert",
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
newData: { id: 1 },
|
||||||
|
description: "Insert row into users",
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
const button = screen.getByRole("button", { name: "Changes queue" });
|
||||||
|
expect(button.className).toContain("border-amber-500");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
|
import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
|
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||||
|
|
||||||
export function TabBar() {
|
export function TabBar() {
|
||||||
const tabs = useDbViewerStore((state) => state.tabs);
|
const tabs = useDbViewerStore((state) => state.tabs);
|
||||||
@@ -8,6 +10,9 @@ export function TabBar() {
|
|||||||
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
|
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
|
||||||
const openQueryTab = useDbViewerStore((state) => state.openQueryTab);
|
const openQueryTab = useDbViewerStore((state) => state.openQueryTab);
|
||||||
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
||||||
|
const changesPanelExpanded = useDbViewerStore(
|
||||||
|
(state) => state.changesPanelExpanded,
|
||||||
|
);
|
||||||
const toggleChangesPanel = useDbViewerStore(
|
const toggleChangesPanel = useDbViewerStore(
|
||||||
(state) => state.toggleChangesPanel,
|
(state) => state.toggleChangesPanel,
|
||||||
);
|
);
|
||||||
@@ -16,6 +21,28 @@ export function TabBar() {
|
|||||||
(c) => c.status === "pending",
|
(c) => c.status === "pending",
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!changesPanelExpanded) return;
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
toggleChangesPanel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
toggleChangesPanel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [changesPanelExpanded, toggleChangesPanel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-9 items-stretch border-b border-border">
|
<div className="flex h-9 items-stretch border-b border-border">
|
||||||
{/* Left: open tabs (scrollable) */}
|
{/* Left: open tabs (scrollable) */}
|
||||||
@@ -79,26 +106,33 @@ export function TabBar() {
|
|||||||
<Play className="h-3 w-3 fill-current" />
|
<Play className="h-3 w-3 fill-current" />
|
||||||
Query
|
Query
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div className="relative" ref={menuRef}>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
if (changesQueue.length > 0) toggleChangesPanel();
|
onClick={() => {
|
||||||
}}
|
if (changesQueue.length > 0) toggleChangesPanel();
|
||||||
aria-label="Changes queue"
|
}}
|
||||||
className={[
|
aria-label="Changes queue"
|
||||||
"flex items-center gap-1.5 rounded-md border border-border bg-surface px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer",
|
className={[
|
||||||
pendingCount > 0
|
"flex items-center gap-1.5 rounded-md border bg-surface px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer",
|
||||||
? "text-amber-400 border-amber-500/40 hover:bg-surface-raised"
|
pendingCount > 0
|
||||||
: "text-text-muted hover:text-text hover:bg-surface-raised",
|
? "text-amber-400 border-amber-500 bg-amber-500/10 hover:bg-amber-500/20"
|
||||||
].join(" ")}
|
: "border-border text-text-muted hover:text-text hover:bg-surface-raised",
|
||||||
>
|
].join(" ")}
|
||||||
<ListChecks className="h-3.5 w-3.5" />
|
>
|
||||||
{pendingCount > 0 && (
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 px-1 text-[10px] font-bold text-white">
|
{pendingCount > 0 && (
|
||||||
{pendingCount}
|
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 px-1 text-[10px] font-bold text-white">
|
||||||
</span>
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{changesPanelExpanded && (
|
||||||
|
<div className="absolute right-0 top-full mt-1.5 z-30 w-[380px] max-w-[calc(100vw-2rem)] rounded-xl bg-surface border border-border shadow-lg overflow-hidden">
|
||||||
|
<ChangesQueuePanel />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
import { Tooltip } from "../ui/Tooltip";
|
import { Tooltip } from "../ui/Tooltip";
|
||||||
|
import { exportData } from "../../lib/exportData";
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
import type { ColumnInfo } from "../../lib/types";
|
||||||
|
|
||||||
const AUTO_REFRESH_OPTIONS = [
|
const AUTO_REFRESH_OPTIONS = [
|
||||||
@@ -41,77 +42,6 @@ type SortRule = {
|
|||||||
|
|
||||||
// ─── helpers ────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
function exportData(
|
|
||||||
rows: unknown[][],
|
|
||||||
columns: ColumnInfo[],
|
|
||||||
format: string,
|
|
||||||
tableName: string,
|
|
||||||
) {
|
|
||||||
const headers = columns.map((c) => c.name);
|
|
||||||
let content: string;
|
|
||||||
let mime: string;
|
|
||||||
|
|
||||||
switch (format) {
|
|
||||||
case "json": {
|
|
||||||
const jsonRows = rows.map((row) => {
|
|
||||||
const obj: Record<string, unknown> = {};
|
|
||||||
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
|
|
||||||
return obj;
|
|
||||||
});
|
|
||||||
content = JSON.stringify(jsonRows, null, 2);
|
|
||||||
mime = "application/json";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "csv": {
|
|
||||||
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
|
|
||||||
for (const row of rows) {
|
|
||||||
csvRows.push(
|
|
||||||
row.map((cell) => {
|
|
||||||
const s = cell === null || cell === undefined ? "" : String(cell);
|
|
||||||
return `"${s.replace(/"/g, '""')}"`;
|
|
||||||
}).join(","),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
content = csvRows.join("\n");
|
|
||||||
mime = "text/csv";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "sql": {
|
|
||||||
const lines = [`-- ${tableName}`];
|
|
||||||
for (const row of rows) {
|
|
||||||
const vals = row.map((cell) =>
|
|
||||||
cell === null ? "NULL"
|
|
||||||
: typeof cell === "number" ? String(cell)
|
|
||||||
: `'${String(cell).replace(/'/g, "''")}'`,
|
|
||||||
);
|
|
||||||
lines.push(`INSERT INTO ${tableName} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
|
|
||||||
}
|
|
||||||
content = lines.join("\n");
|
|
||||||
mime = "application/sql";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "md": {
|
|
||||||
const mdRows = [`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`];
|
|
||||||
for (const row of rows) {
|
|
||||||
mdRows.push(`| ${row.map((cell) => cell === null ? "*NULL*" : String(cell)).join(" | ")} |`);
|
|
||||||
}
|
|
||||||
content = mdRows.join("\n");
|
|
||||||
mime = "text/markdown";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([content], { type: mime });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${tableName}.${format === "md" ? "md" : format}`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format an execution duration using the most sensible unit:
|
* Format an execution duration using the most sensible unit:
|
||||||
* ms below a second, seconds (1 decimal) up to a minute, minutes beyond.
|
* ms below a second, seconds (1 decimal) up to a minute, minutes beyond.
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { TableOverflowMenu } from "./TableOverflowMenu";
|
import { TableOverflowMenu } from "./TableOverflowMenu";
|
||||||
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
|
import * as commands from "../../lib/commands";
|
||||||
|
import * as exportData from "../../lib/exportData";
|
||||||
|
|
||||||
describe("TableOverflowMenu", () => {
|
describe("TableOverflowMenu", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -10,6 +14,9 @@ describe("TableOverflowMenu", () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
useDbViewerStore.getState().reset();
|
||||||
|
useUiStore.setState({ activeConnectionId: "c1" });
|
||||||
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders menu trigger button", () => {
|
it("renders menu trigger button", () => {
|
||||||
@@ -34,4 +41,44 @@ describe("TableOverflowMenu", () => {
|
|||||||
await user.click(screen.getByText("Open in new tab"));
|
await user.click(screen.getByText("Open in new tab"));
|
||||||
expect(onOpenTab).toHaveBeenCalledWith("public", "users", true);
|
expect(onOpenTab).toHaveBeenCalledWith("public", "users", true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Copy table schema calls getTableDdl and writes clipboard", async () => {
|
||||||
|
vi.spyOn(commands, "getTableDdl").mockResolvedValue("CREATE TABLE t (id int)");
|
||||||
|
const writeText = (navigator.clipboard as any).writeText as ReturnType<typeof vi.fn>;
|
||||||
|
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||||
|
fireEvent.click(screen.getByText(/copy table schema/i));
|
||||||
|
await waitFor(() => expect(writeText).toHaveBeenCalledWith("CREATE TABLE t (id int)"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Empty Table opens confirm then stages an empty_table change", async () => {
|
||||||
|
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||||
|
fireEvent.click(screen.getByText(/empty table/i));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /empty table/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const q = useDbViewerStore.getState().changesQueue;
|
||||||
|
expect(q[q.length - 1]).toEqual(expect.objectContaining({ type: "empty_table", schema: "public", table: "t" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Delete Table opens confirm then stages a drop_table change", async () => {
|
||||||
|
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||||
|
fireEvent.click(screen.getByText(/delete table/i));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /delete table/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const q = useDbViewerStore.getState().changesQueue;
|
||||||
|
expect(q[q.length - 1]).toEqual(expect.objectContaining({ type: "drop_table", schema: "public", table: "t" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Export data calls exportData when rows and columns are provided", async () => {
|
||||||
|
const spy = vi.spyOn(exportData, "exportData");
|
||||||
|
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null }];
|
||||||
|
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||||
|
fireEvent.click(screen.getByText(/export data \(csv\)/i));
|
||||||
|
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "csv", "public.t"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,23 +1,43 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { MoreVertical } from "lucide-react";
|
import { MoreVertical } from "lucide-react";
|
||||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||||
|
import { ImportDialog } from "./ImportDialog";
|
||||||
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
|
import { exportData } from "../../lib/exportData";
|
||||||
|
import * as cmd from "../../lib/commands";
|
||||||
|
import type { ColumnInfo } from "../../lib/types";
|
||||||
|
|
||||||
interface TableOverflowMenuProps {
|
interface TableOverflowMenuProps {
|
||||||
schema: string;
|
schema: string;
|
||||||
table: string;
|
table: string;
|
||||||
onOpenTab: (schema: string, table: string, forceNew?: boolean) => string;
|
onOpenTab: (schema: string, table: string, forceNew?: boolean) => string;
|
||||||
|
connectionId?: string;
|
||||||
|
columns?: ColumnInfo[];
|
||||||
|
rows?: unknown[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
stub?: boolean;
|
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMenuProps) {
|
export function TableOverflowMenu({
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
onOpenTab,
|
||||||
|
connectionId: connectionIdProp,
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
}: TableOverflowMenuProps) {
|
||||||
|
const storeConnectionId = useUiStore((s) => s.activeConnectionId);
|
||||||
|
const connectionId = connectionIdProp ?? storeConnectionId;
|
||||||
|
const addChange = useDbViewerStore((s) => s.addChange);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
|
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,20 +60,40 @@ export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMen
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleAction = (id: string) => {
|
const handleAction = async (id: string) => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "open":
|
case "open":
|
||||||
onOpenTab(schema, table, true);
|
onOpenTab(schema, table, true);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
break;
|
break;
|
||||||
case "copy-schema": {
|
case "copy-schema": {
|
||||||
const sql = `-- Schema for ${schema}.${table}\n-- TODO: fetch schema DDL`;
|
if (!connectionId) break;
|
||||||
if (navigator.clipboard) {
|
try {
|
||||||
void navigator.clipboard.writeText(sql);
|
const ddl = await cmd.getTableDdl(connectionId, schema, table);
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
void navigator.clipboard.writeText(ddl);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore copy failures */
|
||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "export-csv":
|
||||||
|
case "export-json":
|
||||||
|
case "export-sql":
|
||||||
|
case "export-md": {
|
||||||
|
const format = id.replace("export-", "");
|
||||||
|
if (rows && rows.length > 0 && columns && columns.length > 0) {
|
||||||
|
exportData(rows, columns, format, `${schema}.${table}`);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "import":
|
||||||
|
setImportOpen(true);
|
||||||
|
setOpen(false);
|
||||||
|
break;
|
||||||
case "empty":
|
case "empty":
|
||||||
setConfirmAction("empty");
|
setConfirmAction("empty");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -70,9 +110,11 @@ export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMen
|
|||||||
const items: MenuItem[] = [
|
const items: MenuItem[] = [
|
||||||
{ id: "open", label: "Open in new tab" },
|
{ id: "open", label: "Open in new tab" },
|
||||||
{ id: "copy-schema", label: "Copy table schema" },
|
{ id: "copy-schema", label: "Copy table schema" },
|
||||||
{ id: "export-csv", label: "Export data (CSV)", stub: true },
|
{ id: "export-csv", label: "Export data (CSV)" },
|
||||||
{ id: "export-json", label: "Export data (JSON)", stub: true },
|
{ id: "export-json", label: "Export data (JSON)" },
|
||||||
{ id: "export-sql", label: "Export data (SQL)", stub: true },
|
{ id: "export-sql", label: "Export data (SQL)" },
|
||||||
|
{ id: "export-md", label: "Export data (Markdown)" },
|
||||||
|
{ id: "import", label: "Import data (CSV/JSON)" },
|
||||||
{ id: "empty", label: "Empty Table", danger: true },
|
{ id: "empty", label: "Empty Table", danger: true },
|
||||||
{ id: "delete", label: "Delete Table", danger: true },
|
{ id: "delete", label: "Delete Table", danger: true },
|
||||||
];
|
];
|
||||||
@@ -93,19 +135,12 @@ export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMen
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleAction(item.id)}
|
onClick={() => handleAction(item.id)}
|
||||||
disabled={item.stub}
|
|
||||||
className={[
|
className={[
|
||||||
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
|
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
|
||||||
item.danger ? "text-error hover:bg-error/10" : "text-text-muted hover:text-text hover:bg-surface-raised",
|
item.danger ? "text-red-400 hover:bg-red-500/10 hover:text-red-300" : "text-text-muted hover:text-text hover:bg-surface-raised",
|
||||||
item.stub ? "opacity-50 cursor-not-allowed" : "",
|
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
{item.stub && (
|
|
||||||
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-surface-raised text-text-subtle">
|
|
||||||
Soon
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -118,6 +153,12 @@ export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMen
|
|||||||
message={`Are you sure you want to delete ALL rows from "${schema}"."${table}"? This action cannot be undone.`}
|
message={`Are you sure you want to delete ALL rows from "${schema}"."${table}"? This action cannot be undone.`}
|
||||||
confirmLabel="Empty Table"
|
confirmLabel="Empty Table"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
|
addChange({
|
||||||
|
type: "empty_table",
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
description: `Empty Table: ${schema}.${table}`,
|
||||||
|
});
|
||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
}}
|
}}
|
||||||
onCancel={() => setConfirmAction(null)}
|
onCancel={() => setConfirmAction(null)}
|
||||||
@@ -130,11 +171,36 @@ export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMen
|
|||||||
message={`Are you sure you want to permanently delete "${schema}"."${table}"? All data will be lost.`}
|
message={`Are you sure you want to permanently delete "${schema}"."${table}"? All data will be lost.`}
|
||||||
confirmLabel="Delete Table"
|
confirmLabel="Delete Table"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
|
addChange({
|
||||||
|
type: "drop_table",
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
description: `Drop Table: ${schema}.${table}`,
|
||||||
|
});
|
||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
}}
|
}}
|
||||||
onCancel={() => setConfirmAction(null)}
|
onCancel={() => setConfirmAction(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
schema={schema}
|
||||||
|
table={table}
|
||||||
|
columns={columns?.map((c) => c.name) ?? []}
|
||||||
|
onStage={(change) => {
|
||||||
|
addChange({
|
||||||
|
type: "bulk_insert",
|
||||||
|
schema: change.schema,
|
||||||
|
table: change.table,
|
||||||
|
columns: change.columns,
|
||||||
|
rows: change.rows,
|
||||||
|
description: change.description,
|
||||||
|
});
|
||||||
|
setImportOpen(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setImportOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,6 +98,8 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
|||||||
<TableOverflowMenu
|
<TableOverflowMenu
|
||||||
schema={table.schema}
|
schema={table.schema}
|
||||||
table={table.name}
|
table={table.name}
|
||||||
|
connectionId={connectionId ?? undefined}
|
||||||
|
columns={cols}
|
||||||
onOpenTab={handleOpenTab}
|
onOpenTab={handleOpenTab}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
import { QueryEditor } from "./QueryEditor";
|
import { QueryEditor } from "./QueryEditor";
|
||||||
import { editor as monacoEditor } from "monaco-editor";
|
import { editor as monacoEditor } from "monaco-editor";
|
||||||
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
|
|
||||||
// Monaco editor loads from CDN — mock it for tests to avoid network dependency
|
// Monaco editor loads from CDN — mock it for tests to avoid network dependency
|
||||||
const { registeredActions } = vi.hoisted(() => ({
|
const { registeredActions, updateOptions } = vi.hoisted(() => ({
|
||||||
registeredActions: [] as Array<{ id: string; keybindings: number[]; run: () => void }>,
|
registeredActions: [] as Array<{ id: string; keybindings: number[]; run: () => void }>,
|
||||||
|
updateOptions: vi.fn(),
|
||||||
}));
|
}));
|
||||||
const { editorOptions } = vi.hoisted(() => ({ editorOptions: [] as Array<Record<string, unknown>> }));
|
const { editorOptions } = vi.hoisted(() => ({ editorOptions: [] as Array<Record<string, unknown>> }));
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ vi.mock("@monaco-editor/react", () => ({
|
|||||||
getValue: () => value,
|
getValue: () => value,
|
||||||
setValue: (v: string) => onChange?.(v),
|
setValue: (v: string) => onChange?.(v),
|
||||||
focus: vi.fn(),
|
focus: vi.fn(),
|
||||||
|
updateOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -40,7 +43,10 @@ vi.mock("@monaco-editor/react", () => ({
|
|||||||
describe("QueryEditor", () => {
|
describe("QueryEditor", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
registeredActions.length = 0;
|
registeredActions.length = 0;
|
||||||
|
editorOptions.length = 0;
|
||||||
|
updateOptions.mockClear();
|
||||||
vi.mocked(monacoEditor.remeasureFonts).mockClear();
|
vi.mocked(monacoEditor.remeasureFonts).mockClear();
|
||||||
|
useSettingsStore.setState({ settings: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a textarea editor", () => {
|
it("renders a textarea editor", () => {
|
||||||
@@ -81,6 +87,37 @@ describe("QueryEditor", () => {
|
|||||||
expect(monacoEditor.remeasureFonts).toHaveBeenCalled();
|
expect(monacoEditor.remeasureFonts).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calls updateOptions with editor settings when settings change", () => {
|
||||||
|
const settings = {
|
||||||
|
confirm_before_delete: true,
|
||||||
|
default_folder_id: null,
|
||||||
|
theme: "dark" as const,
|
||||||
|
font_size: "medium" as const,
|
||||||
|
default_ports: {},
|
||||||
|
tag_order: null,
|
||||||
|
table_refresh_rate: 5,
|
||||||
|
table_page_size: 50,
|
||||||
|
shortcuts: {},
|
||||||
|
accent_color: "blue",
|
||||||
|
editor_font_size: 13,
|
||||||
|
editor_font_family: "Space Mono",
|
||||||
|
editor_word_wrap: "off" as const,
|
||||||
|
editor_minimap: false,
|
||||||
|
editor_tab_size: 4,
|
||||||
|
};
|
||||||
|
useSettingsStore.setState({ settings });
|
||||||
|
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
|
||||||
|
updateOptions.mockClear();
|
||||||
|
act(() => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
settings: { ...settings, editor_font_size: 16, editor_word_wrap: "on" as const },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(updateOptions).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ fontSize: 16, wordWrap: "on" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("wraps the editor without padding, border, or rounding", () => {
|
it("wraps the editor without padding, border, or rounding", () => {
|
||||||
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
|
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
|
||||||
const wrapper = screen.getByTestId("query-editor");
|
const wrapper = screen.getByTestId("query-editor");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import Editor, { type OnMount, type BeforeMount } from "@monaco-editor/react";
|
import Editor, { type OnMount, type BeforeMount } from "@monaco-editor/react";
|
||||||
import * as monaco from "monaco-editor";
|
import * as monaco from "monaco-editor";
|
||||||
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
|
|
||||||
interface QueryEditorProps {
|
interface QueryEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -15,8 +16,38 @@ export function QueryEditor({
|
|||||||
onRun,
|
onRun,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
}: QueryEditorProps) {
|
}: QueryEditorProps) {
|
||||||
|
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||||
|
|
||||||
|
const editorFontFamily = useSettingsStore(
|
||||||
|
(s) => s.settings?.editor_font_family ?? "Space Mono",
|
||||||
|
);
|
||||||
|
const editorFontSize = useSettingsStore(
|
||||||
|
(s) => s.settings?.editor_font_size ?? 13,
|
||||||
|
);
|
||||||
|
const editorWordWrap = useSettingsStore(
|
||||||
|
(s) => s.settings?.editor_word_wrap ?? "off",
|
||||||
|
);
|
||||||
|
const editorMinimap = useSettingsStore(
|
||||||
|
(s) => s.settings?.editor_minimap ?? false,
|
||||||
|
);
|
||||||
|
const editorTabSize = useSettingsStore(
|
||||||
|
(s) => s.settings?.editor_tab_size ?? 4,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editorRef.current?.updateOptions?.({
|
||||||
|
fontFamily: editorFontFamily,
|
||||||
|
fontSize: editorFontSize,
|
||||||
|
wordWrap: editorWordWrap === "on" ? "on" : "off",
|
||||||
|
minimap: { enabled: editorMinimap },
|
||||||
|
tabSize: editorTabSize,
|
||||||
|
});
|
||||||
|
monaco.editor.remeasureFonts();
|
||||||
|
}, [editorFontFamily, editorFontSize, editorWordWrap, editorMinimap, editorTabSize]);
|
||||||
|
|
||||||
const handleMount: OnMount = useCallback(
|
const handleMount: OnMount = useCallback(
|
||||||
(editor) => {
|
(editor) => {
|
||||||
|
editorRef.current = editor;
|
||||||
editor.addAction({
|
editor.addAction({
|
||||||
id: "run-query",
|
id: "run-query",
|
||||||
label: "Run Query",
|
label: "Run Query",
|
||||||
@@ -71,15 +102,16 @@ export function QueryEditor({
|
|||||||
onChange={(v) => onChange(v ?? "")}
|
onChange={(v) => onChange(v ?? "")}
|
||||||
onMount={handleMount}
|
onMount={handleMount}
|
||||||
options={{
|
options={{
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: editorMinimap },
|
||||||
fontSize: 13,
|
fontSize: editorFontSize,
|
||||||
fontFamily: "'Space Mono', 'Fira Code', monospace",
|
fontFamily: editorFontFamily,
|
||||||
lineNumbers: "on",
|
lineNumbers: "on",
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
wordWrap: "off",
|
wordWrap: editorWordWrap === "on" ? "on" : "off",
|
||||||
readOnly,
|
readOnly,
|
||||||
placeholder: "Enter your SQL query…",
|
placeholder: "Enter your SQL query…",
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
|
tabSize: editorTabSize,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -166,5 +166,10 @@ function baseSettings() {
|
|||||||
table_page_size: 50,
|
table_page_size: 50,
|
||||||
shortcuts: {},
|
shortcuts: {},
|
||||||
accent_color: "#2563EB",
|
accent_color: "#2563EB",
|
||||||
|
editor_font_size: 13,
|
||||||
|
editor_font_family: "Space Mono",
|
||||||
|
editor_word_wrap: "off" as const,
|
||||||
|
editor_minimap: false,
|
||||||
|
editor_tab_size: 4,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { EditorSettingsTab } from "./EditorSettingsTab";
|
||||||
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
settings: {
|
||||||
|
confirm_before_delete: true, default_folder_id: null, theme: "dark", font_size: "medium",
|
||||||
|
default_ports: {}, tag_order: null, table_refresh_rate: 0, table_page_size: 50,
|
||||||
|
shortcuts: {}, accent_color: "#2563EB",
|
||||||
|
editor_font_size: 13, editor_font_family: "Space Mono", editor_word_wrap: "off",
|
||||||
|
editor_minimap: false, editor_tab_size: 4,
|
||||||
|
} as any, loading: false, error: null,
|
||||||
|
});
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EditorSettingsTab", () => {
|
||||||
|
it("renders the five editor option controls", () => {
|
||||||
|
render(<EditorSettingsTab />);
|
||||||
|
expect(screen.getByLabelText(/font size/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/font family/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/word wrap/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("switch", { name: /minimap/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/tab size/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls updateSetting when word wrap changes", () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
useSettingsStore.setState({ updateSetting: update } as any);
|
||||||
|
render(<EditorSettingsTab />);
|
||||||
|
fireEvent.change(screen.getByLabelText(/word wrap/i), { target: { value: "on" } });
|
||||||
|
expect(update).toHaveBeenCalledWith("editor_word_wrap", "on");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls updateSetting when minimap toggled", () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
useSettingsStore.setState({ updateSetting: update } as any);
|
||||||
|
render(<EditorSettingsTab />);
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: /minimap/i }));
|
||||||
|
expect(update).toHaveBeenCalledWith("editor_minimap", "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
|
import { Select } from "../ui/Select";
|
||||||
|
import { SettingsRow } from "../ui/SettingsRow";
|
||||||
|
import { Toggle } from "../ui/Toggle";
|
||||||
|
|
||||||
|
const FONT_FAMILY_OPTIONS = [
|
||||||
|
{ value: "Space Mono", label: "Space Mono" },
|
||||||
|
{ value: "Fira Code", label: "Fira Code" },
|
||||||
|
{ value: "Menlo", label: "Menlo" },
|
||||||
|
{ value: "Monaco", label: "Monaco" },
|
||||||
|
{ value: "Consolas", label: "Consolas" },
|
||||||
|
{ value: "JetBrains Mono", label: "JetBrains Mono" },
|
||||||
|
{ value: "monospace", label: "monospace" },
|
||||||
|
];
|
||||||
|
const FONT_SIZE_OPTIONS = [8,10,11,12,13,14,16,18,20,24].map((v) => ({ value: String(v), label: String(v) }));
|
||||||
|
const TAB_SIZE_OPTIONS = [2,4,6,8].map((v) => ({ value: String(v), label: String(v) }));
|
||||||
|
const WORD_WRAP_OPTIONS = [{ value: "off", label: "Off" }, { value: "on", label: "On" }];
|
||||||
|
|
||||||
|
export function EditorSettingsTab() {
|
||||||
|
const { settings, updateSetting } = useSettingsStore();
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
const set = (key: string) => (value: string) => { void updateSetting(key, value); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<SettingsRow title="Font size" description="Editor font size in pixels">
|
||||||
|
<Select label="Font size" value={String(settings.editor_font_size)} onChange={set("editor_font_size")} options={FONT_SIZE_OPTIONS} />
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow title="Font family" description="Monospace font for the SQL editor">
|
||||||
|
<Select label="Font family" value={settings.editor_font_family} onChange={set("editor_font_family")} options={FONT_FAMILY_OPTIONS} />
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow title="Word wrap" description="Wrap long lines in the editor">
|
||||||
|
<Select label="Word wrap" value={settings.editor_word_wrap} onChange={set("editor_word_wrap")} options={WORD_WRAP_OPTIONS} />
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow title="Minimap" description="Show the code minimap">
|
||||||
|
<Toggle label="Minimap" checked={settings.editor_minimap} onChange={(c) => void updateSetting("editor_minimap", c ? "true" : "false")} />
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow title="Tab size" description="Spaces per indentation level">
|
||||||
|
<Select label="Tab size" value={String(settings.editor_tab_size)} onChange={set("editor_tab_size")} options={TAB_SIZE_OPTIONS} />
|
||||||
|
</SettingsRow>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,11 @@ vi.mock("../../lib/commands", () => ({
|
|||||||
table_page_size: 50,
|
table_page_size: 50,
|
||||||
shortcuts: {},
|
shortcuts: {},
|
||||||
accent_color: "#2563EB",
|
accent_color: "#2563EB",
|
||||||
|
editor_font_size: 13,
|
||||||
|
editor_font_family: "Space Mono",
|
||||||
|
editor_word_wrap: "off",
|
||||||
|
editor_minimap: false,
|
||||||
|
editor_tab_size: 4,
|
||||||
}),
|
}),
|
||||||
updateSetting: vi.fn().mockResolvedValue(undefined),
|
updateSetting: vi.fn().mockResolvedValue(undefined),
|
||||||
getConnections: vi.fn().mockResolvedValue([]),
|
getConnections: vi.fn().mockResolvedValue([]),
|
||||||
@@ -68,6 +73,11 @@ const baseSettings = {
|
|||||||
table_page_size: 50,
|
table_page_size: 50,
|
||||||
shortcuts: {} as Record<string, string>,
|
shortcuts: {} as Record<string, string>,
|
||||||
accent_color: "#2563EB",
|
accent_color: "#2563EB",
|
||||||
|
editor_font_size: 13,
|
||||||
|
editor_font_family: "Space Mono",
|
||||||
|
editor_word_wrap: "off" as const,
|
||||||
|
editor_minimap: false,
|
||||||
|
editor_tab_size: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("SettingsPage", () => {
|
describe("SettingsPage", () => {
|
||||||
@@ -118,14 +128,18 @@ describe("SettingsPage", () => {
|
|||||||
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-general");
|
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-general");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches to the Editor tab and shows placeholder", async () => {
|
it("switches to the Editor tab and shows editor settings", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<SettingsPage />);
|
render(<SettingsPage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("tab", { name: /editor/i })).toBeInTheDocument();
|
expect(screen.getByRole("tab", { name: /editor/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
await user.click(screen.getByRole("tab", { name: /editor/i }));
|
await user.click(screen.getByRole("tab", { name: /editor/i }));
|
||||||
expect(screen.getByText(/editor settings are coming soon/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/font size/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/font family/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/word wrap/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("switch", { name: /minimap/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/tab size/i)).toBeInTheDocument();
|
||||||
expect(screen.getByRole("tab", { name: /editor/i })).toHaveAttribute("aria-selected", "true");
|
expect(screen.getByRole("tab", { name: /editor/i })).toHaveAttribute("aria-selected", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
|||||||
import { TagsSettingsTab } from "./TagsSettingsTab";
|
import { TagsSettingsTab } from "./TagsSettingsTab";
|
||||||
import { ShortcutsSettingsTab } from "./ShortcutsSettingsTab";
|
import { ShortcutsSettingsTab } from "./ShortcutsSettingsTab";
|
||||||
import { AdvancedSettingsTab } from "./AdvancedSettingsTab";
|
import { AdvancedSettingsTab } from "./AdvancedSettingsTab";
|
||||||
|
import { EditorSettingsTab } from "./EditorSettingsTab";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Cog,
|
Cog,
|
||||||
@@ -42,14 +43,7 @@ export function SettingsPage() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const renderEditor = () => (
|
const renderEditor = () => <EditorSettingsTab />;
|
||||||
<section>
|
|
||||||
<h2 className="text-sm font-medium text-text mb-3">Editor</h2>
|
|
||||||
<div className="py-8 text-center text-sm text-text-muted">
|
|
||||||
Editor settings are coming soon.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ vi.mock("../lib/commands", () => ({
|
|||||||
getSchemas: vi.fn().mockResolvedValue([]),
|
getSchemas: vi.fn().mockResolvedValue([]),
|
||||||
getTables: vi.fn().mockResolvedValue([]),
|
getTables: vi.fn().mockResolvedValue([]),
|
||||||
getConnectionPassword: vi.fn().mockResolvedValue("pw"),
|
getConnectionPassword: vi.fn().mockResolvedValue("pw"),
|
||||||
|
getConnectionSshPassword: vi.fn().mockResolvedValue(null),
|
||||||
|
getConnectionSshPassphrase: vi.fn().mockResolvedValue(null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockCommands = vi.mocked(commands);
|
const mockCommands = vi.mocked(commands);
|
||||||
@@ -27,10 +29,10 @@ const mockConnection = {
|
|||||||
keychain_ref: null,
|
keychain_ref: null,
|
||||||
tag_ids: [],
|
tag_ids: [],
|
||||||
environment: null,
|
environment: null,
|
||||||
ssh_host: null,
|
ssh_host: null as string | null,
|
||||||
ssh_port: null,
|
ssh_port: null,
|
||||||
ssh_user: null,
|
ssh_user: null,
|
||||||
ssh_auth_method: null,
|
ssh_auth_method: null as string | null,
|
||||||
ssh_private_key_path: null,
|
ssh_private_key_path: null,
|
||||||
ssl_mode: null,
|
ssl_mode: null,
|
||||||
ssl_ca_path: null,
|
ssl_ca_path: null,
|
||||||
@@ -65,6 +67,10 @@ describe("useDbConnection", () => {
|
|||||||
mockCommands.getDatabases.mockResolvedValue(["mydb", "otherdb"]);
|
mockCommands.getDatabases.mockResolvedValue(["mydb", "otherdb"]);
|
||||||
mockCommands.getSchemas.mockResolvedValue(["app", "public"]);
|
mockCommands.getSchemas.mockResolvedValue(["app", "public"]);
|
||||||
mockCommands.getTables.mockResolvedValue([]);
|
mockCommands.getTables.mockResolvedValue([]);
|
||||||
|
mockCommands.getConnectionSshPassword.mockResolvedValue(null);
|
||||||
|
mockCommands.getConnectionSshPassphrase.mockResolvedValue(null);
|
||||||
|
mockConnection.ssh_host = null;
|
||||||
|
mockConnection.ssh_auth_method = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("connects and smart-selects the public schema when available", async () => {
|
it("connects and smart-selects the public schema when available", async () => {
|
||||||
@@ -150,4 +156,22 @@ describe("useDbConnection", () => {
|
|||||||
]);
|
]);
|
||||||
expect(useDbViewerStore.getState().currentSchema).toBe("public");
|
expect(useDbViewerStore.getState().currentSchema).toBe("public");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fetches ssh secrets from keychain before connecting when ssh_host is set", async () => {
|
||||||
|
mockConnection.ssh_host = "bastion.example.com";
|
||||||
|
mockConnection.ssh_auth_method = "password";
|
||||||
|
mockCommands.getConnectionSshPassword.mockResolvedValue("sshpw");
|
||||||
|
mockCommands.getConnectionSshPassphrase.mockResolvedValue(null);
|
||||||
|
render(<Harness />);
|
||||||
|
fireEvent.click(screen.getByText("connect"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCommands.dbConnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
const config = mockCommands.dbConnect.mock.calls[0][1];
|
||||||
|
expect(mockCommands.getConnectionSshPassword).toHaveBeenCalledWith("c1");
|
||||||
|
expect(mockCommands.getConnectionSshPassphrase).toHaveBeenCalledWith("c1");
|
||||||
|
expect(config.ssh_password).toBe("sshpw");
|
||||||
|
expect(config.ssh_passphrase).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -30,6 +30,12 @@ export function useDbConnection(connectionId: string) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const password = await useConnectionStore.getState().getConnectionPassword(conn.id).catch(() => null);
|
const password = await useConnectionStore.getState().getConnectionPassword(conn.id).catch(() => null);
|
||||||
|
const sshPassword = conn.ssh_host
|
||||||
|
? await cmd.getConnectionSshPassword(conn.id).catch(() => null)
|
||||||
|
: null;
|
||||||
|
const sshPassphrase = conn.ssh_host
|
||||||
|
? await cmd.getConnectionSshPassphrase(conn.id).catch(() => null)
|
||||||
|
: null;
|
||||||
const input: ConnectionInput = {
|
const input: ConnectionInput = {
|
||||||
name: conn.name,
|
name: conn.name,
|
||||||
db_type: conn.db_type,
|
db_type: conn.db_type,
|
||||||
@@ -45,6 +51,8 @@ export function useDbConnection(connectionId: string) {
|
|||||||
ssh_auth_method:
|
ssh_auth_method:
|
||||||
conn.ssh_auth_method as "password" | "key" | null | undefined,
|
conn.ssh_auth_method as "password" | "key" | null | undefined,
|
||||||
ssh_private_key_path: conn.ssh_private_key_path,
|
ssh_private_key_path: conn.ssh_private_key_path,
|
||||||
|
ssh_password: sshPassword,
|
||||||
|
ssh_passphrase: sshPassphrase,
|
||||||
ssl_mode:
|
ssl_mode:
|
||||||
conn.ssl_mode as
|
conn.ssl_mode as
|
||||||
| "disable"
|
| "disable"
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { buildChangePayload, buildChangeSql } from "./changePayload";
|
||||||
|
import type { QueueItem } from "../stores/dbViewerStore";
|
||||||
|
|
||||||
|
const base = { id: "c1", status: "pending" as const, createdAt: 0 };
|
||||||
|
|
||||||
|
describe("buildChangePayload", () => {
|
||||||
|
it("insert -> {schema, table, data}", () => {
|
||||||
|
const item: QueueItem = { ...base, type: "insert", sql: "", schema: "public", table: "t",
|
||||||
|
newData: { a: 1 } } as unknown as QueueItem;
|
||||||
|
const p = buildChangePayload(item);
|
||||||
|
expect(p).toEqual({ id: "c1", type: "insert", schema: "public", table: "t", data: "{\"a\":1}" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete -> {schema, table, primary_key}", () => {
|
||||||
|
const item = { ...base, type: "delete", sql: "", schema: "public", table: "t",
|
||||||
|
primaryKey: { id: 5 } } as unknown as QueueItem;
|
||||||
|
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "delete", schema: "public", table: "t", primary_key: "{\"id\":5}" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bulk_insert -> {schema, table, columns, rows}", () => {
|
||||||
|
const item = { ...base, type: "bulk_insert", sql: "", schema: "public", table: "t",
|
||||||
|
columns: ["a", "b"], rows: [[1, 2], [3, 4]] } as unknown as QueueItem;
|
||||||
|
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "bulk_insert", schema: "public", table: "t", columns: ["a", "b"], rows: [[1, 2], [3, 4]] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drop_table -> {schema, table}", () => {
|
||||||
|
const item = { ...base, type: "drop_table", sql: "", schema: "public", table: "t" } as unknown as QueueItem;
|
||||||
|
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "drop_table", schema: "public", table: "t" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty_table -> {schema, table}", () => {
|
||||||
|
const item = { ...base, type: "empty_table", sql: "", schema: "public", table: "t" } as unknown as QueueItem;
|
||||||
|
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "empty_table", schema: "public", table: "t" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("alter_table -> {schema, table, sql, rollback_sql}", () => {
|
||||||
|
const item = { ...base, type: "alter_table", sql: "ALTER TABLE t ADD c int", schema: "public", table: "t" } as unknown as QueueItem;
|
||||||
|
expect(buildChangePayload(item)).toEqual({ id: "c1", type: "alter_table", schema: "public", table: "t", sql: "ALTER TABLE t ADD c int", rollback_sql: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildChangeSql", () => {
|
||||||
|
it("insert", () => {
|
||||||
|
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any;
|
||||||
|
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')');
|
||||||
|
});
|
||||||
|
it("update", () => {
|
||||||
|
const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any;
|
||||||
|
expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5');
|
||||||
|
});
|
||||||
|
it("delete", () => {
|
||||||
|
const item = { id: "1", type: "delete", schema: "public", table: "t", primaryKey: { id: 5 } } as any;
|
||||||
|
expect(buildChangeSql(item)).toBe('DELETE FROM "public"."t" WHERE "id" = 5');
|
||||||
|
});
|
||||||
|
it("bulk_insert", () => {
|
||||||
|
const item = { id: "1", type: "bulk_insert", schema: "public", table: "t", columns: ["a", "b"], rows: [[1, "y"], [2, null]] } as any;
|
||||||
|
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'y\'), (2, NULL)');
|
||||||
|
});
|
||||||
|
it("empty_table / drop_table", () => {
|
||||||
|
expect(buildChangeSql({ id: "1", type: "empty_table", schema: "public", table: "t" } as any)).toBe('DELETE FROM "public"."t"');
|
||||||
|
expect(buildChangeSql({ id: "1", type: "drop_table", schema: "public", table: "t" } as any)).toBe('DROP TABLE "public"."t"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { QueueItem } from "../stores/dbViewerStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload emitted for a single queued change, keyed to match the Rust
|
||||||
|
* `Change` enum variants (snake_case). Sent to `executeChange`.
|
||||||
|
*/
|
||||||
|
export type ChangePayload = Record<string, unknown>;
|
||||||
|
|
||||||
|
function j(v: unknown): string {
|
||||||
|
return JSON.stringify(v ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function q(s: string): string {
|
||||||
|
return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lit(v: unknown): string {
|
||||||
|
if (v === null || v === undefined) return "NULL";
|
||||||
|
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||||
|
if (typeof v === "number") return String(v);
|
||||||
|
if (typeof v === "string") return `'${v.replace(/'/g, "''")}'`;
|
||||||
|
return `'${JSON.stringify(v).replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableRef(schema: string | undefined, table: string | undefined): string {
|
||||||
|
if (!table) return "-";
|
||||||
|
return schema ? `${q(schema)}.${q(table)}` : q(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChangeSql(item: QueueItem): string {
|
||||||
|
const t = tableRef(item.schema, item.table);
|
||||||
|
switch (item.type) {
|
||||||
|
case "insert": {
|
||||||
|
const data = item.newData ?? {};
|
||||||
|
const cols = Object.keys(data);
|
||||||
|
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`;
|
||||||
|
}
|
||||||
|
case "update": {
|
||||||
|
const data = item.newData ?? {};
|
||||||
|
const pk = item.primaryKey ?? {};
|
||||||
|
const setClause = Object.keys(data).map((c) => `${q(c)} = ${lit(data[c])}`).join(", ");
|
||||||
|
const whereClause = Object.keys(pk).map((c) => `${q(c)} = ${lit(pk[c])}`).join(" AND ");
|
||||||
|
return `UPDATE ${t} SET ${setClause} WHERE ${whereClause}`;
|
||||||
|
}
|
||||||
|
case "delete": {
|
||||||
|
const pk = item.primaryKey ?? {};
|
||||||
|
const whereClause = Object.keys(pk).map((c) => `${q(c)} = ${lit(pk[c])}`).join(" AND ");
|
||||||
|
return `DELETE FROM ${t} WHERE ${whereClause}`;
|
||||||
|
}
|
||||||
|
case "bulk_insert": {
|
||||||
|
const cols = item.columns ?? [];
|
||||||
|
const rows = item.rows ?? [];
|
||||||
|
const valueRows = rows
|
||||||
|
.map((row) => `(${row.map(lit).join(", ")})`)
|
||||||
|
.join(", ");
|
||||||
|
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES ${valueRows}`;
|
||||||
|
}
|
||||||
|
case "empty_table":
|
||||||
|
return `DELETE FROM ${t}`;
|
||||||
|
case "drop_table":
|
||||||
|
return `DROP TABLE ${t}`;
|
||||||
|
default:
|
||||||
|
return item.sql ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChangePayload(item: QueueItem): ChangePayload {
|
||||||
|
const schema = item.schema ?? "";
|
||||||
|
const table = item.table ?? "";
|
||||||
|
switch (item.type) {
|
||||||
|
case "insert":
|
||||||
|
return { id: item.id, type: "insert", schema, table, data: j(item.newData) };
|
||||||
|
case "update":
|
||||||
|
return { id: item.id, type: "update", schema, table,
|
||||||
|
primary_key: j(item.primaryKey), old_data: j(item.oldData), new_data: j(item.newData) };
|
||||||
|
case "delete":
|
||||||
|
return { id: item.id, type: "delete", schema, table, primary_key: j(item.primaryKey) };
|
||||||
|
case "alter_table":
|
||||||
|
return { id: item.id, type: "alter_table", schema, table, sql: item.sql, rollback_sql: "" };
|
||||||
|
case "bulk_insert":
|
||||||
|
return { id: item.id, type: "bulk_insert", schema, table,
|
||||||
|
columns: item.columns ?? [], rows: item.rows ?? [] };
|
||||||
|
case "drop_table":
|
||||||
|
return { id: item.id, type: "drop_table", schema, table };
|
||||||
|
case "empty_table":
|
||||||
|
return { id: item.id, type: "empty_table", schema, table };
|
||||||
|
default:
|
||||||
|
return { id: item.id, type: item.type, sql: item.sql };
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-2
@@ -1,6 +1,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, ChangeItem, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph } from "./types";
|
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph } from "./types";
|
||||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||||
|
import type { ChangePayload } from "./changePayload";
|
||||||
|
|
||||||
// NOTE on argument key naming:
|
// NOTE on argument key naming:
|
||||||
// Tauri v2's #[tauri::command] macro converts Rust snake_case parameter names
|
// Tauri v2's #[tauri::command] macro converts Rust snake_case parameter names
|
||||||
@@ -45,6 +46,32 @@ export async function deleteConnectionPassword(connectionId: string): Promise<vo
|
|||||||
return invoke<void>("delete_connection_password", { connectionId });
|
return invoke<void>("delete_connection_password", { connectionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Keychain: SSH secrets ────────────────────────────────────
|
||||||
|
|
||||||
|
export async function saveConnectionSshPassword(connectionId: string, password: string): Promise<void> {
|
||||||
|
return invoke<void>("save_connection_ssh_password", { connectionId, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConnectionSshPassword(connectionId: string): Promise<string | null> {
|
||||||
|
return invoke<string | null>("get_connection_ssh_password", { connectionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConnectionSshPassword(connectionId: string): Promise<void> {
|
||||||
|
return invoke<void>("delete_connection_ssh_password", { connectionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveConnectionSshPassphrase(connectionId: string, passphrase: string): Promise<void> {
|
||||||
|
return invoke<void>("save_connection_ssh_passphrase", { connectionId, passphrase });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConnectionSshPassphrase(connectionId: string): Promise<string | null> {
|
||||||
|
return invoke<string | null>("get_connection_ssh_passphrase", { connectionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConnectionSshPassphrase(connectionId: string): Promise<void> {
|
||||||
|
return invoke<void>("delete_connection_ssh_passphrase", { connectionId });
|
||||||
|
}
|
||||||
|
|
||||||
export async function recreateDemoDb(): Promise<string> {
|
export async function recreateDemoDb(): Promise<string> {
|
||||||
return invoke<string>("recreate_demo_db");
|
return invoke<string>("recreate_demo_db");
|
||||||
}
|
}
|
||||||
@@ -83,10 +110,14 @@ export async function getTableData(
|
|||||||
return invoke<QueryResult>("get_table_data", { connectionId, schema, table, page, pageSize, filters, sorts });
|
return invoke<QueryResult>("get_table_data", { connectionId, schema, table, page, pageSize, filters, sorts });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeChange(connectionId: string, change: ChangeItem): Promise<void> {
|
export async function executeChange(connectionId: string, change: ChangePayload): Promise<void> {
|
||||||
return invoke<void>("execute_change", { connectionId, change });
|
return invoke<void>("execute_change", { connectionId, change });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTableDdl(connectionId: string, schema: string, table: string): Promise<string> {
|
||||||
|
return invoke<string>("get_table_ddl", { connectionId, schema, table });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getFkPreview(
|
export async function getFkPreview(
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
schema: string,
|
schema: string,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseCsv } from "./csvParser";
|
||||||
|
|
||||||
|
describe("parseCsv", () => {
|
||||||
|
it("parses a simple header + rows", () => {
|
||||||
|
expect(parseCsv("a,b,c\n1,2,3\n4,5,6")).toEqual({
|
||||||
|
headers: ["a", "b", "c"], rows: [["1", "2", "3"], ["4", "5", "6"]],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles quoted fields containing commas and quotes", () => {
|
||||||
|
expect(parseCsv('x,y\n"a,b","c""d"""')).toEqual({
|
||||||
|
headers: ["x", "y"], rows: [["a,b", 'c"d"']],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports CRLF line endings", () => {
|
||||||
|
expect(parseCsv("a,b\r\n1,2\r\n")).toEqual({ headers: ["a", "b"], rows: [["1", "2"]] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips a leading UTF-8 BOM", () => {
|
||||||
|
expect(parseCsv("\uFEFFa,b\n1,2")).toEqual({ headers: ["a", "b"], rows: [["1", "2"]] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty rows for header-only input", () => {
|
||||||
|
expect(parseCsv("a,b,c")).toEqual({ headers: ["a", "b", "c"], rows: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on empty input", () => {
|
||||||
|
expect(() => parseCsv("")).toThrow(/empty/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ragged rows pad with empty strings", () => {
|
||||||
|
expect(parseCsv("a,b\n1")).toEqual({ headers: ["a", "b"], rows: [["1", ""]] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export interface ParsedCsv {
|
||||||
|
headers: string[];
|
||||||
|
rows: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCsv(input: string): ParsedCsv {
|
||||||
|
const text = input.replace(/^\uFEFF/, "");
|
||||||
|
if (text.trim() === "") throw new Error("CSV input is empty");
|
||||||
|
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let field = "";
|
||||||
|
let row: string[] = [];
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (text[i + 1] === '"') { field += '"'; i++; }
|
||||||
|
else inQuotes = false;
|
||||||
|
} else field += ch;
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (ch === ',') {
|
||||||
|
row.push(field); field = "";
|
||||||
|
} else if (ch === '\n' || ch === '\r') {
|
||||||
|
if (ch === '\r' && text[i + 1] === '\n') i++;
|
||||||
|
row.push(field); field = "";
|
||||||
|
rows.push(row); row = [];
|
||||||
|
} else field += ch;
|
||||||
|
}
|
||||||
|
if (field !== "" || row.length > 0) { row.push(field); rows.push(row); }
|
||||||
|
if (rows.length === 0) throw new Error("CSV input is empty");
|
||||||
|
|
||||||
|
const [headers, ...data] = rows;
|
||||||
|
const width = headers.length;
|
||||||
|
const padded = data.map((r) => {
|
||||||
|
const out = r.slice(0, width);
|
||||||
|
while (out.length < width) out.push("");
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
return { headers, rows: padded };
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { exportData } from "./exportData";
|
||||||
|
import type { ColumnInfo } from "./types";
|
||||||
|
|
||||||
|
const columns: ColumnInfo[] = [
|
||||||
|
{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null },
|
||||||
|
{ name: "v", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("exportData", () => {
|
||||||
|
it("csv quotes cells and escapes quotes", () => {
|
||||||
|
const create = vi.spyOn(document, "createElement");
|
||||||
|
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
|
||||||
|
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||||
|
const click = vi.fn();
|
||||||
|
create.mockReturnValue({ click } as unknown as HTMLAnchorElement);
|
||||||
|
exportData([[1, "a"], [2, 'b"c']], columns, "csv", "t");
|
||||||
|
expect(click).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("json serializes rows as objects", () => {
|
||||||
|
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
|
||||||
|
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||||
|
const click = vi.fn();
|
||||||
|
vi.spyOn(document, "createElement").mockReturnValue({ click } as unknown as HTMLAnchorElement);
|
||||||
|
exportData([[1, "a"]], columns, "json", "t");
|
||||||
|
expect(click).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ColumnInfo } from "./types";
|
||||||
|
|
||||||
|
export function exportData(
|
||||||
|
rows: unknown[][],
|
||||||
|
columns: ColumnInfo[],
|
||||||
|
format: string,
|
||||||
|
tableName: string,
|
||||||
|
) {
|
||||||
|
const headers = columns.map((c) => c.name);
|
||||||
|
let content: string;
|
||||||
|
let mime: string;
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case "json": {
|
||||||
|
const jsonRows = rows.map((row) => {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
content = JSON.stringify(jsonRows, null, 2);
|
||||||
|
mime = "application/json";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "csv": {
|
||||||
|
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
|
||||||
|
for (const row of rows) {
|
||||||
|
csvRows.push(
|
||||||
|
row.map((cell) => {
|
||||||
|
const s = cell === null || cell === undefined ? "" : String(cell);
|
||||||
|
return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
}).join(","),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
content = csvRows.join("\n");
|
||||||
|
mime = "text/csv";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "sql": {
|
||||||
|
const lines = [`-- ${tableName}`];
|
||||||
|
for (const row of rows) {
|
||||||
|
const vals = row.map((cell) =>
|
||||||
|
cell === null ? "NULL"
|
||||||
|
: typeof cell === "number" ? String(cell)
|
||||||
|
: `'${String(cell).replace(/'/g, "''")}'`,
|
||||||
|
);
|
||||||
|
lines.push(`INSERT INTO ${tableName} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
|
||||||
|
}
|
||||||
|
content = lines.join("\n");
|
||||||
|
mime = "application/sql";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "md": {
|
||||||
|
const mdRows = [`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`];
|
||||||
|
for (const row of rows) {
|
||||||
|
mdRows.push(`| ${row.map((cell) => cell === null ? "*NULL*" : String(cell)).join(" | ")} |`);
|
||||||
|
}
|
||||||
|
content = mdRows.join("\n");
|
||||||
|
mime = "text/markdown";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([content], { type: mime });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${tableName}.${format === "md" ? "md" : format}`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { normalizeImport, coerceRow } from "./importNormalize";
|
||||||
|
|
||||||
|
describe("normalizeImport", () => {
|
||||||
|
it("parses CSV input", () => {
|
||||||
|
expect(normalizeImport("a,b\n1,2\n3,4")).toEqual({
|
||||||
|
headers: ["a", "b"],
|
||||||
|
rows: [
|
||||||
|
["1", "2"],
|
||||||
|
["3", "4"],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a JSON array of objects", () => {
|
||||||
|
expect(normalizeImport('[{"a":"1","b":"2"},{"a":"3","b":"4"}]')).toEqual({
|
||||||
|
headers: ["a", "b"],
|
||||||
|
rows: [
|
||||||
|
["1", "2"],
|
||||||
|
["3", "4"],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a JSON object whose sole value is an array", () => {
|
||||||
|
expect(normalizeImport('{"data":[{"x":"10","y":"20"},{"x":"30","y":"40"}]}')).toEqual({
|
||||||
|
headers: ["x", "y"],
|
||||||
|
rows: [
|
||||||
|
["10", "20"],
|
||||||
|
["30", "40"],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on empty input", () => {
|
||||||
|
expect(() => normalizeImport("")).toThrow(/empty/i);
|
||||||
|
expect(() => normalizeImport(" ")).toThrow(/empty/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on invalid JSON", () => {
|
||||||
|
expect(() => normalizeImport('{"a":')).toThrow(/invalid json/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("coerceRow", () => {
|
||||||
|
it("converts empty strings to null", () => {
|
||||||
|
expect(coerceRow("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts boolean literals", () => {
|
||||||
|
expect(coerceRow("true")).toBe(true);
|
||||||
|
expect(coerceRow("false")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts numeric strings to numbers", () => {
|
||||||
|
expect(coerceRow("42")).toBe(42);
|
||||||
|
expect(coerceRow("4.5")).toBe(4.5);
|
||||||
|
expect(coerceRow("-7")).toBe(-7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps other values as strings", () => {
|
||||||
|
expect(coerceRow("abc")).toBe("abc");
|
||||||
|
expect(coerceRow("12abc")).toBe("12abc");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { parseCsv } from "./csvParser";
|
||||||
|
|
||||||
|
export interface NormalizedImport {
|
||||||
|
headers: string[];
|
||||||
|
rows: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeImport(text: string): NormalizedImport {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed === "") throw new Error("Import input is empty");
|
||||||
|
|
||||||
|
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Record<string, unknown>[];
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
rows = parsed as Record<string, unknown>[];
|
||||||
|
} else if (parsed && typeof parsed === "object") {
|
||||||
|
const values = Object.values(parsed);
|
||||||
|
const arrays = values.filter((v): v is Record<string, unknown>[] => Array.isArray(v));
|
||||||
|
if (arrays.length === 1 && values.length === 1) {
|
||||||
|
rows = arrays[0];
|
||||||
|
} else {
|
||||||
|
rows = [parsed as Record<string, unknown>];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("JSON import must be an array of objects or an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) return { headers: [], rows: [] };
|
||||||
|
|
||||||
|
const headers = Object.keys(rows[0] ?? {});
|
||||||
|
const data = rows.map((r) => headers.map((h) => String((r as Record<string, unknown>)[h] ?? "")));
|
||||||
|
return { headers, rows: data };
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseCsv(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceRow(v: string): unknown {
|
||||||
|
if (v === "") return null;
|
||||||
|
if (v === "true") return true;
|
||||||
|
if (v === "false") return false;
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
TableNode,
|
TableNode,
|
||||||
GraphColumn,
|
GraphColumn,
|
||||||
Relationship,
|
Relationship,
|
||||||
|
Settings,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
describe("ActiveView", () => {
|
describe("ActiveView", () => {
|
||||||
@@ -97,6 +98,13 @@ describe("Connection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ConnectionInput", () => {
|
describe("ConnectionInput", () => {
|
||||||
|
it("accepts ssh_password", () => {
|
||||||
|
const input: ConnectionInput = {
|
||||||
|
name: "x", db_type: "postgresql", host: "h", port: 5432, ssh_password: "ssh-pw",
|
||||||
|
};
|
||||||
|
expect(input.ssh_password).toBe("ssh-pw");
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts all SSH/SSL fields", () => {
|
it("accepts all SSH/SSL fields", () => {
|
||||||
const input: ConnectionInput = {
|
const input: ConnectionInput = {
|
||||||
name: "Test",
|
name: "Test",
|
||||||
@@ -431,3 +439,17 @@ describe("Schema graph types", () => {
|
|||||||
expect(col.fk_ref).toBeNull();
|
expect(col.fk_ref).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Settings", () => {
|
||||||
|
it("includes the five editor option fields", () => {
|
||||||
|
const s: Settings = {
|
||||||
|
confirm_before_delete: true, default_folder_id: null, theme: "dark",
|
||||||
|
font_size: "medium", default_ports: {}, tag_order: null, table_refresh_rate: 0,
|
||||||
|
table_page_size: 50, shortcuts: {}, accent_color: "#2563EB",
|
||||||
|
editor_font_size: 13, editor_font_family: "Space Mono",
|
||||||
|
editor_word_wrap: "off", editor_minimap: false, editor_tab_size: 4,
|
||||||
|
};
|
||||||
|
expect(s.editor_font_size).toBe(13);
|
||||||
|
expect(s.editor_word_wrap).toBe("off");
|
||||||
|
});
|
||||||
|
});
|
||||||
+9
-1
@@ -68,6 +68,7 @@ export interface ConnectionInput {
|
|||||||
ssh_user?: string | null;
|
ssh_user?: string | null;
|
||||||
ssh_auth_method?: "password" | "key" | null;
|
ssh_auth_method?: "password" | "key" | null;
|
||||||
ssh_private_key_path?: string | null;
|
ssh_private_key_path?: string | null;
|
||||||
|
ssh_password?: string | null;
|
||||||
ssh_passphrase?: string | null;
|
ssh_passphrase?: string | null;
|
||||||
// SSL/TLS fields
|
// SSL/TLS fields
|
||||||
ssl_mode?: "disable" | "require" | "verify-ca" | "verify-full" | null;
|
ssl_mode?: "disable" | "require" | "verify-ca" | "verify-full" | null;
|
||||||
@@ -98,6 +99,11 @@ export interface Settings {
|
|||||||
table_page_size: number;
|
table_page_size: number;
|
||||||
shortcuts: Record<string, string>;
|
shortcuts: Record<string, string>;
|
||||||
accent_color: string;
|
accent_color: string;
|
||||||
|
editor_font_size: number;
|
||||||
|
editor_font_family: string;
|
||||||
|
editor_word_wrap: "off" | "on";
|
||||||
|
editor_minimap: boolean;
|
||||||
|
editor_tab_size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActiveView = "home" | "settings" | "new-connection" | "db-viewer";
|
export type ActiveView = "home" | "settings" | "new-connection" | "db-viewer";
|
||||||
@@ -159,7 +165,9 @@ export type ChangeItemType =
|
|||||||
| "update"
|
| "update"
|
||||||
| "delete"
|
| "delete"
|
||||||
| "create_index"
|
| "create_index"
|
||||||
| "drop_index";
|
| "drop_index"
|
||||||
|
| "bulk_insert"
|
||||||
|
| "empty_table";
|
||||||
|
|
||||||
export interface ChangeItem {
|
export interface ChangeItem {
|
||||||
type: ChangeItemType;
|
type: ChangeItemType;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getDescendantFolderIds,
|
getDescendantFolderIds,
|
||||||
getFolderPathLabel,
|
getFolderPathLabel,
|
||||||
isDestructiveQuery,
|
isDestructiveQuery,
|
||||||
|
isSchemaModifyingQuery,
|
||||||
pickDefaultSchema,
|
pickDefaultSchema,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import type { Connection, Folder, Tag } from "./types";
|
import type { Connection, Folder, Tag } from "./types";
|
||||||
@@ -415,3 +416,24 @@ describe("pickDefaultSchema", () => {
|
|||||||
expect(pickDefaultSchema([])).toBeNull();
|
expect(pickDefaultSchema([])).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isSchemaModifyingQuery", () => {
|
||||||
|
it("returns true for CREATE / DROP / ALTER / TRUNCATE", () => {
|
||||||
|
expect(isSchemaModifyingQuery("CREATE TABLE t (id int)")).toBe(true);
|
||||||
|
expect(isSchemaModifyingQuery("DROP TABLE t")).toBe(true);
|
||||||
|
expect(isSchemaModifyingQuery("ALTER TABLE t ADD COLUMN c int")).toBe(true);
|
||||||
|
expect(isSchemaModifyingQuery("TRUNCATE TABLE t")).toBe(true);
|
||||||
|
});
|
||||||
|
it("returns false for data-only and read statements", () => {
|
||||||
|
expect(isSchemaModifyingQuery("SELECT * FROM t")).toBe(false);
|
||||||
|
expect(isSchemaModifyingQuery("INSERT INTO t VALUES (1)")).toBe(false);
|
||||||
|
expect(isSchemaModifyingQuery("UPDATE t SET c = 1")).toBe(false);
|
||||||
|
expect(isSchemaModifyingQuery("DELETE FROM t")).toBe(false);
|
||||||
|
expect(isSchemaModifyingQuery("REPLACE INTO t VALUES (1)")).toBe(false);
|
||||||
|
expect(isSchemaModifyingQuery("WITH cte AS (SELECT 1) SELECT * FROM cte")).toBe(false);
|
||||||
|
});
|
||||||
|
it("strips comments before checking", () => {
|
||||||
|
expect(isSchemaModifyingQuery("-- note\nCREATE TABLE t (id int)")).toBe(true);
|
||||||
|
expect(isSchemaModifyingQuery("/* x */ SELECT 1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+31
-9
@@ -149,6 +149,21 @@ const DESTRUCTIVE_KEYWORDS = new Set([
|
|||||||
"TRUNCATE", "CREATE", "REPLACE",
|
"TRUNCATE", "CREATE", "REPLACE",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the first significant keyword of `sql` (uppercased) after stripping
|
||||||
|
* comments and collapsing whitespace, or null when there is none.
|
||||||
|
*/
|
||||||
|
export function firstSignificantKeyword(sql: string): string | null {
|
||||||
|
// Strip block comments /* ... */
|
||||||
|
let stripped = sql.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||||
|
// Strip line comments -- ...
|
||||||
|
stripped = stripped.replace(/--[^\n]*/g, " ");
|
||||||
|
// Collapse whitespace
|
||||||
|
const tokens = stripped.trim().split(/\s+/);
|
||||||
|
if (tokens.length === 0 || tokens[0].length === 0) return null;
|
||||||
|
return tokens[0].toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect whether `sql` is a data-modifying statement by checking the
|
* Detect whether `sql` is a data-modifying statement by checking the
|
||||||
* first significant keyword after stripping comments and whitespace.
|
* first significant keyword after stripping comments and whitespace.
|
||||||
@@ -158,15 +173,22 @@ const DESTRUCTIVE_KEYWORDS = new Set([
|
|||||||
* accidental data loss, not malicious access.
|
* accidental data loss, not malicious access.
|
||||||
*/
|
*/
|
||||||
export function isDestructiveQuery(sql: string): boolean {
|
export function isDestructiveQuery(sql: string): boolean {
|
||||||
// Strip block comments /* ... */
|
const first = firstSignificantKeyword(sql);
|
||||||
let stripped = sql.replace(/\/\*[\s\S]*?\*\//g, " ");
|
return first !== null && DESTRUCTIVE_KEYWORDS.has(first);
|
||||||
// Strip line comments -- ...
|
}
|
||||||
stripped = stripped.replace(/--[^\n]*/g, " ");
|
|
||||||
// Collapse whitespace
|
const SCHEMA_MODIFYING_KEYWORDS = new Set([
|
||||||
const tokens = stripped.trim().split(/\s+/);
|
"CREATE", "DROP", "ALTER", "TRUNCATE",
|
||||||
if (tokens.length === 0 || tokens[0].length === 0) return false;
|
]);
|
||||||
const first = tokens[0].toUpperCase();
|
|
||||||
return DESTRUCTIVE_KEYWORDS.has(first);
|
/**
|
||||||
|
* Detect whether `sql` changes the database schema (DDL) by checking the
|
||||||
|
* first significant keyword. Used to auto-refresh the schema tree after a
|
||||||
|
* successful query run.
|
||||||
|
*/
|
||||||
|
export function isSchemaModifyingQuery(sql: string): boolean {
|
||||||
|
const k = firstSignificantKeyword(sql);
|
||||||
|
return k !== null && SCHEMA_MODIFYING_KEYWORDS.has(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
|||||||
if (input.password) {
|
if (input.password) {
|
||||||
await cmd.saveConnectionPassword(conn.id, input.password);
|
await cmd.saveConnectionPassword(conn.id, input.password);
|
||||||
}
|
}
|
||||||
|
// Persist SSH secrets to OS keychain (not SQLite): password for password
|
||||||
|
// auth, passphrase for private-key auth.
|
||||||
|
if (input.ssh_host && (input.ssh_auth_method ?? "password") === "password" && input.ssh_password) {
|
||||||
|
await cmd.saveConnectionSshPassword(conn.id, input.ssh_password);
|
||||||
|
}
|
||||||
|
if (input.ssh_host && input.ssh_passphrase) {
|
||||||
|
await cmd.saveConnectionSshPassphrase(conn.id, input.ssh_passphrase);
|
||||||
|
}
|
||||||
set((s) => ({ connections: [...s.connections, conn] }));
|
set((s) => ({ connections: [...s.connections, conn] }));
|
||||||
},
|
},
|
||||||
deleteConnection: async (id) => {
|
deleteConnection: async (id) => {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { useDbViewerStore } from "./dbViewerStore";
|
import { useDbViewerStore } from "./dbViewerStore";
|
||||||
import type { QueryResult, TableInfo } from "../lib/types";
|
import type { QueryResult, TableInfo } from "../lib/types";
|
||||||
|
import * as commands from "../lib/commands";
|
||||||
|
|
||||||
|
vi.mock("../lib/commands", () => ({
|
||||||
|
getDatabases: vi.fn(),
|
||||||
|
getSchemas: vi.fn(),
|
||||||
|
getTables: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useDbViewerStore.getState().reset();
|
useDbViewerStore.getState().reset();
|
||||||
@@ -63,6 +70,26 @@ describe("dbViewerStore", () => {
|
|||||||
expect(state.activeTabId).toBe(state.tabs[0].id);
|
expect(state.activeTabId).toBe(state.tabs[0].id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("closeTabsForTable closes matching table tabs and keeps others", () => {
|
||||||
|
useDbViewerStore.getState().openTab("public", "users");
|
||||||
|
useDbViewerStore.getState().openTab("public", "posts", true);
|
||||||
|
useDbViewerStore.getState().closeTabsForTable("public", "users");
|
||||||
|
const tabs = useDbViewerStore.getState().tabs;
|
||||||
|
expect(tabs).toHaveLength(1);
|
||||||
|
expect(tabs[0].table).toBe("posts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closeTabsForTable fixes the active tab when it is closed", () => {
|
||||||
|
useDbViewerStore.getState().openTab("public", "users");
|
||||||
|
const usersId = useDbViewerStore.getState().tabs[0].id;
|
||||||
|
useDbViewerStore.getState().openTab("public", "posts", true);
|
||||||
|
useDbViewerStore.getState().setActiveTab(usersId);
|
||||||
|
useDbViewerStore.getState().closeTabsForTable("public", "users");
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
expect(st.tabs).toHaveLength(1);
|
||||||
|
expect(st.activeTabId).toBe(st.tabs[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
it("setPage updates pagination", () => {
|
it("setPage updates pagination", () => {
|
||||||
const store = useDbViewerStore.getState();
|
const store = useDbViewerStore.getState();
|
||||||
store.openTab("public", "users");
|
store.openTab("public", "users");
|
||||||
@@ -116,6 +143,17 @@ describe("dbViewerStore", () => {
|
|||||||
expect(queue[0].createdAt).toBeGreaterThan(0);
|
expect(queue[0].createdAt).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("addChange stages a bulk_insert with columns+rows", () => {
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "bulk_insert", schema: "public", table: "t",
|
||||||
|
columns: ["a", "b"], rows: [[1, 2]], description: "Import",
|
||||||
|
} as any);
|
||||||
|
const q = useDbViewerStore.getState().changesQueue;
|
||||||
|
expect(q[q.length - 1].type).toBe("bulk_insert");
|
||||||
|
expect((q[q.length - 1] as any).columns).toEqual(["a", "b"]);
|
||||||
|
expect((q[q.length - 1] as any).rows).toEqual([[1, 2]]);
|
||||||
|
});
|
||||||
|
|
||||||
it("cancelChange marks change as cancelled", () => {
|
it("cancelChange marks change as cancelled", () => {
|
||||||
const store = useDbViewerStore.getState();
|
const store = useDbViewerStore.getState();
|
||||||
store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" });
|
store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" });
|
||||||
@@ -147,6 +185,20 @@ describe("dbViewerStore", () => {
|
|||||||
expect(change.error).toBe("Constraint violation");
|
expect(change.error).toBe("Constraint violation");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removeChange drops the change from the queue", () => {
|
||||||
|
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
|
||||||
|
const id = useDbViewerStore.getState().changesQueue[0].id;
|
||||||
|
useDbViewerStore.getState().removeChange(id);
|
||||||
|
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearChanges empties the queue", () => {
|
||||||
|
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
|
||||||
|
useDbViewerStore.getState().addChange({ type: "delete", schema: "public", table: "t", primaryKey: { id: 1 }, description: "y" } as any);
|
||||||
|
useDbViewerStore.getState().clearChanges();
|
||||||
|
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("reset clears all state", () => {
|
it("reset clears all state", () => {
|
||||||
const store = useDbViewerStore.getState();
|
const store = useDbViewerStore.getState();
|
||||||
store.openTab("public", "users");
|
store.openTab("public", "users");
|
||||||
@@ -181,6 +233,37 @@ describe("dbViewerStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("refreshTree", () => {
|
||||||
|
it("fetches databases/schemas/tables and populates", async () => {
|
||||||
|
vi.mocked(commands.getDatabases).mockResolvedValue(["mydb"]);
|
||||||
|
vi.mocked(commands.getSchemas).mockResolvedValue(["public"]);
|
||||||
|
vi.mocked(commands.getTables).mockResolvedValue([
|
||||||
|
{ name: "users", schema: "public", table_type: "TABLE" },
|
||||||
|
] as any);
|
||||||
|
useDbViewerStore.setState({ currentSchema: "public" });
|
||||||
|
await useDbViewerStore.getState().refreshTree("c1");
|
||||||
|
expect(useDbViewerStore.getState().databases).toEqual(["mydb"]);
|
||||||
|
expect(useDbViewerStore.getState().schemas).toEqual(["public"]);
|
||||||
|
expect(useDbViewerStore.getState().tables).toHaveLength(1);
|
||||||
|
expect(commands.getTables).toHaveBeenCalledWith("c1", "public");
|
||||||
|
});
|
||||||
|
it("falls back to no schema when currentSchema is null", async () => {
|
||||||
|
vi.mocked(commands.getDatabases).mockResolvedValue([] as any);
|
||||||
|
vi.mocked(commands.getSchemas).mockResolvedValue([] as any);
|
||||||
|
vi.mocked(commands.getTables).mockResolvedValue([] as any);
|
||||||
|
useDbViewerStore.setState({ currentSchema: null });
|
||||||
|
await useDbViewerStore.getState().refreshTree("c1");
|
||||||
|
expect(commands.getTables).toHaveBeenCalledWith("c1", undefined);
|
||||||
|
});
|
||||||
|
it("swallows fetch errors", async () => {
|
||||||
|
vi.mocked(commands.getDatabases).mockResolvedValue([] as any);
|
||||||
|
vi.mocked(commands.getSchemas).mockRejectedValue(new Error("boom"));
|
||||||
|
await expect(
|
||||||
|
useDbViewerStore.getState().refreshTree("c1"),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("tabType discriminator", () => {
|
describe("tabType discriminator", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useDbViewerStore.getState().reset();
|
useDbViewerStore.getState().reset();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../lib/types";
|
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../lib/types";
|
||||||
|
import { getDatabases, getSchemas, getTables } from "../lib/commands";
|
||||||
|
|
||||||
// ─── Local types ────────────────────────────────────────────────
|
// ─── Local types ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ export interface QueueItem {
|
|||||||
primaryKey?: Record<string, unknown>;
|
primaryKey?: Record<string, unknown>;
|
||||||
oldData?: Record<string, unknown> | null;
|
oldData?: Record<string, unknown> | null;
|
||||||
newData?: Record<string, unknown> | null;
|
newData?: Record<string, unknown> | null;
|
||||||
|
columns?: string[];
|
||||||
|
rows?: unknown[][];
|
||||||
status: QueueStatus;
|
status: QueueStatus;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
@@ -99,6 +102,7 @@ interface DbViewerState {
|
|||||||
openQueryTab: () => void;
|
openQueryTab: () => void;
|
||||||
setDefaultPageSize: (size: number) => void;
|
setDefaultPageSize: (size: number) => void;
|
||||||
closeTab: (tabId: string) => void;
|
closeTab: (tabId: string) => void;
|
||||||
|
closeTabsForTable: (schema: string, table: string) => void;
|
||||||
setActiveTab: (tabId: string) => void;
|
setActiveTab: (tabId: string) => void;
|
||||||
setPage: (tabId: string, page: number) => void;
|
setPage: (tabId: string, page: number) => void;
|
||||||
setPageSize: (tabId: string, pageSize: number) => void;
|
setPageSize: (tabId: string, pageSize: number) => void;
|
||||||
@@ -120,9 +124,13 @@ interface DbViewerState {
|
|||||||
primaryKey?: Record<string, unknown>;
|
primaryKey?: Record<string, unknown>;
|
||||||
oldData?: Record<string, unknown> | null;
|
oldData?: Record<string, unknown> | null;
|
||||||
newData?: Record<string, unknown> | null;
|
newData?: Record<string, unknown> | null;
|
||||||
|
columns?: string[];
|
||||||
|
rows?: unknown[][];
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
}) => void;
|
}) => void;
|
||||||
cancelChange: (changeId: string) => void;
|
cancelChange: (changeId: string) => void;
|
||||||
|
removeChange: (changeId: string) => void;
|
||||||
|
clearChanges: () => void;
|
||||||
markChangeCommitted: (changeId: string) => void;
|
markChangeCommitted: (changeId: string) => void;
|
||||||
markChangeFailed: (changeId: string, error: string) => void;
|
markChangeFailed: (changeId: string, error: string) => void;
|
||||||
toggleChangesPanel: () => void;
|
toggleChangesPanel: () => void;
|
||||||
@@ -138,6 +146,7 @@ interface DbViewerState {
|
|||||||
schemas: string[],
|
schemas: string[],
|
||||||
tables: TableInfo[],
|
tables: TableInfo[],
|
||||||
) => void;
|
) => void;
|
||||||
|
refreshTree: (connectionId: string, schema?: string) => Promise<void>;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +229,21 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
|||||||
set({ tabs: remaining, activeTabId: newActiveId });
|
set({ tabs: remaining, activeTabId: newActiveId });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
closeTabsForTable: (schema, table) => {
|
||||||
|
const { tabs, activeTabId } = get();
|
||||||
|
const remaining = tabs.filter(
|
||||||
|
(t) => !(t.tabType === "table" && t.schema === schema && t.table === table),
|
||||||
|
);
|
||||||
|
if (remaining.length === tabs.length) return;
|
||||||
|
const newActiveId =
|
||||||
|
activeTabId !== null && !remaining.some((t) => t.id === activeTabId)
|
||||||
|
? remaining.length > 0
|
||||||
|
? remaining[remaining.length - 1].id
|
||||||
|
: null
|
||||||
|
: activeTabId;
|
||||||
|
set({ tabs: remaining, activeTabId: newActiveId });
|
||||||
|
},
|
||||||
|
|
||||||
setActiveTab: (tabId) => set({ activeTabId: tabId }),
|
setActiveTab: (tabId) => set({ activeTabId: tabId }),
|
||||||
|
|
||||||
setPage: (tabId, page) =>
|
setPage: (tabId, page) =>
|
||||||
@@ -327,6 +351,8 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
|||||||
primaryKey: input.primaryKey,
|
primaryKey: input.primaryKey,
|
||||||
oldData: input.oldData ?? null,
|
oldData: input.oldData ?? null,
|
||||||
newData: input.newData ?? null,
|
newData: input.newData ?? null,
|
||||||
|
columns: input.columns,
|
||||||
|
rows: input.rows,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
@@ -341,6 +367,13 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
|||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
removeChange: (changeId) =>
|
||||||
|
set((state) => ({
|
||||||
|
changesQueue: state.changesQueue.filter((c) => c.id !== changeId),
|
||||||
|
})),
|
||||||
|
|
||||||
|
clearChanges: () => set({ changesQueue: [] }),
|
||||||
|
|
||||||
markChangeCommitted: (changeId) =>
|
markChangeCommitted: (changeId) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
changesQueue: state.changesQueue.map((c) =>
|
changesQueue: state.changesQueue.map((c) =>
|
||||||
@@ -371,6 +404,22 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
|||||||
populate: (databases, schemas, tables) =>
|
populate: (databases, schemas, tables) =>
|
||||||
set({ databases, schemas, tables }),
|
set({ databases, schemas, tables }),
|
||||||
|
|
||||||
|
// Best-effort re-fetch of the schema tree (databases/schemas/tables) so
|
||||||
|
// newly created/dropped objects show up without a manual refresh. A
|
||||||
|
// failure must never surface to the user.
|
||||||
|
refreshTree: async (connectionId, schema) => {
|
||||||
|
try {
|
||||||
|
const [dbs, scs, tbls] = await Promise.all([
|
||||||
|
getDatabases(connectionId),
|
||||||
|
getSchemas(connectionId),
|
||||||
|
getTables(connectionId, schema ?? get().currentSchema ?? undefined),
|
||||||
|
]);
|
||||||
|
get().populate(dbs, scs, tbls);
|
||||||
|
} catch {
|
||||||
|
// Best-effort refresh; a failure must not surface to the user.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
tabCounter = 0;
|
tabCounter = 0;
|
||||||
changeCounter = 0;
|
changeCounter = 0;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe("settingsStore", () => {
|
describe("settingsStore", () => {
|
||||||
it("load fetches settings", async () => {
|
it("load fetches settings", async () => {
|
||||||
const settings = { confirm_before_delete: true, default_folder_id: null, theme: "dark" as const, font_size: "medium" as const, default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, tag_order: null, table_refresh_rate: 0, table_page_size: 50, shortcuts: {}, accent_color: "#2563EB" };
|
const settings = { confirm_before_delete: true, default_folder_id: null, theme: "dark" as const, font_size: "medium" as const, default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, tag_order: null, table_refresh_rate: 0, table_page_size: 50, shortcuts: {}, accent_color: "#2563EB", editor_font_size: 13, editor_font_family: "Space Mono", editor_word_wrap: "off" as const, editor_minimap: false, editor_tab_size: 4 };
|
||||||
vi.spyOn(commands, "getSettings").mockResolvedValue(settings);
|
vi.spyOn(commands, "getSettings").mockResolvedValue(settings);
|
||||||
await useSettingsStore.getState().load();
|
await useSettingsStore.getState().load();
|
||||||
expect(useSettingsStore.getState().settings).toEqual(settings);
|
expect(useSettingsStore.getState().settings).toEqual(settings);
|
||||||
@@ -17,7 +17,7 @@ describe("settingsStore", () => {
|
|||||||
|
|
||||||
it("updateSetting persists then reloads", async () => {
|
it("updateSetting persists then reloads", async () => {
|
||||||
vi.spyOn(commands, "updateSetting").mockResolvedValue(undefined);
|
vi.spyOn(commands, "updateSetting").mockResolvedValue(undefined);
|
||||||
const settings = { confirm_before_delete: true, default_folder_id: null, theme: "light" as const, font_size: "medium" as const, default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, tag_order: null, table_refresh_rate: 0, table_page_size: 50, shortcuts: {}, accent_color: "#2563EB" };
|
const settings = { confirm_before_delete: true, default_folder_id: null, theme: "light" as const, font_size: "medium" as const, default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, tag_order: null, table_refresh_rate: 0, table_page_size: 50, shortcuts: {}, accent_color: "#2563EB", editor_font_size: 13, editor_font_family: "Space Mono", editor_word_wrap: "off" as const, editor_minimap: false, editor_tab_size: 4 };
|
||||||
vi.spyOn(commands, "getSettings").mockResolvedValue(settings);
|
vi.spyOn(commands, "getSettings").mockResolvedValue(settings);
|
||||||
await useSettingsStore.getState().updateSetting("theme", "light");
|
await useSettingsStore.getState().updateSetting("theme", "light");
|
||||||
expect(commands.updateSetting).toHaveBeenCalledWith("theme", "light");
|
expect(commands.updateSetting).toHaveBeenCalledWith("theme", "light");
|
||||||
|
|||||||
Reference in New Issue
Block a user