v0.7.8: MySQL/SQLite backup-sync, Excel export, query cancel, settings import/export, Windows title-bar fix, SQLite table editor (#16)

* [P1-T1] feat(backup): MySQL/SQLite backup models + db_type on SyncOptions (Task 1.1)

* [P1-T2] feat: enable tools(mysql,sqlite) + tableManagement(sqlite) + add fflate (Task 1.2)

* [P1-T3] feat(cancel): CancelHandle enum + CancelRegistry (Task 1.3)

* [P2-T1] feat(export): hand-rolled XLSX writer with inline-string cells (Task 2.1)

* [P2-T2] feat(backup): SQLite .dump/restore/sync core, fail-closed virtual tables (Task 2.2)

* [P2-T3] feat(backup): MySQL dump/restore/sync arg builders + tool resolution (Task 2.3)

* [P2-T4] feat(settings): pure import validator + SettingsExport envelope + Store.apply_settings (Task 2.4)

* [P2-T5] feat(table-editor): SQLite create/diff/rebuild SQL generation, fail-closed AUTOINCREMENT (Task 2.5)

* [P3-T1] feat(commands): MySQL/SQLite backup + settings export/import commands + wrappers (Task 3.1)

* [P3-T2] feat(cancel): capture cancel primitives at connect; cancel_query command; SQLite interrupt test (Task 3.2)

* [P3-T3] feat(table-editor): SQLite object-change dispatch + execute_change Ddl/RebuildTable (Task 3.3)

* [P4-T1] feat(tools): DB-aware backup/restore/sync pages (Task 4.1)

* [P4-T2] feat(export): xlsx export in grid toolbar + overflow menu (Task 4.2)

* [P4-T3] feat(query): cancel button wired to cancelQuery (Task 4.3)

* [P4-T4] feat(settings): export/import buttons + validation gate (Task 4.4)

* [P4-T5] fix(ui): gate macOS overlay drag strip to macOS only (Task 4.5)

* [P4-T6] feat(table-editor): SQLite Create/Edit Table mode (Task 4.6)

* [P5-T1] chore: bump 0.7.7 -> 0.7.8 + README/AGENTS/ROADMAP status (Task 5.1)

* [P5-T2] build(release): bundle mariadb-dump + mariadb client (system-first fallback) (Task 5.2)

* fix(cancel): propagate cancellations past wrapped->raw fallback (SQLite/PG/MySQL) + MySQL CONNECTION_ID cast

* fix(export): Excel export from overflow menu did nothing + add export success/error toasts

* fix(export): tree kebab export fetches table data when rows not loaded

* docs(readme): surface v0.7.8 features (MySQL/SQLite backup-sync, Excel export, query cancel, SQLite table editor, settings import/export)
This commit is contained in:
2026-08-08 00:09:36 +08:00
committed by GitHub
parent 9fb3222956
commit 32a7b852ec
59 changed files with 4068 additions and 572 deletions
+108
View File
@@ -167,6 +167,114 @@ jobs:
"$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; } "$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; }
done done
- name: Build MariaDB client tools (bundled)
shell: bash
run: |
set -euo pipefail
MARIADB_VER="11.4.5"
OUT="${GITHUB_WORKSPACE}/src-tauri/resources/mysql_tools"
mkdir -p "$OUT"
case "${{ matrix.platform }}" in
ubuntu-22.04)
sudo apt-get update -y
sudo apt-get install -y cmake build-essential libssl-dev libzstd-dev pkg-config
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
cd /tmp/mariadb-server
# Client-only build: wolfSSL + zlib are vendored, so the bundled
# clients need no system OpenSSL on the user's machine.
cmake -DCMAKE_BUILD_TYPE=Release \
-DWITHOUT_SERVER=ON \
-DWITHOUT_TOKUDB=1 \
-DWITHOUT_ROCKSDB=1 \
-DWITHOUT_MROONGA=1 \
-DWITHOUT_SPIDER=1 \
-DWITHOUT_SEQUENCE=1 \
-DWITH_UNIT_TESTS=OFF \
-DWITH_SSL=bundled \
-DWITH_ZLIB=bundled .
make -j"$(nproc)" mariadb-dump mariadb
# Clients build into client/ (stable across MariaDB 10.x/11.x);
# fall back to a broad search if a future version moves them.
for b in mariadb-dump mariadb; do
src=$(find client -maxdepth 1 -type f -name "$b" -print -quit 2>/dev/null || true)
test -n "$src" || src=$(find . -type f -name "$b" -print -quit || true)
test -n "$src" || { echo "build produced no $b binary"; exit 1; }
cp "$src" "$OUT"/
done
# Print the clients' shared-library deps for the first CI run.
# TODO(RELEASE): if the --version smoke check below fails with a
# shared-library error, the clients linked a shared libmariadb/
# libedit — copy it into "$OUT" and patchelf --set-rpath '$ORIGIN'
# exactly like the pg_tools step does for libpq.
ldd "$OUT"/mariadb-dump || true
;;
macos-latest|macos-15-intel)
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
cd /tmp/mariadb-server
if [ "$(uname -m)" = arm64 ]; then
SSL_DIR="/opt/homebrew/opt/openssl"
else
SSL_DIR="/usr/local/opt/openssl"
fi
[ -d "$SSL_DIR" ] || brew install openssl
cmake -DCMAKE_BUILD_TYPE=Release \
-DWITHOUT_SERVER=ON \
-DWITHOUT_TOKUDB=1 \
-DWITHOUT_ROCKSDB=1 \
-DWITHOUT_MROONGA=1 \
-DWITHOUT_SPIDER=1 \
-DWITHOUT_SEQUENCE=1 \
-DWITH_UNIT_TESTS=OFF \
-DWITH_SSL="$SSL_DIR" \
-DWITH_ZLIB=bundled .
make -j"$(sysctl -n hw.ncpu)" mariadb-dump mariadb
for b in mariadb-dump mariadb; do
src=$(find client -maxdepth 1 -type f -name "$b" -print -quit 2>/dev/null || true)
test -n "$src" || src=$(find . -type f -name "$b" -print -quit || true)
test -n "$src" || { echo "build produced no $b binary"; exit 1; }
cp "$src" "$OUT"/
done
# TODO(RELEASE): if the --version smoke check below fails with a
# dyld error, rewrite the lib dependency to @loader_path like the
# pg_tools step does for libpq (see also the Linux shared-lib note).
otool -L "$OUT"/mariadb-dump || true
;;
windows-latest)
URL="https://archive.mariadb.org/mariadb-${MARIADB_VER}/winx64-packages/mariadb-${MARIADB_VER}-winx64.zip"
curl -fsSL "$URL" -o /tmp/mariadb.zip
# TODO(RELEASE): pin the sha256 printed on the first CI run, then
# uncomment the check so later releases verify the download.
# EXPECTED="<sha256 of mariadb-${MARIADB_VER}-winx64.zip>"
# echo "$EXPECTED /tmp/mariadb.zip" | sha256sum -c -
sha256sum /tmp/mariadb.zip
unzip -o /tmp/mariadb.zip -d /tmp/mariadb
WINROOT="/tmp/mariadb/mariadb-${MARIADB_VER}-winx64"
cp "$WINROOT"/bin/mariadb-dump.exe "$WINROOT"/bin/mariadb.exe "$OUT"/
# The clients' only runtime dependency is the client library
# (lib/libmariadb.dll); the winx64 package statically links
# wolfSSL, so there are no OpenSSL DLLs to bundle. bin/*.dll is
# just the embedded server.dll, which the client tools don't need.
cp "$WINROOT"/lib/libmariadb.dll "$OUT"/
;;
esac
(cd "$OUT" && sha256sum * | tee checksums.txt)
# Resolve the per-platform binary suffix WITHOUT a command
# substitution (see the pg_tools step for why).
if [ "${{ matrix.platform }}" = windows-latest ]; then
BIN_EXT=".exe"
else
BIN_EXT=""
fi
for b in mariadb-dump mariadb; do
f="$OUT/${b}${BIN_EXT}"
test -f "$f" || { echo "missing $f"; exit 1; }
done
# Sanity: every tool must run (loader path is correct) — this catches
# a missing shared library before we ship a broken bundle.
for b in mariadb-dump mariadb; do
"$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; }
done
- name: Build and upload to GitHub Release - name: Build and upload to GitHub Release
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
+9 -4
View File
@@ -166,7 +166,7 @@ Cut a release by tagging the **`prod`** branch once the PR is merged — `git ta
**Before tagging**, keep everything in sync: **Before tagging**, keep everything in sync:
- Version number across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` - Version number across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`
- `src/lib/version.test.ts` and `src/lib/docs-coverage.test.ts` if they assert the version - `src/lib/version.test.ts` and `src/lib/docs-coverage.test.ts` if they assert the version
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.7/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md). - **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.8/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step. - **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
### Adding a Tauri Command ### Adding a Tauri Command
@@ -270,7 +270,8 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Column show/hide | ✅ | Toggle visibility per column | | Column show/hide | ✅ | Toggle visibility per column |
| Column resize (drag handle) | ✅ | Double-click to auto-fit | | Column resize (drag handle) | ✅ | Double-click to auto-fit |
| 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, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8) |
| Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) |
| Auto-refresh timer | ✅ | Configurable interval in settings | | Auto-refresh timer | ✅ | Configurable interval in settings |
| 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 | | 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 schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed | | Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
@@ -296,6 +297,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Table options | ✅ | Tablespace picker (non-system, from `pg_tablespace`) + row-level security toggle in the Options section | | Table options | ✅ | Tablespace picker (non-system, from `pg_tablespace`) + row-level security toggle in the Options section |
| Roles & grants management | ✅ | Objects → Roles: list non-system roles (attributes incl. connection limit — `rolconnlimit::int8` cast fix), role detail with attribute grid + memberships + privilege explorer; create/edit/drop roles staged through the queue | | Roles & grants management | ✅ | Objects → Roles: list non-system roles (attributes incl. connection limit — `rolconnlimit::int8` cast fix), role detail with attribute grid + memberships + privilege explorer; create/edit/drop roles staged through the queue |
| Role privilege explorer | ✅ | Per-role privileges grouped by object class (tables/sequences/routines/schemas/databases) with collapsible sections — collapsed shows first 5 + fade, expand shows all, chevron hidden when ≤5; GRANT/REVOKE composer (object class + schema + name, WITH GRANT OPTION); aclexplode-based queries (`role_sequence_grants` removed in PG 15, `schema_privileges` never existed) | | Role privilege explorer | ✅ | Per-role privileges grouped by object class (tables/sequences/routines/schemas/databases) with collapsible sections — collapsed shows first 5 + fade, expand shows all, chevron hidden when ≤5; GRANT/REVOKE composer (object class + schema + name, WITH GRANT OPTION); aclexplode-based queries (`role_sequence_grants` removed in PG 15, `schema_privileges` never existed) |
| SQLite table editor (Create/Edit Table) | ✅ | Visual Create Table / Edit Table extended to SQLite: SQLite-aware type mapping (`INTEGER PRIMARY KEY AUTOINCREMENT` instead of `serial`, `TEXT`/`REAL`/`BLOB`) and ALTER TABLE limits respected (v0.7.8) |
### Object Explorer (non-table objects) ### Object Explorer (non-table objects)
| Feature | Status | Details | | Feature | Status | Details |
@@ -329,6 +331,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Query history / recent queries | ✅ | v5 `query_history` table + v6 `favorite` column; consecutive-identical dedup + retention pruning (500/connection); `get_query_history`/`clear_query_history`/`set_history_favorite` commands; **QueryHistoryDropdown** in the query toolbar (load / run / favorite / clear, lazy fetch, loading + error states) | | Query history / recent queries | ✅ | v5 `query_history` table + v6 `favorite` column; consecutive-identical dedup + retention pruning (500/connection); `get_query_history`/`clear_query_history`/`set_history_favorite` commands; **QueryHistoryDropdown** in the query toolbar (load / run / favorite / clear, lazy fetch, loading + error states) |
| SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) | | SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) |
| Multiple result sets | ❌ | | | Multiple result sets | ❌ | |
| Cancel long-running queries | ✅ | Per-connection cancel from the query toolbar (`pg_cancel_backend` / MySQL `KILL` / SQLite interruption) instead of waiting or killing the app (v0.7.8) |
| 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 |
@@ -344,7 +347,8 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` | | DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` |
| Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below | | Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below |
| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5) | | Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5) |
| SQLite .dump | | | | SQLite .dump (backup/restore) | | Backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX (v0.7.8) |
| Backup / Restore / Sync for MySQL & SQLite | ✅ | MySQL backup/restore via mysqldump (system-first); DB-to-DB sync extended beyond PostgreSQL (v0.7.8) |
| Table structure export (DDL) | ❌ | | | Table structure export (DDL) | ❌ | |
### Settings ### Settings
@@ -352,6 +356,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| :--- | :---: | :--- | | :--- | :---: | :--- |
| Settings screen (redesigned) | ✅ | DB-viewer-styled shell: icon+text sidebar (Back on top, accent background), header shows the active tab, border-sharp sections with gap-spaced rows (no cards), Back returns to the view it was opened from (push/pop in `uiStore`) | | Settings screen (redesigned) | ✅ | DB-viewer-styled shell: icon+text sidebar (Back on top, accent background), header shows the active tab, border-sharp sections with gap-spaced rows (no cards), Back returns to the view it was opened from (push/pop in `uiStore`) |
| Theme (dark/light/system) | ✅ | Applied live via a `.light` class on the document root (dark-first base palette); "system" follows the OS via `matchMedia` and live-updates; native window chrome synced through Tauri `setTheme`/`setBackgroundColor` with a macOS **Overlay** titlebar (in-flow drag strip) | | Theme (dark/light/system) | ✅ | Applied live via a `.light` class on the document root (dark-first base palette); "system" follows the OS via `matchMedia` and live-updates; native window chrome synced through Tauri `setTheme`/`setBackgroundColor` with a macOS **Overlay** titlebar (in-flow drag strip) |
| Windows/Linux title bar fix | ✅ | macOS-only overlay drag strip (`h-7` in `App.tsx`) gated to macOS; Windows/Linux use the native title bar for dragging (v0.7.8) |
| Font size | ✅ | rem scale via `data-font-size` on the root (`small`/`medium`/`large`) | | Font size | ✅ | rem scale via `data-font-size` on the root (`small`/`medium`/`large`) |
| Accent color | ✅ | 10-preset circle palette in General → Appearance; applied via `--color-accent` on the root; hover/muted shades derive from it via `color-mix` | | Accent color | ✅ | 10-preset circle palette in General → Appearance; applied via `--color-accent` on the root; hover/muted shades derive from it via `color-mix` |
| Default folder for new connections | ✅ | Honored on startup — Home opens into `default_folder_id` unless the user has already navigated | | Default folder for new connections | ✅ | Honored on startup — Home opens into `default_folder_id` unless the user has already navigated |
@@ -364,7 +369,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| More keyboard shortcuts | ❌ | Only 2 configurable actions | | More keyboard shortcuts | ❌ | Only 2 configurable actions |
| Editor settings | ✅ | Five options wired to the settings store + live Monaco `updateOptions` | | 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 | | Export/import settings (theme, accent, editor options, page sizes, defaults) as a JSON file (v0.7.8) |
### Demo & Onboarding ### Demo & Onboarding
| Feature | Status | Details | | Feature | Status | Details |
+32 -23
View File
@@ -9,7 +9,7 @@
<p> <p>
<i>A lightweight, open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis.</i><br /> <i>A lightweight, open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis.</i><br />
Unlimited connections, tabs, and saved queries — with first-class <code>pg_dump</code>, <code>pg_restore</code>, and DB-to-DB sync. Unlimited connections, tabs, and saved queries — with first-class backup, restore, and DB-to-DB sync for PostgreSQL, MySQL, and SQLite.
</p> </p>
<p> <p>
@@ -30,21 +30,21 @@
## Download ## Download
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.7**): Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.8**):
| OS | Architecture | Download | | OS | Architecture | Download |
| :--------------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | | :--------------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.7_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_aarch64.dmg) | | **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.8_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_aarch64.dmg) |
| **macOS** | Intel | [Gridline_0.7.7_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_x64.dmg) | | **macOS** | Intel | [Gridline_0.7.8_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_x64.dmg) |
| **Windows** | x64 | [Gridline_0.7.7_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_x64-setup.exe) | | **Windows** | x64 | [Gridline_0.7.8_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.7_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_amd64.deb) | | **Debian / Ubuntu** | amd64 | [Gridline_0.7.8_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_amd64.deb) |
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.7-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline-0.7.7-1.x86_64.rpm) | | **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.8-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline-0.7.8-1.x86_64.rpm) |
| **Other Linux** | amd64 | [Gridline_0.7.7_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_amd64.AppImage) | | **Other Linux** | amd64 | [Gridline_0.7.8_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_amd64.AppImage) |
> Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. All installers are **unsigned** — see the [notes](#which-file-should-i-download) on first-launch warnings. > Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. All installers are **unsigned** — see the [notes](#which-file-should-i-download) on first-launch warnings.
<!-- <!--
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.7 MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.8
release assets, not at a moving "latest" target. On every new release, release assets, not at a moving "latest" target. On every new release,
update BOTH tables here (Download + Which file should I download?) to the update BOTH tables here (Download + Which file should I download?) to the
new version's asset names, which are tauri-action's default naming: new version's asset names, which are tauri-action's default naming:
@@ -61,6 +61,10 @@ Gridline is a modern, open-source database GUI client built with [Tauri 2.0](htt
- **No caps** on connections, tabs, or saved queries. - **No caps** on connections, tabs, or saved queries.
- **Deep PostgreSQL tooling** — visual `pg_dump`, `pg_restore`, and DB-to-DB sync. - **Deep PostgreSQL tooling** — visual `pg_dump`, `pg_restore`, and DB-to-DB sync.
- **Backup, restore & sync for every database** — PostgreSQL, MySQL (`mysqldump` / bundled MariaDB clients), and SQLite (`.dump`), with direct DB-to-DB sync between live connections.
- **Excel export** — CSV, JSON, SQL, Markdown, and `.xlsx` from any data grid.
- **Cancel long-running queries** — a per-connection cancel button instead of waiting it out or killing the app.
- **SQLite table editor** — the visual Create Table / Edit Table flow now works on SQLite too (`INTEGER PRIMARY KEY AUTOINCREMENT` instead of `serial`).
- **Full MySQL + SQLite browsing** — connect, browse, query, and edit MySQL and SQLite the same way you do PostgreSQL. - **Full MySQL + SQLite browsing** — connect, browse, query, and edit MySQL and SQLite the same way you do PostgreSQL.
- **Full object explorer** — not just tables, but functions, triggers, sequences, enums, extensions, materialized views, and procedures. - **Full object explorer** — not just tables, but functions, triggers, sequences, enums, extensions, materialized views, and procedures.
- **Interactive ER diagram** — explore relationships visually with crow's-foot cardinality notation. - **Interactive ER diagram** — explore relationships visually with crow's-foot cardinality notation.
@@ -85,6 +89,8 @@ Gridline is built for developers and small teams who manage multiple database en
<details> <details>
<summary>View the full changelog</summary> <summary>View the full changelog</summary>
- **v0.7.8** — **SQLite `.dump` backup/restore** plus **backup / restore / sync for MySQL & SQLite** (mysqldump-based, system-first); **Excel (.xlsx) export** in the grid toolbar alongside CSV/JSON/SQL/Markdown; **cancel long-running queries** per connection; **settings export/import** (JSON); **Windows/Linux title bar fix** (macOS-only overlay drag strip); **SQLite table editor** (Create/Edit Table with SQLite-aware type mapping); version bump.
- **v0.7.7** — PostgreSQL **roles & grants** management (create/edit/drop roles with attributes + a per-role privilege explorer across tables/sequences/routines/schemas/databases — collapsible grouped lists with GRANT/REVOKE staging) and **table maintenance** (VACUUM / ANALYZE / REINDEX from the table menu). **Create Table / Edit Table** visual editor: columns grid with type dropdowns and drag-to-reorder, single + composite primary keys, column-diff staging (ADD / DROP / RENAME COLUMN, ALTER TYPE, SET | DROP DEFAULT, SET | DROP NOT NULL — one statement per queue item), atomic column-reorder table rebuild (single transaction, preserves constraints/indexes/FKs/grants/sequences, fail-closed for triggers/RLS/inheritance/partitioning). **FK management** — multi-column composer with cross-schema references and ON DELETE/UPDATE, inlined into CREATE TABLE; **table options** (tablespace, row-level security); changes queue auto-refreshes the object tree after schema-modifying commits; version bump. - **v0.7.7** — PostgreSQL **roles & grants** management (create/edit/drop roles with attributes + a per-role privilege explorer across tables/sequences/routines/schemas/databases — collapsible grouped lists with GRANT/REVOKE staging) and **table maintenance** (VACUUM / ANALYZE / REINDEX from the table menu). **Create Table / Edit Table** visual editor: columns grid with type dropdowns and drag-to-reorder, single + composite primary keys, column-diff staging (ADD / DROP / RENAME COLUMN, ALTER TYPE, SET | DROP DEFAULT, SET | DROP NOT NULL — one statement per queue item), atomic column-reorder table rebuild (single transaction, preserves constraints/indexes/FKs/grants/sequences, fail-closed for triggers/RLS/inheritance/partitioning). **FK management** — multi-column composer with cross-schema references and ON DELETE/UPDATE, inlined into CREATE TABLE; **table options** (tablespace, row-level security); changes queue auto-refreshes the object tree after schema-modifying commits; version bump.
- **v0.7.6** — Full PostgreSQL object management (create/edit/drop for enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints) staged through the changes queue with generated-SQL previews; Enable Keychain toggle wired (default ON, opt-out; OFF = session-only); Objects view upgraded to the shared tabbed workspace (object detail tabs with per-type icons, inline manual query + changes queue); ⌘K object search fixes; version bump. - **v0.7.6** — Full PostgreSQL object management (create/edit/drop for enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints) staged through the changes queue with generated-SQL previews; Enable Keychain toggle wired (default ON, opt-out; OFF = session-only); Objects view upgraded to the shared tabbed workspace (object detail tabs with per-type icons, inline manual query + changes queue); ⌘K object search fixes; version bump.
@@ -178,12 +184,15 @@ Gridline is built for developers and small teams who manage multiple database en
<details> <details>
<summary>Show features</summary> <summary>Show features</summary>
- **Visual Backup** — `pg_dump` wrapper with format selector, schema filter, no-owner toggle, real-time progress. - **Visual Backup** — `pg_dump` (format selector, schema filter, no-owner), `mysqldump`, and SQLite `.dump` wrappers with real-time progress.
- **Visual Restore** — `pg_restore` wrapper with clean toggle and destructive confirmation. - **Visual Restore** — `pg_restore` / `mysql` / SQLite restore with clean toggle and destructive confirmation.
- **DB-to-DB Sync** — pipe `pg_dump``pg_restore` between two connections. - **DB-to-DB Sync** — pipe dump → restore between two live connections (PostgreSQL, MySQL, and SQLite).
- **Bundled client tools** — `pg_dump`/`pg_restore`/`psql` ship with the app; system tools are preferred when present, bundled tools are the fallback. - **Bundled client tools** — `pg_dump`/`pg_restore`/`psql` plus `mariadb-dump`/`mariadb` ship with the app; system tools are preferred when present, bundled tools are the fallback.
- **Roles & grants** — create/edit/drop roles with attributes; a per-role privilege explorer grouped by object class (tables, sequences, routines, schemas, databases) with collapsible lists and GRANT/REVOKE staging. - **Roles & grants** — create/edit/drop roles with attributes; a per-role privilege explorer grouped by object class (tables, sequences, routines, schemas, databases) with collapsible lists and GRANT/REVOKE staging.
- **Table maintenance** — VACUUM, ANALYZE, and REINDEX from the table menu. - **Table maintenance** — VACUUM, ANALYZE, and REINDEX from the table menu.
- **Excel export** — hand-rolled `.xlsx` writer (inline strings, formula-injection safe) alongside CSV/JSON/SQL/Markdown in the grid toolbar and table menu.
- **Cancel long-running queries** — PG cancel request / MySQL `KILL QUERY` / SQLite interrupt, wired to the toolbar Cancel button.
- **Settings export / import** — share theme, accent, editor options, page sizes, and shortcuts across machines (JSON).
</details> </details>
@@ -294,16 +303,16 @@ Code signing **will be added in the future** (Apple Developer Program + a Window
#### Which file should I download? #### Which file should I download?
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.7**): Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.8**):
| Your system | Download this | Notes | | Your system | Download this | Notes |
| :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- | | :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.7_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_aarch64.dmg) | `aarch64` = Apple's own chip | | macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.8_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_aarch64.dmg) | `aarch64` = Apple's own chip |
| macOS **Intel** | [Gridline_0.7.7_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_x64.dmg) | `x64` = Intel/AMD | | macOS **Intel** | [Gridline_0.7.8_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_x64.dmg) | `x64` = Intel/AMD |
| **Windows** (most PCs) | [Gridline_0.7.7_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) | | **Windows** (most PCs) | [Gridline_0.7.8_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | [Gridline_0.7.7_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.7_amd64.deb` | | **Debian / Ubuntu** | [Gridline_0.7.8_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.8_amd64.deb` |
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.7-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline-0.7.7-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.7-1.x86_64.rpm` | | **Fedora / RHEL / openSUSE** | [Gridline-0.7.8-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline-0.7.8-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.8-1.x86_64.rpm` |
| **Any other Linux** | [Gridline_0.7.7_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it | | **Any other Linux** | [Gridline_0.7.8_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu****About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run. **Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu****About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
@@ -313,8 +322,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
```bash ```bash
git checkout prod && git pull git checkout prod && git pull
git tag v0.7.7 git tag v0.7.8
git push origin v0.7.7 git push origin v0.7.8
``` ```
GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**. GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**.
@@ -398,7 +407,7 @@ gridline/
## Roadmap ## Roadmap
The full plan — next-up (v0.7.7), queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**. The full plan — next-up (v0.7.9), queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**.
Highlights of what's next: Highlights of what's next:
+6 -6
View File
@@ -6,9 +6,11 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
--- ---
## 🎯 Next up (0.7.8) — Quick wins ## 🎯 Next up (0.7.9) — TBD
Scope: small, individually shippable items that round out tooling gaps. Picked to follow the v0.7.7 features milestone; tag **v0.7.8** when done. Scope to be determined.
## ✅ Shipped (0.7.8)
- **SQLite `.dump` support** — match the pg_dump UX for SQLite (backup a SQLite database to a portable SQL dump, restore it back) - **SQLite `.dump` support** — match the pg_dump UX for SQLite (backup a SQLite database to a portable SQL dump, restore it back)
- **Backup / Restore / Sync for MySQL & SQLite** — extend the pg-only tooling today: SQLite backup/restore + MySQL via `mysqldump` (system-first; decide bundling) - **Backup / Restore / Sync for MySQL & SQLite** — extend the pg-only tooling today: SQLite backup/restore + MySQL via `mysqldump` (system-first; decide bundling)
@@ -17,6 +19,7 @@ Scope: small, individually shippable items that round out tooling gaps. Picked t
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file) - **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
- **Windows/Linux title bar fix** — the macOS "Overlay" drag strip (`h-7` in `App.tsx`) renders on every Tauri platform, so Windows/Linux show a blank grabbable bar between the native title bar and the page; gate the strip to macOS only (native title bar already handles dragging elsewhere) - **Windows/Linux title bar fix** — the macOS "Overlay" drag strip (`h-7` in `App.tsx`) renders on every Tauri platform, so Windows/Linux show a blank grabbable bar between the native title bar and the page; gate the strip to macOS only (native title bar already handles dragging elsewhere)
- **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow is PostgreSQL-only today (`tableManagement` capability + PG-flavored SQL gen in `TableForm`); extend to SQLite: SQLite-aware type mapping (no `serial``INTEGER PRIMARY KEY AUTOINCREMENT` instead), `TEXT`/`REAL`/`BLOB`, and ALTER TABLE limits (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35) - **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow is PostgreSQL-only today (`tableManagement` capability + PG-flavored SQL gen in `TableForm`); extend to SQLite: SQLite-aware type mapping (no `serial``INTEGER PRIMARY KEY AUTOINCREMENT` instead), `TEXT`/`REAL`/`BLOB`, and ALTER TABLE limits (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35)
- **Version bump** 0.7.7 → **0.7.8**.
## 📋 In the queue ## 📋 In the queue
@@ -46,17 +49,14 @@ Supabase and NeonDB presets shipped in v0.7.0. Remaining candidates:
### Query Workbench Upgrades ### Query Workbench Upgrades
- **Multiple result sets** — one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)* - **Multiple result sets** — one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)*
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
- **Result streaming to file** — export 500k+ rows without loading them all into memory - **Result streaming to file** — export 500k+ rows without loading them all into memory
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one) - **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
### Schema & Data Tooling ### Schema & Data Tooling
- **SQLite `.dump` support** — match the pg_dump UX for SQLite
- **Schema diff / compare** — two-database structure diff that pairs naturally with DB-to-DB sync - **Schema diff / compare** — two-database structure diff that pairs naturally with DB-to-DB sync
- **MySQL Objects view + schema visualizer** — functions/triggers/sequences/enums/extensions browsing and ER diagram for MySQL *(deferred from 0.7.0 and out of scope for the object-management release — PostgreSQL-only for now)* - **MySQL Objects view + schema visualizer** — functions/triggers/sequences/enums/extensions browsing and ER diagram for MySQL *(deferred from 0.7.0 and out of scope for the object-management release — PostgreSQL-only for now)*
- **Backup/Restore/Sync for MySQL & SQLite** — pg_dump tooling is PostgreSQL-only today *(deferred from 0.7.0)* - **More export formats** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel
- **More export formats** — Excel (.xlsx), JSONL, Parquet alongside CSV/JSON/SQL/Markdown
## 🔮 Planned ## 🔮 Planned
+3
View File
@@ -19,6 +19,7 @@
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@xyflow/react": "^12.11.2", "@xyflow/react": "^12.11.2",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"fflate": "^0.8.3",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"lucide-react": "^1.26.0", "lucide-react": "^1.26.0",
"monaco-editor": "^0.56.0", "monaco-editor": "^0.56.0",
@@ -461,6 +462,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline", "name": "gridline",
"private": true, "private": true,
"version": "0.7.7", "version": "0.7.8",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond", "description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -28,6 +28,7 @@
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@xyflow/react": "^12.11.2", "@xyflow/react": "^12.11.2",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"fflate": "^0.8.3",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"lucide-react": "^1.26.0", "lucide-react": "^1.26.0",
"monaco-editor": "^0.56.0", "monaco-editor": "^0.56.0",
+1 -1
View File
@@ -1783,7 +1783,7 @@ dependencies = [
[[package]] [[package]]
name = "gridline" name = "gridline"
version = "0.7.7" version = "0.7.8"
dependencies = [ dependencies = [
"chrono", "chrono",
"deadpool-postgres", "deadpool-postgres",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.7.7" version = "0.7.8"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond" description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+13
View File
@@ -0,0 +1,13 @@
# Bundled MariaDB client tools
Static `mariadb-dump` and `mariadb` client binaries (wire-compatible with MySQL
servers) go here. They are NOT committed — `.github/workflows/release.yml`
builds and checksum-verifies them per matrix target, copying the result into
`resources/mysql_tools/{mariadb-dump,mariadb}` (plus `mariadb-dump.exe` /
`mariadb.exe` and the runtime `libmariadb.dll` on Windows). The app resolves
tools system-first and falls back to these bundled binaries (see
`backup::resolve_mysql_tool`).
In `tauri dev`, this dir is usually empty — the app falls back to any
`mariadb-dump`/`mariadb` on `PATH` (system-first resolution). MySQL
backup/restore degrades with a clear error if neither is present.
+164
View File
@@ -0,0 +1,164 @@
//! Per-connection cancellation handles, stored independently of the pool
//! lock so `cancel_query` can dispatch while a long query holds the pool mutex.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use rusqlite::InterruptHandle;
use sqlx::mysql::MySqlConnectOptions;
use tokio_postgres::CancelToken;
use crate::db::tls::TlsDecision;
/// PostgreSQL cancel data. `cancel_token` stores the socket address (incl.
/// SSH tunnel endpoint) so `cancel_query` needs no host. `tls_config` is the
/// rustls config used to **build** the connection (or `None` for NoTls) so the
/// cancel connection reuses the exact same TLS decision.
#[derive(Clone)]
pub struct PgCancel {
pub cancel_token: CancelToken,
pub tls_decision: TlsDecision,
pub tls_config: Option<std::sync::Arc<rustls::ClientConfig>>,
}
/// MySQL cancel data. `conn_id` is the `CONNECTION_ID()` of the dedicated
/// connection currently running a query (one active per connection because the
/// pool lock serializes queries). `connect_options` lets `cancel` open a
/// brand-new connection (bypassing the pool) to run `KILL QUERY ?`.
#[derive(Clone)]
pub struct MySqlCancel {
pub conn_id: Option<i64>,
pub connect_options: MySqlConnectOptions,
}
/// SQLite cancel data — a cloneable, thread-safe interrupt handle.
/// (`InterruptHandle` itself is not `Clone` in rusqlite 0.31, so it's kept
/// behind an `Arc`.)
#[derive(Clone)]
pub struct SqliteCancel {
handle: Arc<InterruptHandle>,
}
impl SqliteCancel {
pub fn new(handle: InterruptHandle) -> Self {
Self { handle: Arc::new(handle) }
}
pub fn interrupt(&self) {
self.handle.interrupt();
}
}
#[derive(Clone)]
pub enum CancelHandle {
Pg(PgCancel),
MySql(MySqlCancel),
Sqlite(SqliteCancel),
}
/// Send + Sync registry keyed by connection id. `cancel_query` takes only
/// this `Mutex` (NOT the pool lock).
#[derive(Default)]
pub struct CancelRegistry {
map: Mutex<HashMap<String, CancelHandle>>,
}
impl CancelRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn set_pg(&self, id: &str, c: PgCancel) {
self.set(id, CancelHandle::Pg(c));
}
pub fn set_mysql(&self, id: &str, c: MySqlCancel) {
self.set(id, CancelHandle::MySql(c));
}
pub fn set_sqlite(&self, id: &str, c: SqliteCancel) {
self.set(id, CancelHandle::Sqlite(c));
}
pub fn set_mysql_conn_id(&self, id: &str, conn_id: Option<i64>) {
let mut g = self.map.lock().unwrap();
if let Some(CancelHandle::MySql(m)) = g.get_mut(id) {
m.conn_id = conn_id;
}
}
fn set(&self, id: &str, h: CancelHandle) {
self.map.lock().unwrap().insert(id.to_string(), h);
}
pub fn get(&self, id: &str) -> Option<CancelHandle> {
self.map.lock().unwrap().get(id).cloned()
}
pub fn remove(&self, id: &str) {
self.map.lock().unwrap().remove(id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sqlite_insert_and_drain() {
let reg = CancelRegistry::new();
let conn = rusqlite::Connection::open_in_memory().unwrap();
let handle = conn.get_interrupt_handle();
reg.set_sqlite("c1", SqliteCancel::new(handle));
assert!(matches!(reg.get("c1"), Some(CancelHandle::Sqlite(_))));
reg.remove("c1");
assert!(reg.get("c1").is_none());
}
#[test]
fn sqlite_interrupt_aborts_running_query() {
use std::sync::{Arc, Mutex};
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE t(n); INSERT INTO t VALUES (0);")
.unwrap();
let handle = conn.get_interrupt_handle();
let conn2 = Arc::new(Mutex::new(conn));
let c = conn2.clone();
let done = Arc::new(Mutex::new(None::<Result<usize, String>>));
let d = done.clone();
let worker = std::thread::spawn(move || {
let l = c.lock().unwrap();
// `query()` binds params only — the first sqlite3_step (where the
// interrupt lands) happens in `rs.next()`, so errors must be
// propagated with `?` rather than swallowed by `is_ok()`.
let r = l
.prepare("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 200000000) SELECT count(*) FROM c")
.unwrap()
.query([])
.and_then(|mut rs| {
let mut n = 0;
while rs.next()?.is_some() {
n += 1;
}
Ok(n)
});
*d.lock().unwrap() = Some(r.map_err(|e| e.to_string()));
});
std::thread::sleep(std::time::Duration::from_millis(50));
handle.interrupt();
worker.join().unwrap();
let outcome = done.lock().unwrap().clone();
assert!(
matches!(&outcome, Some(Err(e)) if e.to_lowercase().contains("interrupted")),
"cancelled query must report interrupted; got {outcome:?}"
);
}
#[test]
fn mysql_overwrites_single_active_slot() {
let reg = CancelRegistry::new();
reg.set_mysql("c1", MySqlCancel { conn_id: Some(1), connect_options: fake_opts() });
reg.set_mysql("c1", MySqlCancel { conn_id: Some(2), connect_options: fake_opts() });
match reg.get("c1") {
Some(CancelHandle::MySql(m)) => assert_eq!(m.conn_id, Some(2)),
_ => panic!("expected MySql"),
}
}
fn fake_opts() -> sqlx::mysql::MySqlConnectOptions {
sqlx::mysql::MySqlConnectOptions::new()
.host("127.0.0.1").port(1).username("u").password("p").database("d")
}
}
+621
View File
@@ -1,3 +1,5 @@
use rusqlite::{types::ValueRef, Connection};
use std::io::Write;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use tauri::{AppHandle, Emitter, Manager, State}; use tauri::{AppHandle, Emitter, Manager, State};
@@ -317,6 +319,285 @@ pub fn run_db_sync(
} }
} }
// ---------------------------------------------------------------------------
// SQLite dump / restore / sync (headless-testable core)
// ---------------------------------------------------------------------------
/// Quote a SQLite identifier with double quotes (preserving the spec's no-injection rule).
fn sqlite_quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
/// SQL literal for a rusqlite value (mirrors sqlite3 .dump output).
fn sqlite_literal(v: ValueRef) -> String {
match v {
ValueRef::Null => "NULL".to_string(),
ValueRef::Integer(i) => i.to_string(),
ValueRef::Real(r) => r.to_string(),
ValueRef::Text(t) => format!("'{}'", String::from_utf8_lossy(t).replace('\'', "''")),
ValueRef::Blob(b) => {
format!("X'{}'", b.iter().map(|x| format!("{:02x}", x)).collect::<String>())
}
}
}
/// Generate a `.dump`-format SQL script from `conn`, streaming to `out`. Calls
/// `on_progress(table_name)` per table. Fails closed on virtual tables.
pub fn dump_sqlite_to<W: Write, F: FnMut(&str)>(
conn: &Connection,
out: &mut W,
mut on_progress: F,
) -> Result<(), String> {
writeln!(out, "PRAGMA foreign_keys=OFF;").map_err(|e| e.to_string())?;
writeln!(out, "BEGIN TRANSACTION;").map_err(|e| e.to_string())?;
// 1. Tables (schema + data), fail-closed on virtual tables.
let table_names: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY name")
.map_err(|e| e.to_string())?
.query_map([], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for name in &table_names {
let create_sql: String = conn
.query_row("SELECT sql FROM sqlite_master WHERE type='table' AND name=?1", [name], |r| {
r.get::<_, String>(0)
})
.map_err(|e| e.to_string())?;
if create_sql.to_lowercase().contains("create virtual table") {
return Err(format!(
"SQLite virtual tables are not supported for .dump in v0.7.8 (table: {name})"
));
}
writeln!(out, "{create_sql};").map_err(|e| e.to_string())?;
on_progress(name);
// Columns excluding generated/hidden (hidden != 0) via pragma_table_xinfo.
let xinfo: Vec<(String, i64)> = conn
.prepare(&format!(
"SELECT name, hidden FROM pragma_table_xinfo(\"{}\")",
name.replace('"', "\"\"")
))
.map_err(|e| e.to_string())?
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
let emitted: Vec<String> = xinfo.iter().filter(|(_, h)| *h == 0).map(|(n, _)| n.clone()).collect();
let col_list = emitted.iter().map(|n| sqlite_quote_ident(n)).collect::<Vec<_>>().join(", ");
// Emit one INSERT per row (faithful to .dump).
let select = format!(
"SELECT {} FROM \"{}\"",
emitted.iter().map(|n| sqlite_quote_ident(n)).collect::<Vec<_>>().join(", "),
name.replace('"', "\"\"")
);
let mut stmt = conn.prepare(&select).map_err(|e| e.to_string())?;
let nrows = stmt
.query_map([], |row| {
let vals: Vec<String> = (0..emitted.len())
.map(|i| sqlite_literal(row.get_ref(i).unwrap_or(ValueRef::Null)))
.collect();
Ok(format!(
"INSERT INTO {} ({}) VALUES ({});",
sqlite_quote_ident(name),
col_list,
vals.join(", ")
))
})
.map_err(|e| e.to_string())?;
for r in nrows.filter_map(|r| r.ok()) {
writeln!(out, "{r}").map_err(|e| e.to_string())?;
}
}
// 2. Indexes, triggers, views (sql not null).
for ty in ["index", "trigger", "view"] {
let sqls: Vec<String> = conn
.prepare("SELECT sql FROM sqlite_master WHERE type=?1 AND sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY name")
.map_err(|e| e.to_string())?
.query_map([ty], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for s in sqls {
writeln!(out, "{s};").map_err(|e| e.to_string())?;
}
}
// 3. sqlite_sequence (AUTOINCREMENT counters) if it exists.
if conn
.prepare("SELECT name FROM sqlite_master WHERE name='sqlite_sequence'")
.map_err(|e| e.to_string())?
.exists([])
.unwrap_or(false)
{
writeln!(out, "DELETE FROM sqlite_sequence;").map_err(|e| e.to_string())?;
let rows: Vec<(String, i64)> = conn
.prepare("SELECT name, seq FROM sqlite_sequence")
.map_err(|e| e.to_string())?
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for (t, seq) in rows {
writeln!(
out,
"INSERT INTO sqlite_sequence VALUES ('{}', {});",
t.replace('\'', "''"),
seq
)
.map_err(|e| e.to_string())?;
}
}
writeln!(out, "COMMIT;").map_err(|e| e.to_string())?;
Ok(())
}
/// Restore a `.dump` SQL script into `conn`. `clean` drops existing
/// user tables/views/indexes/triggers first.
pub fn restore_sqlite(conn: &Connection, dump_text: &str, clean: bool) -> Result<(), String> {
if clean {
let names: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view','index','trigger') AND name NOT LIKE 'sqlite_%' ORDER BY type DESC")
.map_err(|e| e.to_string())?
.query_map([], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
conn.execute_batch("PRAGMA foreign_keys=OFF;").map_err(|e| e.to_string())?;
for n in names {
let _ = conn.execute(&format!("DROP TABLE IF EXISTS \"{}\"", n.replace('"', "\"\"")), []);
let _ = conn.execute(&format!("DROP VIEW IF EXISTS \"{}\"", n.replace('"', "\"\"")), []);
let _ = conn.execute(&format!("DROP INDEX IF EXISTS \"{}\"", n.replace('"', "\"\"")), []);
let _ = conn.execute(&format!("DROP TRIGGER IF EXISTS \"{}\"", n.replace('"', "\"\"")), []);
}
}
// The dump text already wraps in PRAGMA foreign_keys=OFF + BEGIN/COMMIT.
conn.execute_batch(dump_text).map_err(|e| format!("restore failed: {e}"))
}
/// Dump `source_path` and restore into `target_path` (one-shot).
pub fn run_sqlite_sync(source_path: &str, target_path: &str) -> Result<(), String> {
let src = Connection::open(source_path).map_err(|e| format!("open source: {e}"))?;
let mut buf: Vec<u8> = Vec::new();
dump_sqlite_to(&src, &mut std::io::Cursor::new(&mut buf), |_| {})?;
let text = String::from_utf8(buf).map_err(|e| e.to_string())?;
let dst = Connection::open(target_path).map_err(|e| format!("open target: {e}"))?;
restore_sqlite(&dst, &text, true)
}
// ---------------------------------------------------------------------------
// MySQL dump / restore / sync (headless-testable core)
// ---------------------------------------------------------------------------
fn base_mysql_args(conn: &MySqlConnParams) -> Vec<String> {
vec![
format!("--host={}", conn.host),
format!("--port={}", conn.port),
format!("--user={}", conn.username),
]
}
/// `mariadb-dump`/`mysqldump` args. Passwords go via MYSQL_PWD env (set by the
/// command), NEVER --password (process-list visibility).
pub fn build_mysql_dump_args(conn: &MySqlConnParams, options: &MySqlBackupOptions) -> Vec<String> {
let mut a = base_mysql_args(conn);
if options.single_transaction { a.push("--single-transaction".into()); }
if options.no_data { a.push("--no-data".into()); }
if options.routines { a.push("--routines".into()); }
if options.triggers { a.push("--triggers".into()); }
if options.events { a.push("--events".into()); }
a.push(format!("--databases={}", options.database));
a.push(format!("--result-file={}", options.file_path));
a.push("--skip-column-statistics".into());
a
}
pub fn build_mysql_restore_args(conn: &MySqlConnParams, options: &MySqlRestoreOptions) -> Vec<String> {
let mut a = base_mysql_args(conn);
a.push(format!("--database={}", options.database));
a
}
/// System-first mariadb-dump/mariadb (bundled fallback in resources/mysql_tools).
pub fn resolve_mysql_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
let system_ok = Command::new(tool).arg("--version").output().is_ok();
let bundled = app.path().resource_dir().ok()
.map(|rd| rd.join("mysql_tools").join(bundled_bin_name(tool)))
.filter(|p| p.exists())
.map(|p| p.to_string_lossy().to_string());
pick_tool(system_ok, bundled.as_deref(), tool)
}
pub fn resolve_mysql_tool_paths(app: &AppHandle) -> MySqlToolPaths {
let (d, _) = resolve_mysql_tool(app, "mariadb-dump");
let (m, _) = resolve_mysql_tool(app, "mariadb");
MySqlToolPaths { mysqldump: d, mysql: m }
}
/// Headless core: spawn dump with MYSQL_PWD env. `tls_mode` (e.g. `REQUIRED`)
/// is appended as `--ssl-mode=` when routing through an SSH tunnel. (Live
/// behavior = #[ignore] integration test.)
pub fn run_mysql_dump(
conn: &MySqlConnParams,
options: &MySqlBackupOptions,
tools: &MySqlToolPaths,
tls_mode: Option<&str>,
) -> Result<(), String> {
let mut args = build_mysql_dump_args(conn, options);
if let Some(m) = tls_mode {
args.push(format!("--ssl-mode={m}"));
}
let out = Command::new(&tools.mysqldump).env("MYSQL_PWD", &conn.password).args(&args).output()
.map_err(|e| e.to_string())?;
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
}
pub fn run_mysql_restore(
conn: &MySqlConnParams,
options: &MySqlRestoreOptions,
tools: &MySqlToolPaths,
tls_mode: Option<&str>,
) -> Result<(), String> {
let mut args = build_mysql_restore_args(conn, options);
if let Some(m) = tls_mode {
args.push(format!("--ssl-mode={m}"));
}
let file = std::fs::File::open(&options.file_path).map_err(|e| format!("open dump: {e}"))?;
let out = Command::new(&tools.mysql).env("MYSQL_PWD", &conn.password).args(&args).stdin(Stdio::from(file)).output()
.map_err(|e| e.to_string())?;
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
}
pub fn run_mysql_sync(
source: &MySqlConnParams,
target: &MySqlConnParams,
tools: &MySqlToolPaths,
tls_mode: Option<&str>,
) -> Result<(), String> {
let mut dump_args = base_mysql_args(source);
dump_args.push("--single-transaction".into());
dump_args.push("--add-drop-table".into());
dump_args.push(format!("--databases={}", source.database));
if let Some(m) = tls_mode {
dump_args.push(format!("--ssl-mode={m}"));
}
let mut dump = Command::new(&tools.mysqldump).env("MYSQL_PWD", &source.password).args(&dump_args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("dump: {e}"))?;
let stdout = dump.stdout.take().unwrap();
let mut restore_args = base_mysql_args(target);
restore_args.push(format!("--database={}", target.database));
let restore = Command::new(&tools.mysql).env("MYSQL_PWD", &target.password).args(&restore_args).stdin(stdout).output();
let _ = dump.wait();
match restore {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => Err(format!("restore: {}", sanitize_error(&String::from_utf8_lossy(&o.stderr)))),
Err(e) => Err(format!("restore: {e}")),
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tauri commands (thin wrappers: store lookup + keychain + event emission) // Tauri commands (thin wrappers: store lookup + keychain + event emission)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -520,6 +801,346 @@ pub async fn db_sync(
Ok(job_id) Ok(job_id)
} }
// ---------------------------------------------------------------------------
// MySQL dump / restore / sync commands (v0.7.8)
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn detect_mysql_tools(app_handle: AppHandle) -> MySqlToolStatus {
let (d, ds) = resolve_mysql_tool(&app_handle, "mariadb-dump");
let (m, ms) = resolve_mysql_tool(&app_handle, "mariadb");
MySqlToolStatus {
mysqldump_found: Command::new(&d).arg("--version").output().is_ok(),
mysql_found: Command::new(&m).arg("--version").output().is_ok(),
mysqldump_version: get_version(&d),
mysql_version: get_version(&m),
mysqldump_source: ds,
mysql_source: ms,
}
}
/// Resolve (host, port, via_tunnel) for a MySQL connection, routing through the
/// SSH tunnel endpoint when present.
fn mysql_endpoint(
state: &crate::AppState,
connection_id: &str,
conn: &crate::models::Connection,
) -> (String, i64, bool) {
let via_tunnel = state.ssh_manager.lock().unwrap().get_local_port(connection_id);
if let Some(port) = via_tunnel {
return ("127.0.0.1".into(), port as i64, true);
}
(conn.host.clone(), conn.port.unwrap_or(3306), false)
}
#[tauri::command]
pub async fn mysql_dump(
connection_id: String,
options: MySqlBackupOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
let conn = {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
.find(|c| c.id == connection_id)
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
let password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
.unwrap_or_default()
.unwrap_or_default();
let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn);
let params = MySqlConnParams::new(
host,
port,
conn.username.clone().unwrap_or_else(|| "root".into()),
options.database.clone(),
password,
);
let tools = resolve_mysql_tool_paths(&app_handle);
let tls = if via_tunnel { Some("REQUIRED") } else { None };
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let result = run_mysql_dump(&params, &options, &tools, tls);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
#[tauri::command]
pub async fn mysql_restore(
connection_id: String,
options: MySqlRestoreOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
let conn = {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
.find(|c| c.id == connection_id)
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
let password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
.unwrap_or_default()
.unwrap_or_default();
let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn);
let params = MySqlConnParams::new(
host,
port,
conn.username.clone().unwrap_or_else(|| "root".into()),
options.database.clone(),
password,
);
let tools = resolve_mysql_tool_paths(&app_handle);
let tls = if via_tunnel { Some("REQUIRED") } else { None };
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let result = run_mysql_restore(&params, &options, &tools, tls);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
#[tauri::command]
pub async fn mysql_sync(
options: SyncOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
// Get both connections from store
let (source_conn, target_conn) = {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
let src = connections
.iter()
.find(|c| c.id == options.source_connection_id)
.ok_or_else(|| {
format!(
"Source connection not found: {}",
options.source_connection_id
)
})?
.clone();
let tgt = connections
.iter()
.find(|c| c.id == options.target_connection_id)
.ok_or_else(|| {
format!(
"Target connection not found: {}",
options.target_connection_id
)
})?
.clone();
(src, tgt)
};
let src_password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &source_conn.id)
.unwrap_or_default()
.unwrap_or_default();
let tgt_password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &target_conn.id)
.unwrap_or_default()
.unwrap_or_default();
let (src_host, src_port, src_via_tunnel) = mysql_endpoint(&state, &source_conn.id, &source_conn);
let (tgt_host, tgt_port, tgt_via_tunnel) = mysql_endpoint(&state, &target_conn.id, &target_conn);
let source = MySqlConnParams::new(
src_host,
src_port,
source_conn
.username
.clone()
.unwrap_or_else(|| "root".into()),
source_conn
.database
.clone()
.unwrap_or_else(|| "mysql".into()),
src_password,
);
let target = MySqlConnParams::new(
tgt_host,
tgt_port,
target_conn
.username
.clone()
.unwrap_or_else(|| "root".into()),
target_conn
.database
.clone()
.unwrap_or_else(|| "mysql".into()),
tgt_password,
);
let tools = resolve_mysql_tool_paths(&app_handle);
// Through a tunnel the peer is loopback, so force encrypt-only `REQUIRED`
// (mirrors how the app degrades verify-ca/verify-full through tunnels).
let tls = if src_via_tunnel || tgt_via_tunnel {
Some("REQUIRED")
} else {
None
};
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let result = run_mysql_sync(&source, &target, &tools, tls);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
// ---------------------------------------------------------------------------
// SQLite dump / restore / sync commands (v0.7.8) — the stored `conn.host` IS
// the SQLite file path; each command opens its own connection in the blocking
// task (the pool's SQLite handle is never touched).
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn sqlite_dump(
connection_id: String,
options: SqliteBackupOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
let conn = {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
.find(|c| c.id == connection_id)
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
let path = conn.host.clone();
let out_path = options.file_path.clone();
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let result = (|| -> Result<(), String> {
let src = Connection::open(&path).map_err(|e| format!("open source: {e}"))?;
let mut out =
std::fs::File::create(&out_path).map_err(|e| format!("create dump file: {e}"))?;
dump_sqlite_to(&src, &mut out, |_| {})
})();
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
#[tauri::command]
pub async fn sqlite_restore(
connection_id: String,
options: SqliteRestoreOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
let conn = {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
.find(|c| c.id == connection_id)
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
let path = conn.host.clone();
let in_path = options.file_path.clone();
let clean = options.clean;
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let result = (|| -> Result<(), String> {
let dst = Connection::open(&path).map_err(|e| format!("open target: {e}"))?;
let text =
std::fs::read_to_string(&in_path).map_err(|e| format!("read dump: {e}"))?;
restore_sqlite(&dst, &text, clean)
})();
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
#[tauri::command]
pub async fn sqlite_sync(
options: SyncOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
let (source_path, target_path) = {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
let src = connections
.iter()
.find(|c| c.id == options.source_connection_id)
.ok_or_else(|| {
format!(
"Source connection not found: {}",
options.source_connection_id
)
})?
.clone();
let tgt = connections
.iter()
.find(|c| c.id == options.target_connection_id)
.ok_or_else(|| {
format!(
"Target connection not found: {}",
options.target_connection_id
)
})?
.clone();
(src.host, tgt.host)
};
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let result = run_sqlite_sync(&source_path, &target_path);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Test helpers // Test helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+114
View File
@@ -418,3 +418,117 @@ fn bundled_bin_name_appends_exe_on_windows() {
let name = bundled_bin_name("pg_dump"); let name = bundled_bin_name("pg_dump");
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); } if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
} }
// ------------------------------------------------------------------
// SQLite .dump / restore / sync core (Task 2.2)
// ------------------------------------------------------------------
use rusqlite::Connection;
use std::io::Cursor;
fn seed_sqlite() -> Connection {
let c = Connection::open_in_memory().unwrap();
c.execute_batch(
"CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
CREATE INDEX users_name ON users(name);
INSERT INTO users (name) VALUES ('Alice'),('Bob');
CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB);
INSERT INTO blobs VALUES (1, x'010203');",
)
.unwrap();
c
}
#[test]
fn sqlite_dump_and_restore_roundtrip() {
let src = seed_sqlite();
let mut buf: Vec<u8> = Vec::new();
let mut cur = std::io::Cursor::new(&mut buf);
dump_sqlite_to(&src, &mut cur, |_| {}).unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains("PRAGMA foreign_keys=OFF"));
assert!(text.contains("BEGIN TRANSACTION"));
assert!(text.contains("CREATE TABLE users"));
assert!(text.contains("INSERT INTO \"users\""));
assert!(text.contains("X'010203'"), "BLOB must be hex-literal");
assert!(text.contains("CREATE INDEX users_name"));
let dst = Connection::open_in_memory().unwrap();
restore_sqlite(&dst, &text, false).unwrap();
let n: i64 = dst.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0)).unwrap();
assert_eq!(n, 2);
let blobs: i64 = dst.query_row("SELECT COUNT(*) FROM blobs", [], |r| r.get(0)).unwrap();
assert_eq!(blobs, 1);
}
#[test]
fn sqlite_dump_preserves_autoincrement_sequence() {
let src = Connection::open_in_memory().unwrap();
src.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT); INSERT INTO t(v) VALUES ('a'),('b');").unwrap();
let mut buf = Vec::new();
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
let text = String::from_utf8(buf).unwrap();
let dst = Connection::open_in_memory().unwrap();
restore_sqlite(&dst, &text, false).unwrap();
dst.execute("INSERT INTO t(v) VALUES ('c')", []).unwrap();
let id: i64 = dst.query_row("SELECT id FROM t WHERE v='c'", [], |r| r.get(0)).unwrap();
assert_eq!(id, 3);
}
#[test]
fn sqlite_dump_fail_closed_for_virtual_tables() {
let src = Connection::open_in_memory().unwrap();
src.execute_batch("CREATE VIRTUAL TABLE ft USING fts4(content)").unwrap();
let mut buf = Vec::new();
let err = dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap_err();
assert!(err.to_lowercase().contains("virtual table"), "got: {err}");
}
#[test]
fn sqlite_restore_clean_drops_existing() {
let src = seed_sqlite();
let mut buf = Vec::new();
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
let text = String::from_utf8(buf).unwrap();
let dst = Connection::open_in_memory().unwrap();
dst.execute_batch("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); INSERT INTO users VALUES (99,'old');").unwrap();
restore_sqlite(&dst, &text, true).unwrap();
let names: Vec<String> = dst.prepare("SELECT name FROM users ORDER BY id").unwrap().query_map([], |r| r.get::<_, String>(0)).unwrap().filter_map(|r| r.ok()).collect();
assert_eq!(names, vec!["Alice".to_string(), "Bob".to_string()]);
}
// ------------------------------------------------------------------
// MySQL dump / restore / sync arg building (Task 2.3)
// ------------------------------------------------------------------
#[test]
fn mysql_dump_args_single_transaction_no_data_routines() {
let opts = MySqlBackupOptions {
database: "shop".into(), file_path: "/tmp/d.sql".into(),
single_transaction: true, no_data: true, routines: true, triggers: false, events: false,
};
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
assert!(args.iter().any(|a| a == "--single-transaction"));
assert!(args.iter().any(|a| a == "--no-data"));
assert!(args.iter().any(|a| a == "--routines"));
assert!(args.iter().any(|a| a == "--databases=shop"));
assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql"));
// no --password on the command line (uses MYSQL_PWD env)
assert!(args.iter().all(|a| !a.starts_with("--password")));
}
#[test]
fn mysql_restore_args_no_clean_flags() {
let opts = MySqlRestoreOptions { database: "shop".into(), file_path: "/tmp/d.sql".into(), clean: false };
let args = build_mysql_restore_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
assert!(args.iter().any(|a| a == "--database=shop"));
assert!(args.iter().any(|a| a == "--host=h"));
assert!(args.iter().all(|a| a != "--force"));
}
#[test]
fn mysql_env_uses_mysql_pwd_not_password_arg() {
let opts = MySqlBackupOptions { database: "db".into(), file_path: "/tmp/x.sql".into(), single_transaction: false, no_data: false, routines: false, triggers: false, events: false };
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()), &opts);
assert!(args.iter().all(|a| !a.starts_with("--password")));
}
+48 -7
View File
@@ -891,6 +891,7 @@ pub(crate) async fn run_mysql_connect(
config: &crate::db::pool::DbConfig, config: &crate::db::pool::DbConfig,
ssh_manager: &std::sync::Mutex<crate::commands::ssh::SshTunnelManager>, ssh_manager: &std::sync::Mutex<crate::commands::ssh::SshTunnelManager>,
pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>, pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>,
cancel_registry: &crate::cancel::CancelRegistry,
) -> Result<(), String> { ) -> Result<(), String> {
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode}; use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
if config.host.trim().is_empty() { if config.host.trim().is_empty() {
@@ -968,6 +969,10 @@ pub(crate) async fn run_mysql_connect(
} }
} }
// Clone the (already final) options before `connect_with` consumes them so
// `cancel_query` can open its own connection to run `KILL QUERY ?`.
let cancel_opts = opts.clone();
match MySqlPoolOptions::new() match MySqlPoolOptions::new()
.max_connections(5) .max_connections(5)
.acquire_timeout(std::time::Duration::from_secs(10)) .acquire_timeout(std::time::Duration::from_secs(10))
@@ -979,6 +984,13 @@ pub(crate) async fn run_mysql_connect(
.lock() .lock()
.await .await
.register(connection_id, crate::db::pool::DbHandle::MySql(pool)); .register(connection_id, crate::db::pool::DbHandle::MySql(pool));
cancel_registry.set_mysql(
connection_id,
crate::cancel::MySqlCancel {
conn_id: None,
connect_options: cancel_opts,
},
);
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
@@ -1023,6 +1035,8 @@ pub async fn db_connect(
config.ssl_key_path.as_deref(), config.ssl_key_path.as_deref(),
) )
.map_err(|e| sanitize_error(&e))?; .map_err(|e| sanitize_error(&e))?;
// Snapshot before `match tls` consumes the Option below.
let cancel_cfg = tls.clone();
// SSH tunnel: if configured, open a loopback tunnel to the remote DB // SSH tunnel: if configured, open a loopback tunnel to the remote DB
// and connect through it. The blocking ssh2 handshake runs in // and connect through it. The blocking ssh2 handshake runs in
@@ -1080,6 +1094,18 @@ pub async fn db_connect(
match result { match result {
Ok((client, handle)) => { Ok((client, handle)) => {
// Capture a cancel token + the exact TLS decision/config used to
// build the connection so `cancel_query` can open an identical
// (short-lived) cancel connection later.
let pgtoken = client.cancel_token();
state.cancel_registry.set_pg(
&connection_id,
crate::cancel::PgCancel {
cancel_token: pgtoken,
tls_decision: decision,
tls_config: cancel_cfg,
},
);
let mut pm = state.pool_manager.lock().await; let mut pm = state.pool_manager.lock().await;
pm.register( pm.register(
&connection_id, &connection_id,
@@ -1101,8 +1127,14 @@ pub async fn db_connect(
} else if config.db_type == "sqlite" { } else if config.db_type == "sqlite" {
match rusqlite::Connection::open(&config.host) { match rusqlite::Connection::open(&config.host) {
Ok(conn) => { Ok(conn) => {
// Capture the per-connection interrupt handle so `cancel_query`
// can abort a running SQLite query from another thread.
let interrupt = conn.get_interrupt_handle();
let mut pm = state.pool_manager.lock().await; let mut pm = state.pool_manager.lock().await;
pm.register(&connection_id, crate::db::pool::DbHandle::Sqlite(conn)); pm.register(&connection_id, crate::db::pool::DbHandle::Sqlite(conn));
state
.cancel_registry
.set_sqlite(&connection_id, crate::cancel::SqliteCancel::new(interrupt));
Ok(()) Ok(())
} }
Err(e) => Err(format!("Connection failed: {}", e)), Err(e) => Err(format!("Connection failed: {}", e)),
@@ -1113,6 +1145,7 @@ pub async fn db_connect(
&config, &config,
&state.ssh_manager, &state.ssh_manager,
&state.pool_manager, &state.pool_manager,
&state.cancel_registry,
) )
.await .await
} else { } else {
@@ -1130,6 +1163,7 @@ pub async fn db_disconnect(
) -> Result<(), String> { ) -> Result<(), String> {
let mut pm = state.pool_manager.lock().await; let mut pm = state.pool_manager.lock().await;
pm.remove(&connection_id); pm.remove(&connection_id);
state.cancel_registry.remove(&connection_id);
Ok(()) Ok(())
} }
@@ -2323,11 +2357,15 @@ pub(crate) async fn execute_change_inner(
conn.execute(sql, []).map_err(|e| e.to_string())?; conn.execute(sql, []).map_err(|e| e.to_string())?;
return Ok(()); return Ok(());
} }
Change::Ddl { .. } => { Change::Ddl { sql, .. } => {
return Err("Object management is PostgreSQL-only".to_string()); // Object DDL can be multi-statement; run as a batch.
conn.execute_batch(sql).map_err(|e| e.to_string())?;
return Ok(());
} }
Change::RebuildTable { .. } => { Change::RebuildTable { sql, .. } => {
return Err("Object management is PostgreSQL-only".to_string()); // Rebuild script (create tmp / copy / drop / rename).
conn.execute_batch(sql).map_err(|e| e.to_string())?;
return Ok(());
} }
Change::BulkInsert { Change::BulkInsert {
table, table,
@@ -3309,8 +3347,9 @@ mod tests {
}; };
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))); let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let pm = fresh_pool_manager().await; let pm = fresh_pool_manager().await;
let reg = crate::cancel::CancelRegistry::new();
let id = "mysql-empty-host"; let id = "mysql-empty-host";
let res = run_mysql_connect(id, &cfg, &ssh, &pm).await; let res = run_mysql_connect(id, &cfg, &ssh, &pm, &reg).await;
assert!(res.is_err(), "empty host must fail before any network call"); assert!(res.is_err(), "empty host must fail before any network call");
let mut pmg = pm.lock().await; let mut pmg = pm.lock().await;
assert!(pmg.get(id).is_none(), "no handle registered on failure"); assert!(pmg.get(id).is_none(), "no handle registered on failure");
@@ -3369,8 +3408,9 @@ mod tests {
}; };
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))); let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let pm = fresh_pool_manager().await; let pm = fresh_pool_manager().await;
let reg = crate::cancel::CancelRegistry::new();
let id = "mysql-unreachable"; let id = "mysql-unreachable";
let res = run_mysql_connect(id, &cfg, &ssh, &pm).await; let res = run_mysql_connect(id, &cfg, &ssh, &pm, &reg).await;
assert!( assert!(
res.is_err(), res.is_err(),
"port 1 should refuse; must be a clean Err, not panic" "port 1 should refuse; must be a clean Err, not panic"
@@ -3419,8 +3459,9 @@ mod tests {
}; };
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))); let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
let pm = fresh_pool_manager().await; let pm = fresh_pool_manager().await;
let reg = crate::cancel::CancelRegistry::new();
let id = format!("mysql-it-{}", uuid::Uuid::new_v4()); let id = format!("mysql-it-{}", uuid::Uuid::new_v4());
run_mysql_connect(&id, &cfg, &ssh, &pm).await.expect("connect"); run_mysql_connect(&id, &cfg, &ssh, &pm, &reg).await.expect("connect");
let tables = get_tables_inner(&pm, &id, Some(&db)).await.expect("tables"); let tables = get_tables_inner(&pm, &id, Some(&db)).await.expect("tables");
assert!(!tables.is_empty(), "test DB must contain at least one table"); assert!(!tables.is_empty(), "test DB must contain at least one table");
let first = &tables[0]; let first = &tables[0];
+137 -11
View File
@@ -147,13 +147,120 @@ pub async fn get_object_dependencies(connection_id: String, schema: String, obje
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
} }
/// Introspect the live columns of a SQLite table (for the edit/rebuild diff).
/// Mirrors the `PRAGMA table_info` + `PRAGMA index_list`/`index_info` reads used
/// elsewhere in db_viewer; `auto_increment` requires an INTEGER PK whose stored
/// DDL (sqlite_master) actually says AUTOINCREMENT, and `unique` means a
/// single-column unique index (excluding the PK autoindex).
fn sqlite_live_columns(conn: &rusqlite::Connection, table: &str) -> Result<Vec<SqliteColumn>, String> {
let pragma_query = format!("PRAGMA table_info('{}')", table);
let mut stmt = conn.prepare(&pragma_query).map_err(|e| e.to_string())?;
let col_meta: Vec<(String, String, bool, bool, Option<String>)> = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(1)?, // name
row.get::<_, String>(2)?, // type
row.get::<_, bool>(3)?, // notnull
row.get::<_, bool>(5)?, // pk
row.get::<_, Option<String>>(4)?, // dflt_value
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
// AUTOINCREMENT only appears in the stored DDL of an INTEGER PK table.
let autoinc = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
[table],
|row| row.get::<_, String>(0),
)
.map(|sql| sql.to_uppercase().contains("AUTOINCREMENT"))
.unwrap_or(false);
// Columns covered by a single-column unique index (origin != 'pk').
let mut unique_cols: std::collections::HashSet<String> = std::collections::HashSet::new();
if let Ok(mut idx_stmt) = conn.prepare(&format!("PRAGMA index_list('{}')", table)) {
let indexes: Vec<(String, bool, String)> = idx_stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(1)?, // name
row.get::<_, bool>(2)?, // unique
row.get::<_, String>(3)?, // origin
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for (idx_name, is_unique, origin) in indexes {
if !is_unique || origin == "pk" {
continue;
}
if let Ok(mut info_stmt) =
conn.prepare(&format!("PRAGMA index_info('{}')", idx_name.replace('\'', "''")))
{
let cols: Vec<String> = info_stmt
.query_map([], |row| row.get::<_, String>(2))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if cols.len() == 1 {
unique_cols.insert(cols[0].clone());
}
}
}
}
Ok(col_meta
.iter()
.map(|(name, dtype, notnull, is_pk, default)| SqliteColumn {
name: name.clone(),
type_: dtype.clone(),
nullable: !notnull,
default: default.clone(),
is_pk: *is_pk,
auto_increment: autoinc && *is_pk && dtype.trim().eq_ignore_ascii_case("INTEGER"),
unique: unique_cols.contains(name),
})
.collect())
}
/// Build SQL for an object CRUD operation. The pool is resolved only to enforce /// Build SQL for an object CRUD operation. The pool is resolved only to enforce
/// PostgreSQL-only / present-connection; the SQL itself is built by the pure /// PostgreSQL-only / present-connection; the SQL itself is built by the pure
/// `crate::db::object_crud::build_ddl` dispatcher (one statement per String). /// `crate::db::object_crud::build_ddl` dispatcher (one statement per String).
/// SQLite routes through the table-editor builders in `crate::db::object_ddl`
/// (create / edit / rebuild ops on the `table` kind).
pub(crate) async fn build_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> { pub(crate) async fn build_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
let mut pm = pm.lock().await; let mut pm = pm.lock().await;
match pm.get(connection_id) { match pm.get(connection_id) {
Some(DbHandle::Postgresql(_, _)) => build_ddl(kind, params), Some(DbHandle::Postgresql(_, _)) => build_ddl(kind, params),
Some(DbHandle::Sqlite(conn)) => {
// params: { schema, name, action: { op, columns: [...] } }
let action = params.get("action").ok_or("missing action")?;
let op = action
.get("op")
.and_then(|v| v.as_str())
.ok_or("missing op")?;
let cols: Vec<SqliteColumn> = match action.get("columns") {
Some(v) => serde_json::from_value(v.clone()).map_err(|e| e.to_string())?,
None => vec![],
};
let table = params
.get("name")
.and_then(|v| v.as_str())
.ok_or("missing name")?
.to_string();
match op {
"create" => Ok(vec![sqlite_create_table_sql(&table, &cols, &[])?]),
"edit" => {
let old = sqlite_live_columns(conn, &table)?;
sqlite_column_diff_sql(&table, &old, &cols)
}
"rebuild" => {
let old = sqlite_live_columns(conn, &table)?;
sqlite_rebuild_script(&table, &old, &cols)
}
_ => Err(format!("unknown op {op}")),
}
}
Some(_) => Err("Object management is PostgreSQL-only".into()), Some(_) => Err("Object management is PostgreSQL-only".into()),
None => Err("Connection not found".into()), None => Err("Connection not found".into()),
} }
@@ -201,12 +308,19 @@ pub(crate) async fn build_rebuild_script_inner(
new_columns: serde_json::Value, new_columns: serde_json::Value,
) -> Result<String, String> { ) -> Result<String, String> {
let mut pm = pm.lock().await; let mut pm = pm.lock().await;
let client = match pm.get(connection_id) { match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c, Some(DbHandle::Sqlite(conn)) => {
Some(_) => return Err("Rebuild is PostgreSQL-only".into()), let new_cols: Vec<SqliteColumn> =
None => return Err("Connection not found".into()), serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
}; let live = sqlite_live_columns(conn, table)?;
let new_cols: Vec<TableColumn> = serde_json::from_value(new_columns).map_err(|e| e.to_string())?; if let Some(reason) = sqlite_rebuild_refusal(&live) {
return Err(reason);
}
return Ok(sqlite_rebuild_script(table, &live, &new_cols)?.join(";\n"));
}
Some(DbHandle::Postgresql(client, _)) => {
let new_cols: Vec<TableColumn> =
serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
// 1. live columns — validate reorder-only: the (name,type) multiset must be // 1. live columns — validate reorder-only: the (name,type) multiset must be
// unchanged (attribute edits belong in the diff path, not the rebuild). // unchanged (attribute edits belong in the diff path, not the rebuild).
@@ -320,6 +434,10 @@ pub(crate) async fn build_rebuild_script_inner(
.collect(), .collect(),
}; };
rebuild_script(&input, &new_cols) rebuild_script(&input, &new_cols)
}
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
}
} }
#[tauri::command] #[tauri::command]
@@ -417,11 +535,15 @@ pub async fn get_role_privileges(connection_id: String, role: String, state: Sta
/// Check whether a table can be rebuilt (no triggers, policies, inheritance, partitioning, generated columns). /// Check whether a table can be rebuilt (no triggers, policies, inheritance, partitioning, generated columns).
pub(crate) async fn get_table_rebuild_readiness_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str) -> Result<crate::models::RebuildReadiness, String> { pub(crate) async fn get_table_rebuild_readiness_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str) -> Result<crate::models::RebuildReadiness, String> {
let mut pm = pm.lock().await; let mut pm = pm.lock().await;
let client = match pm.get(connection_id) { match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c, Some(DbHandle::Sqlite(conn)) => {
Some(_) => return Err("Rebuild is PostgreSQL-only".into()), let live = sqlite_live_columns(conn, table)?;
None => return Err("Connection not found".into()), Ok(match sqlite_rebuild_refusal(&live) {
}; Some(reason) => crate::models::RebuildReadiness { ok: false, reasons: vec![reason] },
None => crate::models::RebuildReadiness { ok: true, reasons: vec![] },
})
}
Some(DbHandle::Postgresql(client, _)) => {
let row = client.query_one(&crate::db::introspection::pg_rebuild_readiness_query(), &[&schema, &table]).await let row = client.query_one(&crate::db::introspection::pg_rebuild_readiness_query(), &[&schema, &table]).await
.map_err(|e| sanitize(&e.to_string()))?; .map_err(|e| sanitize(&e.to_string()))?;
let mut reasons = Vec::new(); let mut reasons = Vec::new();
@@ -431,6 +553,10 @@ pub(crate) async fn get_table_rebuild_readiness_inner(pm: &tokio::sync::Mutex<Co
if row.get::<_, bool>("is_partitioned") { reasons.push("table is partitioned".into()); } if row.get::<_, bool>("is_partitioned") { reasons.push("table is partitioned".into()); }
if row.get::<_, bool>("has_generated") { reasons.push("table has generated/identity columns".into()); } if row.get::<_, bool>("has_generated") { reasons.push("table has generated/identity columns".into()); }
Ok(crate::models::RebuildReadiness { ok: reasons.is_empty(), reasons }) Ok(crate::models::RebuildReadiness { ok: reasons.is_empty(), reasons })
}
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
}
} }
#[tauri::command] #[tauri::command]
+46 -3
View File
@@ -58,19 +58,62 @@ async fn object_ddl_for_sequence_enum_function() {
} }
#[tokio::test] #[tokio::test]
async fn build_object_ddl_inner_guards_postgresql_only() { async fn build_object_ddl_inner_guards_missing_connection_and_rejects_unknown_op() {
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new()); let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
// Missing connection -> Connection not found // Missing connection -> Connection not found
let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({ let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({
"schema": "public", "name": "s", "action": { "op": "drop" } "schema": "public", "name": "s", "action": { "op": "drop" }
})).await.unwrap_err(); })).await.unwrap_err();
assert!(err.contains("Connection not found"), "{err}"); assert!(err.contains("Connection not found"), "{err}");
// Non-PostgreSQL handle -> PostgreSQL-only error // SQLite now dispatches to the SQLite table builders; non-table ops are rejected.
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap())); pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({ let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({
"schema": "public", "name": "s", "action": { "op": "drop" } "schema": "public", "name": "s", "action": { "op": "drop" }
})).await.unwrap_err(); })).await.unwrap_err();
assert!(err.contains("PostgreSQL-only"), "{err}"); assert!(err.contains("unknown op"), "{err}");
}
/// In-memory SQLite pool registered under id "c" (no Tauri, no live PG).
fn fresh_pool_with_sqlite() -> ConnectionPoolManager {
let mut pm = ConnectionPoolManager::new();
pm.register("c", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
pm
}
#[tokio::test]
async fn build_object_ddl_sqlite_create_yields_sqlite_sql() {
let pm = fresh_pool_with_sqlite();
let params = serde_json::json!({
"schema": "main", "name": "users",
"action": { "op": "create", "columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true, "auto_increment": true, "unique": false },
{ "name": "name", "type": "text", "nullable": true, "default": null, "is_pk": false, "auto_increment": false, "unique": false }
] }
});
let sqls = build_object_ddl_inner(&tokio::sync::Mutex::new(pm), "c", "table", params).await.unwrap();
assert!(sqls.iter().any(|s| s.contains("INTEGER PRIMARY KEY AUTOINCREMENT")), "{sqls:?}");
assert!(sqls.iter().all(|s| !s.contains("serial")), "{sqls:?}");
}
#[tokio::test]
async fn execute_change_sqlite_ddl_runs_create() {
let pm = tokio::sync::Mutex::new(fresh_pool_with_sqlite());
let change = crate::models::db_viewer::Change::Ddl { id: "x".into(), sql: "CREATE TABLE u(id INTEGER)".into() };
let r = crate::commands::db_viewer::execute_change_inner(&pm, "c", change).await;
assert!(r.is_ok(), "{r:?}");
// the table really landed on the live connection
{
let mut g = pm.lock().await;
match g.get("c").unwrap() {
crate::db::pool::DbHandle::Sqlite(conn) => {
let n: i64 = conn
.query_row("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='u'", [], |row| row.get(0))
.unwrap();
assert_eq!(n, 1);
}
_ => panic!("expected a sqlite handle"),
}
}
} }
#[tokio::test] #[tokio::test]
+250 -11
View File
@@ -17,6 +17,30 @@ use std::time::Instant;
use tauri::State; use tauri::State;
use uuid::Uuid; use uuid::Uuid;
// ---------------------------------------------------------------------------
// Cancel-error classification
// ---------------------------------------------------------------------------
// The wrapped→raw fallback exists for queries that can't be wrapped (CTEs,
// multi-statement, non-SELECT). A USER CANCELLATION is NOT a wrapping failure:
// swallowing it would re-run the very query the user just cancelled — and for
// SQLite the interrupt flag is consumed by the aborted step, so the re-run
// runs completely free. These helpers let the fallbacks propagate cancellations.
fn is_sqlite_cancel_error(e: &rusqlite::Error) -> bool {
e.sqlite_error_code() == Some(rusqlite::ErrorCode::OperationInterrupted)
}
fn is_pg_cancel_error(e: &tokio_postgres::Error) -> bool {
e.code() == Some(&tokio_postgres::error::SqlState::QUERY_CANCELED)
}
fn is_mysql_cancel_error(e: &sqlx::Error) -> bool {
match e.as_database_error().and_then(|d| d.code()) {
Some(code) if code == "1317" => true, // ER_QUERY_INTERRUPTED (KILL QUERY)
_ => e.to_string().to_lowercase().contains("interrupted"),
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// QueryHistoryEntry // QueryHistoryEntry
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -87,6 +111,7 @@ impl From<crate::store::SavedQueryRow> for SavedQueryCommand {
pub(crate) async fn execute_query_inner( pub(crate) async fn execute_query_inner(
pool_manager: &mut crate::db::pool::ConnectionPoolManager, pool_manager: &mut crate::db::pool::ConnectionPoolManager,
db_store: &std::sync::Mutex<crate::store::Store>, db_store: &std::sync::Mutex<crate::store::Store>,
cancel_registry: &crate::cancel::CancelRegistry,
connection_id: &str, connection_id: &str,
query: &str, query: &str,
page: i64, page: i64,
@@ -101,7 +126,25 @@ pub(crate) async fn execute_query_inner(
execute_pg_query(client, query, page, page_size).await execute_pg_query(client, query, page, page_size).await
} }
Some(DbHandle::Sqlite(conn)) => execute_sqlite_query(conn, query, page, page_size), Some(DbHandle::Sqlite(conn)) => execute_sqlite_query(conn, query, page, page_size),
Some(DbHandle::MySql(pool)) => execute_mysql_query(pool, query, page, page_size).await, Some(DbHandle::MySql(pool)) => {
// Run on a dedicated pooled connection so the query's MySQL
// CONNECTION_ID can be tracked for cancellation (`KILL QUERY ?`).
let mut conn = pool
.acquire()
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
// CAST to SIGNED: MySQL returns CONNECTION_ID() as BIGINT UNSIGNED,
// which sqlx refuses to decode into i64 (ColumnDecode error) — a
// silent unwrap_or(-1) here would make KILL QUERY -1 fail.
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
.fetch_one(&mut *conn)
.await
.unwrap_or(-1);
cancel_registry.set_mysql_conn_id(connection_id, Some(conn_id));
let result = execute_mysql_query_on(&mut *conn, query, page, page_size).await;
cancel_registry.set_mysql_conn_id(connection_id, None);
result
}
None => { None => {
let elapsed = start.elapsed().as_millis() as i64; let elapsed = start.elapsed().as_millis() as i64;
let err = "Connection not found".to_string(); let err = "Connection not found".to_string();
@@ -210,6 +253,7 @@ async fn execute_pg_query(
// Try the wrapped count query first — if this fails we fall back to raw. // Try the wrapped count query first — if this fails we fall back to raw.
let total_rows: i64 = match client.query_one(&wrapped_count, &[]).await { let total_rows: i64 = match client.query_one(&wrapped_count, &[]).await {
Ok(row) => row.get::<_, i64>(0), Ok(row) => row.get::<_, i64>(0),
Err(e) if is_pg_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => { Err(_) => {
// Wrapping failed — fall back to raw execution. // Wrapping failed — fall back to raw execution.
return execute_pg_raw(client, trimmed, page, page_size, off).await; return execute_pg_raw(client, trimmed, page, page_size, off).await;
@@ -219,6 +263,7 @@ async fn execute_pg_query(
// Now execute the wrapped data query. // Now execute the wrapped data query.
let data_rows = match client.query(&wrapped_data, &[&page_size, &off]).await { let data_rows = match client.query(&wrapped_data, &[&page_size, &off]).await {
Ok(rows) => rows, Ok(rows) => rows,
Err(e) if is_pg_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => { Err(_) => {
return execute_pg_raw(client, trimmed, page, page_size, off).await; return execute_pg_raw(client, trimmed, page, page_size, off).await;
} }
@@ -381,6 +426,7 @@ fn execute_sqlite_query(
// Try the wrapped count query first. // Try the wrapped count query first.
let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) { let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) {
Ok(n) => n, Ok(n) => n,
Err(e) if is_sqlite_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => { Err(_) => {
// Wrapping failed — fall back to raw execution. // Wrapping failed — fall back to raw execution.
return execute_sqlite_raw(conn, trimmed, page, page_size, off); return execute_sqlite_raw(conn, trimmed, page, page_size, off);
@@ -390,6 +436,11 @@ fn execute_sqlite_query(
// Execute the wrapped data query. // Execute the wrapped data query.
let (columns, all_rows) = match execute_sqlite_with_query(conn, &wrapped_data) { let (columns, all_rows) = match execute_sqlite_with_query(conn, &wrapped_data) {
Ok(result) => result, Ok(result) => result,
// The data-step error is already a String (mapped inside
// execute_sqlite_with_query); classify by the interrupt message.
Err(e) if e.to_lowercase().contains("interrupted") => {
return Err("Query cancelled".to_string());
}
Err(_) => { Err(_) => {
return execute_sqlite_raw(conn, trimmed, page, page_size, off); return execute_sqlite_raw(conn, trimmed, page, page_size, off);
} }
@@ -533,8 +584,8 @@ pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde
serde_json::Value::Null serde_json::Value::Null
} }
async fn execute_mysql_query( async fn execute_mysql_query_on(
pool: &sqlx::MySqlPool, conn: &mut sqlx::mysql::MySqlConnection,
query: &str, query: &str,
page: i64, page: i64,
page_size: i64, page_size: i64,
@@ -547,21 +598,23 @@ async fn execute_mysql_query(
// Try the wrapped count first; fall back to raw on failure. // Try the wrapped count first; fall back to raw on failure.
let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed)) let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed))
.fetch_one(pool) .fetch_one(&mut *conn)
.await .await
{ {
Ok(n) => n, Ok(n) => n,
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await, Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
}; };
let data_rows = match sqlx::query(&mysql_wrap_data(trimmed)) let data_rows = match sqlx::query(&mysql_wrap_data(trimmed))
.bind(page_size) .bind(page_size)
.bind(off) .bind(off)
.fetch_all(pool) .fetch_all(&mut *conn)
.await .await
{ {
Ok(rows) => rows, Ok(rows) => rows,
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await, Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
}; };
let columns: Vec<ColumnInfo> = match data_rows.first() { let columns: Vec<ColumnInfo> = match data_rows.first() {
@@ -580,7 +633,7 @@ async fn execute_mysql_query(
is_generated: false, is_generated: false,
}) })
.collect(), .collect(),
None => return execute_mysql_raw(pool, trimmed, page, page_size, off).await, None => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
}; };
let rows: Vec<Vec<serde_json::Value>> = data_rows let rows: Vec<Vec<serde_json::Value>> = data_rows
@@ -602,14 +655,14 @@ async fn execute_mysql_query(
/// mirroring the PG `simple_query` raw path. Used when wrapping fails /// mirroring the PG `simple_query` raw path. Used when wrapping fails
/// (e.g., multi-statement or non-selectable SQL). /// (e.g., multi-statement or non-selectable SQL).
async fn execute_mysql_raw( async fn execute_mysql_raw(
pool: &sqlx::MySqlPool, conn: &mut sqlx::mysql::MySqlConnection,
query: &str, query: &str,
page: i64, page: i64,
page_size: i64, page_size: i64,
off: i64, off: i64,
) -> Result<QueryResult, String> { ) -> Result<QueryResult, String> {
let rows = sqlx::query(query) let rows = sqlx::query(query)
.fetch_all(pool) .fetch_all(&mut *conn)
.await .await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?; .map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
@@ -748,7 +801,70 @@ pub async fn execute_query(
let p = page.unwrap_or(1); let p = page.unwrap_or(1);
let ps = page_size.unwrap_or(50); let ps = page_size.unwrap_or(50);
let mut pm = state.pool_manager.lock().await; let mut pm = state.pool_manager.lock().await;
execute_query_inner(&mut pm, &state.db_store, &connection_id, &query, p, ps).await execute_query_inner(
&mut pm,
&state.db_store,
&state.cancel_registry,
&connection_id,
&query,
p,
ps,
)
.await
}
/// Cancel a query currently running on the given connection.
///
/// - PostgreSQL: opens a short-lived cancel connection (reusing the exact TLS
/// decision/config from connect) and sends a cancel request keyed to the
/// original backend.
/// - MySQL: opens a fresh connection and runs `KILL QUERY <conn_id>` for the
/// connection currently running the query (registered per-query).
/// - SQLite: signals the per-connection `InterruptHandle` (thread-safe).
#[tauri::command]
pub async fn cancel_query(
connection_id: String,
state: State<'_, crate::AppState>,
) -> Result<(), String> {
use sqlx::ConnectOptions;
match state.cancel_registry.get(&connection_id) {
Some(crate::cancel::CancelHandle::Pg(pg)) => {
// A Verify/Require decision always carries a built rustls config,
// so the unwrap on the non-Disable branch is safe by construction.
match pg.tls_decision {
crate::db::tls::TlsDecision::Disable => {
pg.cancel_token.cancel_query(tokio_postgres::NoTls).await
}
_ => {
let connector = tokio_postgres_rustls::MakeRustlsConnect::new(
(*pg.tls_config.expect("tls config for non-disable decision")).clone(),
);
pg.cancel_token.cancel_query(connector).await
}
}
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))
}
Some(crate::cancel::CancelHandle::MySql(m)) => {
let id = m
.conn_id
.ok_or_else(|| "No active query on this connection".to_string())?;
let mut c = m
.connect_options
.connect()
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
sqlx::query(&format!("KILL QUERY {id}"))
.execute(&mut c)
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
Ok(())
}
Some(crate::cancel::CancelHandle::Sqlite(s)) => {
s.interrupt();
Ok(())
}
None => Err("No active cancel handle for this connection".into()),
}
} }
#[tauri::command] #[tauri::command]
@@ -1138,4 +1254,127 @@ mod tests {
let q = mysql_wrap_count("SELECT * FROM t"); let q = mysql_wrap_count("SELECT * FROM t");
assert_eq!(q, "SELECT COUNT(*) FROM (SELECT * FROM t) AS _gridline_cnt"); assert_eq!(q, "SELECT COUNT(*) FROM (SELECT * FROM t) AS _gridline_cnt");
} }
// ------------------------------------------------------------------
// Cancel propagation (v0.7.8 bugfix): a user cancel must NOT be swallowed
// by the wrapped→raw fallback (which would re-run the cancelled query).
// ------------------------------------------------------------------
#[test]
fn is_sqlite_cancel_error_classifies_interrupt() {
let interrupted = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_INTERRUPT),
Some("interrupted".to_string()),
);
assert!(is_sqlite_cancel_error(&interrupted));
let other = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
Some("SQL logic error".to_string()),
);
assert!(!is_sqlite_cancel_error(&other));
}
#[test]
fn sqlite_cancel_aborts_wrapped_query_without_rerun() {
use std::sync::mpsc;
// A slow query whose wrapped COUNT step takes seconds — interrupt() must
// abort it and surface "Query cancelled" instead of falling back to the
// raw re-run (which would consume the interrupt flag and run to
// completion, hiding the cancellation).
let conn = rusqlite::Connection::open_in_memory().unwrap();
let handle = conn.get_interrupt_handle();
let slow = "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 50000000) SELECT count(*) AS n FROM c";
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let res = execute_sqlite_query(&conn, slow, 1, 50);
let cancelled = match &res {
Err(msg) => msg.contains("Query cancelled"),
Ok(_) => false,
};
let _ = tx.send(cancelled);
});
std::thread::sleep(std::time::Duration::from_millis(50));
handle.interrupt();
let cancelled = rx
.recv_timeout(std::time::Duration::from_secs(15))
.expect("query thread must finish");
assert!(cancelled, "cancel must abort the wrapped query with 'Query cancelled' instead of re-running it");
}
#[tokio::test]
#[ignore]
async fn pg_cancel_aborts_wrapped_query_without_rerun() {
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT")
.unwrap_or_else(|_| "5432".into())
.parse()
.unwrap();
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
let (client, conn) = tokio_postgres::connect(
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
tokio_postgres::NoTls,
)
.await
.expect("connect to test PG");
let handle = tokio::spawn(async move {
let _ = conn.await;
});
let token = client.cancel_token();
let run = tokio::spawn(async move {
execute_pg_query(&client, "SELECT pg_sleep(3)", 1, 50).await
});
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
token
.cancel_query(tokio_postgres::NoTls)
.await
.expect("cancel request");
let res = run.await.expect("query task");
assert!(res.is_err(), "pg_sleep must be cancelled, not re-run; got {res:?}");
assert!(res.unwrap_err().contains("Query cancelled"));
let _ = handle;
}
#[tokio::test]
#[ignore]
async fn mysql_cancel_aborts_wrapped_query_without_rerun() {
use sqlx::ConnectOptions;
let h = std::env::var("GRIDLINE_TEST_MYSQL_HOST").expect("set GRIDLINE_TEST_MYSQL_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
.unwrap_or_else(|_| "3306".into())
.parse()
.unwrap();
let u = std::env::var("GRIDLINE_TEST_MYSQL_USER").expect("set GRIDLINE_TEST_MYSQL_USER");
let pw = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
let db = std::env::var("GRIDLINE_TEST_MYSQL_DB").unwrap_or_default();
let opts = sqlx::mysql::MySqlConnectOptions::new()
.host(&h)
.port(p)
.username(&u)
.password(&pw)
.database(&db);
let pool = sqlx::mysql::MySqlPoolOptions::new()
.connect_with(opts.clone())
.await
.expect("connect to test MySQL");
let mut conn = pool.acquire().await.expect("acquire");
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
.fetch_one(&mut *conn)
.await
.unwrap();
let run = tokio::spawn(async move {
execute_mysql_query_on(&mut *conn, "SELECT SLEEP(3)", 1, 50).await
});
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let mut killer = opts.connect().await.expect("killer connect");
sqlx::query(&format!("KILL QUERY {conn_id}"))
.execute(&mut killer)
.await
.expect("kill");
let res = run.await.expect("query task");
assert!(res.is_err(), "SLEEP(3) must be killed, not re-run; got {res:?}");
assert!(res.unwrap_err().contains("Query cancelled"));
}
} }
+90
View File
@@ -1,5 +1,6 @@
use crate::models::Settings; use crate::models::Settings;
use crate::store::Store; use crate::store::Store;
use std::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
pub fn get_settings_inner(state: &Mutex<Store>) -> Result<Settings, String> { pub fn get_settings_inner(state: &Mutex<Store>) -> Result<Settings, String> {
@@ -26,6 +27,68 @@ pub fn update_setting(
update_setting_inner(&state.db_store, &key, &value) update_setting_inner(&state.db_store, &key, &value)
} }
// ---------------------------------------------------------------------------
// Settings export / import (v0.7.8)
// ---------------------------------------------------------------------------
/// Flatten a `Settings` struct into the store's key/value map. Keys and value
/// formats must round-trip through `Store::get_settings` (e.g. a `None`
/// `default_folder_id` is stored as the literal `"null"` sentinel, which
/// `get_settings` filters back to `None`).
fn settings_to_kv(s: &crate::models::Settings) -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert("confirm_before_delete".into(), s.confirm_before_delete.to_string());
if let Some(f) = &s.default_folder_id {
m.insert("default_folder_id".into(), f.clone());
} else {
m.insert("default_folder_id".into(), "null".into());
}
m.insert("theme".into(), s.theme.clone());
m.insert("font_size".into(), s.font_size.clone());
m.insert("accent_color".into(), s.accent_color.clone());
m.insert("table_refresh_rate".into(), s.table_refresh_rate.to_string());
m.insert("table_page_size".into(), s.table_page_size.to_string());
m.insert("editor_font_size".into(), s.editor_font_size.to_string());
m.insert("editor_font_family".into(), s.editor_font_family.clone());
m.insert("editor_word_wrap".into(), s.editor_word_wrap.clone());
m.insert("editor_minimap".into(), s.editor_minimap.to_string());
m.insert("editor_tab_size".into(), s.editor_tab_size.to_string());
if let Some(o) = &s.tag_order {
m.insert("tag_order".into(), o.clone());
}
m.insert(
"default_ports".into(),
serde_json::to_string(&s.default_ports).unwrap_or_default(),
);
m.insert(
"shortcuts".into(),
serde_json::to_string(&s.shortcuts).unwrap_or_default(),
);
m
}
#[tauri::command]
pub fn export_settings(state: tauri::State<crate::AppState>) -> Result<String, String> {
let s = get_settings_inner(&state.db_store)?;
serde_json::to_string(&crate::models::settings::SettingsExport {
schema_version: 1,
settings: s,
})
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn import_settings(
json: String,
state: tauri::State<crate::AppState>,
) -> Result<(), String> {
let env: crate::models::settings::SettingsExport =
serde_json::from_str(&json).map_err(|e| format!("invalid settings file: {e}"))?;
let map = settings_to_kv(&env.settings);
let store = state.db_store.lock().map_err(|e| e.to_string())?;
store.apply_settings(&map)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -59,4 +122,31 @@ mod tests {
update_setting_inner(&st, "accent_color", "#EF4444").unwrap(); update_setting_inner(&st, "accent_color", "#EF4444").unwrap();
assert_eq!(get_settings_inner(&st).unwrap().accent_color, "#EF4444"); assert_eq!(get_settings_inner(&st).unwrap().accent_color, "#EF4444");
} }
#[test]
fn settings_export_envelope_camel_case() {
use std::collections::HashMap;
let env = crate::models::settings::SettingsExport { schema_version: 1, settings: crate::models::Settings {
confirm_before_delete: true, default_folder_id: None, theme: "dark".into(), font_size: "medium".into(),
default_ports: HashMap::new(), tag_order: None, table_refresh_rate: 5, table_page_size: 50,
shortcuts: HashMap::new(), accent_color: "#2563EB".into(), editor_font_size: 14,
editor_font_family: "Menlo".into(), editor_word_wrap: "off".into(), editor_minimap: true, editor_tab_size: 2,
}};
let json = serde_json::to_string(&env).unwrap();
assert!(json.contains("\"schemaVersion\":1"));
assert!(json.contains("\"settings\":"));
}
#[test]
fn store_apply_settings_writes_all_keys() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
let store = crate::store::Store::from_connection(conn);
let mut map = std::collections::HashMap::new();
map.insert("theme".to_string(), "light".to_string());
map.insert("accent_color".to_string(), "#EF4444".to_string());
store.apply_settings(&map).unwrap();
assert_eq!(store.get_settings().unwrap().theme, "light");
assert_eq!(store.get_settings().unwrap().accent_color, "#EF4444");
}
} }
+152
View File
@@ -1,7 +1,21 @@
//! Pure builders for schema DDL, cross-object search, pg_depend lookups, //! Pure builders for schema DDL, cross-object search, pg_depend lookups,
//! and synthesized object DDL. No DB I/O — deterministic string builders. //! and synthesized object DDL. No DB I/O — deterministic string builders.
use serde::{Deserialize, Serialize};
use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo}; use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo};
/// Column model for the SQLite table editor. Mirrors the frontend payload.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SqliteColumn {
pub name: String,
#[serde(rename = "type")]
pub type_: String,
pub nullable: bool,
pub default: Option<String>,
pub is_pk: bool,
pub auto_increment: bool,
pub unique: bool,
}
/// Double-quote an identifier, doubling embedded quotes. /// Double-quote an identifier, doubling embedded quotes.
pub fn quote_ident(name: &str) -> String { pub fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\"")) format!("\"{}\"", name.replace('"', "\"\""))
@@ -125,6 +139,98 @@ pub fn constraint_ddl(c: &ConstraintInfo) -> String {
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition) quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
} }
// --- SQLite table editor ---
fn validate_type_fragment(t: &str) -> Result<(), String> {
let l = t.trim().to_lowercase();
if l.is_empty() { return Err("column type is required".into()); }
if l.contains(';') || l.contains("--") || l.contains("/*") { return Err("invalid characters in type".into()); }
Ok(())
}
/// CREATE TABLE for SQLite. PK inline for AUTOINCREMENT; single non-AUTOINCREMENT
/// PKs get a table-level PRIMARY KEY clause; FKs appended inline (SQLite grammar).
pub fn sqlite_create_table_sql(table: &str, cols: &[SqliteColumn], fks: &[(&str, &str)]) -> Result<String, String> {
validate_object_name(table)?;
let mut defs: Vec<String> = vec![];
let mut pk_cols: Vec<String> = vec![];
for c in cols {
validate_object_name(&c.name)?;
validate_type_fragment(&c.type_)?;
let mut d = format!("{} {}", quote_ident(&c.name), c.type_.trim());
if c.auto_increment && c.is_pk && c.type_.trim().eq_ignore_ascii_case("INTEGER") {
d = format!("{} INTEGER PRIMARY KEY AUTOINCREMENT", quote_ident(&c.name));
} else {
if !c.nullable { d.push_str(" NOT NULL"); }
if let Some(def) = &c.default { d.push_str(&format!(" DEFAULT {def}")); }
if c.unique && !c.is_pk { d.push_str(" UNIQUE"); }
if c.is_pk { pk_cols.push(quote_ident(&c.name)); }
}
defs.push(d);
}
// Always emit a table-level PRIMARY KEY when there are non-AUTOINCREMENT PK
// columns (single or composite) — the plan draft's `len > 1` condition would
// silently drop a single-column PK constraint.
if !pk_cols.is_empty() {
defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", ")));
}
for (lc, refc) in fks {
defs.push(format!("FOREIGN KEY ({}) REFERENCES {}", quote_ident(lc), refc));
}
Ok(format!("CREATE TABLE \"main\".{} ({})", quote_ident(table), defs.join(", ")))
}
/// Emit one statement per needed edit; falls back to a rebuild script (multi-stmt)
/// for edits SQLite's ALTER TABLE can't express.
pub fn sqlite_column_diff_sql(table: &str, old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
// rename detection: same position+type+nullable+default, name changed
for (i, n) in new.iter().enumerate() {
if let Some(o) = old.get(i) {
if o.name != n.name && o.type_ == n.type_ && o.default == n.default && o.nullable == n.nullable {
return Ok(vec![format!("ALTER TABLE \"main\".{} RENAME COLUMN {} TO {}", quote_ident(table), quote_ident(&o.name), quote_ident(&n.name))]);
}
}
}
// add column (new tail column, safe only if nullable or defaulted)
if new.len() > old.len() {
if let Some(c) = new.last() {
if c.nullable || c.default.is_some() {
validate_object_name(&c.name)?;
validate_type_fragment(&c.type_)?;
let mut d = format!("ALTER TABLE \"main\".{} ADD COLUMN {} {}", quote_ident(table), quote_ident(&c.name), c.type_.trim());
if !c.nullable {
d.push_str(&format!(" DEFAULT {}", c.default.as_deref().unwrap_or("''")));
}
return Ok(vec![d]);
}
}
}
// otherwise: full rebuild (type change, NOT NULL, drop, PK/UNIQUE/FK add, reorder)
sqlite_rebuild_script(table, old, new)
}
pub fn sqlite_rebuild_script(table: &str, _old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
validate_object_name(table)?;
let tmp = format!("_gl_{}_tmp", table);
let create = sqlite_create_table_sql(&tmp, new, &[])?;
let cols = new.iter().map(|c| quote_ident(&c.name)).collect::<Vec<_>>().join(", ");
Ok(vec![
create,
format!("INSERT INTO \"main\".{} ({}) SELECT * FROM \"main\".{}", quote_ident(&tmp), cols, quote_ident(table)),
format!("DROP TABLE \"main\".{}", quote_ident(table)),
format!("ALTER TABLE \"main\".{} RENAME TO {}", quote_ident(&tmp), quote_ident(table)),
])
}
/// Fail-closed readiness reason, or None if rebuild is safe.
/// AUTOINCREMENT tables are refused in v0.7.8 (rowid counter would be lost).
pub fn sqlite_rebuild_refusal(old: &[SqliteColumn]) -> Option<String> {
if old.iter().any(|c| c.auto_increment) {
return Some("rebuild is not supported for AUTOINCREMENT tables in v0.7.8 (rowid counter would be lost)".into());
}
None
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -258,4 +364,50 @@ mod tests {
let ddl = constraint_ddl(&c); let ddl = constraint_ddl(&c);
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)"); assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
} }
#[test]
fn sqlite_create_with_autoincrement_pk() {
let cols = vec![
SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false },
SqliteColumn { name: "name".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
];
let sql = sqlite_create_table_sql("users", &cols, &[]).unwrap();
assert!(sql.contains("\"id\" INTEGER PRIMARY KEY AUTOINCREMENT"));
assert!(sql.contains("\"name\" TEXT"));
assert!(sql.starts_with("CREATE TABLE \"main\".\"users\""));
}
#[test]
fn sqlite_create_rejects_bad_identifier() {
let cols = vec![SqliteColumn { name: "a;b".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
assert!(sqlite_create_table_sql("bad name", &cols, &[]).is_err());
}
#[test]
fn sqlite_edit_add_column_when_safe() {
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false }];
let new = vec![
old[0].clone(),
SqliteColumn { name: "email".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
];
let stmts = sqlite_column_diff_sql("users", &old, &new).unwrap();
assert_eq!(stmts.len(), 1);
assert!(stmts[0].contains("ALTER TABLE \"main\".\"users\" ADD COLUMN \"email\" TEXT"));
}
#[test]
fn sqlite_edit_rename_column() {
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let new = vec![SqliteColumn { name: "id2".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
assert!(stmts.iter().any(|s| s.contains("RENAME COLUMN \"id\" TO \"id2\"")));
}
#[test]
fn sqlite_edit_typechange_requires_rebuild() {
let old = vec![SqliteColumn { name: "v".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let new = vec![SqliteColumn { name: "v".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
assert!(stmts.iter().any(|s| s.contains("CREATE TABLE \"main\".\"_gl_t_tmp\"")), "type change must rebuild; got {stmts:?}");
}
} }
+16
View File
@@ -2,6 +2,7 @@
// of runtime usage, producing expected dead_code/unused warnings during development. // of runtime usage, producing expected dead_code/unused warnings during development.
#![allow(dead_code)] #![allow(dead_code)]
mod cancel;
mod commands; mod commands;
mod db; mod db;
mod models; mod models;
@@ -17,6 +18,7 @@ pub struct AppState {
pub db_store: StdMutex<Store>, pub db_store: StdMutex<Store>,
pub pool_manager: tokio::sync::Mutex<ConnectionPoolManager>, pub pool_manager: tokio::sync::Mutex<ConnectionPoolManager>,
pub ssh_manager: StdMutex<SshTunnelManager>, pub ssh_manager: StdMutex<SshTunnelManager>,
pub cancel_registry: crate::cancel::CancelRegistry,
} }
use commands::{ use commands::{
@@ -48,6 +50,7 @@ pub fn run() {
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(Arc::new(Ssh2Backend))), ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
cancel_registry: crate::cancel::CancelRegistry::default(),
}) })
.setup(move |app| { .setup(move |app| {
let state = app.state::<AppState>(); let state = app.state::<AppState>();
@@ -69,6 +72,9 @@ pub fn run() {
if let Ok(mut mgr) = s.ssh_manager.lock() { if let Ok(mut mgr) = s.ssh_manager.lock() {
mgr.close_tunnel(id); mgr.close_tunnel(id);
} }
// Drop the cancel handles for the evicted connection
// (tokens/interrupts outlive the pool otherwise).
s.cancel_registry.remove(id);
} }
})); }));
@@ -143,8 +149,18 @@ pub fn run() {
backup::pg_dump, backup::pg_dump,
backup::pg_restore, backup::pg_restore,
backup::db_sync, backup::db_sync,
backup::detect_mysql_tools,
backup::mysql_dump,
backup::mysql_restore,
backup::mysql_sync,
backup::sqlite_dump,
backup::sqlite_restore,
backup::sqlite_sync,
settings::export_settings,
settings::import_settings,
schema_graph::get_schema_graph, schema_graph::get_schema_graph,
query::execute_query, query::execute_query,
query::cancel_query,
query::get_query_history, query::get_query_history,
query::clear_query_history, query::clear_query_history,
query::set_history_favorite, query::set_history_favorite,
+125
View File
@@ -26,6 +26,12 @@ pub struct SyncOptions {
pub target_connection_id: String, pub target_connection_id: String,
pub schema: Option<String>, pub schema: Option<String>,
pub tables: Option<Vec<String>>, pub tables: Option<Vec<String>>,
#[serde(default = "default_sync_db_type")]
pub db_type: String,
}
fn default_sync_db_type() -> String {
"postgresql".into()
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -46,6 +52,73 @@ pub struct PgToolPaths {
pub psql: String, pub psql: String,
} }
/// Connection params for a MySQL server (decoupled from store/keychain so the
/// dump/restore/sync core stays headless-testable). Mirrors `PgConnParams`.
#[derive(Debug, Clone)]
pub struct MySqlConnParams {
pub host: String,
pub port: i64,
pub username: String,
pub database: String,
pub password: String,
}
impl MySqlConnParams {
pub fn new(host: String, port: i64, username: String, database: String, password: String) -> Self {
Self { host, port, username, database, password }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MySqlBackupOptions {
pub database: String,
pub file_path: String,
pub single_transaction: bool,
pub no_data: bool,
pub routines: bool,
pub triggers: bool,
pub events: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MySqlRestoreOptions {
pub database: String,
pub file_path: String,
pub clean: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SqliteBackupOptions {
pub file_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SqliteRestoreOptions {
pub file_path: String,
pub clean: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MySqlToolStatus {
pub mysqldump_found: bool,
pub mysql_found: bool,
pub mysqldump_version: Option<String>,
pub mysql_version: Option<String>,
pub mysqldump_source: Option<String>,
pub mysql_source: Option<String>,
}
#[derive(Debug, Clone)]
pub struct MySqlToolPaths {
pub mysqldump: String,
pub mysql: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupJob { pub struct BackupJob {
pub id: String, pub id: String,
@@ -117,4 +190,56 @@ mod tests {
assert!(json.contains("\"pg_dump_source\":\"system\"")); assert!(json.contains("\"pg_dump_source\":\"system\""));
assert!(json.contains("\"pg_restore_source\":\"bundled\"")); assert!(json.contains("\"pg_restore_source\":\"bundled\""));
} }
#[test]
fn mysql_backup_options_serialize_camel_case() {
let opts = MySqlBackupOptions {
database: "shop".into(),
file_path: "/tmp/dump.sql".into(),
single_transaction: true,
no_data: false,
routines: true,
triggers: true,
events: false,
};
let json = serde_json::to_string(&opts).unwrap();
assert!(json.contains("\"singleTransaction\":true"));
assert!(json.contains("\"filePath\":\"/tmp/dump.sql\""));
assert!(!json.contains("no_owner"));
}
#[test]
fn sqlite_restore_options_serialize_camel_case() {
let opts = SqliteRestoreOptions { file_path: "/tmp/in.sql".into(), clean: true };
let json = serde_json::to_string(&opts).unwrap();
assert!(json.contains("\"filePath\":\"/tmp/in.sql\""));
assert!(json.contains("\"clean\":true"));
}
#[test]
fn mysql_tool_status_reports_source() {
let s = MySqlToolStatus {
mysqldump_found: true,
mysql_found: true,
mysqldump_version: Some("mariadb-dump 10.6".into()),
mysql_version: Some("mariadb 10.6".into()),
mysqldump_source: Some("bundled".into()),
mysql_source: Some("system".into()),
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"mysqldumpSource\":\"bundled\""));
}
#[test]
fn sync_options_carry_db_type() {
let s = SyncOptions {
source_connection_id: "a".into(),
target_connection_id: "b".into(),
schema: None,
tables: None,
db_type: "mysql".into(),
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"dbType\":\"mysql\""));
}
} }
+7
View File
@@ -20,3 +20,10 @@ pub struct Settings {
pub editor_minimap: bool, pub editor_minimap: bool,
pub editor_tab_size: i64, pub editor_tab_size: i64,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SettingsExport {
pub schema_version: u32,
pub settings: Settings,
}
+15
View File
@@ -575,6 +575,21 @@ impl Store {
Ok(()) Ok(())
} }
/// Bulk-write settings keys in one transaction (used by settings import).
pub fn apply_settings(&self, map: &std::collections::HashMap<String, String>) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute_batch("BEGIN").map_err(|e| e.to_string())?;
for (k, v) in map.iter() {
conn.execute(
"INSERT INTO settings(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
rusqlite::params![k, v],
)
.map_err(|e| e.to_string())?;
}
conn.execute_batch("COMMIT").map_err(|e| e.to_string())?;
Ok(())
}
/// Insert a row into the `query_history` table. /// Insert a row into the `query_history` table.
/// Dedups consecutive identical queries per connection (UPDATE the last row /// Dedups consecutive identical queries per connection (UPDATE the last row
/// instead of INSERTing a new one) and prunes to at most 500 rows per connection. /// instead of INSERTing a new one) and prunes to at most 500 rows per connection.
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline", "productName": "Gridline",
"version": "0.7.7", "version": "0.7.8",
"identifier": "com.adrianbonpin.gridline", "identifier": "com.adrianbonpin.gridline",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -26,7 +26,7 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"resources": ["resources/pg_tools/*"], "resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+3 -1
View File
@@ -10,6 +10,7 @@ import { ErrorBanner } from "./components/ui/ErrorBanner";
import { ToastContainer } from "./components/ui/Toast"; import { ToastContainer } from "./components/ui/Toast";
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen"; import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
import { useAppearance } from "./hooks/useAppearance"; import { useAppearance } from "./hooks/useAppearance";
import { isMacOS } from "./lib/platform";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
const VIEW_TITLES: Record<string, string> = { const VIEW_TITLES: Record<string, string> = {
@@ -79,7 +80,8 @@ export default function App() {
return ( return (
<div className="h-svh bg-canvas select-none flex flex-col overflow-hidden"> <div className="h-svh bg-canvas select-none flex flex-col overflow-hidden">
{typeof window !== "undefined" && {typeof window !== "undefined" &&
"__TAURI_INTERNALS__" in window && ( "__TAURI_INTERNALS__" in window &&
isMacOS() && (
// macOS "Overlay" title bar: in-flow strip the window can be // macOS "Overlay" title bar: in-flow strip the window can be
// dragged by; traffic lights float over it. Only in Tauri. // dragged by; traffic lights float over it. Only in Tauri.
<div <div
@@ -8,9 +8,15 @@ import * as commands from "../../lib/commands";
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) })); vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn().mockResolvedValue("/tmp/backup.dump") })); vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn().mockResolvedValue("/tmp/backup.dump") }));
const mockConnections: any[] = [];
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
}));
describe("BackupPage", () => { describe("BackupPage", () => {
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 }); useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] }); useNotificationStore.setState({ notifications: [] });
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]); vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
@@ -25,8 +31,56 @@ describe("BackupPage", () => {
pg_dump_source: "bundled", pg_dump_source: "bundled",
pg_restore_source: "bundled", pg_restore_source: "bundled",
}); });
mockConnections.push({ id: "c1", db_type: "postgresql", name: "p" });
render(<BackupPage connectionId="c1" />); render(<BackupPage connectionId="c1" />);
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
expect(screen.queryByText(/brew install|apt install/i)).toBeNull(); expect(screen.queryByText(/brew install|apt install/i)).toBeNull();
}); });
}); });
describe("BackupPage DB-aware", () => {
beforeEach(() => {
mockConnections.length = 0;
});
it("shows a single SQL format for MySQL (no custom/tar/directory)", async () => {
mockConnections.push({ id: "c1", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: true,
mysqlFound: true,
mysqldumpVersion: "8.0",
mysqlVersion: "8.0",
mysqldumpSource: "system",
mysqlSource: "system",
});
render(<BackupPage connectionId="c1" />);
await waitFor(() => expect(screen.queryByText(/checking for mysqldump/i)).not.toBeInTheDocument());
expect(screen.queryByText("Custom Archive")).toBeNull();
expect(screen.queryByText("Tarball")).toBeNull();
expect(screen.queryByText("Directory")).toBeNull();
expect(screen.getByText("Plain SQL")).toBeTruthy();
});
it("renders a plain file-picker backup for SQLite (no format selector, no tool card)", () => {
mockConnections.push({ id: "c2", db_type: "sqlite", name: "s" });
render(<BackupPage connectionId="c2" />);
expect(screen.queryByText("Custom Archive")).toBeNull();
expect(screen.queryByText(/pg_dump/i)).toBeNull();
expect(screen.queryByText(/mysqldump/i)).toBeNull();
});
it("still lists Custom Archive for PostgreSQL (regression guard)", async () => {
mockConnections.push({ id: "c3", db_type: "postgresql", name: "p" });
vi.spyOn(commands, "detectPgTools").mockResolvedValue({
pg_dump_found: true,
pg_restore_found: true,
pg_dump_version: "16",
pg_restore_version: "16",
pg_dump_source: "system",
pg_restore_source: "system",
});
render(<BackupPage connectionId="c3" />);
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
expect(screen.getByText("Custom Archive")).toBeTruthy();
});
});
+282 -44
View File
@@ -5,8 +5,16 @@ import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress"; import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgDump, getSchemas } from "../../lib/commands"; import { useConnectionStore } from "../../stores/connectionStore";
import type { PgToolStatus } from "../../lib/types"; import {
detectPgTools,
pgDump,
getSchemas,
detectMysqlTools,
mysqlDump,
sqliteDump,
} from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
interface BackupPageProps { interface BackupPageProps {
connectionId: string; connectionId: string;
@@ -14,30 +22,51 @@ interface BackupPageProps {
type BackupFormat = "plain" | "custom" | "tar" | "directory"; type BackupFormat = "plain" | "custom" | "tar" | "directory";
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = { const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq", darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch", linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_dump is in your PATH.", win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_dump is in your PATH.",
}; };
function getPlatformInstructions(): string { const MYSQL_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install mysql-client",
linux: "sudo apt install mysql-client # Debian/Ubuntu\nsudo dnf install mysql # Fedora\nsudo pacman -S mariadb # Arch",
win32: "Download MySQL installer from https://dev.mysql.com/downloads/installer/ and ensure mysqldump is in your PATH.",
};
function getPlatformInstructions(map: Record<string, string>): string {
const platform = const platform =
typeof navigator !== "undefined" typeof navigator !== "undefined"
? navigator.platform.toLowerCase() ? navigator.platform.toLowerCase()
: ""; : "";
if (platform.includes("mac") || platform.includes("darwin")) if (platform.includes("mac") || platform.includes("darwin"))
return PLATFORM_INSTALL_INSTRUCTIONS.darwin; return map.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux; if (platform.includes("linux")) return map.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32; if (platform.includes("win")) return map.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux; return map.linux;
} }
export function BackupPage({ connectionId }: BackupPageProps) { export function BackupPage({ connectionId }: BackupPageProps) {
const connection = useConnectionStore((s) =>
s.connections.find((c) => c.id === connectionId),
);
const dbType = connection?.db_type ?? "postgresql";
const database = connection?.database ?? null;
const isPg = dbType === "postgresql";
const isMysql = dbType === "mysql";
const isSqlite = dbType === "sqlite";
const [format, setFormat] = useState<BackupFormat>("custom"); const [format, setFormat] = useState<BackupFormat>("custom");
const [filePath, setFilePath] = useState(""); const [filePath, setFilePath] = useState("");
const [schema, setSchema] = useState(""); const [schema, setSchema] = useState("");
const [noOwner, setNoOwner] = useState(true); const [noOwner, setNoOwner] = useState(true);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null); const [singleTransaction, setSingleTransaction] = useState(true);
const [noData, setNoData] = useState(false);
const [routines, setRoutines] = useState(true);
const [triggers, setTriggers] = useState(true);
const [events, setEvents] = useState(false);
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true); const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]); const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
@@ -71,10 +100,15 @@ export function BackupPage({ connectionId }: BackupPageProps) {
useEffect(() => { useEffect(() => {
setCheckingTools(true); setCheckingTools(true);
setPgToolStatus(null);
setMysqlToolStatus(null);
setAvailableSchemas([]);
if (isPg) {
detectPgTools() detectPgTools()
.then((status) => setToolStatus(status)) .then((status) => setPgToolStatus(status))
.catch(() => .catch(() =>
setToolStatus({ setPgToolStatus({
pg_dump_found: false, pg_dump_found: false,
pg_restore_found: false, pg_restore_found: false,
pg_dump_version: null, pg_dump_version: null,
@@ -88,57 +122,157 @@ export function BackupPage({ connectionId }: BackupPageProps) {
getSchemas(connectionId) getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas)) .then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([])); .catch(() => setAvailableSchemas([]));
}, [connectionId]); } else if (isMysql) {
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
setMysqlToolStatus({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else {
setCheckingTools(false);
}
}, [connectionId, isPg, isMysql]);
const handlePickFile = useCallback(async () => { const handlePickFile = useCallback(async () => {
const extensions: Record<BackupFormat, string[]> = { let defaultPath = "backup";
let extensions: string[] = [];
if (isPg) {
const pgExtensions: Record<BackupFormat, string[]> = {
plain: ["sql"], plain: ["sql"],
custom: ["dump", "custom"], custom: ["dump", "custom"],
tar: ["tar"], tar: ["tar"],
directory: [], directory: [],
}; };
const picked = await save({ extensions = pgExtensions[format];
defaultPath: `backup.${ defaultPath = `backup.${
format === "custom" format === "custom"
? "dump" ? "dump"
: format === "plain" : format === "plain"
? "sql" ? "sql"
: "tar" : "tar"
}`, }`;
filters: [{ name: "Backup", extensions: extensions[format] }], } else if (isMysql) {
extensions = ["sql"];
defaultPath = "backup.sql";
} else {
extensions = ["db", "sqlite", "sql"];
defaultPath = "backup.db";
}
const picked = await save({
defaultPath,
filters: [{ name: "Backup", extensions }],
}); });
if (picked) setFilePath(picked); if (picked) setFilePath(picked);
}, [format]); }, [format, isPg, isMysql, isSqlite]);
const handleStartBackup = useCallback(async () => { const runWithProgress = useCallback(
if (!filePath) { async (type: BackupJob["type"], action: () => Promise<unknown>) => {
notify("Please select a file path", "error"); const jobId = `${type}-${Date.now()}`;
return; startJob(jobId, type);
}
const jobId = `dump-${Date.now()}`;
startJob(jobId, "dump");
pendingJobRef.current = jobId; pendingJobRef.current = jobId;
try { try {
// pgDump returns the job ID immediately — completion // Command returns the job ID immediately — completion
// comes via Tauri events handled by the backupStore // comes via Tauri events handled by the backupStore
await pgDump(connectionId, { await action();
format,
filePath,
schema: schema || undefined,
tables: undefined,
noOwner,
});
} catch (e) { } catch (e) {
// If the command itself fails (e.g. connection not found), // If the command itself fails (e.g. connection not found),
// the event won't fire — handle here // the event won't fire — handle here
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg); useBackupStore.getState().failJob(jobId, msg);
} }
}, [filePath, format, schema, noOwner, connectionId, startJob, notify]); },
[startJob],
);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found; const handleStartBackup = useCallback(async () => {
const toolsBundled = toolStatus?.pg_dump_source === "bundled"; if (!filePath) {
notify("Please select a file path", "error");
return;
}
if (isMysql && !database) {
notify("MySQL connection has no database selected", "error");
return;
}
await runWithProgress("dump", () => {
if (isPg) {
return pgDump(connectionId, {
format,
filePath,
schema: schema || undefined,
tables: undefined,
noOwner,
});
}
if (isMysql) {
return mysqlDump(connectionId, {
database: database!,
filePath,
singleTransaction,
noData,
routines,
triggers,
events,
});
}
return sqliteDump(connectionId, { filePath });
});
}, [
filePath,
database,
isPg,
isMysql,
isSqlite,
format,
schema,
noOwner,
singleTransaction,
noData,
routines,
triggers,
events,
connectionId,
notify,
runWithProgress,
]);
const toolsMissing = isPg
? pgToolStatus && !pgToolStatus.pg_dump_found
: isMysql
? mysqlToolStatus && !mysqlToolStatus.mysqldumpFound
: false;
const toolsBundled = isPg
? pgToolStatus?.pg_dump_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqldumpSource === "bundled"
: false;
const checkingMessage = isPg
? "Checking for pg_dump..."
: isMysql
? "Checking for mysqldump..."
: null;
const headerDescription = isPg
? "Create a database backup via pg_dump"
: isMysql
? "Create a database backup via mysqldump"
: "Create a database backup";
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -147,7 +281,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
<HardDrive size={14} className="text-accent" /> <HardDrive size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Backup</span> <span className="text-xs font-medium text-text">Backup</span>
<span className="text-[11px] text-text-muted"> <span className="text-[11px] text-text-muted">
Create a database backup via pg_dump {headerDescription}
</span> </span>
</div> </div>
@@ -155,10 +289,10 @@ export function BackupPage({ connectionId }: BackupPageProps) {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border"> <div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */} {/* Tool check */}
{checkingTools && ( {checkingTools && checkingMessage && (
<div className="glass p-4 text-center"> <div className="glass p-4 text-center">
<p className="text-sm text-text-muted"> <p className="text-sm text-text-muted">
Checking for pg_dump... {checkingMessage}
</p> </p>
</div> </div>
)} )}
@@ -166,14 +300,18 @@ export function BackupPage({ connectionId }: BackupPageProps) {
{toolsMissing && !toolsBundled && ( {toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2"> <div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold"> <p className="text-amber-300 text-sm font-semibold">
pg_dump not found {isPg ? "pg_dump not found" : "mysqldump not found"}
</p> </p>
<p className="text-amber-200/80 text-xs leading-relaxed"> <p className="text-amber-200/80 text-xs leading-relaxed">
The PostgreSQL client tools are required for The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
backup/restore operations. Install them using: backup/restore operations. Install them using:
</p> </p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed"> <pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions()} {getPlatformInstructions(
isPg
? PG_INSTALL_INSTRUCTIONS
: MYSQL_INSTALL_INSTRUCTIONS,
)}
</pre> </pre>
</div> </div>
)} )}
@@ -183,6 +321,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
{/* Configuration card */} {/* Configuration card */}
<div className="p-5 space-y-5"> <div className="p-5 space-y-5">
{/* Format */} {/* Format */}
{isPg ? (
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium"> <label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format Format
@@ -206,6 +345,16 @@ export function BackupPage({ connectionId }: BackupPageProps) {
</option> </option>
</select> </select>
</div> </div>
) : isMysql ? (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<div className="text-sm text-text py-2">
Plain SQL
</div>
</div>
) : null}
{/* Output file */} {/* Output file */}
<div className="space-y-1 w-full"> <div className="space-y-1 w-full">
@@ -234,6 +383,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
</div> </div>
{/* Schema (optional) */} {/* Schema (optional) */}
{(isPg || isMysql) && (
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium"> <label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "} Schema{" "}
@@ -256,8 +406,10 @@ export function BackupPage({ connectionId }: BackupPageProps) {
))} ))}
</select> </select>
</div> </div>
)}
{/* No-owner toggle */} {/* PostgreSQL: no-owner toggle */}
{isPg && (
<label className="flex items-center gap-2.5 cursor-pointer group"> <label className="flex items-center gap-2.5 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
@@ -274,6 +426,93 @@ export function BackupPage({ connectionId }: BackupPageProps) {
</code> </code>
</span> </span>
</label> </label>
)}
{/* MySQL: option toggles */}
{isMysql && (
<div className="space-y-2">
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={singleTransaction}
onChange={(e) =>
setSingleTransaction(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Single Transaction{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--single-transaction
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={noData}
onChange={(e) =>
setNoData(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
No Data{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--no-data
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={routines}
onChange={(e) =>
setRoutines(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Routines{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--routines
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={triggers}
onChange={(e) =>
setTriggers(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Triggers{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--triggers
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={events}
onChange={(e) =>
setEvents(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Events{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--events
</code>
</span>
</label>
</div>
)}
</div> </div>
{/* Progress */} {/* Progress */}
@@ -305,4 +544,3 @@ export function BackupPage({ connectionId }: BackupPageProps) {
</div> </div>
); );
} }
+17 -1
View File
@@ -5,7 +5,7 @@ import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar"; import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
import { DbViewerToolbar } from "./DbViewerToolbar"; import { DbViewerToolbar } from "./DbViewerToolbar";
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils"; import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
import { executeQuery } from "../../lib/commands"; import { executeQuery, cancelQuery } from "../../lib/commands";
import { getCapabilities } from "../../lib/dbCapabilities"; import { getCapabilities } from "../../lib/dbCapabilities";
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor }))); const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
@@ -27,6 +27,7 @@ import { useDbConnection } from "../../hooks/useDbConnection";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
import { useSettingsStore } from "../../stores/settingsStore"; import { useSettingsStore } from "../../stores/settingsStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { useShortcut } from "../../hooks/useShortcut"; import { useShortcut } from "../../hooks/useShortcut";
import { ConnectionDropBanner } from "./ConnectionDropBanner"; import { ConnectionDropBanner } from "./ConnectionDropBanner";
import { ToolsPage } from "./ToolsPage"; import { ToolsPage } from "./ToolsPage";
@@ -184,6 +185,7 @@ export function DbViewerScreen({
const viewCapabilityKey = NAV_CAPABILITY_KEY[currentView] ?? "explorer"; const viewCapabilityKey = NAV_CAPABILITY_KEY[currentView] ?? "explorer";
const viewSupported = capabilities[viewCapabilityKey]; const viewSupported = capabilities[viewCapabilityKey];
const settings = useSettingsStore((s) => s.settings); const settings = useSettingsStore((s) => s.settings);
const notify = useNotificationStore((s) => s.notify);
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize); const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter); const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
const setFilterRules = useDbViewerStore((s) => s.setFilterRules); const setFilterRules = useDbViewerStore((s) => s.setFilterRules);
@@ -230,6 +232,8 @@ export function DbViewerScreen({
const tables = useDbViewerStore((s) => s.tables); const tables = useDbViewerStore((s) => s.tables);
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit); const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
const isMatview = const isMatview =
activeTab && activeTab.tabType === "table" activeTab && activeTab.tabType === "table"
? tables.some( ? tables.some(
@@ -1028,6 +1032,18 @@ const onQueriesPanelResizeStart = useCallback(
onRestore={handleRestoreSql} onRestore={handleRestoreSql}
onRunFromHistory={handleRunFromHistory} onRunFromHistory={handleRunFromHistory}
dbType={currentConnection?.db_type} dbType={currentConnection?.db_type}
isRunning={isRunning}
onCancel={async () => {
try {
await cancelQuery(connectionId);
notify("Query cancelled", "info");
} catch (e) {
notify(
e instanceof Error ? e.message : String(e),
"error",
);
}
}}
/> />
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<QueryEditor <QueryEditor
@@ -83,7 +83,7 @@ describe("DbViewerSidebar", () => {
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument(); expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
}); });
it("hides Objects and Tools for SQLite", () => { it("shows Tools but hides Objects for SQLite", () => {
render( render(
<TooltipProvider> <TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.sqlite} /> <DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.sqlite} />
@@ -92,11 +92,11 @@ describe("DbViewerSidebar", () => {
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument(); expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
expect(screen.getByLabelText("Queries")).toBeInTheDocument(); expect(screen.getByLabelText("Queries")).toBeInTheDocument();
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument(); expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/tools/i)).not.toBeInTheDocument();
}); });
it("hides Objects, Visualizer, and Tools for MySQL", () => { it("shows Tools but hides Objects and Visualizer for MySQL", () => {
render( render(
<TooltipProvider> <TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.mysql} /> <DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.mysql} />
@@ -104,9 +104,9 @@ describe("DbViewerSidebar", () => {
); );
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument(); expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
expect(screen.getByLabelText("Queries")).toBeInTheDocument(); expect(screen.getByLabelText("Queries")).toBeInTheDocument();
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/tools/i)).not.toBeInTheDocument();
}); });
it("shows no top nav items for Redis (unsupported browsing)", () => { it("shows no top nav items for Redis (unsupported browsing)", () => {
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { RestorePage } from "./RestorePage";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as commands from "../../lib/commands";
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn().mockResolvedValue("/tmp/backup.dump") }));
const mockConnections: any[] = [];
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
}));
const pgToolsOk = {
pg_dump_found: true,
pg_restore_found: true,
pg_dump_version: "16",
pg_restore_version: "16",
pg_dump_source: "system",
pg_restore_source: "system",
};
const mysqlToolsOk = {
mysqldumpFound: true,
mysqlFound: true,
mysqldumpVersion: "8.0",
mysqlVersion: "8.0",
mysqldumpSource: "system",
mysqlSource: "system",
};
describe("RestorePage DB-aware", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
});
it("shows format selector for PostgreSQL", async () => {
mockConnections.push({ id: "c1", db_type: "postgresql", name: "p" });
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
render(<RestorePage connectionId="c1" />);
await waitFor(() => expect(screen.queryByText(/checking for pg_restore/i)).not.toBeInTheDocument());
expect(screen.getByText("Custom Archive")).toBeTruthy();
});
it("renders a plain MySQL restore (no format selector)", async () => {
mockConnections.push({ id: "c2", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
render(<RestorePage connectionId="c2" />);
await waitFor(() => expect(screen.queryByText(/checking for mysqldump/i)).not.toBeInTheDocument());
expect(screen.queryByText("Custom Archive")).toBeNull();
expect(screen.queryByText(/Plain SQL restores run via psql/i)).toBeNull();
});
it("renders a plain SQLite restore (no format selector, no tool card)", () => {
mockConnections.push({ id: "c3", db_type: "sqlite", name: "s" });
render(<RestorePage connectionId="c3" />);
expect(screen.queryByText("Custom Archive")).toBeNull();
expect(screen.queryByText(/pg_restore/i)).toBeNull();
expect(screen.queryByText(/mysqldump/i)).toBeNull();
});
});
+157 -36
View File
@@ -5,38 +5,62 @@ import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress"; import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgRestore, getSchemas } from "../../lib/commands"; import { useConnectionStore } from "../../stores/connectionStore";
import type { PgToolStatus } from "../../lib/types"; import {
detectPgTools,
pgRestore,
getSchemas,
detectMysqlTools,
mysqlRestore,
sqliteRestore,
} from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
interface RestorePageProps { interface RestorePageProps {
connectionId: string; connectionId: string;
} }
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = { const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq", darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch", linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.", win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
}; };
function getPlatformInstructions(): string { const MYSQL_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install mysql-client",
linux: "sudo apt install mysql-client # Debian/Ubuntu\nsudo dnf install mysql # Fedora\nsudo pacman -S mariadb # Arch",
win32: "Download MySQL installer from https://dev.mysql.com/downloads/installer/ and ensure mysql is in your PATH.",
};
function getPlatformInstructions(map: Record<string, string>): string {
const platform = const platform =
typeof navigator !== "undefined" typeof navigator !== "undefined"
? navigator.platform.toLowerCase() ? navigator.platform.toLowerCase()
: ""; : "";
if (platform.includes("mac") || platform.includes("darwin")) if (platform.includes("mac") || platform.includes("darwin"))
return PLATFORM_INSTALL_INSTRUCTIONS.darwin; return map.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux; if (platform.includes("linux")) return map.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32; if (platform.includes("win")) return map.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux; return map.linux;
} }
export function RestorePage({ connectionId }: RestorePageProps) { export function RestorePage({ connectionId }: RestorePageProps) {
const connection = useConnectionStore((s) =>
s.connections.find((c) => c.id === connectionId),
);
const dbType = connection?.db_type ?? "postgresql";
const database = connection?.database ?? null;
const isPg = dbType === "postgresql";
const isMysql = dbType === "mysql";
const isSqlite = dbType === "sqlite";
const [filePath, setFilePath] = useState(""); const [filePath, setFilePath] = useState("");
const [format, setFormat] = useState("custom"); const [format, setFormat] = useState("custom");
const [clean, setClean] = useState(true); const [clean, setClean] = useState(true);
const [schema, setSchema] = useState(""); const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false); const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null); const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true); const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]); const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
@@ -68,10 +92,15 @@ export function RestorePage({ connectionId }: RestorePageProps) {
useEffect(() => { useEffect(() => {
setCheckingTools(true); setCheckingTools(true);
setConfirmed(false); setConfirmed(false);
setPgToolStatus(null);
setMysqlToolStatus(null);
setAvailableSchemas([]);
if (isPg) {
detectPgTools() detectPgTools()
.then((status) => setToolStatus(status)) .then((status) => setPgToolStatus(status))
.catch(() => .catch(() =>
setToolStatus({ setPgToolStatus({
pg_dump_found: false, pg_dump_found: false,
pg_restore_found: false, pg_restore_found: false,
pg_dump_version: null, pg_dump_version: null,
@@ -85,47 +114,130 @@ export function RestorePage({ connectionId }: RestorePageProps) {
getSchemas(connectionId) getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas)) .then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([])); .catch(() => setAvailableSchemas([]));
}, [connectionId]); } else if (isMysql) {
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
setMysqlToolStatus({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else {
setCheckingTools(false);
}
}, [connectionId, isPg, isMysql]);
const handlePickFile = useCallback(async () => { const handlePickFile = useCallback(async () => {
const extensions = isPg
? ["dump", "sql", "tar", "custom", "gz"]
: isMysql
? ["sql"]
: ["db", "sqlite", "sql"];
const picked = await open({ const picked = await open({
multiple: false, multiple: false,
filters: [ filters: [
{ {
name: "Backup Files", name: "Backup Files",
extensions: ["dump", "sql", "tar", "custom", "gz"], extensions,
}, },
], ],
}); });
if (picked && typeof picked === "string") setFilePath(picked); if (picked && typeof picked === "string") setFilePath(picked);
}, []); }, [isPg, isMysql, isSqlite]);
const runWithProgress = useCallback(
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
const jobId = `${type}-${Date.now()}`;
startJob(jobId, type);
pendingJobRef.current = jobId;
try {
await action();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
},
[startJob],
);
const handleStartRestore = useCallback(async () => { const handleStartRestore = useCallback(async () => {
if (!filePath) { if (!filePath) {
notify("Please select a file path", "error"); notify("Please select a file path", "error");
return; return;
} }
const jobId = `restore-${Date.now()}`; if (isMysql && !database) {
startJob(jobId, "restore"); notify("MySQL connection has no database selected", "error");
pendingJobRef.current = jobId; return;
}
try { await runWithProgress("restore", () => {
await pgRestore(connectionId, { if (isPg) {
return pgRestore(connectionId, {
format, format,
filePath, filePath,
clean, clean,
schema: schema || undefined, schema: schema || undefined,
}); });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
} }
}, [filePath, format, clean, schema, connectionId, startJob, notify]); if (isMysql) {
return mysqlRestore(connectionId, {
database: database!,
filePath,
clean,
});
}
return sqliteRestore(connectionId, { filePath, clean });
});
}, [
filePath,
database,
isPg,
isMysql,
isSqlite,
format,
clean,
schema,
connectionId,
notify,
runWithProgress,
]);
const toolsMissing = toolStatus && !toolStatus.pg_restore_found; const toolsMissing = isPg
const toolsBundled = toolStatus?.pg_restore_source === "bundled"; ? pgToolStatus && !pgToolStatus.pg_restore_found
: isMysql
? mysqlToolStatus && !mysqlToolStatus.mysqlFound
: false;
const toolsBundled = isPg
? pgToolStatus?.pg_restore_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqlSource === "bundled"
: false;
const canStart = filePath && confirmed && !isRunning; const canStart = filePath && confirmed && !isRunning;
const checkingMessage = isPg
? "Checking for pg_restore..."
: isMysql
? "Checking for mysql..."
: null;
const headerDescription = isPg
? "Restore a database from a backup file"
: isMysql
? "Restore a database from a SQL dump"
: "Restore a database from a backup file";
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Toolbar header */} {/* Toolbar header */}
@@ -133,7 +245,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
<Upload size={14} className="text-accent" /> <Upload size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Restore</span> <span className="text-xs font-medium text-text">Restore</span>
<span className="text-[11px] text-text-muted"> <span className="text-[11px] text-text-muted">
Restore a database from a backup file {headerDescription}
</span> </span>
</div> </div>
@@ -141,10 +253,10 @@ export function RestorePage({ connectionId }: RestorePageProps) {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border"> <div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */} {/* Tool check */}
{checkingTools && ( {checkingTools && checkingMessage && (
<div className="glass p-4 text-center"> <div className="glass p-4 text-center">
<p className="text-sm text-text-muted"> <p className="text-sm text-text-muted">
Checking for pg_restore... {checkingMessage}
</p> </p>
</div> </div>
)} )}
@@ -152,14 +264,18 @@ export function RestorePage({ connectionId }: RestorePageProps) {
{toolsMissing && !toolsBundled && ( {toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2"> <div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold"> <p className="text-amber-300 text-sm font-semibold">
pg_restore not found {isPg ? "pg_restore not found" : "mysql client not found"}
</p> </p>
<p className="text-amber-200/80 text-xs leading-relaxed"> <p className="text-amber-200/80 text-xs leading-relaxed">
The PostgreSQL client tools are required for The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
backup/restore operations. Install them using: backup/restore operations. Install them using:
</p> </p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed"> <pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions()} {getPlatformInstructions(
isPg
? PG_INSTALL_INSTRUCTIONS
: MYSQL_INSTALL_INSTRUCTIONS,
)}
</pre> </pre>
</div> </div>
)} )}
@@ -168,7 +284,8 @@ export function RestorePage({ connectionId }: RestorePageProps) {
<> <>
{/* Configuration card */} {/* Configuration card */}
<div className="p-5 space-y-5"> <div className="p-5 space-y-5">
{/* Format */} {/* Format (PostgreSQL only) */}
{isPg && (
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium"> <label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format Format
@@ -190,6 +307,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</option> </option>
</select> </select>
</div> </div>
)}
{/* Backup file */} {/* Backup file */}
<div className="space-y-1 w-full"> <div className="space-y-1 w-full">
@@ -218,6 +336,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</div> </div>
{/* Schema (optional) */} {/* Schema (optional) */}
{(isPg || isMysql) && (
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium"> <label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "} Schema{" "}
@@ -240,11 +359,13 @@ export function RestorePage({ connectionId }: RestorePageProps) {
))} ))}
</select> </select>
</div> </div>
)}
{/* Clean toggle */} {/* Clean toggle */}
{(isPg || isMysql || isSqlite) && (
<label <label
className={`flex items-center gap-2.5 cursor-pointer group ${ className={`flex items-center gap-2.5 cursor-pointer group ${
format === "plain" isPg && format === "plain"
? "opacity-40 pointer-events-none" ? "opacity-40 pointer-events-none"
: "" : ""
}`} }`}
@@ -255,7 +376,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
onChange={(e) => onChange={(e) =>
setClean(e.target.checked) setClean(e.target.checked)
} }
disabled={format === "plain"} disabled={isPg && format === "plain"}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed" className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
/> />
<span className="text-sm text-text-muted group-hover:text-text transition-colors"> <span className="text-sm text-text-muted group-hover:text-text transition-colors">
@@ -265,7 +386,8 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</code> </code>
</span> </span>
</label> </label>
{format === "plain" && ( )}
{isPg && format === "plain" && (
<p className="text-[11px] text-text-muted/70 -mt-3"> <p className="text-[11px] text-text-muted/70 -mt-3">
Plain SQL restores run via psql and don't Plain SQL restores run via psql and don't
support DROP-before-CREATE. Use Custom support DROP-before-CREATE. Use Custom
@@ -325,4 +447,3 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</div> </div>
); );
} }
+1
View File
@@ -58,6 +58,7 @@ export function SyncDialog({ open, onClose }: SyncDialogProps) {
targetConnectionId, targetConnectionId,
schema: schema || undefined, schema: schema || undefined,
tables: undefined, tables: undefined,
dbType: "postgresql",
}); });
notify("Sync completed successfully", "success"); notify("Sync completed successfully", "success");
onClose(); onClose();
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { SyncPage } from "./SyncPage";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as commands from "../../lib/commands";
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
const mockConnections: any[] = [];
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
}));
const pgToolsOk = {
pg_dump_found: true,
pg_restore_found: true,
pg_dump_version: "16",
pg_restore_version: "16",
pg_dump_source: "system",
pg_restore_source: "system",
};
const mysqlToolsOk = {
mysqldumpFound: true,
mysqlFound: true,
mysqldumpVersion: "8.0",
mysqlVersion: "8.0",
mysqldumpSource: "system",
mysqlSource: "system",
};
describe("SyncPage DB-aware", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
});
it("filters target connections to the same db_type as source (mysql)", async () => {
mockConnections.push(
{ id: "pg1", db_type: "postgresql", name: "Postgres 1" },
{ id: "my1", db_type: "mysql", name: "MySQL 1" },
{ id: "my2", db_type: "mysql", name: "MySQL 2" },
{ id: "sq1", db_type: "sqlite", name: "SQLite 1" },
);
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
render(<SyncPage />);
await waitFor(() => expect(screen.queryByText(/checking for/i)).not.toBeInTheDocument());
const [sourceSelect, targetSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[];
fireEvent.change(sourceSelect, { target: { value: "my1" } });
const options = Array.from(targetSelect.options).map((o) => o.value);
expect(options).toContain("my1");
expect(options).toContain("my2");
expect(options).not.toContain("pg1");
expect(options).not.toContain("sq1");
});
});
+143 -52
View File
@@ -5,16 +5,24 @@ import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore"; import { useBackupStore } from "../../stores/backupStore";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, dbSync, getSchemas } from "../../lib/commands"; import {
import type { PgToolStatus } from "../../lib/types"; detectPgTools,
dbSync,
getSchemas,
detectMysqlTools,
mysqlSync,
sqliteSync,
} from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob, DbType } from "../../lib/types";
export function SyncPage() { export function SyncPage() {
const [sourceConnectionId, setSourceConnectionId] = useState(""); const [sourceConnectionId, setSourceConnectionId] = useState("");
const [targetConnectionId, setTargetConnectionId] = useState(""); const [targetConnectionId, setTargetConnectionId] = useState("");
const [schema, setSchema] = useState(""); const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false); const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null); const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true); const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]); const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const connections = useConnectionStore((s) => s.connections); const connections = useConnectionStore((s) => s.connections);
@@ -27,6 +35,13 @@ export function SyncPage() {
const isRunning = activeJob?.status === "running"; const isRunning = activeJob?.status === "running";
const pendingJobRef = useRef<string | null>(null); const pendingJobRef = useRef<string | null>(null);
const sourceConnection = connections.find(
(c) => c.id === sourceConnectionId,
);
const dbType: DbType | null = sourceConnection?.db_type ?? null;
const isPg = dbType === "postgresql";
const isMysql = dbType === "mysql";
useEffect(() => { useEffect(() => {
if (!pendingJobRef.current || !activeJob) return; if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return; if (activeJob.id !== pendingJobRef.current) return;
@@ -43,13 +58,18 @@ export function SyncPage() {
} }
}, [activeJob, notify]); }, [activeJob, notify]);
// Tool detection: depends on the selected source connection's DB type
useEffect(() => { useEffect(() => {
setCheckingTools(true); setPgToolStatus(null);
setMysqlToolStatus(null);
setConfirmed(false); setConfirmed(false);
if (isPg) {
setCheckingTools(true);
detectPgTools() detectPgTools()
.then((status) => setToolStatus(status)) .then((status) => setPgToolStatus(status))
.catch(() => .catch(() =>
setToolStatus({ setPgToolStatus({
pg_dump_found: false, pg_dump_found: false,
pg_restore_found: false, pg_restore_found: false,
pg_dump_version: null, pg_dump_version: null,
@@ -59,7 +79,23 @@ export function SyncPage() {
}), }),
) )
.finally(() => setCheckingTools(false)); .finally(() => setCheckingTools(false));
}, []); } else if (isMysql) {
setCheckingTools(true);
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
setMysqlToolStatus({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
}
}, [isPg, isMysql]);
// Fetch schemas from the source connection when it changes // Fetch schemas from the source connection when it changes
useEffect(() => { useEffect(() => {
@@ -68,10 +104,31 @@ export function SyncPage() {
setSchema(""); setSchema("");
return; return;
} }
if (!isPg && !isMysql) {
setAvailableSchemas([]);
setSchema("");
return;
}
getSchemas(sourceConnectionId) getSchemas(sourceConnectionId)
.then((schemas) => setAvailableSchemas(schemas)) .then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([])); .catch(() => setAvailableSchemas([]));
}, [sourceConnectionId]); }, [sourceConnectionId, isPg, isMysql]);
const runWithProgress = useCallback(
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
const jobId = `${type}-${Date.now()}`;
startJob(jobId, type);
pendingJobRef.current = jobId;
try {
await action();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
},
[startJob],
);
const handleStartSync = useCallback(async () => { const handleStartSync = useCallback(async () => {
if (!sourceConnectionId || !targetConnectionId) { if (!sourceConnectionId || !targetConnectionId) {
@@ -82,35 +139,61 @@ export function SyncPage() {
notify("Source and target must be different", "error"); notify("Source and target must be different", "error");
return; return;
} }
const jobId = `sync-${Date.now()}`; if (!dbType) {
startJob(jobId, "sync"); notify("Unable to determine database type for sync", "error");
pendingJobRef.current = jobId; return;
}
try { const options = {
await dbSync({
sourceConnectionId, sourceConnectionId,
targetConnectionId, targetConnectionId,
schema: schema || undefined, schema: schema || undefined,
tables: undefined, tables: undefined,
}); dbType,
} catch (e) { };
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
}, [sourceConnectionId, targetConnectionId, schema, startJob, notify]);
const toolsMissing = await runWithProgress("sync", () => {
toolStatus && if (isPg) return dbSync(options);
(!toolStatus.pg_dump_found || !toolStatus.pg_restore_found); if (isMysql) return mysqlSync(options);
const toolsBundled = return sqliteSync(options);
toolStatus?.pg_dump_source === "bundled" && });
toolStatus?.pg_restore_source === "bundled"; }, [
sourceConnectionId,
targetConnectionId,
dbType,
isPg,
isMysql,
schema,
notify,
runWithProgress,
]);
const toolsMissing = isPg
? pgToolStatus &&
(!pgToolStatus.pg_dump_found || !pgToolStatus.pg_restore_found)
: isMysql
? mysqlToolStatus &&
(!mysqlToolStatus.mysqldumpFound || !mysqlToolStatus.mysqlFound)
: false;
const toolsBundled = isPg
? pgToolStatus?.pg_dump_source === "bundled" &&
pgToolStatus?.pg_restore_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqldumpSource === "bundled" &&
mysqlToolStatus?.mysqlSource === "bundled"
: false;
const canStart = const canStart =
sourceConnectionId && targetConnectionId && confirmed && !isRunning; sourceConnectionId && targetConnectionId && confirmed && !isRunning;
const postgresqlConnections = connections.filter( const checkingMessage = isPg
(c) => c.db_type === "postgresql", ? "Checking for pg_dump / pg_restore..."
); : isMysql
? "Checking for mysqldump / mysql..."
: null;
const targetConnections = dbType
? connections.filter((c) => c.db_type === dbType)
: [];
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -119,7 +202,7 @@ export function SyncPage() {
<ArrowLeftRight size={14} className="text-accent" /> <ArrowLeftRight size={14} className="text-accent" />
<span className="text-xs font-medium text-text">DB Sync</span> <span className="text-xs font-medium text-text">DB Sync</span>
<span className="text-[11px] text-text-muted"> <span className="text-[11px] text-text-muted">
Transfer data between PostgreSQL databases via pipe Transfer data between databases via pipe
</span> </span>
</div> </div>
@@ -127,10 +210,10 @@ export function SyncPage() {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border"> <div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */} {/* Tool check */}
{checkingTools && ( {checkingTools && checkingMessage && (
<div className="glass p-4 text-center"> <div className="glass p-4 text-center">
<p className="text-sm text-text-muted"> <p className="text-sm text-text-muted">
Checking for pg_dump / pg_restore... {checkingMessage}
</p> </p>
</div> </div>
)} )}
@@ -138,19 +221,27 @@ export function SyncPage() {
{toolsMissing && !toolsBundled && ( {toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2"> <div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold"> <p className="text-amber-300 text-sm font-semibold">
PostgreSQL tools not found {isPg
? "PostgreSQL tools not found"
: "MySQL tools not found"}
</p> </p>
<p className="text-amber-200/80 text-xs leading-relaxed"> <p className="text-amber-200/80 text-xs leading-relaxed">
Both pg_dump and pg_restore are required for Both {isPg ? "pg_dump and pg_restore" : "mysqldump and mysql"} are required for
database sync. database sync.
</p> </p>
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5"> <ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
{!toolStatus?.pg_dump_found && ( {isPg && !pgToolStatus?.pg_dump_found && (
<li>pg_dump is missing.</li> <li>pg_dump is missing.</li>
)} )}
{!toolStatus?.pg_restore_found && ( {isPg && !pgToolStatus?.pg_restore_found && (
<li>pg_restore is missing.</li> <li>pg_restore is missing.</li>
)} )}
{isMysql && !mysqlToolStatus?.mysqldumpFound && (
<li>mysqldump is missing.</li>
)}
{isMysql && !mysqlToolStatus?.mysqlFound && (
<li>mysql is missing.</li>
)}
</ul> </ul>
</div> </div>
)} )}
@@ -168,24 +259,21 @@ export function SyncPage() {
</label> </label>
<select <select
value={sourceConnectionId} value={sourceConnectionId}
onChange={(e) => onChange={(e) => {
setSourceConnectionId( setSourceConnectionId(
e.target.value, e.target.value,
) );
} setTargetConnectionId("");
}}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer" className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
> >
<option value=""> <option value="">
Select source... Select source...
</option> </option>
{postgresqlConnections.map((c) => ( {connections.map((c) => (
<option <option
key={c.id} key={c.id}
value={c.id} value={c.id}
disabled={
c.id ===
targetConnectionId
}
> >
{c.name} {c.name}
</option> </option>
@@ -204,18 +292,20 @@ export function SyncPage() {
e.target.value, e.target.value,
) )
} }
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer" disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
> >
<option value=""> <option value="">
Select target... {sourceConnectionId
? "Select target..."
: "Select a source first"}
</option> </option>
{postgresqlConnections.map((c) => ( {targetConnections.map((c) => (
<option <option
key={c.id} key={c.id}
value={c.id} value={c.id}
disabled={ disabled={
c.id === c.id === sourceConnectionId
sourceConnectionId
} }
> >
{c.name} {c.name}
@@ -226,6 +316,7 @@ export function SyncPage() {
</div> </div>
{/* Schema (optional) */} {/* Schema (optional) */}
{(isPg || isMysql) && (
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium"> <label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "} Schema{" "}
@@ -253,12 +344,13 @@ export function SyncPage() {
))} ))}
</select> </select>
</div> </div>
)}
{/* Flow indicator */} {/* Flow indicator */}
{sourceConnectionId && targetConnectionId && ( {sourceConnectionId && targetConnectionId && (
<div className="flex items-center gap-3 text-[11px] text-text-muted"> <div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="font-medium text-text"> <span className="font-medium text-text">
{postgresqlConnections.find( {connections.find(
(c) => (c) =>
c.id === sourceConnectionId, c.id === sourceConnectionId,
)?.name ?? sourceConnectionId} )?.name ?? sourceConnectionId}
@@ -268,7 +360,7 @@ export function SyncPage() {
className="text-accent shrink-0" className="text-accent shrink-0"
/> />
<span className="font-medium text-text"> <span className="font-medium text-text">
{postgresqlConnections.find( {connections.find(
(c) => (c) =>
c.id === targetConnectionId, c.id === targetConnectionId,
)?.name ?? targetConnectionId} )?.name ?? targetConnectionId}
@@ -329,4 +421,3 @@ export function SyncPage() {
</div> </div>
); );
} }
@@ -4,6 +4,8 @@ import type { ComponentProps } from "react";
import { TableControls, formatDuration } from "./TableControls"; import { TableControls, formatDuration } from "./TableControls";
import { TooltipProvider } from "../ui/Tooltip"; import { TooltipProvider } from "../ui/Tooltip";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as exportData from "../../lib/exportData";
import type { ViewerTab } from "../../stores/dbViewerStore"; import type { ViewerTab } from "../../stores/dbViewerStore";
const columns = [ const columns = [
@@ -331,4 +333,28 @@ describe("TableControls", () => {
screen.getByText("Drop columns here to add filters"), screen.getByText("Drop columns here to add filters"),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("export dropdown includes the Excel option", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/export/i));
expect(screen.getByText("JSON")).toBeInTheDocument();
expect(screen.getByText("CSV")).toBeInTheDocument();
expect(screen.getByText("SQL")).toBeInTheDocument();
expect(screen.getByText("Markdown")).toBeInTheDocument();
expect(screen.getByText("Excel")).toBeInTheDocument();
});
it("notifies after a successful export", () => {
seed([makeTab()], "tab-1");
useNotificationStore.getState().notifications.length = 0;
vi.spyOn(exportData, "exportData").mockImplementation(() => {});
renderControls();
fireEvent.click(screen.getByLabelText(/export/i));
fireEvent.click(screen.getByText("Excel"));
const st = useNotificationStore.getState();
expect(
st.notifications.some((n) => n.message.toLowerCase().includes("exported")),
).toBe(true);
});
}); });
@@ -5,6 +5,7 @@ import {
ChevronDown, FileJson, FileText, Terminal, ChevronDown, FileJson, FileText, Terminal,
} from "lucide-react"; } from "lucide-react";
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore"; import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { FilterBuilder } from "./FilterBuilder"; import { FilterBuilder } from "./FilterBuilder";
import { Tooltip } from "../ui/Tooltip"; import { Tooltip } from "../ui/Tooltip";
import { exportData } from "../../lib/exportData"; import { exportData } from "../../lib/exportData";
@@ -26,6 +27,7 @@ const EXPORT_FORMATS = [
{ label: "CSV", ext: "csv" }, { label: "CSV", ext: "csv" },
{ label: "SQL", ext: "sql" }, { label: "SQL", ext: "sql" },
{ label: "Markdown", ext: "md" }, { label: "Markdown", ext: "md" },
{ label: "Excel", ext: "xlsx" },
] as const; ] as const;
// ─── helpers ──────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────
@@ -430,6 +432,7 @@ export function TableControls({
variant = "table", variant = "table",
}: TableControlsProps) { }: TableControlsProps) {
const isQuery = variant === "query"; const isQuery = variant === "query";
const notify = useNotificationStore((s) => s.notify);
const tabs = useDbViewerStore((s) => s.tabs); const tabs = useDbViewerStore((s) => s.tabs);
const activeTabId = useDbViewerStore((s) => s.activeTabId); const activeTabId = useDbViewerStore((s) => s.activeTabId);
const setPage = useDbViewerStore((s) => s.setPage); const setPage = useDbViewerStore((s) => s.setPage);
@@ -495,7 +498,20 @@ export function TableControls({
}; };
const handleExport = (format: string) => { const handleExport = (format: string) => {
const label =
EXPORT_FORMATS.find((f) => f.ext === format)?.label ?? format.toUpperCase();
try {
exportData(rows, columns, format, table); exportData(rows, columns, format, table);
notify(
`Exported ${rows.length} row${rows.length === 1 ? "" : "s"} as ${label}`,
"success",
);
} catch (e) {
notify(
`Export failed: ${e instanceof Error ? e.message : String(e)}`,
"error",
);
}
setExportOpen(false); setExportOpen(false);
}; };
@@ -56,6 +56,7 @@ describe("TableOverflowMenu", () => {
expect(screen.getByText("Open in new tab")).toBeInTheDocument(); expect(screen.getByText("Open in new tab")).toBeInTheDocument();
expect(screen.getByText("Copy table schema")).toBeInTheDocument(); expect(screen.getByText("Copy table schema")).toBeInTheDocument();
expect(screen.getByText("Export data (CSV)")).toBeInTheDocument(); expect(screen.getByText("Export data (CSV)")).toBeInTheDocument();
expect(screen.getByText("Export data (Excel)")).toBeInTheDocument();
}); });
it("fires onOpenTab when menu item clicked", async () => { it("fires onOpenTab when menu item clicked", async () => {
@@ -118,6 +119,41 @@ describe("TableOverflowMenu", () => {
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "csv", "public.t")); await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "csv", "public.t"));
}); });
it("Excel export calls exportData and notifies", async () => {
const spy = vi.spyOn(exportData, "exportData").mockImplementation(() => {});
const { useNotificationStore } = await import("../../stores/notificationStore");
useNotificationStore.getState().notifications.length = 0;
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }];
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 \(excel\)/i));
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "xlsx", "public.t"));
const st = useNotificationStore.getState();
expect(st.notifications.some((n) => n.message.toLowerCase().includes("exported"))).toBe(true);
});
it("tree kebab export fetches table data when rows are absent", async () => {
const spy = vi.spyOn(exportData, "exportData").mockImplementation(() => {});
const getSpy = vi.spyOn(commands, "getTableData").mockResolvedValue({
columns: [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }],
rows: [[42]],
total_rows: 1,
page: 1,
page_size: 1000,
});
const { useNotificationStore } = await import("../../stores/notificationStore");
useNotificationStore.getState().notifications.length = 0;
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} connectionId="c1" />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/export data \(excel\)/i));
await waitFor(() =>
expect(getSpy).toHaveBeenCalledWith("c1", "public", "t", 1, 1000),
);
await waitFor(() => expect(spy).toHaveBeenCalled());
const st = useNotificationStore.getState();
expect(st.notifications.some((n) => n.message.includes("Exported"))).toBe(true);
});
it("maintenance items are gated by capability and run via confirm", async () => { it("maintenance items are gated by capability and run via confirm", async () => {
vi.spyOn(commands, "runMaintenance").mockResolvedValue({ duration_ms: 3, message: "VACUUM completed" }); vi.spyOn(commands, "runMaintenance").mockResolvedValue({ duration_ms: 3, message: "VACUUM completed" });
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" />); render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" />);
+39 -1
View File
@@ -133,10 +133,47 @@ export function TableOverflowMenu({
case "export-csv": case "export-csv":
case "export-json": case "export-json":
case "export-sql": case "export-sql":
case "export-md": { case "export-md":
case "export-xlsx": {
const format = id.replace("export-", ""); const format = id.replace("export-", "");
const label =
format === "xlsx"
? "Excel"
: format === "md"
? "Markdown"
: format.toUpperCase();
try {
if (rows && rows.length > 0 && columns && columns.length > 0) { if (rows && rows.length > 0 && columns && columns.length > 0) {
exportData(rows, columns, format, `${schema}.${table}`); exportData(rows, columns, format, `${schema}.${table}`);
notify(
`Exported ${rows.length} row${rows.length === 1 ? "" : "s"} as ${label}`,
"success",
);
} else if (connectionId) {
// Tree kebab: no rows are loaded here — fetch the table data
// first, then export (capped at 1000 rows per fetch).
const result = await cmd.getTableData(connectionId, schema, table, 1, 1000);
if (!result.rows.length) {
notify("Nothing to export", "info");
} else {
exportData(result.rows, result.columns, format, `${schema}.${table}`);
const truncated =
result.total_rows > result.rows.length
? ` (first ${result.rows.length} of ${result.total_rows})`
: "";
notify(
`Exported ${result.rows.length} row${result.rows.length === 1 ? "" : "s"} as ${label}${truncated}`,
"success",
);
}
} else {
notify("Nothing to export", "info");
}
} catch (e) {
notify(
`Export failed: ${e instanceof Error ? e.message : String(e)}`,
"error",
);
} }
setOpen(false); setOpen(false);
break; break;
@@ -233,6 +270,7 @@ export function TableOverflowMenu({
{ id: "export-json", label: "Export data (JSON)" }, { id: "export-json", label: "Export data (JSON)" },
{ id: "export-sql", label: "Export data (SQL)" }, { id: "export-sql", label: "Export data (SQL)" },
{ id: "export-md", label: "Export data (Markdown)" }, { id: "export-md", label: "Export data (Markdown)" },
{ id: "export-xlsx", label: "Export data (Excel)" },
{ id: "import", label: "Import data (CSV/JSON)" }, { id: "import", label: "Import data (CSV/JSON)" },
{ id: "create_index", label: "Create Index…" }, { id: "create_index", label: "Create Index…" },
{ id: "create_constraint", label: "Create Constraint…" }, { id: "create_constraint", label: "Create Constraint…" },
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { TableForm } from "./TableForm"; import { TableForm } from "./TableForm";
import { useDbViewerStore } from "../../../stores/dbViewerStore"; import { useDbViewerStore } from "../../../stores/dbViewerStore";
import { useConnectionStore } from "../../../stores/connectionStore";
import * as cmd from "../../../lib/commands"; import * as cmd from "../../../lib/commands";
import * as objectCrud from "../../../lib/objectCrud"; import * as objectCrud from "../../../lib/objectCrud";
@@ -514,3 +515,117 @@ describe("TableForm", () => {
expect(local.value).toBe("category_id"); expect(local.value).toBe("category_id");
}); });
}); });
describe("TableForm SQLite mode", () => {
beforeEach(() => {
useConnectionStore.setState({
connections: [
{
id: "c1",
db_type: "sqlite",
name: "SQLite",
host: "",
port: null,
username: null,
database: null,
folder_id: null,
keychain_ref: null,
tag_ids: [],
created_at: "",
updated_at: "",
favorite: false,
} as any,
],
});
});
afterEach(() => {
useConnectionStore.setState({ connections: [] });
});
it("shows SQLite types in the dropdown and not serial", () => {
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "main",
name: "products",
action: {
op: "create",
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
},
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
const typeSel = screen.getByLabelText("type") as HTMLSelectElement;
const values = Array.from(typeSel.options).map((o) => o.value);
expect(values).toContain("integer");
expect(values).toContain("text");
expect(values).toContain("real");
expect(values).toContain("blob");
expect(values).not.toContain("serial");
});
it("shows INTEGER PRIMARY KEY AUTOINCREMENT in the SQL preview", async () => {
(cmd.buildObjectDdl as any).mockResolvedValue([
'CREATE TABLE "main"."t" ("id" INTEGER PRIMARY KEY AUTOINCREMENT)',
]);
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "main",
name: "t",
action: {
op: "create",
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
},
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
fireEvent.click(screen.getByLabelText("Column settings"));
fireEvent.click(screen.getByLabelText("Auto-Increment"));
fireEvent.click(screen.getByLabelText("SQL"));
expect(
await screen.findByText('CREATE TABLE "main"."t" ("id" INTEGER PRIMARY KEY AUTOINCREMENT)'),
).toBeInTheDocument();
});
it("fixes schema to main and hides the schema picker", async () => {
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "public",
name: "t",
action: { op: "create", columns: [] },
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
expect(screen.queryByLabelText("Schema")).toBeNull();
await waitFor(() => {
expect((useDbViewerStore.getState().tabs[0].form?.params as any).schema).toBe("main");
});
});
});
+41 -10
View File
@@ -30,6 +30,7 @@ import type {
ColumnInfo, ColumnInfo,
ConstraintInfo, ConstraintInfo,
TablespaceInfo, TablespaceInfo,
DbType,
} from "../../../lib/types"; } from "../../../lib/types";
import { getCapabilities } from "../../../lib/dbCapabilities"; import { getCapabilities } from "../../../lib/dbCapabilities";
import { FkPanel, type FkDefinition } from "./FkPanel"; import { FkPanel, type FkDefinition } from "./FkPanel";
@@ -75,6 +76,8 @@ const PG_TYPES = [
"money", "money",
]; ];
const SQLITE_TYPES = ["integer", "real", "text", "blob", "numeric"];
interface TableFormColumn { interface TableFormColumn {
rowId: string; rowId: string;
name: string; name: string;
@@ -96,6 +99,7 @@ interface SqlColumn {
default: string | null; default: string | null;
is_pk: boolean; is_pk: boolean;
unique?: boolean; unique?: boolean;
auto_increment?: boolean;
} }
interface TableFormAction { interface TableFormAction {
@@ -112,10 +116,14 @@ interface TableFormParams {
action: TableFormAction; action: TableFormAction;
} }
function toSqlColumn(c: TableFormColumn, mode: "create" | "edit"): SqlColumn { function toSqlColumn(
c: TableFormColumn,
mode: "create" | "edit",
dbType: DbType | undefined,
): SqlColumn {
let type = c.type; let type = c.type;
const base = c.type.trim().toLowerCase(); const base = c.type.trim().toLowerCase();
if (mode === "create" && c.auto_increment) { if (mode === "create" && c.auto_increment && dbType !== "sqlite") {
if (base === "integer" || base === "int" || base === "int4") if (base === "integer" || base === "int" || base === "int4")
type = "serial"; type = "serial";
else if (base === "bigint" || base === "int8") type = "bigserial"; else if (base === "bigint" || base === "int8") type = "bigserial";
@@ -124,7 +132,7 @@ function toSqlColumn(c: TableFormColumn, mode: "create" | "edit"): SqlColumn {
if (c.params && c.params.trim()) { if (c.params && c.params.trim()) {
type = `${type}(${c.params.trim()})`; type = `${type}(${c.params.trim()})`;
} }
return { const out: SqlColumn = {
name: c.name, name: c.name,
type, type,
nullable: c.nullable, nullable: c.nullable,
@@ -132,6 +140,10 @@ function toSqlColumn(c: TableFormColumn, mode: "create" | "edit"): SqlColumn {
is_pk: c.is_pk, is_pk: c.is_pk,
unique: c.unique ?? undefined, unique: c.unique ?? undefined,
}; };
if (dbType === "sqlite" && c.auto_increment) {
out.auto_increment = true;
}
return out;
} }
function sameColumns(a: TableFormColumn[], b: TableFormColumn[]): boolean { function sameColumns(a: TableFormColumn[], b: TableFormColumn[]): boolean {
@@ -175,6 +187,10 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
x: 0, x: 0,
}); });
function pickTypeList(dbType: DbType | undefined): string[] {
return dbType === "sqlite" ? SQLITE_TYPES : PG_TYPES;
}
// serial types only exist for the integer family (short + long forms). // serial types only exist for the integer family (short + long forms).
function supportsAutoIncrement(type: string): boolean { function supportsAutoIncrement(type: string): boolean {
const t = type.trim().toLowerCase(); const t = type.trim().toLowerCase();
@@ -203,9 +219,12 @@ function buildTablePayload(
params: TableFormParams, params: TableFormParams,
op: "create" | "edit" | "rebuild", op: "create" | "edit" | "rebuild",
foreignKeys: FkDefinition[] = [], foreignKeys: FkDefinition[] = [],
dbType: DbType | undefined,
): Record<string, unknown> { ): Record<string, unknown> {
const action = params.action; const action = params.action;
const sqlColumns = action.columns.map((c) => toSqlColumn(c, action.op)); const sqlColumns = action.columns.map((c) =>
toSqlColumn(c, action.op, dbType),
);
return { return {
...params, ...params,
action: { action: {
@@ -283,6 +302,13 @@ export function TableForm({
); );
}; };
// SQLite has a single schema.
useEffect(() => {
if (dbType === "sqlite" && params.schema !== "main") {
setParams({ ...params, schema: "main" });
}
}, [dbType, params.schema]);
// Rebuild readiness check // Rebuild readiness check
useEffect(() => { useEffect(() => {
if (!isRebuild) { if (!isRebuild) {
@@ -343,7 +369,7 @@ export function TableForm({
: cmd.buildObjectDdl( : cmd.buildObjectDdl(
connectionId, connectionId,
"table", "table",
buildTablePayload(params, op, createFks), buildTablePayload(params, op, createFks, dbType),
); );
promise promise
.then((sqls: string[] | string) => { .then((sqls: string[] | string) => {
@@ -469,7 +495,7 @@ export function TableForm({
const sqls = await cmd.buildObjectDdl( const sqls = await cmd.buildObjectDdl(
connectionId, connectionId,
"table", "table",
buildTablePayload(params, op, createFks), buildTablePayload(params, op, createFks, dbType),
); );
sqls.forEach((sql, i) => sqls.forEach((sql, i) =>
useDbViewerStore.getState().addChange({ useDbViewerStore.getState().addChange({
@@ -581,7 +607,7 @@ export function TableForm({
{view === "visual" ? ( {view === "visual" ? (
<> <>
{mode === "create" && ( {mode === "create" && dbType !== "sqlite" && (
<FormRow label="Schema"> <FormRow label="Schema">
{schemas && schemas.length > 0 ? ( {schemas && schemas.length > 0 ? (
<select <select
@@ -697,6 +723,7 @@ export function TableForm({
column: c.name, column: c.name,
}) })
} }
dbType={dbType}
/> />
))} ))}
</SortableContext> </SortableContext>
@@ -806,6 +833,7 @@ interface ColumnRowProps {
onRemove: () => void; onRemove: () => void;
onFk: () => void; onFk: () => void;
hasFk: boolean; hasFk: boolean;
dbType: DbType | undefined;
} }
function ColumnRow({ function ColumnRow({
@@ -816,6 +844,7 @@ function ColumnRow({
onRemove, onRemove,
onFk, onFk,
hasFk, hasFk,
dbType,
}: ColumnRowProps) { }: ColumnRowProps) {
const { const {
attributes, attributes,
@@ -872,7 +901,9 @@ function ColumnRow({
Primary key Primary key
</label> </label>
{mode === "create" && {mode === "create" &&
supportsAutoIncrement(c.type) && ( (dbType === "sqlite"
? supportsAutoIncrement(c.type) && c.is_pk
: supportsAutoIncrement(c.type)) && (
<label className="flex items-center gap-2 px-2 py-1 text-xs text-text whitespace-nowrap cursor-pointer"> <label className="flex items-center gap-2 px-2 py-1 text-xs text-text whitespace-nowrap cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -982,10 +1013,10 @@ function ColumnRow({
onChange={(e) => setCell(index, "type", e.target.value)} onChange={(e) => setCell(index, "type", e.target.value)}
className="min-w-0 flex-1 bg-transparent font-mono text-xs text-text outline-none cursor-pointer" className="min-w-0 flex-1 bg-transparent font-mono text-xs text-text outline-none cursor-pointer"
> >
{c.type !== "" && !PG_TYPES.includes(c.type) && ( {c.type !== "" && !pickTypeList(dbType).includes(c.type) && (
<option value={c.type}>{c.type}</option> <option value={c.type}>{c.type}</option>
)} )}
{PG_TYPES.map((t) => ( {pickTypeList(dbType).map((t) => (
<option key={t} value={t}> <option key={t} value={t}>
{t} {t}
</option> </option>
@@ -78,6 +78,32 @@ describe("QueryToolbar", () => {
useDbViewerStore.getState().reset(); useDbViewerStore.getState().reset();
}); });
const baseProps = {
onRun: () => {},
onFormat: () => {},
connectionId: "conn-1",
onRestore: () => {},
onRunFromHistory: () => {},
};
it("shows a Cancel button when isRunning is true", () => {
render(
<TooltipProvider>
<QueryToolbar {...baseProps} isRunning={true} onCancel={() => {}} />
</TooltipProvider>,
);
expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy();
});
it("hides the Cancel button when not running", () => {
render(
<TooltipProvider>
<QueryToolbar {...baseProps} isRunning={false} onCancel={() => {}} />
</TooltipProvider>,
);
expect(screen.queryByRole("button", { name: /cancel/i })).toBeNull();
});
it("renders Run Query and the format icon button", () => { it("renders Run Query and the format icon button", () => {
renderToolbar({}); renderToolbar({});
expect( expect(
+22 -2
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Play, Wand2, Save } from "lucide-react"; import { Play, Square, Wand2, Save } from "lucide-react";
import { Tooltip } from "../ui/Tooltip"; import { Tooltip } from "../ui/Tooltip";
import { QueryHistoryDropdown } from "./QueryHistoryDropdown"; import { QueryHistoryDropdown } from "./QueryHistoryDropdown";
import { SaveQueryDialog } from "./SaveQueryDialog"; import { SaveQueryDialog } from "./SaveQueryDialog";
@@ -36,6 +36,8 @@ interface QueryToolbarProps {
onRunFromHistory: (sql: string) => void; onRunFromHistory: (sql: string) => void;
dbType?: DbType; dbType?: DbType;
readOnly?: boolean; readOnly?: boolean;
isRunning?: boolean;
onCancel?: () => void;
} }
export function QueryToolbar({ export function QueryToolbar({
@@ -46,10 +48,13 @@ export function QueryToolbar({
onRunFromHistory, onRunFromHistory,
dbType, dbType,
readOnly = false, readOnly = false,
isRunning: isRunningProp,
onCancel,
}: QueryToolbarProps) { }: QueryToolbarProps) {
const tabs = useDbViewerStore((s) => s.tabs); const tabs = useDbViewerStore((s) => s.tabs);
const activeTabId = useDbViewerStore((s) => s.activeTabId); const activeTabId = useDbViewerStore((s) => s.activeTabId);
const isRunning = tabs.find((t) => t.id === activeTabId)?.loading ?? false; const isRunning =
isRunningProp ?? tabs.find((t) => t.id === activeTabId)?.loading ?? false;
const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const activeTab = tabs.find((t) => t.id === activeTabId); const activeTab = tabs.find((t) => t.id === activeTabId);
@@ -100,6 +105,21 @@ export function QueryToolbar({
</button> </button>
</Tooltip> </Tooltip>
{/* Cancel Query */}
{isRunning && onCancel && (
<Tooltip content="Cancel running query" side="bottom">
<button
type="button"
onClick={onCancel}
className="flex items-center gap-1.5 rounded-md border border-red-500/50 bg-red-500/10 px-2.5 py-1 font-medium text-red-400 transition-colors hover:bg-red-500/20 cursor-pointer"
aria-label="Cancel query"
>
<Square className="h-3 w-3 fill-current" />
<span>Cancel</span>
</button>
</Tooltip>
)}
{/* History dropdown */} {/* History dropdown */}
<QueryHistoryDropdown <QueryHistoryDropdown
connectionId={connectionId} connectionId={connectionId}
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GeneralSettingsTab } from "./GeneralSettingsTab";
import { useSettingsStore } from "../../stores/settingsStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as commands from "../../lib/commands";
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: vi.fn(),
open: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-fs", () => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}));
vi.mock("../../lib/commands", () => ({
getSettings: vi.fn().mockResolvedValue({
theme: "system",
font_size: "medium",
default_folder_id: null,
confirm_before_delete: true,
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null, redis: 6379 },
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,
}),
updateSetting: vi.fn().mockResolvedValue(undefined),
exportSettings: vi.fn().mockResolvedValue('{"schemaVersion":1,"settings":{}}'),
importSettings: vi.fn().mockResolvedValue(undefined),
recreateDemoDb: vi.fn().mockResolvedValue("Demo re-added"),
regenerateDemoDb: vi.fn().mockResolvedValue("Demo regenerated"),
}));
const baseSettings = {
theme: "system" as const,
font_size: "medium" as const,
default_folder_id: null,
confirm_before_delete: true,
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null as number | null, redis: 6379 },
tag_order: null,
table_refresh_rate: 0,
table_page_size: 50,
shortcuts: {} as Record<string, string>,
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("GeneralSettingsTab settings export/import", () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ settings: baseSettings, loading: false, error: null });
useConnectionStore.setState({
connections: [],
folders: [],
tags: [],
tagOrder: [],
loading: false,
error: null,
});
useNotificationStore.setState({ notifications: [] });
});
it("renders Export Settings and Import Settings buttons", () => {
render(<GeneralSettingsTab />);
expect(screen.getByRole("button", { name: /export settings/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /import settings/i })).toBeInTheDocument();
});
it("exports settings to the chosen file and shows a success toast", async () => {
const user = userEvent.setup();
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const exportedJson = JSON.stringify({ schemaVersion: 1, settings: baseSettings });
(commands.exportSettings as ReturnType<typeof vi.fn>).mockResolvedValue(exportedJson);
(save as ReturnType<typeof vi.fn>).mockResolvedValue("/tmp/gridline-settings.json");
(writeTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
render(<GeneralSettingsTab />);
await user.click(screen.getByRole("button", { name: /export settings/i }));
await waitFor(() => {
expect(writeTextFile).toHaveBeenCalledWith("/tmp/gridline-settings.json", exportedJson);
});
expect(useNotificationStore.getState().notifications).toContainEqual(
expect.objectContaining({ type: "success", message: expect.stringMatching(/exported/i) })
);
});
it("imports settings from the chosen file, reloads settings, and shows a success toast", async () => {
const user = userEvent.setup();
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const importedJson = JSON.stringify({ schemaVersion: 1, settings: baseSettings });
(open as ReturnType<typeof vi.fn>).mockResolvedValue("/tmp/gridline-settings.json");
(readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(importedJson);
render(<GeneralSettingsTab />);
await user.click(screen.getByRole("button", { name: /import settings/i }));
await waitFor(() => {
expect(commands.importSettings).toHaveBeenCalledWith(importedJson);
});
expect(useNotificationStore.getState().notifications).toContainEqual(
expect.objectContaining({ type: "success", message: expect.stringMatching(/imported/i) })
);
});
it("surfaces an error toast when importing an invalid settings file", async () => {
const user = userEvent.setup();
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const invalidJson = JSON.stringify({
schemaVersion: 1,
settings: { ...baseSettings, theme: "purple" },
});
(open as ReturnType<typeof vi.fn>).mockResolvedValue("/tmp/bad.json");
(readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(invalidJson);
render(<GeneralSettingsTab />);
await user.click(screen.getByRole("button", { name: /import settings/i }));
await waitFor(() => {
expect(useNotificationStore.getState().notifications).toContainEqual(
expect.objectContaining({
type: "error",
message: expect.stringMatching(/invalid settings/i),
})
);
});
});
it("surfaces an error toast when importing malformed JSON", async () => {
const user = userEvent.setup();
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
(open as ReturnType<typeof vi.fn>).mockResolvedValue("/tmp/bad.json");
(readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue("not-json");
render(<GeneralSettingsTab />);
await user.click(screen.getByRole("button", { name: /import settings/i }));
await waitFor(() => {
expect(useNotificationStore.getState().notifications).toContainEqual(
expect.objectContaining({
type: "error",
message: expect.stringMatching(/invalid settings/i),
})
);
});
});
});
@@ -7,8 +7,10 @@ import { AccentPicker } from "../ui/AccentPicker";
import { SettingsRow } from "../ui/SettingsRow"; import { SettingsRow } from "../ui/SettingsRow";
import { ConfirmDialog } from "../ui/ConfirmDialog"; import { ConfirmDialog } from "../ui/ConfirmDialog";
import * as cmd from "../../lib/commands"; import * as cmd from "../../lib/commands";
import { validateSettingsExport } from "../../lib/settingsImport";
import type { FontSize } from "../../lib/types"; import type { FontSize } from "../../lib/types";
import { useState } from "react"; import { useState } from "react";
import { save, open } from "@tauri-apps/plugin-dialog";
const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [ const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [
{ value: "small", label: "Small" }, { value: "small", label: "Small" },
@@ -72,6 +74,53 @@ export function GeneralSettingsTab() {
} }
}; };
const handleExport = async () => {
try {
const json = await cmd.exportSettings();
const path = await save({
defaultPath: "gridline-settings.json",
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (!path) return;
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
await writeTextFile(path, json);
notify("Settings exported", "success");
} catch (e) {
notify(e instanceof Error ? e.message : String(e), "error");
}
};
const handleImport = async () => {
try {
const p = await open({
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (!p || Array.isArray(p)) return;
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const text = await readTextFile(p);
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
notify("Invalid settings: file is not valid JSON", "error");
return;
}
const r = validateSettingsExport(parsed);
if (!r.ok) {
notify(
`Invalid settings: ${r.errors.map((e) => e.field).join(", ")}`,
"error",
);
return;
}
await cmd.importSettings(text);
await load();
notify("Settings imported", "success");
} catch (e) {
notify(e instanceof Error ? e.message : String(e), "error");
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<section> <section>
@@ -148,6 +197,36 @@ export function GeneralSettingsTab() {
</div> </div>
</section> </section>
<section>
<h2 className="text-sm font-medium text-text mb-3">Data</h2>
<div className="flex flex-col gap-4">
<SettingsRow
title="Export settings"
description="Save your settings to a JSON file."
>
<button
type="button"
onClick={handleExport}
className="rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-raised transition-colors"
>
Export settings
</button>
</SettingsRow>
<SettingsRow
title="Import settings"
description="Restore settings from a previously exported JSON file."
>
<button
type="button"
onClick={handleImport}
className="rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-raised transition-colors"
>
Import settings
</button>
</SettingsRow>
</div>
</section>
<section> <section>
<h2 className="text-sm font-medium text-text mb-3">Demo</h2> <h2 className="text-sm font-medium text-text mb-3">Demo</h2>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
+3 -3
View File
@@ -1,11 +1,11 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import tauriConf from "../../src-tauri/tauri.conf.json"; import tauriConf from "../../src-tauri/tauri.conf.json";
describe("tauri bundle config (v0.7.7)", () => { describe("tauri bundle config (v0.7.8)", () => {
it("declares bundled pg_tools resources", () => { it("declares bundled pg_tools resources", () => {
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*"); expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
}); });
it("version is 0.7.7", () => { it("version is 0.7.8", () => {
expect(tauriConf.version).toBe("0.7.7"); expect(tauriConf.version).toBe("0.7.8");
}); });
}); });
+9
View File
@@ -44,6 +44,7 @@ import {
getObjectDdl, getObjectDdl,
getObjectDependencies, getObjectDependencies,
} from "./commands"; } from "./commands";
import * as cmd from "./commands";
import type { SchemaGraph } from "./types"; import type { SchemaGraph } from "./types";
import type { QueryHistoryEntry } from "./commands"; import type { QueryHistoryEntry } from "./commands";
@@ -424,3 +425,11 @@ describe("v0.7.7 command wrappers", () => {
expect(result).toEqual(mockScript); expect(result).toEqual(mockScript);
}); });
}); });
describe("v0.7.8 command wrappers exist", () => {
it("exports the backup/cancel/settings wrappers", () => {
for (const name of ["cancelQuery","mysqlDump","mysqlRestore","mysqlSync","detectMysqlTools","sqliteDump","sqliteRestore","sqliteSync","exportSettings","importSettings"]) {
expect(typeof (cmd as Record<string, unknown>)[name]).toBe("function");
}
});
});
+43 -1
View File
@@ -1,5 +1,5 @@
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, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types"; import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types";
import type { FilterRule, SortRule } from "../stores/dbViewerStore"; import type { FilterRule, SortRule } from "../stores/dbViewerStore";
import type { ChangePayload } from "./changePayload"; import type { ChangePayload } from "./changePayload";
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud"; import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
@@ -162,6 +162,48 @@ export async function dbSync(options: SyncOptions): Promise<string> {
return invoke<string>("db_sync", { options }); return invoke<string>("db_sync", { options });
} }
// ─── v0.7.8: Cancel / MySQL / SQLite / Settings export-import ────
export async function cancelQuery(connectionId: string): Promise<void> {
return invoke<void>("cancel_query", { connectionId });
}
export async function detectMysqlTools(): Promise<MySqlToolStatus> {
return invoke<MySqlToolStatus>("detect_mysql_tools");
}
export async function mysqlDump(connectionId: string, options: MySqlBackupOptions): Promise<string> {
return invoke<string>("mysql_dump", { connectionId, options });
}
export async function mysqlRestore(connectionId: string, options: MySqlRestoreOptions): Promise<string> {
return invoke<string>("mysql_restore", { connectionId, options });
}
export async function mysqlSync(options: SyncOptions): Promise<string> {
return invoke<string>("mysql_sync", { options });
}
export async function sqliteDump(connectionId: string, options: SqliteBackupOptions): Promise<string> {
return invoke<string>("sqlite_dump", { connectionId, options });
}
export async function sqliteRestore(connectionId: string, options: SqliteRestoreOptions): Promise<string> {
return invoke<string>("sqlite_restore", { connectionId, options });
}
export async function sqliteSync(options: SyncOptions): Promise<string> {
return invoke<string>("sqlite_sync", { options });
}
export async function exportSettings(): Promise<string> {
return invoke<string>("export_settings");
}
export async function importSettings(json: string): Promise<void> {
return invoke<void>("import_settings", { json });
}
// ─── Object Explorer (Functions, Triggers, Sequences, Enums, Extensions) ──── // ─── Object Explorer (Functions, Triggers, Sequences, Enums, Extensions) ────
export async function getFunctions(connectionId: string, schema?: string): Promise<FunctionInfo[]> { export async function getFunctions(connectionId: string, schema?: string): Promise<FunctionInfo[]> {
+30 -6
View File
@@ -11,19 +11,19 @@ describe("dbCapabilities", () => {
}); });
}); });
it("gives MySQL explorer/queries/editing/import/ddl but not objects/visualizer/tools", () => { it("gives MySQL explorer/queries/editing/import/ddl/tools but not objects/visualizer", () => {
const c = DB_CAPABILITIES.mysql; const c = DB_CAPABILITIES.mysql;
expect(c.explorer).toBe(true); expect(c.explorer).toBe(true);
expect(c.queries).toBe(true); expect(c.queries).toBe(true);
expect(c.editing).toBe(true); expect(c.editing).toBe(true);
expect(c.import).toBe(true); expect(c.import).toBe(true);
expect(c.ddl).toBe(true); expect(c.ddl).toBe(true);
expect(c.tools).toBe(true);
expect(c.objects).toBe(false); expect(c.objects).toBe(false);
expect(c.visualizer).toBe(false); expect(c.visualizer).toBe(false);
expect(c.tools).toBe(false);
}); });
it("gives SQLite explorer/queries/visualizer/editing/import/ddl but not objects/tools", () => { it("gives SQLite explorer/queries/visualizer/editing/import/ddl/tools/tableManagement but not objects", () => {
const c = DB_CAPABILITIES.sqlite; const c = DB_CAPABILITIES.sqlite;
expect(c.explorer).toBe(true); expect(c.explorer).toBe(true);
expect(c.queries).toBe(true); expect(c.queries).toBe(true);
@@ -31,8 +31,9 @@ describe("dbCapabilities", () => {
expect(c.editing).toBe(true); expect(c.editing).toBe(true);
expect(c.import).toBe(true); expect(c.import).toBe(true);
expect(c.ddl).toBe(true); expect(c.ddl).toBe(true);
expect(c.tools).toBe(true);
expect(c.tableManagement).toBe(true);
expect(c.objects).toBe(false); expect(c.objects).toBe(false);
expect(c.tools).toBe(false);
}); });
it("gives Redis nothing (connection+test only)", () => { it("gives Redis nothing (connection+test only)", () => {
@@ -79,14 +80,37 @@ describe("v0.7.7 capabilities", () => {
expect(DB_CAPABILITIES.postgresql.roles).toBe(true); expect(DB_CAPABILITIES.postgresql.roles).toBe(true);
expect(DB_CAPABILITIES.postgresql.tableManagement).toBe(true); expect(DB_CAPABILITIES.postgresql.tableManagement).toBe(true);
}); });
it("mysql/sqlite/redis disable maintenance, roles, tableManagement", () => { it("mysql/sqlite/redis disable maintenance and roles; only sqlite also gets tableManagement", () => {
for (const t of ["mysql", "sqlite", "redis"] as const) { for (const t of ["mysql", "sqlite", "redis"] as const) {
expect(DB_CAPABILITIES[t].maintenance).toBe(false); expect(DB_CAPABILITIES[t].maintenance).toBe(false);
expect(DB_CAPABILITIES[t].roles).toBe(false); expect(DB_CAPABILITIES[t].roles).toBe(false);
expect(DB_CAPABILITIES[t].tableManagement).toBe(false);
} }
expect(DB_CAPABILITIES.sqlite.tableManagement).toBe(true);
expect(DB_CAPABILITIES.mysql.tableManagement).toBe(false);
expect(DB_CAPABILITIES.redis.tableManagement).toBe(false);
}); });
it("getCapabilities is safe for unknown types", () => { it("getCapabilities is safe for unknown types", () => {
expect(getCapabilities("bogus").maintenance).toBe(false); expect(getCapabilities("bogus").maintenance).toBe(false);
}); });
}); });
describe("dbCapabilities v0.7.8", () => {
it("enables tools for mysql and sqlite", () => {
expect(DB_CAPABILITIES.mysql.tools).toBe(true);
expect(DB_CAPABILITIES.sqlite.tools).toBe(true);
expect(DB_CAPABILITIES.postgresql.tools).toBe(true);
});
it("enables tableManagement for sqlite", () => {
expect(DB_CAPABILITIES.sqlite.tableManagement).toBe(true);
expect(DB_CAPABILITIES.mysql.tableManagement).toBe(false);
});
it("still reports editing/import/ddl for mysql and sqlite", () => {
expect(DB_CAPABILITIES.mysql.editing).toBe(true);
expect(DB_CAPABILITIES.mysql.import).toBe(true);
expect(DB_CAPABILITIES.mysql.ddl).toBe(true);
expect(DB_CAPABILITIES.sqlite.editing).toBe(true);
expect(DB_CAPABILITIES.sqlite.objects).toBe(false);
});
});
+2 -2
View File
@@ -35,8 +35,8 @@ const ALL_FALSE: DbCapabilities = {
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = { export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true, maintenance: true, roles: true, tableManagement: true }, postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true, maintenance: true, roles: true, tableManagement: true },
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true }, mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true, tools: true },
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true }, sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true, tools: true, tableManagement: true },
redis: { ...ALL_FALSE }, redis: { ...ALL_FALSE },
}; };
+12 -7
View File
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
import agents from "../../AGENTS.md?raw"; import agents from "../../AGENTS.md?raw";
import readme from "../../README.md?raw"; import readme from "../../README.md?raw";
describe("v0.7.7 docs coverage", () => { describe("v0.7.8 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => { it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing"); expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/); expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -24,8 +24,8 @@ describe("v0.7.7 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/); expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/); expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
}); });
it("README declares v0.7.7", () => { it("README declares v0.7.8", () => {
expect(readme).toContain("0.7.7"); expect(readme).toContain("0.7.8");
}); });
it("AGENTS.md marks schema CRUD complete", () => { it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/); expect(agents).toMatch(/Schema CRUD \| ✅/);
@@ -59,9 +59,14 @@ describe("v0.7.7 docs coverage", () => {
it("AGENTS.md marks the Objects view tabbed workspace complete", () => { it("AGENTS.md marks the Objects view tabbed workspace complete", () => {
expect(agents).toMatch(/Objects view tabbed workspace \| ✅/); expect(agents).toMatch(/Objects view tabbed workspace \| ✅/);
}); });
it("README links to v0.7.7 assets in both download tables", () => { it("AGENTS.md marks xlsx/SQLite-dump/cancel/settings-import complete", () => {
expect(readme).toContain("releases/download/v0.7.7/"); expect(agents).toMatch(/Excel \(\.xlsx\) export \| ✅/);
expect(readme).toContain("Gridline_0.7.7_aarch64.dmg"); expect(agents).toMatch(/Cancel long-running queries \| ✅/);
expect(readme).toContain("Gridline-0.7.7-1.x86_64.rpm"); expect(agents).toMatch(/Settings export\/import \| ✅/);
});
it("README links to v0.7.8 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.7.8/");
expect(readme).toContain("Gridline_0.7.8_aarch64.dmg");
expect(readme).toContain("Gridline-0.7.8-1.x86_64.rpm");
}); });
}); });
+22
View File
@@ -26,4 +26,26 @@ describe("exportData", () => {
exportData([[1, "a"]], columns, "json", "t"); exportData([[1, "a"]], columns, "json", "t");
expect(click).toHaveBeenCalled(); expect(click).toHaveBeenCalled();
}); });
it("xlsx produces a Blob with the xlsx MIME and extension download", () => {
const origCreateObjectURL = URL.createObjectURL;
const origRevokeObjectURL = URL.revokeObjectURL;
globalThis.URL.createObjectURL = vi.fn(() => "blob:x") as any;
globalThis.URL.revokeObjectURL = vi.fn() as any;
const a = { click: vi.fn(), href: "", download: "" };
vi.spyOn(document, "createElement").mockReturnValue(a as any);
const rows = [[1]];
const cols: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
try {
exportData(rows, cols, "xlsx", "t");
expect(a.download).toBe("t.xlsx");
expect((URL.createObjectURL as any).mock.calls[0][0] instanceof Blob).toBe(true);
expect((URL.createObjectURL as any).mock.calls[0][0].type).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
} finally {
globalThis.URL.createObjectURL = origCreateObjectURL;
globalThis.URL.revokeObjectURL = origRevokeObjectURL;
}
});
}); });
+12
View File
@@ -1,3 +1,4 @@
import { buildXlsx } from "./xlsx";
import type { ColumnInfo } from "./types"; import type { ColumnInfo } from "./types";
export function exportData( export function exportData(
@@ -11,6 +12,17 @@ export function exportData(
let mime: string; let mime: string;
switch (format) { switch (format) {
case "xlsx": {
const bytes = buildXlsx(rows, columns);
const blob = new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${tableName}.xlsx`;
a.click();
URL.revokeObjectURL(url);
return;
}
case "json": { case "json": {
const jsonRows = rows.map((row) => { const jsonRows = rows.map((row) => {
const obj: Record<string, unknown> = {}; const obj: Record<string, unknown> = {};
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { isMacOS } from "./platform";
describe("isMacOS", () => {
afterEach(() => {
vi.stubGlobal("navigator", undefined);
});
it("true on MacIntel/Mac platform", () => {
vi.stubGlobal("navigator", { platform: "MacIntel", userAgent: "Mozilla/5.0 (Macintosh; X)" });
expect(isMacOS()).toBe(true);
});
it("false on Win32", () => {
vi.stubGlobal("navigator", { platform: "Win32", userAgent: "Mozilla/5.0 (Windows NT 10.0)" });
expect(isMacOS()).toBe(false);
});
it("false on Linux", () => {
vi.stubGlobal("navigator", { platform: "Linux x86_64", userAgent: "Mozilla/5.0 (X11; Linux)" });
expect(isMacOS()).toBe(false);
});
});
+9
View File
@@ -0,0 +1,9 @@
/** True only on macOS (the macOS "Overlay" drag strip is gated on this). Uses
* navigator.platform/userAgent (the established pattern in BackupPage.tsx);
* @tauri-apps/plugin-os is not installed and getCurrentWindow() has no osLabel(). */
export function isMacOS(): boolean {
if (typeof navigator === "undefined") return false;
const p = (navigator.platform || "").toLowerCase();
const u = (navigator.userAgent || "").toLowerCase();
return p.includes("mac") || u.includes("macintosh") || u.includes("mac os");
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { validateSettingsExport } from "./settingsImport";
import type { Settings } from "./types";
const good: Settings = {
confirm_before_delete: true, default_folder_id: null, theme: "dark", font_size: "medium",
default_ports: { postgresql: 5432 }, tag_order: null, table_refresh_rate: 5, table_page_size: 50,
shortcuts: { open_command_palette: "Cmd+K" }, accent_color: "#2563EB",
editor_font_size: 14, editor_font_family: "Menlo", editor_word_wrap: "off", editor_minimap: true, editor_tab_size: 2,
};
describe("validateSettingsExport", () => {
it("accepts a well-formed object", () => {
const r = validateSettingsExport({ schemaVersion: 1, settings: good });
expect(r.ok).toBe(true);
});
it("rejects an invalid theme", () => {
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, theme: "purple" as never } });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors.some((e) => e.field === "theme")).toBe(true);
});
it("clamps editor_font_size to [8,24]", () => {
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, editor_font_size: 999 } });
expect(r.ok).toBe(true);
if (r.ok) expect(r.settings.editor_font_size).toBe(24);
});
it("tolerates unknown keys (version skew)", () => {
const r = validateSettingsExport({ schemaVersion: 99, settings: { ...good, futureField: true } } as never);
expect(r.ok).toBe(true);
});
it("requires a non-negative table_refresh_rate", () => {
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, table_refresh_rate: -1 } });
expect(r.ok).toBe(false);
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { Settings } from "./types";
export interface SettingsExport { schemaVersion: number; settings: Settings }
export type ValidationOk = { ok: true; settings: Settings };
export type ValidationErr = { ok: false; errors: { field: string; message: string }[] };
export type ValidationResult = ValidationOk | ValidationErr;
const THEMES = ["dark", "light", "system"];
const FONT_SIZES = ["small", "medium", "large"];
const FONT_FAMILIES = ["Space Mono", "Fira Code", "Menlo", "Monaco", "Consolas", "JetBrains Mono", "monospace"];
const WORD_WRAPS = ["off", "on"];
function clamp(n: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, n)); }
export function validateSettingsExport(input: unknown): ValidationResult {
const errors: ValidationErr["errors"] = [];
if (typeof input !== "object" || input === null || !("settings" in input)) {
return { ok: false, errors: [{ field: "settings", message: "missing settings object" }] };
}
const s = (input as { settings: Record<string, unknown> }).settings;
const out: Record<string, unknown> = {};
const enumCheck = (field: keyof Settings, allow: readonly string[], val: unknown) => {
if (typeof val === "string" && allow.includes(val)) out[field] = val;
else errors.push({ field, message: `invalid ${field}` });
};
enumCheck("theme", THEMES, s.theme);
enumCheck("font_size", FONT_SIZES, s.font_size);
enumCheck("editor_word_wrap", WORD_WRAPS, s.editor_word_wrap);
if (typeof s.accent_color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(s.accent_color)) out.accent_color = s.accent_color;
else errors.push({ field: "accent_color", message: "invalid hex color" });
if (typeof s.editor_font_size === "number") out.editor_font_size = clamp(Math.trunc(s.editor_font_size), 8, 24);
else errors.push({ field: "editor_font_size", message: "must be a number" });
if (typeof s.editor_tab_size === "number") out.editor_tab_size = clamp(Math.trunc(s.editor_tab_size), 2, 8);
else errors.push({ field: "editor_tab_size", message: "must be a number" });
enumCheck("editor_font_family", FONT_FAMILIES, s.editor_font_family);
if (typeof s.editor_minimap === "boolean") out.editor_minimap = s.editor_minimap;
else errors.push({ field: "editor_minimap", message: "must be boolean" });
if (typeof s.table_page_size === "number" && s.table_page_size > 0) out.table_page_size = Math.trunc(s.table_page_size);
else errors.push({ field: "table_page_size", message: "must be positive" });
if (typeof s.table_refresh_rate === "number" && s.table_refresh_rate >= 0) out.table_refresh_rate = s.table_refresh_rate;
else errors.push({ field: "table_refresh_rate", message: "must be non-negative" });
out.confirm_before_delete = typeof s.confirm_before_delete === "boolean" ? s.confirm_before_delete : true;
out.default_folder_id = typeof s.default_folder_id === "string" || s.default_folder_id === null ? s.default_folder_id : null;
out.tag_order = typeof s.tag_order === "string" || s.tag_order === null ? s.tag_order : null;
out.default_ports = s.default_ports && typeof s.default_ports === "object" ? s.default_ports : {};
out.shortcuts = s.shortcuts && typeof s.shortcuts === "object" ? s.shortcuts : {};
if (errors.length) return { ok: false, errors };
return { ok: true, settings: out as unknown as Settings };
}
+44
View File
@@ -302,6 +302,7 @@ export interface SyncOptions {
targetConnectionId: string; targetConnectionId: string;
schema?: string; schema?: string;
tables?: string[]; tables?: string[];
dbType: DbType;
} }
export interface PgToolStatus { export interface PgToolStatus {
@@ -313,6 +314,49 @@ export interface PgToolStatus {
pg_restore_source: string | null; pg_restore_source: string | null;
} }
// ─── Backup Types: MySQL / SQLite / Settings (v0.7.8) ───────────
// NOTE: the Rust `MySqlToolStatus` model is `#[serde(rename_all = "camelCase")]`,
// so these interfaces use camelCase keys to match the actual IPC payloads.
export interface MySqlToolStatus {
mysqldumpFound: boolean;
mysqlFound: boolean;
mysqldumpVersion: string | null;
mysqlVersion: string | null;
mysqldumpSource: string | null;
mysqlSource: string | null;
}
export interface MySqlBackupOptions {
database: string;
filePath: string;
singleTransaction: boolean;
noData: boolean;
routines: boolean;
triggers: boolean;
events: boolean;
}
export interface MySqlRestoreOptions {
database: string;
filePath: string;
clean: boolean;
}
export interface SqliteBackupOptions {
filePath: string;
}
export interface SqliteRestoreOptions {
filePath: string;
clean: boolean;
}
export interface SettingsExport {
schemaVersion: number;
settings: Settings;
}
export type PgObjectType = export type PgObjectType =
| "table" | "view" | "materialized view" | "function" | "procedure" | "table" | "view" | "materialized view" | "function" | "procedure"
| "trigger" | "sequence" | "enum" | "extension" | "index" | "constraint"; | "trigger" | "sequence" | "enum" | "extension" | "index" | "constraint";
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json"; import pkg from "../../package.json";
describe("version", () => { describe("version", () => {
it("declares v0.7.7 across the app shell", () => { it("declares v0.7.8 across the app shell", () => {
expect(pkg.version).toBe("0.7.7"); expect(pkg.version).toBe("0.7.8");
}); });
}); });
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { unzipSync } from "fflate";
import { buildXlsx } from "./xlsx";
import type { ColumnInfo } from "./types";
const cols: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
function sheetXML(out: Uint8Array): string {
const files = unzipSync(out);
return new TextDecoder().decode(files["xl/worksheets/sheet1.xml"]);
}
describe("buildXlsx", () => {
it("emits headers + rows as inline strings", () => {
const out = buildXlsx([[1, "Alice"], [2, "Bob"]], cols);
const xml = sheetXML(out);
expect(xml).toContain('t="inlineStr"');
expect(xml).toContain("id");
expect(xml).toContain("Alice");
expect(xml).toContain("Bob");
});
it("escapes XML-special characters in cell text", () => {
const out = buildXlsx([[1, "a<b>&c"]], cols);
const xml = sheetXML(out);
expect(xml).toContain("a&lt;b&gt;&amp;c");
expect(xml).not.toContain("a<b>&c");
});
it("emits formula-triggering values as inline strings (no formula evaluation)", () => {
const out = buildXlsx([[1, "=1+1"], [2, "+5"], [3, "@SUM"]], cols);
const xml = sheetXML(out);
expect(xml).toContain("=1+1");
expect(xml).not.toMatch(/<c[^>]*?><f>/);
});
it("declares a column width per header", () => {
const out = buildXlsx([[1, "x"]], cols);
const xml = sheetXML(out);
expect(xml).toContain("<cols>");
expect(xml).toContain("width=");
});
it("renders null cells as empty inline strings", () => {
const out = buildXlsx([[1, null]], cols);
const xml = sheetXML(out);
expect(xml).toContain("inlineStr");
});
});
+63
View File
@@ -0,0 +1,63 @@
import { zipSync, strToU8 } from "fflate";
import type { ColumnInfo } from "./types";
/** Minimal OOXML spreadsheet: one sheet, every cell as an inline string
* (t="inlineStr") so Excel never evaluates a cell as a formula the
* formula-injection mitigation required by the spec. */
const XML_ESCAPES: [RegExp, string][] = [
[/&/g, "&amp;"],
[/</g, "&lt;"],
[/>/g, "&gt;"],
[/"/g, "&quot;"],
];
function esc(s: string): string {
for (const [re, w] of XML_ESCAPES) s = s.replace(re, w);
return s;
}
function colLetter(n: number): string {
let s = "";
for (let i = n; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + ((i - 1) % 26)) + s;
return s;
}
function buildColsXML(columns: ColumnInfo[]): string {
const maxW = columns.map((c) => Math.min(60, Math.max(8, c.name.length + 2)));
return `<cols>${maxW.map((w, i) => `\n <col min="${i + 1}" max="${i + 1}" width="${w}" customWidth="1"/>`).join("")}\n</cols>`;
}
function buildCellsXML(rows: unknown[][], columns: ColumnInfo[]): string {
let cells = "";
cells += `<row r="1">` + columns
.map((c, i) => `<c r="${colLetter(i + 1)}1" t="inlineStr"><is><t>${esc(c.name)}</t></is></c>`)
.join("") + `</row>`;
rows.forEach((row, rIdx) => {
const r = rIdx + 2;
cells += `<row r="${r}">` + row
.map((v, i) => {
const ref = `${colLetter(i + 1)}${r}`;
const t = v === null || v === undefined ? "" : String(v);
return `<c r="${ref}" t="inlineStr"><is><t>${esc(t)}</t></is></c>`;
})
.join("") + `</row>`;
});
return cells;
}
export function buildXlsx(rows: unknown[][], columns: ColumnInfo[]): Uint8Array {
const colsXML = buildColsXML(columns);
const cells = buildCellsXML(rows, columns);
const sheet1 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">${colsXML}<sheetData>${cells}</sheetData></worksheet>`;
const workbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>`;
const ct = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>`;
const rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`;
const rootRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`;
return zipSync({
"[Content_Types].xml": strToU8(ct),
"_rels/.rels": strToU8(rootRels),
"xl/workbook.xml": strToU8(workbook),
"xl/_rels/workbook.xml.rels": strToU8(rels),
"xl/worksheets/sheet1.xml": strToU8(sheet1),
});
}