diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 4eda1970..7d469f53 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"path_provider_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.22\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.2+2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_macos-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_linux-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_windows-0.1.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-03-21 20:27:26.235937","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-04-19 12:51:07.216547","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05ef09f0..ff2c9c6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,8 @@ name: CI on: push: branches: [ main, dev, '**-connector', '**-connection-pull' ] + tags: + - 'v*' pull_request: branches: [ main, dev ] @@ -44,3 +46,31 @@ jobs: - name: Analyze code run: flutter analyze + + # Release smoke build: same tag also runs release.yml (artifacts). This job + # gates tags on a real linux release compile after tests + analyze. + build-linux-release: + name: Build Linux (release) + needs: [test, analyze] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.38.5' + channel: 'stable' + cache: true + + - name: Get dependencies + run: flutter pub get + + - name: Build Linux (release) + run: flutter build linux --release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd375a14..a8584512 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,21 @@ -# Сборка релизов и публикация в GitHub Releases. -# Запуск: создайте и запушьте тег, например v0.1.0: -# git tag v0.1.0 -# git push origin v0.1.0 +# Release: сборка артефактов → единая публикация с git-cliff и SHA256SUMS. +# Триггер: git tag v0.1.0 && git push origin v0.1.0 +# +# Рекомендуемые коммиты: Conventional Commits (feat:, fix:, …) — см. cliff.toml name: Release on: push: tags: - - 'v*' # v0.1.0, v1.0.0, etc. + - 'v*' + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: build-windows: @@ -36,16 +43,11 @@ jobs: $dir = "build\windows\x64\runner\Release" Compress-Archive -Path "$dir\*" -DestinationPath "Querya-Desktop-$tag-windows.zip" - - name: Create Release and upload Windows - uses: softprops/action-gh-release@v2 + - uses: actions/upload-artifact@v4 with: - tag_name: ${{ github.ref_name }} - name: Release ${{ github.ref_name }} - generate_release_notes: true - files: Querya-Desktop-${{ github.ref_name }}-windows.zip - allow_updates: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + name: bundle-windows + path: Querya-Desktop-${{ github.ref_name }}-windows.zip + if-no-files-found: error build-linux: name: Build Linux @@ -74,15 +76,73 @@ jobs: - name: Zip Linux artifact run: | cd build/linux/x64/release/bundle - zip -r ../../../../../../Querya-Desktop-${{ github.ref_name }}-linux.zip . + zip -r "${GITHUB_WORKSPACE}/Querya-Desktop-${{ github.ref_name }}-linux.zip" . + + - uses: actions/upload-artifact@v4 + with: + name: bundle-linux + path: Querya-Desktop-${{ github.ref_name }}-linux.zip + if-no-files-found: error + + publish: + name: Publish GitHub Release + needs: [build-windows, build-linux] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: taiki-e/install-action@v2 + with: + tool: git-cliff@2.11.0 + + - uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: bundle-* + merge-multiple: true + + - name: Layout distribution files + run: | + set -euo pipefail + mkdir -p dist + find artifacts -name '*.zip' -exec mv -t dist/ {} + + test "$(find dist -name '*.zip' | wc -l)" -eq 2 + cd dist + sha256sum *.zip | tee SHA256SUMS.txt + + - name: Generate changelog (git-cliff + GitHub API) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPO: ${{ github.repository }} + run: | + set -euo pipefail + TAG="${{ github.ref_name }}" + # Текст страницы релиза: без глобального header из cliff.toml + git-cliff --latest \ + --config cliff.toml \ + --strip header \ + --github-token "$GITHUB_TOKEN" \ + --github-repo "$GITHUB_REPO" \ + -o release-body.md + # Вложение: полный фрагмент с header (удобно архивировать / копировать в docs) + git-cliff --latest \ + --config cliff.toml \ + --github-token "$GITHUB_TOKEN" \ + --github-repo "$GITHUB_REPO" \ + -o "CHANGELOG-${TAG}.md" - - name: Upload to Release (Linux) + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} name: Release ${{ github.ref_name }} - generate_release_notes: true - files: Querya-Desktop-${{ github.ref_name }}-linux.zip - allow_updates: true + body_path: release-body.md + fail_on_unmatched_files: true + files: | + dist/*.zip + dist/SHA256SUMS.txt + CHANGELOG-${{ github.ref_name }}.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 5c073f8c..229ea50b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ Thumbs.db *.dll *.exe flutter_*.log +design-front diff --git a/README.md b/README.md index 64d85520..4edbf11d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,13 @@ A lightweight desktop client for SQL and NoSQL databases. Connect to PostgreSQL, - **Cross-platform:** Windows, Linux, macOS (Flutter desktop). - **Multi-database:** PostgreSQL, MySQL, Redis, MongoDB (more can be added). - **UI:** Custom window (no system title bar), resizable left panel (connection tree) and bottom split (query editor / results), dark theme, [shadcn_flutter](https://pub.dev/packages/shadcn_flutter) components. -- **Flow:** Right-click “Servers” → “New connection” → pick database type → (future: connection form and query execution). +- **Flow:** Right-click “Servers” → “New connection” → pick database type → configure and save (stored in local SQLite). + +## Database drivers + +- **PostgreSQL** — `postgres` (Dart); browser, SQL workspace, table/view browsing, server stats. +- **MySQL / MariaDB** — `mysql_client` (Dart); browser (databases, tables, views), SQL workspace with configurable statement timeout, paginated table/view data (read-oriented browse SQL). +- **Redis** / **MongoDB** — see connection panels and workspace. ## Tech stack diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..7b6f17bf --- /dev/null +++ b/cliff.toml @@ -0,0 +1,84 @@ +# git-cliff — https://git-cliff.org/docs/configuration +# Conventional Commits: https://www.conventionalcommits.org/ +# +# Явный remote: SSH origin (`git@github.com:…`) без этого даёт пустые ссылки в changelog. +# Форки могут переопределить секцию или задать GITHUB_REPO при генерации. + +[remote.github] +owner = "QueryaHub" +repo = "Querya-Desktop" + +[changelog] +header = """ +# Changelog + +Release history. Commit messages should follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, …) so entries group correctly. + +""" +body = """ +{%- macro commit_url(id) -%} +https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/commit/{{ id }} +{%- endmacro -%} + +{% macro print_commit(commit) -%} +- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}\ +{% if commit.breaking %}[**breaking**] {% endif %}\ +{{ commit.message | upper_first | trim }} — \ +[`{{ commit.id | truncate(length=7, end="") }}`]({{ self::commit_url(id=commit.id) }}) +{%- endmacro %} + +{% if version %} +## [{{ version }}] — {{ timestamp | date(format="%Y-%m-%d") }} +{% if previous.version %} +[Compare {{ previous.version }}…{{ version }}](https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/compare/{{ previous.version }}..{{ version }}) +{% endif %} +{% else %} +## [unreleased] +{% endif %} + +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group }} +{% for commit in commits -%} +{{ self::print_commit(commit=commit) }} +{% endfor %} +{% endfor %} +{%- if github %} +{%- set newbies = github.contributors | filter(attribute="is_first_time", value=true) %} +{%- if newbies | length != 0 %} + +### New contributors +{% for c in newbies -%} +- @{{ c.username }}{% if c.pr_number %} ([#{{ c.pr_number }}](https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/pull/{{ c.pr_number }})){% endif %} +{% endfor %} +{%- endif %} +{%- endif %} +""" +footer = """ +""" +trim = true + +[git] +conventional_commits = true +# Включать и неконвенциональные коммиты (попадут в «Прочее»). +filter_unconventional = false +split_commits = false +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^doc", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Tests" }, + { message = "^build", group = "Build" }, + { message = "^ci", group = "CI" }, + { message = "^chore", group = "Chore" }, + { message = "^revert", group = "Revert" }, + { message = ".*", group = "Other" }, +] +protect_breaking_commits = true +filter_commits = false +tag_pattern = "v[0-9].*" +skip_tags = "beta|alpha|rc" +topo_order = false +sort_commits = "newest" diff --git a/docs/mysql-implementation-plan.md b/docs/mysql-implementation-plan.md new file mode 100644 index 00000000..70edf84d --- /dev/null +++ b/docs/mysql-implementation-plan.md @@ -0,0 +1,316 @@ +# План: MySQL / MariaDB по образцу PostgreSQL + +Документ описывает текущую реализацию PostgreSQL в Querya Desktop, проверяет каждый пункт плана по MySQL и детализирует рекомендуемые практики, компромиссы и порядок работ. + +--- + +## Содержание + +1. [Актуальная архитектура PostgreSQL в проекте](#1-актуальная-архитектура-postgresql-в-проекте) +2. [Выбор клиентской библиотеки Dart](#2-выбор-клиентской-библиотеки-dart) +3. [Слой `MysqlConnection`](#3-слой-mysqlconnection) +4. [Пул, сервис, жизненный цикл](#4-пул-сервис-жизненный-цикл) +5. [Форма подключения и `ConnectionRow`](#5-форма-подключения-и-connectionrow) +6. [Интеграция в панель соединений и главный экран](#6-интеграция-в-панель-соединений-и-главный-экран) +7. [Завершение приложения и отмена запросов](#7-завершение-приложения-и-отмена-запросов) +8. [Функциональность UI: дерево, SQL, просмотр данных](#8-функциональность-ui-дерево-sql-просмотр-данных) +9. [Настройки, тесты, безопасность](#9-настройки-тесты-безопасность) +10. [Документация и статус драйвера](#10-документация-и-статус-драйвера) +11. [Поэтапный roadmap](#11-поэтапный-roadmap) + +--- + +## 1. Актуальная архитектура PostgreSQL в проекте + +Ниже — «эталон», от которого логично отталкиваться для MySQL. + +### 1.1 Зависимости + +| Что | Где | Заметка | +|-----|-----|---------| +| Пакет `postgres` | `pubspec.yaml` | Протокол PostgreSQL на чистом Dart, без FFI-плагинов для драйвера. | + +### 1.2 Соединение + +**Файл:** `lib/core/database/postgres_connection.dart` + +- **Конфигурация:** `PostgresConnection` и `PostgresConnection.fromConnectionRow` читают `ConnectionRow`: `host`, `port`, `username`, `password`, `databaseName`, `useSSL`, `connectionString`. +- **Два режима входа:** (1) отдельные поля + `Connection.open(endpoint, settings)`; (2) непустой `connectionString` — разбор URI, подмена имени БД через `replaceDatabaseInConnectionString`, чтобы пул мог открывать разные каталоги без дублирования учётных данных. +- **Параметры сессии:** `setSessionReadOnly` — `SET default_transaction_read_only` (важно для разделения «браузер / только чтение» и «SQL-редактор с записями»). +- **Таймауты:** в `ConnectionSettings` заданы `connectTimeout` и `queryTimeout`; отдельно `execute(..., timeout: ...)` для отдельных запросов. +- **Метаданные:** большой набор методов (`listDatabases`, `listSchemas`, `listTables`, `serverStats`, привилегии, индексы, …) — для MySQL понадобится **свой** слой запросов, а не копипаста SQL от PostgreSQL. + +### 1.3 Пул и сервис + +**Файлы:** `lib/core/database/postgres_connection_pool.dart`, `postgres_service.dart` + +- **Ключ пула:** строка вида `` `${id}::$database::${mode.name}` `` — см. `keyFor` в пуле. +- **Режимы:** `PgSessionMode.readOnly` / `readWrite`; при повторном `acquire` переоткрытие и повторная установка read-only, если сессия отвалилась. +- **Lease:** `PgLease` с `release()` — счётчик ссылок, таймер простоя `idleDisposeDelay`, LRU-вытеснение при `maxEntries`. +- **Прерывание:** `interrupt` → `forceClose` на сокете (отмена клиентского I/O; сервер может ещё какое-то время держать запрос). +- **Синглтон:** `PostgresService.instance` — единая точка для UI. + +### 1.4 Локальное хранилище + +**Файл:** `lib/core/storage/local_db.dart` + +- Таблица `connections` уже содержит все нужные поля для SQL-драйверов: `host`, `port`, `username`, `password`, `database_name`, `use_ssl`, `connection_string`. +- Тип задаётся строкой: для PG — `'postgresql'`. Для MySQL — **`'mysql'`** (уже используется в `new_connection_dialog.dart` / иконках). + +### 1.5 UI + +- **Форма:** `lib/features/postgresql/postgresql_connection_form.dart` — тест соединения перед сохранением, валидация URI vs host/db. +- **Браузер:** `lib/features/connections/connections_panel.dart` — `_PostgresConnectionTile`, вызовы `PostgresService.instance.acquire`, разворачиваемое дерево. +- **Рабочая область:** каталог `lib/features/postgresql/` (`postgres_sql_workspace.dart`, `postgres_table_view.dart`, `postgres_workspace_home.dart`, …). +- **Таймаут SQL-редактора:** `AppSettings.getPostgresSqlStmtTimeoutSeconds()` используется в `postgres_sql_workspace.dart` — для MySQL разумно завести **отдельный** ключ настроек (см. раздел 9). + +### 1.6 Завершение работы + +**Файл:** `lib/app/app_shutdown.dart` — `disconnectAllExternalServices()` вызывает `PostgresService.instance.disconnectAll()` наряду с Mongo и Redis. + +### 1.7 Заглушки и драйвер-менеджер + +- **Stub:** в `connections_panel.dart` удаляются записи с `type == 'mysql' && name == 'MySQL connection'`. +- **Driver Manager:** `lib/features/connections/driver_manager_dialog.dart` — у MySQL `fixedStatus: DriverStatus.comingSoon`, без `DownloadableDriver`. + +--- + +## 2. Выбор клиентской библиотеки Dart + +**Проверка пункта плана:** зависимость должна закрыть протокол MySQL, желательно на чистом Dart (как `postgres`), с поддержкой desktop (Linux/macOS/Windows). + +### 2.1 Критерии выбора + +1. **Лицензия** — совместимость с продуктом (BSD/MIT/Apache предпочтительно; читать полный текст). +2. **Активность** — последние релизы, совместимость с Dart 3.x. +3. **TLS** — возможность включить шифрование; для продакшена желательно проверка сертификата (хотя бы опционально `badCertificateCallback` только для dev). +4. **Аутентификация** — MySQL 8 по умолчанию `caching_sha2_password`; убедиться, что пакет её поддерживает или документировать ограничение + `mysql_native_password` на сервере. +5. **Кодировка** — явная установка `utf8mb4` на соединении (по умолчанию на многих серверах уже так; лучше зафиксировать в коде после handshake). +6. **API** — потоковый доступ к результатам для больших выборок; параметризованные запросы для метаданных и пользовательского SQL, где это уместно. + +### 2.2 Типичные кандидаты (на момент планирования) + +| Пакет | Плюсы | На что смотреть | +|-------|--------|-----------------| +| **`mysql_client`** | Современный async API, ориентация на чистый Dart | Документация по SSL, ограничения по платформам | +| **`mysql1`** | Долго в экосистеме, много примеров | Актуальность под Dart 3, API может быть менее удобным | + +**Практика:** после выбора пакета зафиксировать в `README` или в этом документе **версию** и известные ограничения (например, «не поддерживается X»). + +### 2.3 Что не забыть в `pubspec` + +- Зависимость только в `dependencies` (не дублировать конфликтующие транспорты). +- При необходимости — отдельная секция в документации про **MariaDB** (обычно тот же протокол с мелкими отличиями в `information_schema`). + +--- + +## 3. Слой `MysqlConnection` + +**Проверка пункта плана:** один класс (или небольшой модуль), инкапсулирующий низкоуровневый клиент и дающий приложению стабильный API, похожий на `PostgresConnection`. + +### 3.1 Конфигурация из `ConnectionRow` + +- Повторить паттерн `fromConnectionRow` с дефолтами: порт **3306**, имя БД по умолчанию можно оставить пустым только если библиотека позволяет подключаться без default schema — иначе требовать явную БД или `mysql` / первую доступную после `SHOW DATABASES`. +- **URI:** распространённые формы `mysql://user:pass@host:3306/db?ssl-mode=REQUIRED`. Реализовать: + - разбор query-параметров (`ssl`, `ssl-mode`, таймауты, если поддерживаются); + - функцию уровня `replaceDatabaseInMysqlConnectionString` (аналог PG), если дерево переключает каталоги на одном сохранённом URI. + +### 3.2 Методы жизненного цикла + +| Метод | Назначение | +|-------|------------| +| `connect()` | Установить соединение, применить session defaults (charset, timezone — опционально). | +| `disconnect()` | Мягкое закрытие. | +| `forceClose()` | Аналог PG: оборвать сокет для `interrupt` из пула. | + +### 3.3 Выполнение SQL + +- Единая точка `execute` / `query` с опциональным **таймаутом** на уровне приложения (таймер Future + отмена — если библиотека не поддерживает отмену нативно). +- Для **пользовательского SQL** из редактора — не подмешивать автоматически `LIMIT` без явного согласия пользователя; для **просмотра таблицы** — пагинация `LIMIT/OFFSET` или keyset (лучше для больших таблиц). + +### 3.4 Метаданные (MySQL-специфика) + +В MySQL **DATABASE и SCHEMA — синонимы**; «схема» в смысле PostgreSQL часто соответствует **отдельной базе данных**. Для дерева типичны два UX: + +1. **Одна БД на подключение** (как в многих клиентах): в форме задаётся default database, дерево показывает таблицы/вью внутри неё. +2. **Серверный уровень:** сначала `SHOW DATABASES` / `information_schema.SCHEMATA`, затем при выборе БД — переключение `USE db` или отдельное соединение с этой БД (второе согласуется с текущим пулом по ключу `database`). + +Рекомендуемые запросы каталога (с параметрами, не конкатенацией строк для имён из БД): + +- Список БД: `SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE ...` (исключить `information_schema`, `mysql`, `performance_schema`, `sys` — опционально, в настройках). +- Таблицы: `information_schema.TABLES` с фильтром `TABLE_SCHEMA = ?`. +- Колонки: `information_schema.COLUMNS`. +- Ограничения / индексы: `information_schema.STATISTICS`, `TABLE_CONSTRAINTS`, при необходимости `SHOW CREATE TABLE` для точного DDL. + +**Квотирование идентификаторов:** обратные апострофы `` `identifier` ``; экранирование `` ` `` как `` `` ` ``. + +### 3.5 Read-only сессия + +- В MySQL нет прямого аналога `default_transaction_read_only` на всю сессию как в PG. +- Практики: + - **`SET SESSION TRANSACTION READ ONLY`** — влияет на последующие транзакции в режимах, где это поддерживается; уточнить для целевых версий MySQL/MariaDB. + - Альтернатива для «безопасного браузера»: отдельный пользователь БД с правами только `SELECT` (политика на стороне сервера) — вне scope клиента, но стоит упомянуть в документации. +- Для первой версии клиента можно **дублировать семантику PG**: после `connect` выставлять read-only флаг там, где сервер позволяет, а для SQL-редактора — режим `readWrite` без этих SET. + +### 3.6 Ошибки + +- Обернуть ошибки драйвера в свой тип (как `PostgresConnectionException`), чтобы UI показывал понятные сообщения и мог классифицировать «сеть / доступ / синтаксис». + +--- + +## 4. Пул, сервис, жизненный цикл + +**Проверка пункта плана:** повторить паттерн `PostgresConnectionPool` / `PostgresService`. + +### 4.1 Структура + +- **`MysqlSessionMode`** — `readOnly` / `readWrite` (зеркало `PgSessionMode`). +- **`MysqlLease`** — `release()` с тем же ref-count и idle-таймером. +- **`MysqlConnectionPool`** — те же константы `defaultIdleDisposeDelay`, `defaultMaxEntries`, та же логика `keyFor`, `_evictIfNeededBeforeNewSlot`, `interrupt`, `disconnectAll`. + +### 4.2 Фабрика подключения + +- Вынести `createAndConnect` в тестируемую функцию (как `_defaultCreateAndConnect` в `postgres_service.dart`), чтобы в unit-тестах подставлять mock. + +### 4.3 DRY (не обязательно в первой итерации) + +- Долгосрочно можно обобщить «пул по ключу» в generic, если MySQL и PG пулы окажутся идентичны по логике; **не усложнять преждевременно** — сначала два явных класса проще сопровождать. + +--- + +## 5. Форма подключения и `ConnectionRow` + +**Файл-образец:** `lib/features/postgresql/postgresql_connection_form.dart` + +### 5.1 Поля UI + +- Имя подключения, хост, порт (3306), пользователь, пароль, имя БД (опционально в зависимости от выбранного UX), чекбокс SSL, опционально поле **Connection URL**. +- **Тест соединения** перед сохранением — обязателен для согласованности с PG. +- Маскирование пароля, опция «показать пароль». + +### 5.2 Валидация + +- URI: префиксы `mysql://`, `mariadb://` (если поддерживаете оба в одной форме). +- Без URI: обязательны хост и (по политике) база. + +### 5.3 Сохранение + +- `ConnectionRow(type: 'mysql', ...)`, `createdAt` — как у других форм. +- Пароль хранится в том же SQLite, что и для PG/Mongo — **как и сейчас в приложении**; для усиленной безопасности в будущем рассмотреть keyring OS (отдельная задача). + +--- + +## 6. Интеграция в панель соединений и главный экран + +**Проверка пункта плана:** `connections_panel.dart`, `main_screen.dart`, `workspace_panel.dart`. + +### 6.1 `connections_panel.dart` + +- В `_createConnection` добавить ветку `ConnectionType.mysql` → `showMysqlConnectionForm(...)`. +- Добавить `_MysqlConnectionTile` по аналогии с `_PostgresConnectionTile`: раскрытие, индикатор загрузки, `MysqlService.instance.acquire` при обходе дерева. +- **Колбэки:** либо расширить существующие (`onPostgresObjectSelected` можно обобщить до SQL-объектов), либо ввести `onMysqlObjectSelected` — решение за командой; важно не раздувать `main_screen` бесконечными флагами — лучше единый контракт «SQL workspace target» с полем `engine: postgresql | mysql`. + +### 6.2 Главный экран и workspace + +- В `workspace_panel.dart` / `main_screen.dart` сейчас завязка на PostgreSQL (`postgresSqlTabRequestToken` и т.д.). Для MySQL: + - либо отдельная вкладка/токен для MySQL SQL; + - либо одна вкладка «SQL» с выбором активного движка — чище для пользователя, сложнее в состоянии. + +### 6.3 Иконки и тип + +- Уже есть `'mysql'` в `_iconForType` / `_iconAssetForType` — проверить, что новый тайл использует те же ключи. + +--- + +## 7. Завершение приложения и отмена запросов + +**Файл:** `lib/app/app_shutdown.dart` + +- Добавить `await MysqlService.instance.disconnectAll()` в `disconnectAllExternalServices()` **в том же порядке**, что и остальные пулы (после или перед — главное, документировать идемпотентность). + +### 7.1 Отмена долгого запроса + +- **Клиент:** `forceClose` на пуле (уже есть паттерн). +- **Сервер (опционально, продвинуто):** выполнить `KILL QUERY` для другого thread_id нельзя без отдельного соединения с привилегией; для desktop-клиента обычно достаточно обрыва сокета. Если добавите «Отменить запрос» как в некоторых IDE — понадобится хранить `connection_id` из `SELECT CONNECTION_ID()` и второе соединение с правами — **отложить на v2**. + +--- + +## 8. Функциональность UI: дерево, SQL, просмотр данных + +**Ориентир:** каталог `lib/features/postgresql/`. + +### 8.1 Минимально жизнеспособный продукт (MVP) + +1. Подключение + тест. +2. Дерево: базы (или одна база) → таблицы → открыть таблицу. +3. Просмотр данных: сетка с пагинацией, только `SELECT` с квотированием имён. +4. SQL-редактор: выполнение произвольного запроса, отображение результата / сообщения об ошибке, таймаут из настроек. + +### 8.2 Паритет с PostgreSQL (позже) + +- Представления, процедуры/функции, индексы, привилегии — по отдельным экранам, с SQL, специфичным для MySQL. +- Статистика сервера — другие системные таблицы (`performance_schema`, `SHOW GLOBAL STATUS`), осторожно с нагрузкой на сервер. + +### 8.3 Защита от опасных запросов в браузере + +- В PG уже есть паттерны вроде проверок допустимости SELECT для просмотра таблицы — для MySQL аналогично: в read-only режиме не отправлять произвольный DML из «безопасного» UI без подтверждения. + +--- + +## 9. Настройки, тесты, безопасность + +### 9.1 Настройки + +- Зеркало `AppSettingsKeys.postgresSqlStmtTimeoutSeconds` → например `mysql_sql_stmt_timeout_seconds`. +- Опционально: лимит строк в результате, таймаут подключения в UI (если вынесете из кода). + +### 9.2 Тесты + +| Уровень | Что | +|---------|-----| +| Unit | Парсинг connection string, квотирование идентификаторов, ключ пула. | +| Widget | Форма MySQL (как `postgresql_connection_form_test.dart`). | +| Интеграция | Docker `mysql:8` в CI или локально — один тест «подключился и `SELECT 1`». | + +### 9.3 Безопасность + +- Все **динамические имена** объектов из каталога — только через квотирование или параметры (имена схем/таблиц в MySQL нельзя биндить как `?` везде — использовать whitelist после `information_schema`). +- Не логировать пароли и полные URI с паролем. +- TLS: для production включать проверку сертификата; self-signed только с явным флагом «доверять» или импортом CA. + +--- + +## 10. Документация и статус драйвера + +- **`driver_manager_dialog.dart`:** заменить `comingSoon` на `installed` или `available`, когда функциональность готова; при необходимости добавить `DownloadableDriver.mysql`, если появится отдельный артефакт (сейчас PG тянется отдельно — следовать той же модели, если она актуальна для MySQL). +- **`README.md`:** кратко описать поддерживаемые версии MySQL/MariaDB и ограничения драйвера. + +--- + +## 11. Поэтапный roadmap + +| Фаза | Содержание | +|------|------------| +| **A** | Зависимость, `MysqlConnection`, тест `SELECT 1`, форма, сохранение в SQLite. | +| **B** | `MysqlConnectionPool`, `MysqlService`, `app_shutdown`, тайл в панели, простое дерево БД/таблиц. | +| **C** | Просмотр таблицы + SQL workspace + настройки таймаута. | +| **D** | Расширенные объекты (вью, процедуры), привилегии, полировка UX, интеграционные тесты. | + +--- + +## Файлы-ориентиры в репозитории + +| Назначение | PostgreSQL (эталон) | +|------------|---------------------| +| Соединение | `lib/core/database/postgres_connection.dart` | +| Пул | `lib/core/database/postgres_connection_pool.dart` | +| Сервис | `lib/core/database/postgres_service.dart` | +| Форма | `lib/features/postgresql/postgresql_connection_form.dart` | +| Панель | `lib/features/connections/connections_panel.dart` | +| Выход | `lib/app/app_shutdown.dart` | +| Настройки SQL | `lib/core/storage/app_settings.dart`, `lib/features/postgresql/postgres_sql_workspace.dart` | +| Драйверы UI | `lib/features/connections/driver_manager_dialog.dart` | + +--- + +*Документ можно обновлять по мере реализации: зафиксировать выбранный пакет, версии MySQL/MariaDB в CI и отклонения от этого плана.* diff --git a/docs/tags-and-releases.md b/docs/tags-and-releases.md new file mode 100644 index 00000000..48004555 --- /dev/null +++ b/docs/tags-and-releases.md @@ -0,0 +1,95 @@ +# Теги и релизы + +Кратко: как помечать версии в Git и что при этом делает CI. + +## Зачем теги + +- **Версия в Git** — неизменяемая метка на коммите (`v0.2.0` всегда указывает на один и тот же снимок кода). +- **Автосборка** — при пуше тега вида `v*` запускается workflow **Release** (см. `.github/workflows/release.yml`): сборки Linux и Windows, checksums, GitHub Release с текстом из [git-cliff](https://git-cliff.org/) и вложениями. + +Теги **не заменяют** обычные коммиты в `main` / `dev`: сначала коммиты попадают в нужную ветку, затем на выбранный коммит вешается тег. + +## Имя тега + +- Используйте префикс **`v`** и семантическую версию, например: `v0.1.0`, `v1.2.3`. +- В CI и [cliff.toml](../cliff.toml) ожидаются теги, попадающие под шаблон **`v*`** (например `v0.1.0`). Теги вроде `1.0.0` без `v` workflow **не** подхватит. +- Предрелизные суффиксы в имени тега возможны, но в `cliff.toml` для changelog **пропускаются** паттерны вроде `beta`, `alpha`, `rc` в `skip_tags` — ориентируйтесь на обычные релизные теги `v1.0.0`. + +## Создать и отправить тег + +Убедитесь, что нужный коммит уже в удалённом репозитории (или запушьте ветку, затем тег). + +**Тег на текущий коммит:** + +```bash +git checkout main # или dev — как принято у вас +git pull +git tag v0.2.0 +git push origin v0.2.0 +``` + +**Тег на конкретный коммит:** + +```bash +git tag v0.2.0 abc1234 +git push origin v0.2.0 +``` + +**Удалить тег** (осторожно, если релиз уже опубликован): + +```bash +git tag -d v0.2.0 +git push origin :refs/tags/v0.2.0 +``` + +На GitHub вручную: **Releases → Draft** не заменяет пуш тега; для автоматизации ориентируйтесь на `git push origin `. + +## Что появится в GitHub после пуша тега + +1. Запускается workflow **Release**. +2. Собираются артефакты **Windows** и **Linux** (zip). +3. Генерируется **`SHA256SUMS.txt`** для проверки архивов. +4. Формируется описание релиза и файл **`CHANGELOG-<тег>.md`** через **git-cliff** (группировка по Conventional Commits, при наличии токена — данные с GitHub API). +5. Создаётся или обновляется **GitHub Release** с телом из changelog и вложениями (zip + checksums + markdown). + +Отдельно при пуше тега `v*` может выполняться **CI** из `.github/workflows/ci.yml` (тесты, анализ, при необходимости сборка Linux). + +## Коммиты и changelog + +Чтобы разделы релиза (Features, Bug Fixes и т.д.) выглядели аккуратно, сообщения коммитов лучше вести в стиле [Conventional Commits](https://www.conventionalcommits.org/), например: + +- `feat(connections): …` +- `fix(postgres): …` +- `docs: …` + +Остальные сообщения не пропадают: они попадают в группу **Other** (см. [cliff.toml](../cliff.toml)). + +## Версия в приложении + +Номер в **Git-теге** и поле **`version`** в [pubspec.yaml](../pubspec.yaml) — разные сущности. Для согласованности перед релизом обычно обновляют `pubspec.yaml`, коммитят, затем вешают тег на этот коммит (или наоборот — по договорённости в команде). + +## Где смотреть настройки + +| Файл | Назначение | +|------|------------| +| [.github/workflows/release.yml](../.github/workflows/release.yml) | Триггер по тегам `v*`, сборка, релиз | +| [.github/workflows/ci.yml](../.github/workflows/ci.yml) | CI при пуше веток и тегов `v*` | +| [cliff.toml](../cliff.toml) | Правила changelog (группы, теги, remote) | + +## Локальная проверка changelog (опционально) + +Установите [git-cliff](https://git-cliff.org/docs/installation), в корне репозитория: + +```bash +git-cliff --latest --strip header +``` + +Для расширенного контекста (PR, контрибьюторы) нужен токен GitHub: + +```bash +export GITHUB_TOKEN=ghp_... +export GITHUB_REPO=QueryaHub/Querya-Desktop +git-cliff --latest --github-token "$GITHUB_TOKEN" --github-repo "$GITHUB_REPO" --strip header +``` + +(В форке подставьте свой `owner/repo`.) diff --git a/lib/app/app_shutdown.dart b/lib/app/app_shutdown.dart index 67f050d2..8a601419 100644 --- a/lib/app/app_shutdown.dart +++ b/lib/app/app_shutdown.dart @@ -1,11 +1,13 @@ import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; -/// Disconnects all pooled / cached client connections (PostgreSQL pool, Mongo, -/// Redis). Safe to call when no connections exist. +/// Disconnects all pooled / cached client connections (PostgreSQL pool, MySQL, +/// Mongo, Redis). Safe to call when no connections exist. Future disconnectAllExternalServices() async { await PostgresService.instance.disconnectAll(); + await MysqlService.instance.disconnectAll(); await MongoService.instance.disconnectAll(); await RedisService.instance.disconnectAll(); } diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart new file mode 100644 index 00000000..68657f16 --- /dev/null +++ b/lib/core/database/mysql_connection.dart @@ -0,0 +1,333 @@ +import 'dart:async'; + +import 'package:mysql_client/mysql_client.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Replaces the database in a `mysql://` / `mariadb://` URI (path or `database=`). +String replaceDatabaseInMysqlConnectionString( + String connectionString, + String newDatabase, +) { + final uri = Uri.parse(connectionString.trim()); + if (uri.scheme != 'mysql' && uri.scheme != 'mariadb') { + throw ArgumentError( + 'Invalid connection string scheme: ${uri.scheme}. ' + 'Expected "mysql" or "mariadb".', + ); + } + final params = Map.from(uri.queryParameters); + if (params.containsKey('database')) { + params['database'] = newDatabase; + return uri.replace(queryParameters: params).toString(); + } + if (uri.pathSegments.isNotEmpty && uri.pathSegments.first.isNotEmpty) { + return uri.replace(path: '/$newDatabase').toString(); + } + params['database'] = newDatabase; + return uri.replace(queryParameters: params).toString(); +} + +/// Pure-Dart MySQL client wrapper around [MySQLConnection] (`mysql_client`). +class MysqlConnection { + MysqlConnection({ + required this.id, + required this.name, + required this.host, + this.port = 3306, + this.username, + this.password, + this.database, + this.useSSL = true, + this.connectionString, + }); + + factory MysqlConnection.fromConnectionRow( + ConnectionRow row, { + String? database, + }) { + return MysqlConnection( + id: row.id ?? 0, + name: row.name, + host: row.host ?? 'localhost', + port: row.port ?? 3306, + username: row.username, + password: row.password, + database: database ?? row.databaseName, + useSSL: row.useSSL, + connectionString: row.connectionString, + ); + } + + final int id; + final String name; + final String host; + final int port; + final String? username; + final String? password; + final String? database; + final bool useSSL; + final String? connectionString; + + MySQLConnection? _conn; + bool _isConnected = false; + + bool get isConnected => _isConnected && _conn != null; + + bool get _usesConnectionString => + connectionString != null && connectionString!.trim().isNotEmpty; + + static String _escapeSqlString(String s) { + return s.replaceAll(r'\', r'\\').replaceAll("'", "''"); + } + + /// MySQL identifier quoting (backticks). + static String quoteIdentifier(String id) { + return '`${id.replaceAll('`', '``')}`'; + } + + Future connect({int connectTimeoutMs = 10000}) async { + if (_isConnected && _conn != null) return; + try { + final user = username ?? ''; + final pass = password ?? ''; + if (_usesConnectionString) { + final dbName = database; + final uriStr = dbName != null && dbName.isNotEmpty + ? replaceDatabaseInMysqlConnectionString( + connectionString!.trim(), + dbName, + ) + : connectionString!.trim(); + final parsed = _parseMysqlUri(uriStr, fallbackSsl: useSSL); + _conn = await MySQLConnection.createConnection( + host: parsed.host, + port: parsed.port, + userName: parsed.userName, + password: parsed.password, + secure: parsed.secure, + databaseName: parsed.databaseName, + ); + await _conn!.connect(timeoutMs: connectTimeoutMs); + } else { + _conn = await MySQLConnection.createConnection( + host: host, + port: port, + userName: user, + password: pass, + secure: useSSL, + databaseName: database, + ); + await _conn!.connect(timeoutMs: connectTimeoutMs); + } + _isConnected = true; + } catch (e) { + _isConnected = false; + _conn = null; + rethrow; + } + } + + /// Parsed URI fields for `mysql://` / `mariadb://`. + static ({ + dynamic host, + int port, + String userName, + String password, + String? databaseName, + bool secure, + }) _parseMysqlUri(String raw, {required bool fallbackSsl}) { + final uri = Uri.parse(raw); + if (uri.scheme != 'mysql' && uri.scheme != 'mariadb') { + throw ArgumentError('Expected mysql or mariadb scheme'); + } + final hostStr = uri.host.isEmpty ? 'localhost' : uri.host; + final port = uri.hasPort ? uri.port : 3306; + + String userName = ''; + String password = ''; + final info = uri.userInfo; + if (info.isNotEmpty) { + final colon = info.indexOf(':'); + if (colon >= 0) { + userName = Uri.decodeComponent(info.substring(0, colon)); + password = Uri.decodeComponent(info.substring(colon + 1)); + } else { + userName = Uri.decodeComponent(info); + } + } + + String? db; + if (uri.pathSegments.isNotEmpty && uri.pathSegments.first.isNotEmpty) { + db = Uri.decodeComponent(uri.pathSegments.first); + } + db ??= uri.queryParameters['database']; + + final q = uri.queryParameters; + bool secure = fallbackSsl; + final ssl = (q['ssl-mode'] ?? q['sslmode'] ?? q['ssl'])?.toLowerCase(); + if (ssl == 'false' || + ssl == '0' || + ssl == 'disable' || + ssl == 'disabled' || + ssl == 'prefer') { + secure = false; + } + if (ssl == 'require' || ssl == 'verify_ca' || ssl == 'verify_identity') { + secure = true; + } + + return ( + host: hostStr, + port: port, + userName: userName, + password: password, + databaseName: db, + secure: secure, + ); + } + + Future disconnect() async { + _isConnected = false; + final c = _conn; + _conn = null; + try { + if (c != null && c.connected) { + await c.close(); + } + } catch (_) {} + } + + /// Best-effort close. The `mysql_client` driver may not allow graceful [close] + /// while a query is in progress. + Future forceClose() async { + _isConnected = false; + final c = _conn; + _conn = null; + if (c == null) return; + try { + if (c.connected) { + await c.close(); + } + } catch (_) {} + } + + /// Session hint for read-only browsing (MySQL 8+ / MariaDB — semantics differ from PostgreSQL). + Future setSessionReadOnly(bool readOnly) async { + if (!isConnected || _conn == null) return; + if (readOnly) { + await execute('SET SESSION TRANSACTION READ ONLY'); + } else { + await execute('SET SESSION TRANSACTION READ WRITE'); + } + } + + Future testConnection() async { + try { + await connect(); + if (_conn != null) { + await execute('SELECT 1'); + return true; + } + return false; + } catch (_) { + return false; + } finally { + await disconnect(); + } + } + + Future execute( + String sql, [ + Map? params, + ]) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + return _conn!.execute(sql, params); + } + + /// Runs [execute] with an application-level [timeout] (driver limits still apply). + Future executeWithTimeout( + String sql, { + Duration? timeout, + Map? params, + }) async { + final f = execute(sql, params); + if (timeout == null) return f; + return f.timeout(timeout); + } + + /// Lists user-visible databases (excludes typical system schemas). + Future> listDatabases() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + const system = {'information_schema', 'mysql', 'performance_schema', 'sys'}; + final rs = await execute('SHOW DATABASES'); + final out = []; + for (final row in rs.rows) { + final name = row.colAt(0); + if (name != null && + name.isNotEmpty && + !system.contains(name.toLowerCase())) { + out.add(name); + } + } + out.sort(); + return out; + } + + /// Lists views in [schema] (database name). + Future> listViews({required String schema}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final s = _escapeSqlString(schema); + final rs = await execute( + 'SELECT TABLE_NAME FROM information_schema.TABLES ' + "WHERE TABLE_SCHEMA = '$s' AND TABLE_TYPE = 'VIEW' " + 'ORDER BY TABLE_NAME', + ); + return rs.rows.map((r) => r.colAt(0)!).toList(); + } + + /// Column names for [table] in [database] (database = schema in MySQL). + Future> listColumnNames({ + required String database, + required String table, + }) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final d = _escapeSqlString(database); + final t = _escapeSqlString(table); + final rs = await execute( + 'SELECT COLUMN_NAME FROM information_schema.COLUMNS ' + "WHERE TABLE_SCHEMA = '$d' AND TABLE_NAME = '$t' " + 'ORDER BY ORDINAL_POSITION', + ); + return rs.rows.map((r) => r.colAt(0)!).toList(); + } + + /// Lists base tables in [schema] (MySQL: database name). + Future> listTables({required String schema}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final s = _escapeSqlString(schema); + final rs = await execute( + 'SELECT TABLE_NAME FROM information_schema.TABLES ' + "WHERE TABLE_SCHEMA = '$s' AND TABLE_TYPE = 'BASE TABLE' " + 'ORDER BY TABLE_NAME', + ); + return rs.rows.map((r) => r.colAt(0)!).toList(); + } + + Future serverVersion() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final rs = await execute('SELECT VERSION()'); + return rs.rows.first.colAt(0) ?? ''; + } +} diff --git a/lib/core/database/mysql_connection_pool.dart b/lib/core/database/mysql_connection_pool.dart new file mode 100644 index 00000000..941f71bc --- /dev/null +++ b/lib/core/database/mysql_connection_pool.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Session policy for pooled connections: browse vs SQL editor (writes). +enum MysqlSessionMode { + readOnly, + readWrite, +} + +typedef MysqlPoolConnectionFactory = Future Function( + ConnectionRow row, { + required String database, + required MysqlSessionMode mode, +}); + +/// Lease for a pooled [MysqlConnection]. Call [release] when the UI is done. +class MysqlLease { + MysqlLease._(this._pool, this._key, this.connection); + + final MysqlConnectionPool _pool; + final String _key; + final MysqlConnection connection; + + bool _released = false; + + void release() { + if (_released) return; + _released = true; + _pool._release(_key); + } +} + +/// Pooled MySQL connections keyed by `(connection id, database, session mode)`. +class MysqlConnectionPool { + MysqlConnectionPool({ + required this.createAndConnect, + this.idleDisposeDelay = defaultIdleDisposeDelay, + this.maxEntries = defaultMaxEntries, + }); + + static const Duration defaultIdleDisposeDelay = Duration(seconds: 8); + static const int defaultMaxEntries = 32; + + final MysqlPoolConnectionFactory createAndConnect; + final Duration idleDisposeDelay; + final int maxEntries; + + final Map _pool = {}; + + String keyFor(int? id, String database, MysqlSessionMode mode) => + '${id ?? 0}::$database::${mode.name}'; + + Future acquire( + ConnectionRow row, { + required String database, + MysqlSessionMode mode = MysqlSessionMode.readOnly, + }) async { + final k = keyFor(row.id, database, mode); + var entry = _pool[k]; + if (entry != null) { + entry.touch(); + entry.idleTimer?.cancel(); + entry.idleTimer = null; + entry.refs++; + if (!entry.connection.isConnected) { + await entry.connection.connect(); + await entry.connection.setSessionReadOnly( + mode == MysqlSessionMode.readOnly, + ); + } + return MysqlLease._(this, k, entry.connection); + } + + _evictIfNeededBeforeNewSlot(); + + final conn = await createAndConnect(row, database: database, mode: mode); + entry = _PoolEntry(conn)..refs = 1; + _pool[k] = entry; + return MysqlLease._(this, k, conn); + } + + void _evictIfNeededBeforeNewSlot() { + while (_pool.length >= maxEntries) { + final idle = _pool.entries.where((e) => e.value.refs == 0).toList(); + if (idle.isEmpty) { + throw StateError( + 'MySQL connection pool exhausted: $maxEntries slots in use.', + ); + } + idle.sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed)); + _removeEntryClosing(idle.first.key); + } + } + + void _removeEntryClosing(String k) { + final entry = _pool.remove(k); + if (entry == null) return; + entry.idleTimer?.cancel(); + unawaited(entry.connection.forceClose()); + } + + void _release(String k) { + final entry = _pool[k]; + if (entry == null) return; + entry.refs--; + if (entry.refs > 0) return; + entry.idleTimer?.cancel(); + entry.idleTimer = Timer(idleDisposeDelay, () { + final e = _pool[k]; + if (e == null || e.refs > 0) return; + e.idleTimer = null; + unawaited(e.connection.disconnect()); + _pool.remove(k); + }); + } + + void interrupt( + ConnectionRow row, { + required String database, + MysqlSessionMode mode = MysqlSessionMode.readOnly, + }) { + final k = keyFor(row.id, database, mode); + _removeEntryClosing(k); + } + + Future disconnectAll() async { + for (final entry in _pool.values) { + entry.idleTimer?.cancel(); + await entry.connection.forceClose(); + } + _pool.clear(); + } +} + +class _PoolEntry { + _PoolEntry(this.connection) : lastUsed = DateTime.now(); + + final MysqlConnection connection; + int refs = 0; + Timer? idleTimer; + DateTime lastUsed; + + void touch() => lastUsed = DateTime.now(); +} diff --git a/lib/core/database/mysql_service.dart b/lib/core/database/mysql_service.dart new file mode 100644 index 00000000..276ccf60 --- /dev/null +++ b/lib/core/database/mysql_service.dart @@ -0,0 +1,52 @@ +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/mysql_connection_pool.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +export 'mysql_connection_pool.dart' + show MysqlLease, MysqlSessionMode, MysqlConnectionPool; + +Future _defaultCreateAndConnect( + ConnectionRow row, { + required String database, + required MysqlSessionMode mode, +}) async { + final conn = MysqlConnection.fromConnectionRow( + row, + database: database.isEmpty ? null : database, + ); + await conn.connect(); + await conn.setSessionReadOnly(mode == MysqlSessionMode.readOnly); + return conn; +} + +/// Global MySQL connection pool (singleton). +class MysqlService { + MysqlService._() + : _pool = MysqlConnectionPool( + createAndConnect: _defaultCreateAndConnect, + maxEntries: MysqlConnectionPool.defaultMaxEntries, + ); + + static final MysqlService instance = MysqlService._(); + + final MysqlConnectionPool _pool; + + static const Duration idleDisposeDelay = + MysqlConnectionPool.defaultIdleDisposeDelay; + + Future acquire( + ConnectionRow row, { + required String database, + MysqlSessionMode mode = MysqlSessionMode.readOnly, + }) => + _pool.acquire(row, database: database, mode: mode); + + void interrupt( + ConnectionRow row, { + required String database, + MysqlSessionMode mode = MysqlSessionMode.readOnly, + }) => + _pool.interrupt(row, database: database, mode: mode); + + Future disconnectAll() => _pool.disconnectAll(); +} diff --git a/lib/core/editor/sql_syntax_highlighting.dart b/lib/core/editor/sql_syntax_highlighting.dart new file mode 100644 index 00000000..5dfb2276 --- /dev/null +++ b/lib/core/editor/sql_syntax_highlighting.dart @@ -0,0 +1,7 @@ +/// Future work: editable SQL with syntax highlighting (see plan: syntax-highlight-epic). +/// +/// Candidates: custom [EditableText] + [TextPainter], or a dedicated code-editor package. +/// Plain [TextField] remains the source of truth until an editor is chosen. +abstract class SqlSyntaxHighlighting { + const SqlSyntaxHighlighting._(); +} diff --git a/lib/core/layout/window_layout.dart b/lib/core/layout/window_layout.dart new file mode 100644 index 00000000..a926d607 --- /dev/null +++ b/lib/core/layout/window_layout.dart @@ -0,0 +1,81 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +/// Breakpoints and sizes derived from window / overlay size (desktop adaptive UI). +abstract class WindowLayout { + WindowLayout._(); + + static const double narrowWindowWidth = 720; + static const double compactWindowWidth = 520; + + /// Horizontal inset for modal dialogs (clamped by screen). + static double dialogHorizontalInset(double screenWidth) { + return (screenWidth * 0.05).clamp(12.0, 48.0); + } + + static double dialogVerticalInset(double screenHeight) { + return (screenHeight * 0.04).clamp(12.0, 40.0); + } + + /// Use for [Dialog.insetPadding] / modal margins on small windows. + static EdgeInsets dialogSymmetricInsets(BuildContext context) { + final mq = MediaQuery.sizeOf(context); + return EdgeInsets.symmetric( + horizontal: dialogHorizontalInset(mq.width), + vertical: dialogVerticalInset(mq.height), + ); + } + + /// "Select database" and similar pickers. + static double newConnectionDialogMaxWidth(double screenWidth) { + final inset = dialogHorizontalInset(screenWidth) * 2; + return math.min(740, math.max(280.0, screenWidth - inset)); + } + + static double newConnectionDialogHeight(double screenHeight) { + final inset = dialogVerticalInset(screenHeight) * 2; + final h = screenHeight - inset; + return math.min(580, math.max(320.0, h * 0.78)); + } + + static double newConnectionSidebarWidth(double dialogWidth) { + if (dialogWidth < 400) return 88; + if (dialogWidth < 520) return 108; + if (dialogWidth < 640) return 124; + return 140; + } + + /// Grid area width (right of sidebar, inner padding applied separately). + static int dbTypeGridCrossAxisCount(double gridInnerWidth) { + if (gridInnerWidth >= 460) return 4; + if (gridInnerWidth >= 240) return 2; + return 1; + } + + static double dbTypeCardHeight(int crossAxisCount) { + return switch (crossAxisCount) { + 4 => 144, + 2 => 138, + _ => 132, + }; + } + + /// Empty workspace hero content max width (stays within viewport minus padding). + static double heroContentMaxWidth(double viewportWidth) { + final pad = heroHorizontalPadding(viewportWidth) * 2; + final inner = math.max(0.0, viewportWidth - pad); + return math.min(560, inner); + } + + static double heroHorizontalPadding(double viewportWidth) { + if (viewportWidth < compactWindowWidth) return 16; + if (viewportWidth < narrowWindowWidth) return 22; + return 28; + } + + /// Mock window block height scales with available width. + static double heroMockWindowHeight(double contentWidth) { + return (contentWidth * 0.38).clamp(140.0, 220.0); + } +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index c58a5834..5203626a 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -4,6 +4,7 @@ import 'local_db.dart'; abstract final class AppSettingsKeys { static const postgresSqlStmtTimeoutSeconds = 'postgres_sql_stmt_timeout_seconds'; + static const mysqlSqlStmtTimeoutSeconds = 'mysql_sql_stmt_timeout_seconds'; } /// User preferences backed by [LocalDb] (SQLite). @@ -32,4 +33,26 @@ class AppSettings { ); } } + + /// `null` = use driver default for statement duration. + Future getMysqlSqlStmtTimeoutSeconds() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.mysqlSqlStmtTimeoutSeconds, + ); + if (v == null || v.isEmpty) return null; + return int.tryParse(v); + } + + Future setMysqlSqlStmtTimeoutSeconds(int? seconds) async { + if (seconds == null) { + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.mysqlSqlStmtTimeoutSeconds, + ); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.mysqlSqlStmtTimeoutSeconds, + seconds.toString(), + ); + } + } } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index e66c1385..4e8fe381 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,10 +1,12 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'querya_color_scheme.dart'; + /// App theme: dark only. Used for both theme and darkTheme so the app is always dark. abstract class AppTheme { static ThemeData get dark => const ThemeData.dark( - colorScheme: ColorSchemes.darkSlate, - radius: 0.5, + colorScheme: QueryaColorScheme.dark, + radius: 0.58, scaling: 1, typography: Typography.geist(), ); diff --git a/lib/core/theme/querya_color_scheme.dart b/lib/core/theme/querya_color_scheme.dart new file mode 100644 index 00000000..f57e203b --- /dev/null +++ b/lib/core/theme/querya_color_scheme.dart @@ -0,0 +1,34 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'querya_colors.dart'; + +/// Dark [ColorScheme] for Querya desktop — matches [QueryaColors] tokens. +abstract class QueryaColorScheme { + static const ColorScheme dark = ColorScheme( + brightness: Brightness.dark, + background: QueryaColors.canvas, + foreground: Color(0xFFF8FAFC), + card: QueryaColors.surface, + cardForeground: Color(0xFFF8FAFC), + popover: QueryaColors.surface, + popoverForeground: Color(0xFFF8FAFC), + primary: QueryaColors.accentCyan, + primaryForeground: QueryaColors.onAccent, + secondary: Color(0xFF18181B), + secondaryForeground: Color(0xFFF8FAFC), + muted: Color(0xFF18181B), + mutedForeground: QueryaColors.mutedLabel, + accent: Color(0xFF27272A), + accentForeground: Color(0xFFF8FAFC), + destructive: Color(0xFFEF4444), + destructiveForeground: Color(0xFFF8FAFC), + border: QueryaColors.borderSubtle, + input: QueryaColors.borderSubtle, + ring: QueryaColors.accentCyan, + chart1: Color(0xFF2662D9), + chart2: Color(0xFF2EB88A), + chart3: Color(0xFFE88C30), + chart4: Color(0xFFAF57DB), + chart5: Color(0xFFE23670), + ); +} diff --git a/lib/core/theme/querya_colors.dart b/lib/core/theme/querya_colors.dart new file mode 100644 index 00000000..af0707d0 --- /dev/null +++ b/lib/core/theme/querya_colors.dart @@ -0,0 +1,27 @@ +import 'dart:ui'; + +/// Design tokens aligned with [design-front] mockups (near-black canvas, cyan accent). +/// +/// Canonical semantic mapping lives in [QueryaColorScheme]; these are named aliases +/// for documentation and for a few widgets that need the raw accent (e.g. glows). +abstract class QueryaColors { + QueryaColors._(); + + /// Main app background — pure black on reference hero. + static const Color canvas = Color(0xFF000000); + + /// Elevated surface (cards, mock window chrome). + static const Color surface = Color(0xFF0C0C0C); + + /// Brand accent (CTAs, tree icons, focus ring). + static const Color accentCyan = Color(0xFF22D3EE); + + /// Text / icons on filled primary buttons. + static const Color onAccent = Color(0xFF0A0A0A); + + /// Hairline borders on dark UI. + static const Color borderSubtle = Color(0xFF27272A); + + /// Secondary label tone (body muted on mockups). + static const Color mutedLabel = Color(0xFF94A3B8); +} diff --git a/lib/core/theme/querya_typography.dart b/lib/core/theme/querya_typography.dart new file mode 100644 index 00000000..4b5faf39 --- /dev/null +++ b/lib/core/theme/querya_typography.dart @@ -0,0 +1,6 @@ +/// Monospace stack for SQL editors (bundled font can replace this later). +abstract class QueryaTypography { + QueryaTypography._(); + + static const String mono = 'monospace'; +} diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart new file mode 100644 index 00000000..935ff472 --- /dev/null +++ b/lib/features/connections/connection_creation_flow.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart' as material; + +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; +import 'package:querya_desktop/features/mongodb/mongodb_connection_form.dart'; +import 'package:querya_desktop/features/mysql/mysql_connection_form.dart'; +import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; +import 'package:querya_desktop/features/redis/redis_connection_form.dart'; + +/// Picks a database type, opens the matching form, returns a saved row or null. +Future promptCreateConnection( + material.BuildContext context, { + int? folderId, +}) async { + final type = await showNewConnectionDialog(context); + if (type == null) return null; + if (!context.mounted) return null; + switch (type) { + case ConnectionType.postgresql: + return await showPostgresConnectionForm(context, folderId: folderId); + case ConnectionType.mysql: + return await showMysqlConnectionForm(context, folderId: folderId); + case ConnectionType.mongodb: + return await showMongoConnectionForm(context, folderId: folderId); + case ConnectionType.redis: + return await showRedisConnectionForm(context, folderId: folderId); + } +} diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 3c5eca8c..10ad02c2 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,30 +1,62 @@ -import 'package:flutter/material.dart' as material show AlertDialog, BoxConstraints, BuildContext, Column, ConstrainedBox, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, DefaultTextStyle, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color, LayoutBuilder, TextPainter, TextSpan, TextDirection, SelectableText, Padding, Widget, Navigator, ValueKey; +import 'package:flutter/material.dart' as material show AlertDialog, BoxConstraints, BuildContext, Column, ConstrainedBox, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color, LayoutBuilder, TextPainter, TextSpan, TextDirection, SelectableText, Padding, Widget, Navigator, ValueKey, FontWeight, VoidCallback; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mongodb/mongo_database_dialog.dart'; -import 'package:querya_desktop/features/mongodb/mongodb_connection_form.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; -import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; -import 'package:querya_desktop/features/redis/redis_connection_form.dart'; -import 'new_connection_dialog.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'new_folder_dialog.dart'; +material.Widget _sidebarConnectionShell({ + required material.BuildContext context, + required bool isSelected, + required material.VoidCallback? onTap, + required material.Widget child, +}) { + final p = Theme.of(context).colorScheme.primary; + return material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + borderRadius: material.BorderRadius.circular(20), + mouseCursor: onTap != null + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.Container( + decoration: material.BoxDecoration( + color: isSelected ? p.withValues(alpha: 0.16) : null, + borderRadius: material.BorderRadius.circular(20), + border: isSelected + ? material.Border.all(color: p.withValues(alpha: 0.26)) + : null, + ), + child: child, + ), + ), + ); +} + /// Left panel: Browser tree (pgAdmin-style). Uses shadcn layout widgets. class ConnectionsPanel extends StatefulWidget { const ConnectionsPanel({ super.key, + this.selectedConnectionId, this.onConnectionSelected, this.onRedisDatabaseSelected, this.onMongoDBDatabaseSelected, this.onPostgresObjectSelected, this.onPostgresOpenSqlWorkspace, + this.onMysqlObjectSelected, + this.onMysqlOpenSqlWorkspace, /// When true, [initState] does not call [_loadData]. Widget tests that seed /// SQLite in setUp should call [ConnectionsPanelState.reloadConnectionsFromDb] /// inside [WidgetTester.runAsync] so only one load runs (avoids overlapping @@ -32,6 +64,9 @@ class ConnectionsPanel extends StatefulWidget { this.skipInitialDbLoadForTest = false, }); + /// Highlights the active connection row in the sidebar (workspace selection). + final int? selectedConnectionId; + /// Called when the user taps a connection tile. final void Function(ConnectionRow connection)? onConnectionSelected; @@ -55,6 +90,17 @@ class ConnectionsPanel extends StatefulWidget { /// Opens the PostgreSQL workspace home and switches to the SQL tab (e.g. from tree context menu). final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; + /// MySQL table or view selected in the tree. + final void Function( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + )? onMysqlObjectSelected; + + /// Opens the MySQL workspace home and switches to the SQL tab. + final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + final bool skipInitialDbLoadForTest; @override @@ -131,19 +177,14 @@ class ConnectionsPanelState extends State { if (mounted) setState(() => _folders = FoldersStorage.instance.folders); } - Future _createConnection(ConnectionType type, {int? folderId}) async { - ConnectionRow? row; - - if (type == ConnectionType.postgresql) { - row = await showPostgresConnectionForm(context, folderId: folderId); - } else if (type == ConnectionType.mongodb) { - row = await showMongoConnectionForm(context, folderId: folderId); - } else if (type == ConnectionType.redis) { - row = await showRedisConnectionForm(context, folderId: folderId); - } else { - row = null; - } - + Future _createConnection({ + int? folderId, + material.BuildContext? dialogContext, + }) async { + final row = await promptCreateConnection( + dialogContext ?? context, + folderId: folderId, + ); if (row != null && mounted) { await LocalDb.instance.addConnection(row); await _loadData(); @@ -179,9 +220,12 @@ class ConnectionsPanelState extends State { } Widget _buildConnectionTile(ConnectionRow conn) { + final isSelected = + widget.selectedConnectionId != null && widget.selectedConnectionId == conn.id; if (conn.type == 'postgresql') { return _PostgresConnectionTile( connection: conn, + isSelected: isSelected, icon: _iconForType(conn.type), iconAsset: _iconAssetForType(conn.type), onRemove: () => _removeConnection(conn.id!), @@ -189,9 +233,21 @@ class ConnectionsPanelState extends State { onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ); + } else if (conn.type == 'mysql') { + return _MysqlConnectionTile( + connection: conn, + isSelected: isSelected, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + ); } else if (conn.type == 'redis') { return _RedisConnectionTile( connection: conn, + isSelected: isSelected, icon: _iconForType(conn.type), iconAsset: _iconAssetForType(conn.type), onRemove: () => _removeConnection(conn.id!), @@ -201,6 +257,7 @@ class ConnectionsPanelState extends State { } else if (conn.type == 'mongodb') { return _MongoConnectionTile( connection: conn, + isSelected: isSelected, icon: _iconForType(conn.type), iconAsset: _iconAssetForType(conn.type), onRemove: () => _removeConnection(conn.id!), @@ -210,6 +267,7 @@ class ConnectionsPanelState extends State { } return _ConnectionTile( connection: conn, + isSelected: isSelected, icon: _iconForType(conn.type), iconAsset: _iconAssetForType(conn.type), onRemove: () => _removeConnection(conn.id!), @@ -229,7 +287,7 @@ class ConnectionsPanelState extends State { color: theme.colorScheme.background, border: material.Border( right: material.BorderSide( - color: theme.colorScheme.border.withValues(alpha: 0.4), + color: theme.colorScheme.border.withValues(alpha: 0.28), width: 1, ), ), @@ -239,12 +297,18 @@ class ConnectionsPanelState extends State { children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(20, 24, 16, 16), - child: material.DefaultTextStyle( - style: material.TextStyle(color: theme.colorScheme.mutedForeground), - child: const Text('Browser').semiBold().small(), + child: material.Text( + 'SERVERS', + style: material.TextStyle( + fontFamily: QueryaTypography.mono, + fontSize: 11, + letterSpacing: 0.85, + fontWeight: material.FontWeight.w600, + color: theme.colorScheme.mutedForeground, + ), ), ), - Divider(height: 1, color: theme.colorScheme.border.withValues(alpha: 0.3)), + Divider(height: 1, color: theme.colorScheme.border.withValues(alpha: 0.22)), Expanded( child: material.CustomScrollView( slivers: [ @@ -277,10 +341,9 @@ class ConnectionsPanelState extends State { await _loadData(); }, onNewConnection: (folderName) async { - final type = await showNewConnectionDialog(context); - if (type == null || !mounted) return; - final folderId = await LocalDb.instance.getFolderIdByName(folderName); - await _createConnection(type, folderId: folderId); + final folderId = + await LocalDb.instance.getFolderIdByName(folderName); + await _createConnection(folderId: folderId); }, iconForType: _iconForType, onRemoveConnection: _removeConnection, @@ -312,11 +375,9 @@ class ConnectionsPanelState extends State { MenuButton( leading: material.Icon(material.Icons.settings_ethernet_rounded, size: 18, color: theme.colorScheme.mutedForeground), onPressed: (menuContext) async { - final type = await showNewConnectionDialog(menuContext); - if (type == null || !mounted) return; await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - await _createConnection(type); + if (!mounted || !menuContext.mounted) return; + await _createConnection(dialogContext: menuContext); }, child: const Text('New Connection'), ), @@ -384,6 +445,7 @@ class _EmptyState extends StatelessWidget { class _ConnectionTile extends StatelessWidget { const _ConnectionTile({ required this.connection, + this.isSelected = false, required this.icon, this.iconAsset, required this.onRemove, @@ -391,6 +453,7 @@ class _ConnectionTile extends StatelessWidget { }); final ConnectionRow connection; + final bool isSelected; final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; @@ -422,46 +485,45 @@ class _ConnectionTile extends StatelessWidget { ], child: material.Padding( padding: const material.EdgeInsets.only(bottom: 2), - child: material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: onTap, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: material.Row( - children: [ - iconWidget, - const Gap(8), - material.Expanded( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ + child: _sidebarConnectionShell( + context: context, + isSelected: isSelected, + onTap: onTap, + child: material.Padding( + padding: + const material.EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (connection.host != null) material.Text( - connection.name, + '${connection.host}:${connection.port ?? ''}', overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( - fontSize: 13, - color: theme.colorScheme.foreground, + fontSize: 11, + color: theme.colorScheme.mutedForeground, ), ), - if (connection.host != null) - material.Text( - '${connection.host}:${connection.port ?? ''}', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ), + ], ), - ], - ), + ), + ], ), ), ), @@ -585,6 +647,7 @@ class _FolderTile extends StatelessWidget { class _RedisConnectionTile extends StatefulWidget { const _RedisConnectionTile({ required this.connection, + this.isSelected = false, required this.icon, this.iconAsset, required this.onRemove, @@ -593,6 +656,7 @@ class _RedisConnectionTile extends StatefulWidget { }); final ConnectionRow connection; + final bool isSelected; final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; @@ -738,48 +802,46 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { ), // Connection name — clickable for stats material.Expanded( - child: material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: widget.onTap, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 4, vertical: 6), - child: material.Row( - children: [ - iconWidget, - const Gap(8), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) material.Text( - widget.connection.name, + '${widget.connection.host}:${widget.connection.port ?? ''}', overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( - fontSize: 13, - color: theme.colorScheme.foreground, + fontSize: 11, + color: theme.colorScheme.mutedForeground, ), ), - if (widget.connection.host != null) - material.Text( - '${widget.connection.host}:${widget.connection.port ?? ''}', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ), + ], ), - ], - ), + ), + ], ), ), ), @@ -896,6 +958,7 @@ class _RedisDatabaseNode extends StatelessWidget { class _MongoConnectionTile extends StatefulWidget { const _MongoConnectionTile({ required this.connection, + this.isSelected = false, required this.icon, this.iconAsset, required this.onRemove, @@ -904,6 +967,7 @@ class _MongoConnectionTile extends StatefulWidget { }); final ConnectionRow connection; + final bool isSelected; final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; @@ -1070,48 +1134,46 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { ), // Connection name — clickable for stats material.Expanded( - child: material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: widget.onTap, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 4, vertical: 6), - child: material.Row( - children: [ - iconWidget, - const Gap(8), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) material.Text( - widget.connection.name, + '${widget.connection.host}:${widget.connection.port ?? ''}', overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( - fontSize: 13, - color: theme.colorScheme.foreground, + fontSize: 11, + color: theme.colorScheme.mutedForeground, ), ), - if (widget.connection.host != null) - material.Text( - '${widget.connection.host}:${widget.connection.port ?? ''}', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ), + ], ), - ], - ), + ), + ], ), ), ), @@ -1244,6 +1306,7 @@ class _MongoDatabaseNode extends StatelessWidget { class _PostgresConnectionTile extends StatefulWidget { const _PostgresConnectionTile({ required this.connection, + this.isSelected = false, required this.icon, this.iconAsset, required this.onRemove, @@ -1253,6 +1316,7 @@ class _PostgresConnectionTile extends StatefulWidget { }); final ConnectionRow connection; + final bool isSelected; final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; @@ -1379,48 +1443,46 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { ), ), material.Expanded( - child: material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: widget.onTap, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 4, vertical: 6), - child: material.Row( - children: [ - iconWidget, - const Gap(8), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) material.Text( - widget.connection.name, + '${widget.connection.host}:${widget.connection.port ?? ''}', overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( - fontSize: 13, - color: theme.colorScheme.foreground, + fontSize: 11, + color: theme.colorScheme.mutedForeground, ), ), - if (widget.connection.host != null) - material.Text( - '${widget.connection.host}:${widget.connection.port ?? ''}', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ), + ], ), - ], - ), + ), + ], ), ), ), @@ -1476,6 +1538,524 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { } } +// ─── MySQL connection tile (databases → tables) ─────────────────────────────── + +class _MysqlConnectionTile extends StatefulWidget { + const _MysqlConnectionTile({ + required this.connection, + this.isSelected = false, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + this.onMysqlObjectSelected, + this.onMysqlOpenSqlWorkspace, + }); + + final ConnectionRow connection; + final bool isSelected; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + final void Function( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + )? onMysqlObjectSelected; + final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + + @override + State<_MysqlConnectionTile> createState() => _MysqlConnectionTileState(); +} + +class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { + bool _expanded = false; + bool _loading = false; + String? _error; + List _databases = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _databases.isEmpty && !_loading) { + _loadDatabases(); + } + } + + Future _loadDatabases() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + MysqlLease? lease; + try { + final c = widget.connection; + lease = await MysqlService.instance.acquire( + c, + database: c.databaseName ?? '', + mode: MysqlSessionMode.readOnly, + ); + final dbs = await lease.connection.listDatabases(); + if (!mounted) return; + setState(() { + _databases = dbs; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } finally { + lease?.release(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconWidget = widget.iconAsset != null + ? material.Image.asset( + widget.iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ), + ) + : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); + + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.refresh_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) { + _databases = []; + _loadDatabases(); + }, + child: const Text('Refresh databases'), + ), + if (widget.onMysqlOpenSqlWorkspace != null) + MenuButton( + leading: material.Icon(material.Icons.terminal_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onMysqlOpenSqlWorkspace!(widget.connection), + child: const Text('Open in SQL'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ), + material.Expanded( + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: + theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + if (_expanded) ...[ + if (_loading) + material.Padding( + padding: + const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Row( + children: [ + const material.SizedBox( + width: 12, + height: 12, + child: + material.CircularProgressIndicator(strokeWidth: 1.5), + ), + const Gap(8), + const Text('Loading...').muted().xSmall(), + ], + ), + ), + if (_error != null) + material.Padding( + padding: + const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Text( + 'Error', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, color: theme.colorScheme.destructive), + ), + ), + if (_databases.isNotEmpty) + _MysqlDatabasesNode( + connection: widget.connection, + databases: _databases, + onRefreshDatabases: () { + setState(() => _databases = []); + _loadDatabases(); + }, + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + ), + ], + ], + ), + ), + ); + } +} + +class _MysqlDatabasesNode extends material.StatelessWidget { + const _MysqlDatabasesNode({ + required this.connection, + required this.databases, + required this.onRefreshDatabases, + this.onMysqlObjectSelected, + this.onMysqlOpenSqlWorkspace, + }); + + final ConnectionRow connection; + final List databases; + final VoidCallback onRefreshDatabases; + final void Function( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + )? onMysqlObjectSelected; + final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 20), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + _PgTreeRow( + label: 'Databases (${databases.length})', + icon: material.Icons.dns_rounded, + iconSize: 14, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 4, + onTap: null, + connection: connection, + onContextRefresh: onRefreshDatabases, + onOpenSqlWorkspace: onMysqlOpenSqlWorkspace, + ), + for (final db in databases) + _MysqlDatabaseNode( + key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), + connection: connection, + databaseName: db, + onMysqlObjectSelected: onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, + ), + ], + ), + ); + } +} + +class _MysqlDatabaseNode extends StatefulWidget { + const _MysqlDatabaseNode({ + super.key, + required this.connection, + required this.databaseName, + this.onMysqlObjectSelected, + this.onMysqlOpenSqlWorkspace, + }); + + final ConnectionRow connection; + final String databaseName; + final void Function( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + )? onMysqlObjectSelected; + final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + + @override + State<_MysqlDatabaseNode> createState() => _MysqlDatabaseNodeState(); +} + +class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { + bool _expanded = false; + bool _loading = false; + List _tables = []; + List _views = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _tables.isEmpty && _views.isEmpty && !_loading) { + _loadTables(); + } + } + + Future _loadTables() async { + if (!mounted) return; + setState(() => _loading = true); + MysqlLease? lease; + try { + final c = widget.connection; + lease = await MysqlService.instance.acquire( + c, + database: widget.databaseName, + mode: MysqlSessionMode.readOnly, + ); + final tables = + await lease.connection.listTables(schema: widget.databaseName); + final views = + await lease.connection.listViews(schema: widget.databaseName); + if (!mounted) return; + setState(() { + _tables = tables; + _views = views; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + } finally { + lease?.release(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + _PgTreeRow( + label: widget.databaseName, + leading: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + icon: material.Icons.storage_rounded, + iconSize: 14, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 4, + onTap: _toggle, + connection: widget.connection, + onContextRefresh: _loadTables, + onOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + ), + if (_expanded) ...[ + if (_loading) + material.Padding( + padding: + const material.EdgeInsets.only(left: 24, top: 2, bottom: 2), + child: material.Row( + children: [ + const material.SizedBox( + width: 10, + height: 10, + child: + material.CircularProgressIndicator(strokeWidth: 1.5), + ), + const Gap(6), + const Text('Loading...').muted().xSmall(), + ], + ), + ), + if (_tables.isNotEmpty || _views.isNotEmpty) + material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + if (_tables.isNotEmpty) ...[ + _PgTreeRow( + label: 'Tables (${_tables.length})', + icon: material.Icons.table_chart_rounded, + iconSize: 13, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + verticalPadding: 3, + onTap: null, + connection: widget.connection, + onContextRefresh: _loadTables, + onOpenSqlWorkspace: null, + ), + for (final t in _tables) + material.Padding( + padding: const material.EdgeInsets.only(left: 12), + child: _PgTreeRow( + label: t, + icon: material.Icons.grid_on_rounded, + iconSize: 12, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.foreground, + ), + verticalPadding: 2, + onTap: widget.onMysqlObjectSelected == null + ? null + : () => widget.onMysqlObjectSelected!( + widget.connection, + widget.databaseName, + t, + MysqlObjectKind.table, + ), + connection: widget.connection, + onContextRefresh: null, + onOpenSqlWorkspace: null, + ), + ), + ], + if (_views.isNotEmpty) ...[ + _PgTreeRow( + label: 'Views (${_views.length})', + icon: material.Icons.view_agenda_rounded, + iconSize: 13, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + verticalPadding: 3, + onTap: null, + connection: widget.connection, + onContextRefresh: _loadTables, + onOpenSqlWorkspace: null, + ), + for (final v in _views) + material.Padding( + padding: const material.EdgeInsets.only(left: 12), + child: _PgTreeRow( + label: v, + icon: material.Icons.view_week_rounded, + iconSize: 12, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.foreground, + ), + verticalPadding: 2, + onTap: widget.onMysqlObjectSelected == null + ? null + : () => widget.onMysqlObjectSelected!( + widget.connection, + widget.databaseName, + v, + MysqlObjectKind.view, + ), + connection: widget.connection, + onContextRefresh: null, + onOpenSqlWorkspace: null, + ), + ), + ], + ], + ), + ), + ], + ], + ), + ); + } +} + class _PgDatabasesNode extends StatefulWidget { const _PgDatabasesNode({ required this.connection, diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index 768d6a17..fa386c67 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/drivers/driver_storage.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'new_connection_dialog.dart'; @@ -21,7 +22,7 @@ typedef _DriverInfo = ({ final _driverInfoList = <_DriverInfo>[ (type: ConnectionType.postgresql, description: 'PostgreSQL server connection (JDBC driver)', fixedStatus: null, downloadable: DownloadableDriver.postgresql), - (type: ConnectionType.mysql, description: 'MySQL / MariaDB server connection', fixedStatus: DriverStatus.comingSoon, downloadable: null), + (type: ConnectionType.mysql, description: 'MySQL / MariaDB server connection', fixedStatus: DriverStatus.installed, downloadable: null), (type: ConnectionType.redis, description: 'Redis server connection', fixedStatus: DriverStatus.installed, downloadable: null), (type: ConnectionType.mongodb, description: 'MongoDB server connection', fixedStatus: DriverStatus.installed, downloadable: null), ]; @@ -30,10 +31,10 @@ final _driverInfoList = <_DriverInfo>[ void showDriverManagerDialog(BuildContext context) { showAppDialog( context: context, - builder: (context) => const material.Dialog( + builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, - insetPadding: material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), - child: _DriverManagerDialogContent(), + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: const _DriverManagerDialogContent(), ), ); } diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index cf940bc0..62b9bb61 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -1,4 +1,7 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Database type for new connection. @@ -43,10 +46,10 @@ enum _Category { all, sql, nosql } Future showNewConnectionDialog(BuildContext context) { return showAppDialog( context: context, - builder: (context) => const material.Dialog( + builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, - insetPadding: material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), - child: _NewConnectionDialogContent(), + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: const _NewConnectionDialogContent(), ), ); } @@ -86,25 +89,34 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; + final mq = MediaQuery.sizeOf(context); + final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(mq.width); + final dialogH = WindowLayout.newConnectionDialogHeight(mq.height); + final sidebarW = WindowLayout.newConnectionSidebarWidth(dialogMaxW); + final headerPadH = dialogMaxW < 420 ? 16.0 : 24.0; + return material.Container( - constraints: const material.BoxConstraints(maxWidth: 740, minHeight: 380), + width: dialogMaxW, + constraints: material.BoxConstraints( + maxWidth: dialogMaxW, + maxHeight: dialogH, + minHeight: math.min(320.0, dialogH), + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), border: material.Border.all(color: theme.muted), ), - child: material.ConstrainedBox( - constraints: const material.BoxConstraints(maxWidth: 740, minHeight: 380), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.SizedBox( - height: 520, - child: material.Column( + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.SizedBox( + height: dialogH, + child: material.Column( mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + padding: material.EdgeInsets.fromLTRB(headerPadH, 20, headerPadH, 8), child: Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ @@ -147,7 +159,7 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ material.Container( - width: 140, + width: sidebarW, decoration: material.BoxDecoration( border: material.Border( right: material.BorderSide( @@ -186,16 +198,25 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial child: material.LayoutBuilder( builder: (context, constraints) { const spacing = 12.0; - const crossAxisCount = 4; - final cardWidth = (constraints.maxWidth - spacing * (crossAxisCount - 1)) / crossAxisCount; - const cardHeight = 132.0; + final gridPad = dialogMaxW < 420 ? 12.0 : 16.0; + final innerW = math.max(0.0, constraints.maxWidth - gridPad * 2); + final crossAxisCount = + WindowLayout.dbTypeGridCrossAxisCount(innerW); + final cardHeight = + WindowLayout.dbTypeCardHeight(crossAxisCount); + final cardWidth = crossAxisCount > 0 + ? (innerW - spacing * (crossAxisCount - 1)) / + crossAxisCount + : innerW; + final aspect = + cardHeight > 0 ? cardWidth / cardHeight : 1.0; return material.Padding( - padding: const material.EdgeInsets.all(16), + padding: material.EdgeInsets.all(gridPad), child: material.GridView.count( crossAxisCount: crossAxisCount, mainAxisSpacing: spacing, crossAxisSpacing: spacing, - childAspectRatio: cardWidth / cardHeight, + childAspectRatio: aspect.clamp(0.4, 4.0), shrinkWrap: true, physics: const material.ClampingScrollPhysics(), children: [ @@ -216,7 +237,10 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial ), ), material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: material.EdgeInsets.symmetric( + horizontal: headerPadH, + vertical: 14, + ), decoration: material.BoxDecoration( border: material.Border( top: material.BorderSide(color: theme.border.withValues(alpha: 0.3)), @@ -241,7 +265,6 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial ), ], ), - ), ), ), ); @@ -317,7 +340,7 @@ class _DbTypeCardState extends material.State<_DbTypeCard> { child: material.AnimatedContainer( duration: const Duration(milliseconds: 120), curve: material.Curves.easeOut, - padding: const material.EdgeInsets.symmetric(vertical: 12, horizontal: 12), + padding: const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8), decoration: material.BoxDecoration( color: highlighted ? t.muted.withValues(alpha: 0.4) : t.muted.withValues(alpha: 0.12), borderRadius: material.BorderRadius.circular(10), @@ -327,22 +350,52 @@ class _DbTypeCardState extends material.State<_DbTypeCard> { ), ), child: material.Column( - mainAxisSize: material.MainAxisSize.min, - mainAxisAlignment: material.MainAxisAlignment.center, + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - material.SizedBox( - width: 56, - height: 56, - child: widget.type.iconAsset != null - ? material.Image.asset( - widget.type.iconAsset!, - fit: material.BoxFit.contain, - filterQuality: material.FilterQuality.medium, - ) - : material.Icon(widget.type.icon, size: 56, color: t.primary), + material.Expanded( + child: material.Center( + child: material.SizedBox( + width: 52, + height: 52, + child: widget.type.iconAsset != null + ? material.Image.asset( + widget.type.iconAsset!, + fit: material.BoxFit.contain, + filterQuality: material.FilterQuality.medium, + ) + : material.Icon(widget.type.icon, size: 52, color: t.primary), + ), + ), + ), + const material.SizedBox(height: 6), + material.LayoutBuilder( + builder: (context, lc) { + return material.SizedBox( + height: 38, + child: material.FittedBox( + fit: material.BoxFit.scaleDown, + alignment: material.Alignment.center, + child: material.ConstrainedBox( + constraints: material.BoxConstraints( + maxWidth: math.max(48.0, lc.maxWidth), + ), + child: material.Text( + widget.type.label, + textAlign: material.TextAlign.center, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + height: 1.2, + color: t.foreground, + ), + ), + ), + ), + ); + }, ), - const material.SizedBox(height: 10), - Text(widget.type.label).semiBold().small(), ], ), ), diff --git a/lib/features/connections/new_folder_dialog.dart b/lib/features/connections/new_folder_dialog.dart index 8dae9849..15c95a5d 100644 --- a/lib/features/connections/new_folder_dialog.dart +++ b/lib/features/connections/new_folder_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows a dialog to create a new folder (same style as new connection). @@ -6,10 +7,10 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showNewFolderDialog(BuildContext context) { return showAppDialog( context: context, - builder: (context) => const material.Dialog( + builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, - insetPadding: material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), - child: _NewFolderDialogContent(), + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: const _NewFolderDialogContent(), ), ); } diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index acf1c8ad..0b424b2f 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -4,9 +4,11 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material show Scaffold, Container, MainAxisSize, GestureDetector, MouseRegion, SystemMouseCursors, HitTestBehavior, Icons, Icon; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/app_theme.dart'; -import 'package:querya_desktop/shared/widgets/widgets.dart'; - +import 'package:querya_desktop/core/theme/querya_colors.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; @@ -20,6 +22,9 @@ class MainScreen extends StatefulWidget { } class _MainScreenState extends State { + final GlobalKey _connectionsPanelKey = + GlobalKey(); + static const double _minLeftWidth = 180; static const double _maxLeftWidth = 500; /// Minimum width reserved for workspace (avoid Row overflow when window is narrow). @@ -43,12 +48,19 @@ class _MainScreenState extends State { /// Bumped to tell [PostgresWorkspaceHome] to switch to the SQL tab. int _postgresSqlTabRequestToken = 0; + /// When set, user selected a MySQL table or view in the tree. + ({String database, String name, MysqlObjectKind kind})? _selectedMysqlObject; + + /// Bumped to tell [MysqlWorkspaceHome] to switch to the SQL tab. + int _mysqlSqlTabRequestToken = 0; + void _onConnectionSelected(ConnectionRow connection) { setState(() { _activeConnection = connection; _activeRedisDb = null; _activeMongoDB = null; _selectedPostgresObject = null; + _selectedMysqlObject = null; }); } @@ -63,6 +75,7 @@ class _MainScreenState extends State { _activeConnection = connection; _activeRedisDb = null; _activeMongoDB = null; + _selectedMysqlObject = null; _selectedPostgresObject = ( database: database, schema: schema, @@ -72,11 +85,32 @@ class _MainScreenState extends State { }); } + void _onMysqlObjectSelected( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + ) { + setState(() { + _activeConnection = connection; + _activeRedisDb = null; + _activeMongoDB = null; + _selectedPostgresObject = null; + _selectedMysqlObject = ( + database: database, + name: name, + kind: kind, + ); + }); + } + void _onRedisDatabaseSelected(ConnectionRow connection, int database) { setState(() { _activeConnection = connection; _activeRedisDb = database; _activeMongoDB = null; + _selectedPostgresObject = null; + _selectedMysqlObject = null; }); } @@ -85,6 +119,8 @@ class _MainScreenState extends State { _activeConnection = connection; _activeRedisDb = null; _activeMongoDB = database; + _selectedPostgresObject = null; + _selectedMysqlObject = null; }); } @@ -94,10 +130,29 @@ class _MainScreenState extends State { _activeRedisDb = null; _activeMongoDB = null; _selectedPostgresObject = null; + _selectedMysqlObject = null; _postgresSqlTabRequestToken++; }); } + void _onMysqlOpenSqlWorkspace(ConnectionRow connection) { + setState(() { + _activeConnection = connection; + _activeRedisDb = null; + _activeMongoDB = null; + _selectedPostgresObject = null; + _selectedMysqlObject = null; + _mysqlSqlTabRequestToken++; + }); + } + + Future _openNewConnectionFromHero() async { + final row = await promptCreateConnection(context); + if (!mounted || row == null) return; + await LocalDb.instance.addConnection(row); + await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } + @override Widget build(BuildContext context) { final theme = AppTheme.dark.colorScheme; @@ -106,12 +161,12 @@ class _MainScreenState extends State { body: Theme( data: AppTheme.dark, child: WindowBorder( - color: theme.border.withValues(alpha: 0.6), + color: theme.border.withValues(alpha: 0.35), width: 1, child: Column( children: [ _CustomTitleBar(theme: theme), - const Divider(height: 1), + Divider(height: 1, color: theme.border.withValues(alpha: 0.22)), Expanded( child: LayoutBuilder( builder: (context, constraints) { @@ -134,11 +189,15 @@ class _MainScreenState extends State { SizedBox( width: leftW, child: ConnectionsPanel( + key: _connectionsPanelKey, + selectedConnectionId: _activeConnection?.id, onConnectionSelected: _onConnectionSelected, onRedisDatabaseSelected: _onRedisDatabaseSelected, onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, onPostgresObjectSelected: _onPostgresObjectSelected, onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, + onMysqlObjectSelected: _onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: _onMysqlOpenSqlWorkspace, ), ), _VerticalResizeHandle( @@ -168,6 +227,11 @@ class _MainScreenState extends State { selectedMongoDb: _activeMongoDB, selectedPostgresObject: _selectedPostgresObject, postgresSqlTabRequestToken: _postgresSqlTabRequestToken, + selectedMysqlObject: _selectedMysqlObject, + mysqlSqlTabRequestToken: _mysqlSqlTabRequestToken, + onRequestNewConnection: () { + _openNewConnectionFromHero(); + }, ), ), ], @@ -244,6 +308,12 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { child: Row( children: [ const SizedBox(width: 16), + const material.Icon( + material.Icons.search_rounded, + size: 18, + color: QueryaColors.accentCyan, + ), + const Gap(8), const Text('Querya').semiBold().small(), const Gap(24), Menubar( diff --git a/lib/features/main_screen/query_editor_tab.dart b/lib/features/main_screen/query_editor_tab.dart index 240e47b6..7272997e 100644 --- a/lib/features/main_screen/query_editor_tab.dart +++ b/lib/features/main_screen/query_editor_tab.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart' as material - show Padding, EdgeInsets, TextStyle, TextEditingController; + show EdgeInsets, Padding, TextEditingController, TextStyle; +import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; class QueryEditorTab extends StatelessWidget { @@ -68,17 +70,18 @@ class _QueryEditorBodyState extends State<_QueryEditorBody> { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return material.Padding( padding: const material.EdgeInsets.all(12), - child: Card( - padding: material.EdgeInsets.zero, + child: SqlEditorChrome( child: TextField( controller: _owned, maxLines: null, expands: true, - style: const material.TextStyle( - fontFamily: 'monospace', + style: material.TextStyle( + fontFamily: QueryaTypography.mono, fontSize: 13, + color: theme.colorScheme.foreground, ), placeholder: const Text('-- Enter SQL here…\nSELECT 1;'), ), diff --git a/lib/features/main_screen/sql_editor_chrome.dart b/lib/features/main_screen/sql_editor_chrome.dart new file mode 100644 index 00000000..88014e14 --- /dev/null +++ b/lib/features/main_screen/sql_editor_chrome.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_colors.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Outer chrome for SQL editors: subtle border, surface fill, soft cyan glow. +class SqlEditorChrome extends StatelessWidget { + const SqlEditorChrome({super.key, required this.child}); + + final Widget child; + + static material.BoxDecoration inlineFieldDecoration(ThemeData theme) { + final cs = theme.colorScheme; + return material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.45), + ), + boxShadow: [ + material.BoxShadow( + color: QueryaColors.accentCyan.withValues(alpha: 0.07), + blurRadius: 18, + offset: const material.Offset(0, 6), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final glow = QueryaColors.accentCyan.withValues(alpha: 0.1); + return material.Container( + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(14), + boxShadow: [ + material.BoxShadow( + color: glow, + blurRadius: 28, + spreadRadius: 0, + offset: const material.Offset(0, 10), + ), + ], + ), + child: material.Container( + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(14), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.5), + ), + ), + clipBehavior: material.Clip.antiAlias, + child: child, + ), + ); + } +} diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart new file mode 100644 index 00000000..7c0828f6 --- /dev/null +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -0,0 +1,322 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/theme/querya_colors.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Marketing-style empty workspace: badge, copy, mock window, primary CTA. +class WorkspaceEmptyHero extends StatelessWidget { + const WorkspaceEmptyHero({ + super.key, + required this.onNewConnection, + }); + + final VoidCallback onNewConnection; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + return material.LayoutBuilder( + builder: (context, c) { + final vw = c.maxWidth; + final padH = WindowLayout.heroHorizontalPadding(vw); + final padV = vw < WindowLayout.compactWindowWidth ? 24.0 : 32.0; + final maxContent = WindowLayout.heroContentMaxWidth(vw); + final mockH = WindowLayout.heroMockWindowHeight(maxContent); + final compact = vw < WindowLayout.compactWindowWidth; + return material.SingleChildScrollView( + padding: material.EdgeInsets.symmetric(horizontal: padH, vertical: padV), + child: material.Align( + alignment: material.Alignment.topCenter, + child: material.ConstrainedBox( + constraints: material.BoxConstraints(maxWidth: maxContent), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + _HeroBadge(colorScheme: cs, compact: compact), + material.SizedBox(height: compact ? 16 : 20), + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4), + child: material.Text( + 'A lightweight desktop client for every database you ship', + textAlign: material.TextAlign.center, + style: material.TextStyle( + fontSize: 20, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + ), + ), + material.SizedBox(height: compact ? 10 : 12), + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4), + child: material.Text( + 'Connect to PostgreSQL, MySQL, Redis and MongoDB from a single ' + 'focused app with a calm dark interface.', + textAlign: material.TextAlign.center, + style: material.TextStyle( + fontSize: 14, + height: 1.4, + color: cs.mutedForeground, + ), + ), + ), + const material.SizedBox(height: 10), + Text( + 'BUILT WITH FLUTTER · CROSS-PLATFORM', + textAlign: material.TextAlign.center, + style: material.TextStyle( + fontFamily: QueryaTypography.mono, + fontSize: compact ? 9 : 10, + letterSpacing: 0.6, + color: cs.mutedForeground.withValues(alpha: 0.85), + ), + ), + material.SizedBox(height: compact ? 20 : 28), + _MockAppWindow( + colorScheme: cs, + height: mockH, + compact: compact, + ), + material.SizedBox(height: compact ? 20 : 28), + material.Wrap( + alignment: material.WrapAlignment.center, + spacing: 12, + runSpacing: 12, + children: [ + PrimaryButton( + onPressed: onNewConnection, + leading: material.Icon( + material.Icons.add_link_rounded, + size: compact ? 16 : 18, + ), + child: const Text('New connection'), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _HeroBadge extends StatelessWidget { + const _HeroBadge({required this.colorScheme, this.compact = false}); + + final ColorScheme colorScheme; + final bool compact; + + @override + Widget build(BuildContext context) { + return material.Container( + padding: material.EdgeInsets.symmetric( + horizontal: compact ? 10 : 12, + vertical: compact ? 5 : 6, + ), + decoration: material.BoxDecoration( + color: colorScheme.background, + borderRadius: material.BorderRadius.circular(999), + border: material.Border.all( + color: QueryaColors.accentCyan.withValues(alpha: 0.45), + ), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.max, + children: [ + material.Icon( + material.Icons.auto_awesome_rounded, + size: compact ? 12 : 14, + color: colorScheme.primary, + ), + material.SizedBox(width: compact ? 6 : 8), + material.Expanded( + child: material.Text( + 'One app for SQL and NoSQL', + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: compact ? 11 : 12, + color: colorScheme.foreground.withValues(alpha: 0.92), + ), + ), + ), + ], + ), + ); + } +} + +class _MockAppWindow extends StatelessWidget { + const _MockAppWindow({ + required this.colorScheme, + required this.height, + this.compact = false, + }); + + final ColorScheme colorScheme; + final double height; + final bool compact; + + @override + Widget build(BuildContext context) { + final glow = QueryaColors.accentCyan.withValues(alpha: 0.14); + final sidebarW = compact ? 58.0 : 72.0; + final blur = compact ? 28.0 : 40.0; + final radius = compact ? 12.0 : 16.0; + return material.Container( + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(radius), + boxShadow: [ + material.BoxShadow( + color: glow, + blurRadius: blur, + spreadRadius: -4, + offset: material.Offset(0, compact ? 12 : 18), + ), + ], + ), + child: material.Container( + height: height, + decoration: material.BoxDecoration( + color: const Color(0xFF141414), + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all( + color: colorScheme.border.withValues(alpha: 0.5), + ), + ), + clipBehavior: material.Clip.antiAlias, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Container( + padding: material.EdgeInsets.symmetric( + horizontal: compact ? 10 : 12, + vertical: compact ? 8 : 10, + ), + decoration: material.BoxDecoration( + border: material.Border( + bottom: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.35), + ), + ), + ), + child: material.Row( + children: [ + _trafficDot(const Color(0xFFFF5F57)), + const material.SizedBox(width: 6), + _trafficDot(const Color(0xFFFEBC2E)), + const material.SizedBox(width: 6), + _trafficDot(const Color(0xFF28C840)), + ], + ), + ), + material.Expanded( + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Container( + width: sidebarW, + padding: material.EdgeInsets.fromLTRB( + compact ? 8 : 10, + compact ? 8 : 10, + compact ? 6 : 8, + compact ? 8 : 10, + ), + color: const Color(0xFF0F0F0F), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text( + 'SERVERS', + style: material.TextStyle( + fontFamily: QueryaTypography.mono, + fontSize: compact ? 8 : 9, + letterSpacing: 0.5, + color: colorScheme.mutedForeground, + ), + ), + material.SizedBox(height: compact ? 8 : 10), + material.Container( + padding: material.EdgeInsets.symmetric( + horizontal: compact ? 6 : 8, + vertical: compact ? 4 : 6, + ), + decoration: material.BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.2), + borderRadius: + material.BorderRadius.circular(compact ? 6 : 8), + ), + child: material.Text( + 'analytics-prod', + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: compact ? 9 : 10, + color: colorScheme.foreground, + ), + ), + ), + ], + ), + ), + material.Expanded( + child: material.Container( + margin: material.EdgeInsets.all(compact ? 8 : 10), + padding: material.EdgeInsets.all(compact ? 8 : 10), + decoration: material.BoxDecoration( + color: const Color(0xFF0A0A0A), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: colorScheme.border.withValues(alpha: 0.25), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Text( + 'SELECT …', + style: material.TextStyle( + fontFamily: QueryaTypography.mono, + fontSize: compact ? 9 : 11, + height: 1.45, + color: colorScheme.primary, + ), + ), + material.Text( + " interval '1 day'", + style: material.TextStyle( + fontFamily: QueryaTypography.mono, + fontSize: compact ? 9 : 11, + height: 1.45, + color: const Color(0xFFFBBF24), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +material.Widget _trafficDot(Color c) { + return material.Container( + width: 10, + height: 10, + decoration: material.BoxDecoration( + color: c, + shape: material.BoxShape.circle, + ), + ); +} diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index a1ab3d18..beee2950 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -1,7 +1,10 @@ -import 'package:flutter/material.dart' as material show Axis, Container, EdgeInsets, BoxDecoration, GestureDetector, Padding, BorderRadius, Center, CrossAxisAlignment, Icon, Icons, MouseRegion, AnimatedContainer, AnimatedScale, Curves, SystemMouseCursors, LayoutBuilder, HitTestBehavior, SizedBox, SingleChildScrollView, Row, MainAxisSize; +import 'package:flutter/material.dart' as material show Alignment, Axis, Container, EdgeInsets, BoxDecoration, GestureDetector, Padding, BorderRadius, Center, CrossAxisAlignment, Icon, Icons, MouseRegion, AnimatedContainer, AnimatedScale, Curves, SystemMouseCursors, LayoutBuilder, HitTestBehavior, SizedBox, SingleChildScrollView, Row, MainAxisSize; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/mysql/mysql_table_view.dart'; +import 'package:querya_desktop/features/mysql/mysql_workspace_home.dart'; import 'package:querya_desktop/features/mongodb/mongo_explorer_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_stats_view.dart'; import 'package:querya_desktop/features/postgresql/postgres_browser_views.dart'; @@ -14,6 +17,7 @@ import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; import 'query_editor_tab.dart'; import 'results_tab.dart'; +import 'workspace_empty_hero.dart'; Widget _pgObjectWorkspace({ required ConnectionRow connection, @@ -117,6 +121,9 @@ class WorkspacePanel extends StatefulWidget { this.selectedMongoDb, this.selectedPostgresObject, this.postgresSqlTabRequestToken = 0, + this.selectedMysqlObject, + this.mysqlSqlTabRequestToken = 0, + this.onRequestNewConnection, }); /// Currently selected connection from the sidebar. @@ -138,6 +145,16 @@ class WorkspacePanel extends StatefulWidget { /// Incremented by [MainScreen] to switch the PostgreSQL home view to the SQL tab. final int postgresSqlTabRequestToken; + /// When set, the user selected a MySQL table or view in the sidebar tree. + final ({String database, String name, MysqlObjectKind kind})? + selectedMysqlObject; + + /// Incremented by [MainScreen] to switch the MySQL home view to the SQL tab. + final int mysqlSqlTabRequestToken; + + /// Empty-state hero: primary CTA to add a connection. + final void Function()? onRequestNewConnection; + @override State createState() => _WorkspacePanelState(); } @@ -151,6 +168,17 @@ class _WorkspacePanelState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); + if (widget.activeConnection == null) { + return material.Container( + color: theme.colorScheme.background, + alignment: material.Alignment.topCenter, + child: WorkspaceEmptyHero( + onNewConnection: + widget.onRequestNewConnection ?? () {}, + ), + ); + } + // If a PostgreSQL connection is selected → stats, table/view, function, or sequence if (widget.activeConnection != null && widget.activeConnection!.type == 'postgresql') { @@ -172,6 +200,32 @@ class _WorkspacePanelState extends State { ); } + // MySQL / MariaDB + if (widget.activeConnection != null && + widget.activeConnection!.type == 'mysql') { + final my = widget.selectedMysqlObject; + return material.Container( + color: theme.colorScheme.background, + child: material.SizedBox.expand( + child: my == null + ? MysqlWorkspaceHome( + key: ValueKey('mysql_home_${widget.activeConnection!.id}'), + connectionRow: widget.activeConnection!, + sqlTabRequestToken: widget.mysqlSqlTabRequestToken, + ) + : MysqlTableView( + key: ValueKey( + 'mysql_${widget.activeConnection!.id}_${my.database}_${my.name}_${my.kind}', + ), + connectionRow: widget.activeConnection!, + database: my.database, + tableName: my.name, + isView: my.kind == MysqlObjectKind.view, + ), + ), + ); + } + // If a MongoDB connection is selected if (widget.activeConnection != null && widget.activeConnection!.type == 'mongodb') { diff --git a/lib/features/mongodb/mongo_database_dialog.dart b/lib/features/mongodb/mongo_database_dialog.dart index a833dad4..dcf15582 100644 --- a/lib/features/mongodb/mongo_database_dialog.dart +++ b/lib/features/mongodb/mongo_database_dialog.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows dialog to create a new MongoDB database. Future showCreateMongoDBDialog(material.BuildContext context) async { return showAppDialog( context: context, - builder: (context) => const material.Dialog( + builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, - insetPadding: material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), - child: _CreateMongoDBDialogContent(), + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: const _CreateMongoDBDialogContent(), ), ); } diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index e998ca3f..f0526ece 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -47,7 +48,7 @@ Future showMongoConnectionForm( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, - insetPadding: const material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), + insetPadding: WindowLayout.dialogSymmetricInsets(context), child: _MongoConnectionFormContent(folderId: folderId), ), ); diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart new file mode 100644 index 00000000..8227115f --- /dev/null +++ b/lib/features/mysql/mysql_connection_form.dart @@ -0,0 +1,485 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows MySQL / MariaDB connection form dialog. +/// Returns [ConnectionRow] if saved, null if cancelled. +Future showMysqlConnectionForm( + BuildContext context, { + int? folderId, +}) async { + return showAppDialog( + context: context, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: _MysqlConnectionFormContent(folderId: folderId), + ), + ); +} + +class _MysqlConnectionFormContent extends material.StatefulWidget { + const _MysqlConnectionFormContent({this.folderId}); + + final int? folderId; + + @override + material.State<_MysqlConnectionFormContent> createState() => + _MysqlConnectionFormContentState(); +} + +class _MysqlConnectionFormContentState + extends material.State<_MysqlConnectionFormContent> { + final _nameController = material.TextEditingController(); + final _hostController = material.TextEditingController(text: 'localhost'); + final _portController = material.TextEditingController(text: '3306'); + final _databaseController = material.TextEditingController(); + final _usernameController = material.TextEditingController(text: 'root'); + final _passwordController = material.TextEditingController(); + final _connectionStringController = material.TextEditingController(); + + bool _useSSL = true; + bool _showPassword = false; + bool _isTesting = false; + String? _testResult; + Timer? _dismissTimer; + + @override + void initState() { + super.initState(); + _nameController.addListener(_onFieldChanged); + _hostController.addListener(_onFieldChanged); + _portController.addListener(_onFieldChanged); + _databaseController.addListener(_onFieldChanged); + _usernameController.addListener(_onFieldChanged); + _connectionStringController.addListener(_onFieldChanged); + } + + void _onFieldChanged() => setState(() {}); + + bool _looksLikeMysqlUri(String s) { + final t = s.trim().toLowerCase(); + return t.startsWith('mysql://') || t.startsWith('mariadb://'); + } + + bool get _formValid { + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty) { + return _looksLikeMysqlUri(uri); + } + final host = _hostController.text.trim(); + return host.isNotEmpty; + } + + void _showTestResult(String result) { + _dismissTimer?.cancel(); + setState(() { + _isTesting = false; + _testResult = result; + }); + _dismissTimer = Timer(const Duration(seconds: 5), () { + if (mounted) setState(() => _testResult = null); + }); + } + + void _dismissResult() { + _dismissTimer?.cancel(); + _dismissTimer = null; + setState(() => _testResult = null); + } + + Future _testConnection() async { + if (!_formValid) return; + _dismissTimer?.cancel(); + _dismissTimer = null; + setState(() { + _isTesting = true; + _testResult = null; + }); + try { + final uri = _connectionStringController.text.trim(); + final dbText = _databaseController.text.trim(); + final conn = MysqlConnection( + id: 0, + name: _nameController.text.trim().isEmpty + ? 'test' + : _nameController.text.trim(), + host: uri.isNotEmpty ? 'localhost' : _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 3306, + database: dbText.isEmpty ? null : dbText, + username: _usernameController.text.trim().isEmpty + ? null + : _usernameController.text.trim(), + password: _passwordController.text.isEmpty + ? null + : _passwordController.text, + useSSL: _useSSL, + connectionString: uri.isEmpty ? null : uri, + ); + final ok = await conn.testConnection(); + if (mounted) _showTestResult(ok ? 'success' : 'failed'); + } catch (e) { + if (mounted) _showTestResult('error: $e'); + } + } + + void _save() { + if (!_formValid) return; + final name = _nameController.text.trim(); + final host = _hostController.text.trim(); + final port = int.tryParse(_portController.text.trim()) ?? 3306; + final database = _databaseController.text.trim(); + final uri = _connectionStringController.text.trim(); + final displayName = name.isNotEmpty + ? name + : (uri.isNotEmpty + ? 'MySQL (URI)' + : 'MySQL $host:$port${database.isNotEmpty ? '/$database' : ''}'); + final row = ConnectionRow( + type: 'mysql', + name: displayName, + host: uri.isNotEmpty ? null : host, + port: uri.isNotEmpty ? null : port, + username: _usernameController.text.trim().isEmpty + ? null + : _usernameController.text.trim(), + password: + _passwordController.text.isEmpty ? null : _passwordController.text, + databaseName: uri.isNotEmpty + ? null + : (database.isEmpty ? null : database), + useSSL: _useSSL, + connectionString: uri.isEmpty ? null : uri, + folderId: widget.folderId, + createdAt: DateTime.now().toUtc().toIso8601String(), + ); + material.Navigator.of(context).pop(row); + } + + @override + void dispose() { + _dismissTimer?.cancel(); + _nameController.removeListener(_onFieldChanged); + _hostController.removeListener(_onFieldChanged); + _portController.removeListener(_onFieldChanged); + _databaseController.removeListener(_onFieldChanged); + _usernameController.removeListener(_onFieldChanged); + _connectionStringController.removeListener(_onFieldChanged); + _nameController.dispose(); + _hostController.dispose(); + _portController.dispose(); + _databaseController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _connectionStringController.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + + return material.Container( + constraints: + const material.BoxConstraints(maxWidth: 600, maxHeight: 640), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + children: [ + material.SizedBox( + width: 24, + height: 24, + child: material.Image.asset( + 'assets/images/mysql_icon.png', + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + material.Icons.table_chart_rounded, + size: 24, + color: theme.primary, + ), + ), + ), + const Gap(12), + const Text('MySQL Connection').large().semiBold(), + ], + ), + const Gap(8), + const Text('Configure your MySQL or MariaDB connection') + .muted() + .small(), + ], + ), + ), + const material.Divider(height: 1), + material.Expanded( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Connection Name').small().semiBold(), + const Gap(8), + TextField( + controller: _nameController, + placeholder: const Text('My MySQL Server'), + ), + const Gap(16), + const Text('Connection URI (optional)').small().semiBold(), + const Gap(4), + const Text( + 'If set, overrides Host / Port / Database below.', + ).muted().small(), + const Gap(4), + const Text( + 'Query params: ssl-mode (disable, require), database.', + ).muted().small(), + const Gap(8), + TextField( + controller: _connectionStringController, + placeholder: const Text( + 'mysql://user:pass@host:3306/dbname?ssl-mode=disable', + ), + ), + const Gap(16), + material.Row( + children: [ + material.Expanded( + flex: 3, + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Host').small().semiBold(), + const Gap(8), + TextField( + controller: _hostController, + placeholder: const Text('localhost'), + ), + ], + ), + ), + const Gap(12), + material.Expanded( + flex: 1, + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Port').small().semiBold(), + const Gap(8), + TextField( + controller: _portController, + placeholder: const Text('3306'), + ), + ], + ), + ), + ], + ), + const Gap(16), + const Text('Database (optional)').small().semiBold(), + const Gap(8), + TextField( + controller: _databaseController, + placeholder: const Text('Leave empty to browse all'), + ), + const Gap(16), + const Text('Username').small().semiBold(), + const Gap(8), + TextField( + controller: _usernameController, + placeholder: const Text('root'), + ), + const Gap(16), + const Text('Password').small().semiBold(), + const Gap(8), + material.Stack( + children: [ + TextField( + controller: _passwordController, + placeholder: const Text('Password'), + obscureText: !_showPassword, + ), + material.Positioned( + right: 8, + top: 0, + bottom: 0, + child: material.Center( + child: material.IconButton( + icon: material.Icon( + _showPassword + ? material.Icons.visibility_off + : material.Icons.visibility, + size: 20, + ), + onPressed: () => + setState(() => _showPassword = !_showPassword), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(), + ), + ), + ), + ], + ), + const Gap(16), + material.Row( + children: [ + material.Checkbox( + value: _useSSL, + onChanged: (v) => + setState(() => _useSSL = v ?? true), + ), + const Gap(8), + const Text('Use SSL/TLS').small(), + ], + ), + ], + ), + ), + ), + const material.Divider(height: 1), + if (_testResult != null) + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 8, 16, 8), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: _dismissResult, + borderRadius: material.BorderRadius.circular(8), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: material.BoxDecoration( + color: _testResult == 'success' + ? theme.primary.withValues(alpha: 0.12) + : theme.destructive.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: _testResult == 'success' + ? theme.primary.withValues(alpha: 0.35) + : theme.destructive.withValues(alpha: 0.35), + width: 1, + ), + ), + child: material.Row( + children: [ + material.Icon( + _testResult == 'success' + ? material.Icons.check_circle_outline + : material.Icons.info_outline_rounded, + size: 18, + color: _testResult == 'success' + ? theme.primary + : theme.destructive, + ), + const Gap(10), + material.Expanded( + child: Text( + _testResult == 'success' + ? 'Connection successful!' + : _testResult!.startsWith('error:') + ? _testResult!.substring(7) + : 'Connection failed', + style: material.TextStyle( + fontSize: 13, + color: theme.foreground, + ), + ).small(), + ), + material.IconButton( + icon: material.Icon( + material.Icons.close, + size: 18, + color: theme.mutedForeground, + ), + onPressed: _dismissResult, + style: material.IconButton.styleFrom( + minimumSize: const material.Size(28, 28), + padding: material.EdgeInsets.zero, + ), + ), + ], + ), + ), + ), + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + child: material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.spaceBetween, + children: [ + OutlineButton( + onPressed: + _formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: _formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: + _formValid ? theme.primary : theme.mutedForeground, + ), + ), + ), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: _formValid ? _save : null, + child: const Text('Save'), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/mysql/mysql_object_kind.dart b/lib/features/mysql/mysql_object_kind.dart new file mode 100644 index 00000000..f7c4cf32 --- /dev/null +++ b/lib/features/mysql/mysql_object_kind.dart @@ -0,0 +1,5 @@ +/// Object selected in the MySQL browser tree. +enum MysqlObjectKind { + table, + view, +} diff --git a/lib/features/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart new file mode 100644 index 00000000..eb69d33d --- /dev/null +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; +import 'package:querya_desktop/features/mysql/mysql_table_utils.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Dialog to view/edit SQL and run it (read-only SELECT for table browse). +Future showMysqlSqlEditorDialog({ + required material.BuildContext context, + required String initialSql, + required String browseSql, + required void Function(String sql) onRun, +}) { + return showAppDialog( + context: context, + builder: (ctx) => _MysqlSqlEditorDialog( + initialSql: initialSql, + browseSql: browseSql, + onRun: onRun, + ), + ); +} + +class _MysqlSqlEditorDialog extends material.StatefulWidget { + const _MysqlSqlEditorDialog({ + required this.initialSql, + required this.browseSql, + required this.onRun, + }); + + final String initialSql; + final String browseSql; + final void Function(String sql) onRun; + + @override + material.State<_MysqlSqlEditorDialog> createState() => + _MysqlSqlEditorDialogState(); +} + +class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { + late final material.TextEditingController _controller; + String? _error; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController(text: widget.initialSql); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final sql = _controller.text.trim(); + if (sql.isEmpty) { + setState(() => _error = 'Enter a SQL query'); + return; + } + if (!isAllowedMysqlSelectQuery(sql)) { + setState(() => + _error = 'Only a single SELECT (or WITH …) statement is allowed.'); + return; + } + material.Navigator.of(context).pop(); + widget.onRun(sql); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + return material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: material.Container( + constraints: const material.BoxConstraints( + maxWidth: 720, + minWidth: 480, + maxHeight: 520, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('SQL query').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Table browse uses SELECT with LIMIT/OFFSET. ' + 'Edit or write your own SELECT. Reset restores the browse query.', + ).muted().xSmall(), + ], + ), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 24), + child: material.SizedBox( + height: 280, + child: material.Container( + decoration: SqlEditorChrome.inlineFieldDecoration( + Theme.of(context), + ), + child: material.TextField( + controller: _controller, + maxLines: null, + expands: true, + textAlignVertical: material.TextAlignVertical.top, + style: material.TextStyle( + fontFamily: QueryaTypography.mono, + fontSize: 12, + color: theme.foreground, + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + contentPadding: material.EdgeInsets.all(12), + hintText: 'SELECT …', + ), + ), + ), + ), + ), + if (_error != null) + material.Padding( + padding: + const material.EdgeInsets.fromLTRB(24, 8, 24, 0), + child: material.Text( + _error!, + style: material.TextStyle( + color: theme.destructive, fontSize: 12), + ), + ), + material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + OutlineButton( + onPressed: () => + material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(8), + OutlineButton( + onPressed: () { + setState(() { + _error = null; + _controller.text = widget.browseSql; + }); + }, + child: const Text('Reset'), + ), + const Gap(8), + PrimaryButton( + onPressed: _submit, + child: const Text('Run'), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart new file mode 100644 index 00000000..4aad3a38 --- /dev/null +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -0,0 +1,406 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:querya_desktop/core/database/mysql_service.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; +import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +const _maxDisplayRows = 5000; + +/// Ad-hoc SQL editor + results for MySQL / MariaDB. +class MysqlSqlWorkspace extends material.StatefulWidget { + const MysqlSqlWorkspace({ + super.key, + required this.connectionRow, + }); + + final ConnectionRow connectionRow; + + @override + material.State createState() => + _MysqlSqlWorkspaceState(); +} + +class _MysqlSqlWorkspaceState extends material.State { + final _sqlController = material.TextEditingController(); + double _topFractionState = 0.65; + + MysqlLease? _lease; + + bool _running = false; + String? _error; + List _columns = []; + List> _rows = []; + int? _affectedRows; + String? _statusLine; + + int? _queryTimeoutSeconds; + + @override + void initState() { + super.initState(); + material.WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_loadStmtTimeoutSetting()); + }); + } + + Future _loadStmtTimeoutSetting() async { + final t = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); + if (!mounted) return; + setState(() => _queryTimeoutSeconds = t); + } + + void _onStmtTimeoutChanged(int? v) { + setState(() => _queryTimeoutSeconds = v); + unawaited(AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(v)); + } + + String _poolDatabaseKey() => widget.connectionRow.databaseName ?? ''; + + Future _ensureLease() async { + if (_lease != null && _lease!.connection.isConnected) return; + _lease?.release(); + _lease = null; + final lease = await MysqlService.instance.acquire( + widget.connectionRow, + database: _poolDatabaseKey(), + mode: MysqlSessionMode.readWrite, + ); + if (!mounted) { + lease.release(); + return; + } + _lease = lease; + } + + Duration? _statementTimeout() => + _queryTimeoutSeconds == null ? null : Duration(seconds: _queryTimeoutSeconds!); + + @override + void dispose() { + if (_running) { + MysqlService.instance.interrupt( + widget.connectionRow, + database: _poolDatabaseKey(), + mode: MysqlSessionMode.readWrite, + ); + } + _lease?.release(); + _sqlController.dispose(); + super.dispose(); + } + + Future _execute() async { + final sql = _sqlController.text.trim(); + if (sql.isEmpty) return; + + setState(() { + _running = true; + _error = null; + _columns = []; + _rows = []; + _affectedRows = null; + _statusLine = null; + }); + + try { + await _ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + if (mounted) { + setState(() { + _error = 'Could not connect to MySQL.'; + _running = false; + }); + } + return; + } + + final to = _statementTimeout(); + final rs = await conn.executeWithTimeout(sql, timeout: to); + + if (!mounted) return; + + final cols = []; + for (final c in rs.cols) { + cols.add(c.name.isNotEmpty ? c.name : 'col_${cols.length}'); + } + + final outRows = >[]; + var n = 0; + for (final row in rs.rows) { + if (n >= _maxDisplayRows) break; + outRows.add( + List.generate( + row.numOfColumns, + (i) => row.colAt(i) ?? 'NULL', + ), + ); + n++; + } + + int? affected; + if (cols.isEmpty && outRows.isEmpty) { + affected = _affectedInt(rs.affectedRows); + } + + setState(() { + _columns = cols; + _rows = outRows; + _affectedRows = affected; + if (cols.isEmpty && outRows.isEmpty) { + _statusLine = affected != null + ? 'OK. Rows affected: $affected.' + : 'Command completed.'; + } else { + final total = rs.numOfRows; + final truncated = total > _maxDisplayRows; + _statusLine = truncated + ? 'Showing first $_maxDisplayRows of $total row(s).' + : '$total row(s).'; + } + _running = false; + }); + } on TimeoutException catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _running = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _running = false; + }); + } + } + } + + static int? _affectedInt(BigInt v) { + if (v == BigInt.zero) return null; + return v.toInt(); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final topFlex = (_topFractionState * 100).round().clamp(20, 80); + final bottomFlex = 100 - topFlex; + + return material.LayoutBuilder( + builder: (context, constraints) { + final totalHeight = constraints.maxHeight; + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_running) { + unawaited(_execute()); + } + }, + }, + child: material.Focus( + autofocus: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: topFlex, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MysqlSqlToolbar( + onExecute: _running ? null : _execute, + running: _running, + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, + ), + const Divider(height: 1), + Expanded( + child: QueryEditorTab(controller: _sqlController), + ), + ], + ), + ), + _HorizontalResizeHandle( + totalHeight: totalHeight, + onDrag: (dy) { + if (totalHeight <= 0) return; + setState(() { + _topFractionState = (_topFractionState + dy / totalHeight) + .clamp(0.2, 0.85); + }); + }, + ), + Expanded( + flex: bottomFlex, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + ), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + alignment: material.Alignment.centerLeft, + child: const Text('Data Output').semiBold().small(), + ), + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _MysqlSqlToolbar extends material.StatelessWidget { + const _MysqlSqlToolbar({ + required this.onExecute, + required this.running, + required this.queryTimeoutSeconds, + required this.onQueryTimeoutChanged, + }); + + final Future Function()? onExecute; + final bool running; + final int? queryTimeoutSeconds; + final void Function(int?) onQueryTimeoutChanged; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + child: material.Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + const Text('Query').semiBold().small(), + const Spacer(), + OutlineButton( + onPressed: onExecute, + leading: running + ? material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ) + : const material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + ), + child: const Text('Execute (F5)'), + ), + ], + ), + const Gap(8), + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Stmt timeout').small(), + const Gap(6), + material.DropdownButton( + value: queryTimeoutSeconds, + onChanged: running ? null : onQueryTimeoutChanged, + items: const [ + material.DropdownMenuItem( + value: null, + child: material.Text('No limit'), + ), + material.DropdownMenuItem( + value: 10, + child: material.Text('10 s'), + ), + material.DropdownMenuItem( + value: 30, + child: material.Text('30 s'), + ), + material.DropdownMenuItem( + value: 60, + child: material.Text('60 s'), + ), + material.DropdownMenuItem( + value: 120, + child: material.Text('2 min'), + ), + material.DropdownMenuItem( + value: 300, + child: material.Text('5 min'), + ), + material.DropdownMenuItem( + value: 600, + child: material.Text('10 min'), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ); + } +} + +class _HorizontalResizeHandle extends material.StatelessWidget { + const _HorizontalResizeHandle({ + required this.totalHeight, + required this.onDrag, + }); + + final double totalHeight; + final void Function(double dy) onDrag; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + return material.MouseRegion( + cursor: material.SystemMouseCursors.resizeRow, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onVerticalDragUpdate: (e) => onDrag(e.delta.dy), + child: material.Container( + height: 6, + color: theme.border.withValues(alpha: 0.15), + ), + ), + ); + } +} diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart new file mode 100644 index 00000000..b04729b8 --- /dev/null +++ b/lib/features/mysql/mysql_stats_view.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mysql_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Summary when a MySQL connection is selected without a tree object. +class MysqlStatsView extends material.StatefulWidget { + const MysqlStatsView({ + super.key, + required this.connectionRow, + }); + + final ConnectionRow connectionRow; + + @override + material.State createState() => _MysqlStatsViewState(); +} + +class _MysqlStatsViewState extends material.State { + MysqlLease? _lease; + String? _version; + int? _databaseCount; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant MysqlStatsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _disconnect(); + _load(); + } + } + + @override + void dispose() { + _disconnect(); + super.dispose(); + } + + void _disconnect() { + _lease?.release(); + _lease = null; + } + + Future _load() async { + _disconnect(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _version = null; + _databaseCount = null; + }); + try { + final lease = await MysqlService.instance.acquire( + widget.connectionRow, + database: widget.connectionRow.databaseName ?? '', + mode: MysqlSessionMode.readOnly, + ); + if (!mounted) { + lease.release(); + return; + } + _lease = lease; + final v = await lease.connection.serverVersion(); + final dbs = await lease.connection.listDatabases(); + if (!mounted) return; + setState(() { + _version = v; + _databaseCount = dbs.length; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + + if (_loading) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.SizedBox( + width: 28, + height: 28, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), + ), + const Gap(12), + const Text('Loading server info...').muted().small(), + ], + ), + ); + } + + if (_error != null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.error_outline_rounded, + size: 48, + color: cs.destructive, + ), + const Gap(16), + const Text('Could not load server info').large().semiBold(), + const Gap(8), + material.SelectableText( + _error!, + style: material.TextStyle( + color: cs.mutedForeground, + fontSize: 13, + ), + ), + const Gap(24), + OutlineButton( + onPressed: _load, + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 18, + ), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + return material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Server').large().semiBold(), + const Gap(16), + material.Container( + width: double.infinity, + padding: const material.EdgeInsets.all(16), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Version').small().muted(), + const Gap(4), + material.SelectableText( + _version ?? '—', + style: material.TextStyle( + fontSize: 13, + color: cs.foreground, + ), + ), + const Gap(16), + const Text('User databases (approx.)').small().muted(), + const Gap(4), + Text( + '${_databaseCount ?? 0}', + style: material.TextStyle( + fontSize: 20, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/mysql/mysql_table_utils.dart b/lib/features/mysql/mysql_table_utils.dart new file mode 100644 index 00000000..771dced1 --- /dev/null +++ b/lib/features/mysql/mysql_table_utils.dart @@ -0,0 +1,12 @@ +/// Whether [sql] is allowed for the table browser "custom SQL" path (read-only SELECT). +bool isAllowedMysqlSelectQuery(String sql) { + final t = sql.trim(); + if (t.isEmpty) return false; + final lower = t.toLowerCase(); + if (!lower.startsWith('select') && !lower.startsWith('with')) { + return false; + } + // Reject naive multi-statement (semicolon-separated) scripts. + final parts = t.split(';').where((s) => s.trim().isNotEmpty).toList(); + return parts.length <= 1; +} diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart new file mode 100644 index 00000000..b2fe14f3 --- /dev/null +++ b/lib/features/mysql/mysql_table_view.dart @@ -0,0 +1,652 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:mysql_client/mysql_client.dart'; +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/mysql_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_editor_dialog.dart'; +import 'package:querya_desktop/features/mysql/mysql_table_utils.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +const _defaultLimit = 200; + +class MysqlTableView extends material.StatefulWidget { + const MysqlTableView({ + super.key, + required this.connectionRow, + required this.database, + required this.tableName, + this.isView = false, + this.limit = _defaultLimit, + }); + + final ConnectionRow connectionRow; + final String database; + final String tableName; + final bool isView; + final int limit; + + @override + material.State createState() => _MysqlTableViewState(); +} + +class _MysqlTableViewState extends material.State { + MysqlLease? _lease; + MysqlConnection? get _connection => _lease?.connection; + + bool _loading = true; + String? _error; + + List _columnNames = []; + List> _rows = []; + int _rowsOnPage = 0; + int? _totalRowCount; + int _offset = 0; + bool _customSqlActive = false; + String? _customSql; + + final _verticalController = material.ScrollController(); + final _horizontalController = material.ScrollController(); + + String _qualifiedFrom() { + final d = MysqlConnection.quoteIdentifier(widget.database); + final t = MysqlConnection.quoteIdentifier(widget.tableName); + return '$d.$t'; + } + + String _browseDataSql() { + return 'SELECT * FROM ${_qualifiedFrom()} LIMIT ${widget.limit} OFFSET $_offset'; + } + + @override + void initState() { + super.initState(); + _connectAndLoad(); + } + + @override + void didUpdateWidget(covariant MysqlTableView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.database != widget.database || + oldWidget.tableName != widget.tableName || + oldWidget.isView != widget.isView) { + _customSqlActive = false; + _customSql = null; + _disconnectCurrent(interruptIfBusy: true); + _connectAndLoad(); + } + } + + @override + void dispose() { + _verticalController.dispose(); + _horizontalController.dispose(); + _disconnectCurrent(interruptIfBusy: true); + super.dispose(); + } + + void _disconnectCurrent({bool interruptIfBusy = false}) { + if (interruptIfBusy && _loading) { + MysqlService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: MysqlSessionMode.readWrite, + ); + } + _lease?.release(); + _lease = null; + } + + Future _connectAndLoad() async { + _disconnectCurrent(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _columnNames = []; + _rows = []; + _rowsOnPage = 0; + _totalRowCount = null; + _offset = 0; + _customSqlActive = false; + _customSql = null; + }); + try { + final lease = await MysqlService.instance.acquire( + widget.connectionRow, + database: widget.database, + mode: MysqlSessionMode.readWrite, + ); + if (!mounted) { + lease.release(); + return; + } + _lease = lease; + await _fetch(refreshCount: true); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + static int _asInt(String? v) { + if (v == null) return 0; + return int.tryParse(v) ?? 0; + } + + List _resultColumns(IResultSet rs) { + return rs.cols.map((c) => c.name.isNotEmpty ? c.name : 'col').toList(); + } + + List> _resultRows(IResultSet rs) { + final out = >[]; + for (final row in rs.rows) { + out.add( + List.generate( + row.numOfColumns, + (i) => row.colAt(i) ?? 'NULL', + ), + ); + } + return out; + } + + Future _fetch({bool refreshCount = false}) async { + final conn = _connection; + if (conn == null || !conn.isConnected) return; + if (_customSqlActive) { + await _fetchCustom(); + return; + } + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + final from = _qualifiedFrom(); + final countSql = 'SELECT COUNT(*) AS c FROM $from'; + final dataSql = _browseDataSql(); + try { + int totalRows; + if (refreshCount || _totalRowCount == null) { + final countRs = await conn.execute(countSql); + totalRows = countRs.rows.isEmpty + ? 0 + : _asInt(countRs.rows.first.colAt(0)); + } else { + totalRows = _totalRowCount!; + } + + final result = await conn.execute(dataSql); + if (!mounted) return; + + final colNames = _resultColumns(result); + final stringRows = _resultRows(result); + + setState(() { + _columnNames = colNames; + _rows = stringRows; + _rowsOnPage = stringRows.length; + if (refreshCount || _totalRowCount == null) { + _totalRowCount = totalRows; + } + _loading = false; + }); + if (_verticalController.hasClients) { + _verticalController.jumpTo(0); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + Future _fetchCustom() async { + final conn = _connection; + if (conn == null || !conn.isConnected) return; + final sql = _customSql; + if (sql == null || sql.isEmpty) return; + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final result = await conn.execute(sql); + if (!mounted) return; + setState(() { + _columnNames = _resultColumns(result); + _rows = _resultRows(result); + _rowsOnPage = _rows.length; + _totalRowCount = null; + _loading = false; + }); + if (_verticalController.hasClients) { + _verticalController.jumpTo(0); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + void _onSqlRun(String sql) { + final trimmed = sql.trim(); + if (!isAllowedMysqlSelectQuery(trimmed)) return; + final browse = _browseDataSql().trim(); + if (trimmed == browse) { + setState(() { + _customSqlActive = false; + _customSql = null; + }); + unawaited(_fetch(refreshCount: true)); + } else { + setState(() { + _customSqlActive = true; + _customSql = trimmed; + }); + unawaited(_fetchCustom()); + } + } + + void _openSqlEditor() { + showMysqlSqlEditorDialog( + context: context, + initialSql: (_customSqlActive && _customSql != null) + ? _customSql! + : _browseDataSql(), + browseSql: _browseDataSql(), + onRun: _onSqlRun, + ); + } + + void _exitCustomMode() { + setState(() { + _customSqlActive = false; + _customSql = null; + }); + unawaited(_fetch(refreshCount: true)); + } + + void _goToPreviousPage() { + if (_customSqlActive) return; + if (_offset <= 0 || _loading) return; + setState(() { + final next = _offset - widget.limit; + _offset = next < 0 ? 0 : next; + }); + unawaited(_fetch()); + } + + void _goToNextPage() { + if (_customSqlActive) return; + if (_loading) return; + final total = _totalRowCount; + final limit = widget.limit; + if (total != null && _offset + _rowsOnPage >= total) return; + if (total == null && _rowsOnPage < limit) return; + setState(() { + _offset += limit; + }); + unawaited(_fetch()); + } + + bool get _canGoPrevious => + !_customSqlActive && _offset > 0 && !_loading; + + bool get _canGoNext { + if (_customSqlActive) return false; + if (_loading) return false; + final total = _totalRowCount; + final limit = widget.limit; + if (total != null) { + return _offset + _rowsOnPage < total; + } + return _rowsOnPage >= limit; + } + + String _paginationLabel() { + if (_customSqlActive) { + if (_rowsOnPage == 0) return '0 rows (custom SQL)'; + return '$_rowsOnPage row${_rowsOnPage == 1 ? '' : 's'} (custom SQL)'; + } + if (_rowsOnPage == 0) { + final t = _totalRowCount; + if (t == null) return '0 rows'; + return '0 of $t'; + } + final start = _offset + 1; + final end = _offset + _rowsOnPage; + final total = _totalRowCount; + if (total != null) { + return '$start–$end of $total'; + } + return '$start–$end'; + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_loading) { + return material.Container( + color: cs.background, + child: material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 28, + height: 28, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(12), + const Text('Loading data...').muted().small(), + ], + ), + ), + ); + } + + if (_error != null) { + return material.Container( + color: cs.background, + child: material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + const Text('Query Error').large().semiBold(), + const Gap(8), + material.SelectableText(_error!, + style: material.TextStyle( + color: cs.mutedForeground, fontSize: 13)), + const Gap(24), + OutlineButton( + onPressed: () { + if (_customSqlActive) { + unawaited(_fetchCustom()); + } else { + unawaited(_fetch(refreshCount: true)); + } + }, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + + if (_columnNames.isEmpty) { + return material.Container(color: cs.background); + } + + const double rowHeight = 36; + const double headerHeight = 40; + final colCount = _columnNames.length; + final title = + '${widget.database}.${widget.tableName}${widget.isView ? ' (view)' : ''}'; + + return material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 48, + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.4), + ), + ), + ), + child: material.Row( + children: [ + material.Icon( + widget.isView + ? material.Icons.view_agenda_rounded + : material.Icons.table_chart_rounded, + size: 20, + color: cs.primary, + ), + const Gap(8), + material.Expanded( + child: material.Text( + title, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + ), + ), + material.Text( + _paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + const Gap(8), + OutlineButton( + onPressed: _loading + ? null + : () { + if (_customSqlActive) { + unawaited(_fetchCustom()); + } else { + unawaited(_fetch(refreshCount: true)); + } + }, + child: const Text('Refresh'), + ), + const Gap(6), + OutlineButton( + onPressed: _openSqlEditor, + child: const Text('SQL'), + ), + if (_customSqlActive) ...[ + const Gap(6), + OutlineButton( + onPressed: _exitCustomMode, + child: const Text('Browse'), + ), + ], + const Gap(6), + GhostButton( + onPressed: (!_canGoPrevious || _loading) + ? null + : _goToPreviousPage, + child: const Icon(material.Icons.chevron_left_rounded, size: 20), + ), + GhostButton( + onPressed: (!_canGoNext || _loading) ? null : _goToNextPage, + child: const Icon(material.Icons.chevron_right_rounded, size: 20), + ), + ], + ), + ), + material.Expanded( + child: material.LayoutBuilder( + builder: (context, constraints) { + return material.Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + notificationPredicate: (_) => true, + child: material.SingleChildScrollView( + controller: _horizontalController, + scrollDirection: material.Axis.horizontal, + child: material.SizedBox( + width: _calcTableWidth(colCount, constraints.maxWidth), + child: material.Column( + children: [ + material.Container( + height: headerHeight, + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.5)), + ), + ), + child: material.Row( + children: [ + _rowNumberCell(cs, '#', isHeader: true), + for (var i = 0; i < colCount; i++) + _headerCell(cs, _columnNames[i]), + ], + ), + ), + material.Expanded( + child: material.Scrollbar( + controller: _verticalController, + thumbVisibility: true, + child: material.ListView.builder( + controller: _verticalController, + itemCount: _rowsOnPage, + itemExtent: rowHeight, + itemBuilder: (context, rowIdx) { + final row = _rows[rowIdx]; + final isEven = rowIdx % 2 == 0; + final displayRowNum = _customSqlActive + ? rowIdx + 1 + : _offset + rowIdx + 1; + return material.Container( + height: rowHeight, + decoration: material.BoxDecoration( + color: isEven + ? material.Colors.transparent + : cs.muted.withValues(alpha: 0.12), + border: material.Border( + bottom: material.BorderSide( + color: + cs.border.withValues(alpha: 0.15), + ), + ), + ), + child: material.Row( + children: [ + _rowNumberCell(cs, '$displayRowNum'), + for (var c = 0; c < colCount; c++) + _dataCell( + cs, + row.length > c ? row[c] : ''), + ], + ), + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + double _calcTableWidth(int colCount, double availableWidth) { + const double rowNumWidth = 52; + const double minColWidth = 150; + final calculated = rowNumWidth + colCount * minColWidth; + return calculated > availableWidth ? calculated : availableWidth; + } + + material.Widget _rowNumberCell(ColorScheme cs, String text, + {bool isHeader = false}) { + return material.Container( + width: 52, + padding: const material.EdgeInsets.symmetric(horizontal: 8), + alignment: material.Alignment.centerRight, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide(color: cs.border.withValues(alpha: 0.3)), + ), + ), + child: material.Text( + text, + style: material.TextStyle( + fontSize: 11, + fontWeight: + isHeader ? material.FontWeight.w600 : material.FontWeight.normal, + color: cs.mutedForeground.withValues(alpha: 0.7), + fontFamily: 'monospace', + ), + ), + ); + } + + material.Widget _headerCell(ColorScheme cs, String name) { + return material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + child: material.Text( + name, + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ); + } + + material.Widget _dataCell(ColorScheme cs, String value) { + final isNull = value == 'NULL'; + return material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + child: material.Text( + value, + style: material.TextStyle( + fontSize: 12, + color: isNull + ? cs.mutedForeground.withValues(alpha: 0.5) + : cs.foreground, + fontStyle: + isNull ? material.FontStyle.italic : material.FontStyle.normal, + ), + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ); + } +} diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart new file mode 100644 index 00000000..94132a29 --- /dev/null +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -0,0 +1,127 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_workspace.dart'; +import 'package:querya_desktop/features/mysql/mysql_stats_view.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// When a MySQL connection is selected but no tree object: server summary or SQL editor. +class MysqlWorkspaceHome extends material.StatefulWidget { + const MysqlWorkspaceHome({ + super.key, + required this.connectionRow, + this.sqlTabRequestToken = 0, + }); + + final ConnectionRow connectionRow; + + /// Parent increments to switch to the SQL tab (e.g. context menu on connection). + final int sqlTabRequestToken; + + @override + material.State createState() => + _MysqlWorkspaceHomeState(); +} + +class _MysqlWorkspaceHomeState extends material.State { + int _tab = 0; + int _lastAppliedSqlTabToken = 0; + + @override + void initState() { + super.initState(); + _lastAppliedSqlTabToken = widget.sqlTabRequestToken; + } + + @override + void didUpdateWidget(covariant MysqlWorkspaceHome oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _lastAppliedSqlTabToken = widget.sqlTabRequestToken; + return; + } + final t = widget.sqlTabRequestToken; + if (t > _lastAppliedSqlTabToken) { + _lastAppliedSqlTabToken = t; + material.WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(_selectTab(1)); + }); + } + } + + Future _selectTab(int i) async { + if (i == _tab) return; + setState(() => _tab = i); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + child: material.Row( + children: [ + const Text('MySQL').semiBold().small(), + const Spacer(), + ...List.generate(2, (i) { + final labels = ['Server', 'SQL']; + final selected = _tab == i; + return material.Padding( + padding: const material.EdgeInsets.only(left: 6), + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.GestureDetector( + onTap: () => unawaited(_selectTab(i)), + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: material.BoxDecoration( + color: selected + ? theme.colorScheme.background + : material.Colors.transparent, + borderRadius: material.BorderRadius.circular(6), + ), + child: selected + ? Text(labels[i]).small().semiBold() + : Text(labels[i]).small().muted(), + ), + ), + ), + ); + }), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: material.IndexedStack( + index: _tab, + sizing: material.StackFit.expand, + children: [ + MysqlStatsView( + key: ValueKey('mysql_stats_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + ), + MysqlSqlWorkspace( + key: ValueKey('mysql_sql_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/postgresql/postgres_sql_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index e1bf4a9d..86dd0328 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Returns true if [sql] is allowed to run (read-only: SELECT / WITH). @@ -99,8 +102,7 @@ class _PostgresSqlEditorDialogState extends material.State<_PostgresSqlEditorDia final radius = Theme.of(context).radiusXxl; return material.Dialog( backgroundColor: material.Colors.transparent, - insetPadding: - const material.EdgeInsets.symmetric(horizontal: 32, vertical: 24), + insetPadding: WindowLayout.dialogSymmetricInsets(context), child: material.Container( constraints: const material.BoxConstraints( maxWidth: 720, @@ -137,11 +139,8 @@ class _PostgresSqlEditorDialogState extends material.State<_PostgresSqlEditorDia child: material.SizedBox( height: 280, child: material.Container( - decoration: material.BoxDecoration( - color: theme.muted.withValues(alpha: 0.15), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: theme.border.withValues(alpha: 0.4)), + decoration: SqlEditorChrome.inlineFieldDecoration( + Theme.of(context), ), child: material.TextField( controller: _controller, @@ -149,7 +148,7 @@ class _PostgresSqlEditorDialogState extends material.State<_PostgresSqlEditorDia expands: true, textAlignVertical: material.TextAlignVertical.top, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, fontSize: 12, color: theme.foreground, ), diff --git a/lib/features/postgresql/postgres_table_privileges_dialog.dart b/lib/features/postgresql/postgres_table_privileges_dialog.dart index 3130f008..512055d1 100644 --- a/lib/features/postgresql/postgres_table_privileges_dialog.dart +++ b/lib/features/postgresql/postgres_table_privileges_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/database/postgres_metadata.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -79,8 +80,7 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { final radius = Theme.of(context).radiusXxl; return material.Dialog( backgroundColor: material.Colors.transparent, - insetPadding: - const material.EdgeInsets.symmetric(horizontal: 32, vertical: 24), + insetPadding: WindowLayout.dialogSymmetricInsets(context), child: material.Container( constraints: const material.BoxConstraints( maxWidth: 560, diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 79a7b779..30ab6670 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -15,8 +16,7 @@ Future showPostgresConnectionForm( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, - insetPadding: - const material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), + insetPadding: WindowLayout.dialogSymmetricInsets(context), child: _PostgresConnectionFormContent(folderId: folderId), ), ); diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index d79d3c74..798b3a2c 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -14,7 +15,7 @@ Future showRedisConnectionForm( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, - insetPadding: const material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), + insetPadding: WindowLayout.dialogSymmetricInsets(context), child: _RedisConnectionFormContent(folderId: folderId), ), ); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index cfdc2985..e7b0802d 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.yaml b/pubspec.yaml index 2f3c6169..212f020c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: redis: ^4.0.0 postgres: ^3.5.6 fl_chart: ^0.69.0 + mysql_client: ^0.0.27 dev_dependencies: flutter_test: diff --git a/run_linux.sh b/run_linux.sh index 978d34c0..2c068ab8 100755 --- a/run_linux.sh +++ b/run_linux.sh @@ -1,5 +1,10 @@ #!/usr/bin/env bash # Run on Linux with X11 backend to avoid Gdk-CRITICAL (gdk_device_get_source) # when the cursor enters/leaves the window. On Wayland those warnings are harmless. +# +# If build fails with: Failed to find any of [ld.lld, ld] in .../snap/flutter/.../llvm-10/bin +# the Flutter snap ships an incomplete LLVM bin dir. Fix: install Flutter outside snap +# (git clone https://github.com/flutter/flutter.git + add .../flutter/bin to PATH), or see +# https://github.com/canonical/flutter-snap/issues/123 export GDK_BACKEND=x11 exec flutter run -d linux "$@" diff --git a/test/core/database/mysql_connection_test.dart b/test/core/database/mysql_connection_test.dart new file mode 100644 index 00000000..848a8e08 --- /dev/null +++ b/test/core/database/mysql_connection_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mysql_connection.dart'; + +void main() { + group('replaceDatabaseInMysqlConnectionString', () { + test('replaces path segment', () { + expect( + replaceDatabaseInMysqlConnectionString( + 'mysql://u:p@h:3306/olddb', + 'newdb', + ), + 'mysql://u:p@h:3306/newdb', + ); + }); + + test('replaces database query param', () { + expect( + replaceDatabaseInMysqlConnectionString( + 'mysql://h:3306/?database=old', + 'new', + ), + contains('database=new'), + ); + }); + + test('throws on wrong scheme', () { + expect( + () => replaceDatabaseInMysqlConnectionString('postgres://h/db', 'x'), + throwsArgumentError, + ); + }); + }); + + group('MysqlConnection.quoteIdentifier', () { + test('escapes backticks', () { + expect( + MysqlConnection.quoteIdentifier('a`b'), + '`a``b`', + ); + }); + }); +} diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 72e48cc7..bb803b20 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -59,6 +59,7 @@ void main() { tearDown(() async { await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(null); + await AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(null); }); group('AppSettings', () { @@ -79,5 +80,23 @@ void main() { ); expect(await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(), isNull); }); + + test('getMysqlSqlStmtTimeoutSeconds roundtrip', () async { + expect(await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(), isNull); + + await AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(60); + expect(await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(), 60); + + await AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(null); + expect(await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(), isNull); + }); + + test('getMysqlSqlStmtTimeoutSeconds returns null for invalid stored value', () async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.mysqlSqlStmtTimeoutSeconds, + 'not-a-number', + ); + expect(await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(), isNull); + }); }); } diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index b92fac09..d65f43e1 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/features/main_screen/workspace_panel.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -7,6 +8,17 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/layout_overflow.dart'; void main() { + /// Type must not be postgresql/mysql/mongodb/redis so [WorkspacePanel] uses the + /// default Query/Output split (includes resize handle). Not a real DB driver. + const stubSplitWorkspaceConnection = ConnectionRow( + id: 1, + type: '_layout_test_split', + name: 'layout-stub', + host: '127.0.0.1', + port: 0, + createdAt: '0', + ); + group('WorkspacePanel layout (no connection)', () { final sizes = { 'narrow_tall': const material.Size(320, 720), @@ -46,7 +58,7 @@ void main() { darkTheme: AppTheme.dark, themeMode: ThemeMode.dark, home: const material.SizedBox.expand( - child: WorkspacePanel(), + child: WorkspacePanel(activeConnection: stubSplitWorkspaceConnection), ), ), ); diff --git a/test/features/mysql/mysql_table_utils_test.dart b/test/features/mysql/mysql_table_utils_test.dart new file mode 100644 index 00000000..ef992048 --- /dev/null +++ b/test/features/mysql/mysql_table_utils_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/mysql/mysql_table_utils.dart'; + +void main() { + group('isAllowedMysqlSelectQuery', () { + test('allows single SELECT', () { + expect(isAllowedMysqlSelectQuery('SELECT * FROM t'), isTrue); + }); + + test('allows WITH', () { + expect(isAllowedMysqlSelectQuery('WITH x AS (SELECT 1) SELECT * FROM x'), isTrue); + }); + + test('rejects multi-statement', () { + expect(isAllowedMysqlSelectQuery('SELECT 1; SELECT 2'), isFalse); + }); + + test('rejects empty', () { + expect(isAllowedMysqlSelectQuery(''), isFalse); + }); + }); +} diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index fe90529b..84cb9102 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)