From f9691e823c935fb17d289441742d5a3be80d168f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 11 Apr 2026 12:55:11 +0300 Subject: [PATCH 01/29] chore(deps): add mysql_client for MySQL/MariaDB --- .flutter-plugins-dependencies | 2 +- README.md | 8 +- docs/mysql-implementation-plan.md | 316 +++++++++ lib/app/app_shutdown.dart | 6 +- lib/core/database/mysql_connection.dart | 333 +++++++++ lib/core/database/mysql_connection_pool.dart | 146 ++++ lib/core/database/mysql_service.dart | 52 ++ lib/core/storage/app_settings.dart | 23 + .../connections/connections_panel.dart | 546 +++++++++++++++ .../connections/driver_manager_dialog.dart | 2 +- lib/features/main_screen/main_screen.dart | 48 ++ lib/features/main_screen/workspace_panel.dart | 38 + lib/features/mysql/mysql_connection_form.dart | 485 +++++++++++++ lib/features/mysql/mysql_object_kind.dart | 5 + .../mysql/mysql_sql_editor_dialog.dart | 182 +++++ lib/features/mysql/mysql_sql_workspace.dart | 406 +++++++++++ lib/features/mysql/mysql_stats_view.dart | 196 ++++++ lib/features/mysql/mysql_table_utils.dart | 12 + lib/features/mysql/mysql_table_view.dart | 652 ++++++++++++++++++ lib/features/mysql/mysql_workspace_home.dart | 127 ++++ linux/flutter/generated_plugins.cmake | 1 + pubspec.yaml | 1 + run_linux.sh | 5 + test/core/database/mysql_connection_test.dart | 42 ++ test/core/storage/app_settings_test.dart | 19 + .../mysql/mysql_table_utils_test.dart | 22 + windows/flutter/generated_plugins.cmake | 1 + 27 files changed, 3671 insertions(+), 5 deletions(-) create mode 100644 docs/mysql-implementation-plan.md create mode 100644 lib/core/database/mysql_connection.dart create mode 100644 lib/core/database/mysql_connection_pool.dart create mode 100644 lib/core/database/mysql_service.dart create mode 100644 lib/features/mysql/mysql_connection_form.dart create mode 100644 lib/features/mysql/mysql_object_kind.dart create mode 100644 lib/features/mysql/mysql_sql_editor_dialog.dart create mode 100644 lib/features/mysql/mysql_sql_workspace.dart create mode 100644 lib/features/mysql/mysql_stats_view.dart create mode 100644 lib/features/mysql/mysql_table_utils.dart create mode 100644 lib/features/mysql/mysql_table_view.dart create mode 100644 lib/features/mysql/mysql_workspace_home.dart create mode 100644 test/core/database/mysql_connection_test.dart create mode 100644 test/features/mysql/mysql_table_utils_test.dart diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 4eda1970..c00bcc79 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-11 12:30:47.798124","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file 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/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/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/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/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 3c5eca8c..5ae139f8 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,6 +1,7 @@ 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/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'; @@ -11,6 +12,8 @@ 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/mysql/mysql_connection_form.dart'; +import 'package:querya_desktop/features/mysql/mysql_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'; @@ -25,6 +28,8 @@ class ConnectionsPanel extends StatefulWidget { 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 @@ -55,6 +60,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 @@ -136,6 +152,8 @@ class ConnectionsPanelState extends State { if (type == ConnectionType.postgresql) { row = await showPostgresConnectionForm(context, folderId: folderId); + } else if (type == ConnectionType.mysql) { + row = await showMysqlConnectionForm(context, folderId: folderId); } else if (type == ConnectionType.mongodb) { row = await showMongoConnectionForm(context, folderId: folderId); } else if (type == ConnectionType.redis) { @@ -189,6 +207,16 @@ class ConnectionsPanelState extends State { onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ); + } else if (conn.type == 'mysql') { + return _MysqlConnectionTile( + connection: conn, + 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, @@ -1476,6 +1504,524 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { } } +// ─── MySQL connection tile (databases → tables) ─────────────────────────────── + +class _MysqlConnectionTile extends StatefulWidget { + const _MysqlConnectionTile({ + required this.connection, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + this.onMysqlObjectSelected, + this.onMysqlOpenSqlWorkspace, + }); + + final ConnectionRow connection; + 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: 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: [ + 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..dc3ece3c 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -21,7 +21,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), ]; diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index acf1c8ad..e9882cf0 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/connections/connections_panel.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'; @@ -43,12 +44,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 +71,7 @@ class _MainScreenState extends State { _activeConnection = connection; _activeRedisDb = null; _activeMongoDB = null; + _selectedMysqlObject = null; _selectedPostgresObject = ( database: database, schema: schema, @@ -72,11 +81,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 +115,8 @@ class _MainScreenState extends State { _activeConnection = connection; _activeRedisDb = null; _activeMongoDB = database; + _selectedPostgresObject = null; + _selectedMysqlObject = null; }); } @@ -94,10 +126,22 @@ 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++; + }); + } + @override Widget build(BuildContext context) { final theme = AppTheme.dark.colorScheme; @@ -139,6 +183,8 @@ class _MainScreenState extends State { onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, onPostgresObjectSelected: _onPostgresObjectSelected, onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, + onMysqlObjectSelected: _onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: _onMysqlOpenSqlWorkspace, ), ), _VerticalResizeHandle( @@ -168,6 +214,8 @@ class _MainScreenState extends State { selectedMongoDb: _activeMongoDB, selectedPostgresObject: _selectedPostgresObject, postgresSqlTabRequestToken: _postgresSqlTabRequestToken, + selectedMysqlObject: _selectedMysqlObject, + mysqlSqlTabRequestToken: _mysqlSqlTabRequestToken, ), ), ], diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index a1ab3d18..1cbad5c3 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart' as material show Axis, Container, EdgeIns 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'; @@ -117,6 +120,8 @@ class WorkspacePanel extends StatefulWidget { this.selectedMongoDb, this.selectedPostgresObject, this.postgresSqlTabRequestToken = 0, + this.selectedMysqlObject, + this.mysqlSqlTabRequestToken = 0, }); /// Currently selected connection from the sidebar. @@ -138,6 +143,13 @@ 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; + @override State createState() => _WorkspacePanelState(); } @@ -172,6 +184,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/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart new file mode 100644 index 00000000..92ba4cee --- /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/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: + const material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), + 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..b75d472a --- /dev/null +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart' as material; +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: + const material.EdgeInsets.symmetric(horizontal: 32, vertical: 24), + 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: 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)), + ), + child: material.TextField( + controller: _controller, + maxLines: null, + expands: true, + textAlignVertical: material.TextAlignVertical.top, + style: material.TextStyle( + fontFamily: 'monospace', + 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/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/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) From f72fb08ce93d8683325adb6d18a7cb5bd5a734c5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 00:03:00 +0300 Subject: [PATCH 02/29] ci build --- .github/workflows/ci.yml | 30 +++++++++++ .github/workflows/release.yml | 98 ++++++++++++++++++++++++++++------- cliff.toml | 84 ++++++++++++++++++++++++++++++ docs/tags-and-releases.md | 95 +++++++++++++++++++++++++++++++++ 4 files changed, 288 insertions(+), 19 deletions(-) create mode 100644 cliff.toml create mode 100644 docs/tags-and-releases.md 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/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/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`.) From fbf462a30e702d3caa13c5553c83da8d7c2e5fd6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 03/29] theme: add Querya design tokens (querya_colors) --- lib/core/theme/querya_colors.dart | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 lib/core/theme/querya_colors.dart 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); +} From 50378a862e7edfade7808318559ae48360db87d9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 04/29] theme: add Querya dark ColorScheme --- lib/core/theme/querya_color_scheme.dart | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 lib/core/theme/querya_color_scheme.dart 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), + ); +} From 1a545c285d5b2be6380552054148af0341b84596 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 05/29] theme: shared monospace stack for SQL editors --- lib/core/theme/querya_typography.dart | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 lib/core/theme/querya_typography.dart 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'; +} From 55dd7d004f097201071a8f95c6be4ae60e631f82 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 06/29] theme: wire QueryaColorScheme and radius --- lib/core/theme/app_theme.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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(), ); From 05033aac051e364ea117e2ca493a700ea25f011f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 07/29] layout: responsive breakpoints and dialog insets --- lib/core/layout/window_layout.dart | 79 ++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 lib/core/layout/window_layout.dart diff --git a/lib/core/layout/window_layout.dart b/lib/core/layout/window_layout.dart new file mode 100644 index 00000000..fd17520e --- /dev/null +++ b/lib/core/layout/window_layout.dart @@ -0,0 +1,79 @@ +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. + static double heroContentMaxWidth(double viewportWidth) { + return math.min(560, math.max(260.0, viewportWidth * 0.92)); + } + + 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); + } +} From f6d6a0a817b35ee371f7fa4fec02934ab1553857 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 08/29] editor: placeholder for future SQL syntax highlighting --- lib/core/editor/sql_syntax_highlighting.dart | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 lib/core/editor/sql_syntax_highlighting.dart 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._(); +} From 909ad5ab5664f5a425e004834b0b45bc63f642d8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 09/29] ui: SQL editor chrome with glow and inline decoration --- .../main_screen/sql_editor_chrome.dart | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 lib/features/main_screen/sql_editor_chrome.dart 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, + ), + ); + } +} From 830136775dd9141a5c1f907f9f384f7370de4138 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 10/29] connections: extract promptCreateConnection flow --- .../connections/connection_creation_flow.dart | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 lib/features/connections/connection_creation_flow.dart diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart new file mode 100644 index 00000000..184c5909 --- /dev/null +++ b/lib/features/connections/connection_creation_flow.dart @@ -0,0 +1,27 @@ +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; + return switch (type) { + ConnectionType.postgresql => + await showPostgresConnectionForm(context, folderId: folderId), + ConnectionType.mysql => + await showMysqlConnectionForm(context, folderId: folderId), + ConnectionType.mongodb => + await showMongoConnectionForm(context, folderId: folderId), + ConnectionType.redis => + await showRedisConnectionForm(context, folderId: folderId), + }; +} From b3abc6066619208f77be8f84453e880c65c79108 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 11/29] ui: empty workspace hero and mock window --- .../main_screen/workspace_empty_hero.dart | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 lib/features/main_screen/workspace_empty_hero.dart 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..2c71b215 --- /dev/null +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -0,0 +1,310 @@ +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: Text( + 'A lightweight desktop client for every database you ship', + textAlign: material.TextAlign.center, + ) + .large() + .semiBold(), + ), + material.SizedBox(height: compact ? 10 : 12), + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4), + child: Text( + 'Connect to PostgreSQL, MySQL, Redis and MongoDB from a single ' + 'focused app with a calm dark interface.', + textAlign: material.TextAlign.center, + ).muted().small(), + ), + 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.min, + children: [ + material.Icon( + material.Icons.auto_awesome_rounded, + size: compact ? 12 : 14, + color: colorScheme.primary, + ), + material.SizedBox(width: compact ? 6 : 8), + Text( + 'One app for SQL and NoSQL', + 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, + ), + ); +} From 1e3ddc571839104c19d75e5cc6cdb3d4794ff241 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 12/29] ui: apply SqlEditorChrome to query editor tab --- lib/features/main_screen/query_editor_tab.dart | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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;'), ), From 1f84db59aca048bb5beea62cd86c560bf7119d2d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 13/29] ui: responsive SQL editor dialog chrome (PostgreSQL) --- .../postgresql/postgres_sql_editor_dialog.dart | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) 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, ), From 5229c19cedc87f11068b9d8136f3c12c8b3d540c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 14/29] ui: responsive SQL editor dialog chrome (MySQL) --- lib/features/mysql/mysql_sql_editor_dialog.dart | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/features/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart index b75d472a..eb69d33d 100644 --- a/lib/features/mysql/mysql_sql_editor_dialog.dart +++ b/lib/features/mysql/mysql_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/features/mysql/mysql_table_utils.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -72,8 +75,7 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { 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, @@ -110,11 +112,8 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { 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, @@ -122,7 +121,7 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { expands: true, textAlignVertical: material.TextAlignVertical.top, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, fontSize: 12, color: theme.foreground, ), From 4d7ff974f1d51208a63beaf564ee6957f309f367 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 15/29] ui: SERVERS header, selection capsule, creation flow --- .../connections/connections_panel.dart | 448 ++++++++++-------- 1 file changed, 241 insertions(+), 207 deletions(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 5ae139f8..4a32e54e 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,4 +1,4 @@ -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, 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, 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'; @@ -7,22 +7,49 @@ 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/mysql/mysql_connection_form.dart'; import 'package:querya_desktop/features/mysql/mysql_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 '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, @@ -37,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; @@ -147,21 +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.mysql) { - row = await showMysqlConnectionForm(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(); @@ -197,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!), @@ -210,6 +236,7 @@ class ConnectionsPanelState extends State { } else if (conn.type == 'mysql') { return _MysqlConnectionTile( connection: conn, + isSelected: isSelected, icon: _iconForType(conn.type), iconAsset: _iconAssetForType(conn.type), onRemove: () => _removeConnection(conn.id!), @@ -220,6 +247,7 @@ class ConnectionsPanelState extends State { } else if (conn.type == 'redis') { return _RedisConnectionTile( connection: conn, + isSelected: isSelected, icon: _iconForType(conn.type), iconAsset: _iconAssetForType(conn.type), onRemove: () => _removeConnection(conn.id!), @@ -229,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!), @@ -238,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!), @@ -257,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, ), ), @@ -267,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: [ @@ -305,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, @@ -340,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); + await _createConnection(dialogContext: menuContext); }, child: const Text('New Connection'), ), @@ -412,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, @@ -419,6 +453,7 @@ class _ConnectionTile extends StatelessWidget { }); final ConnectionRow connection; + final bool isSelected; final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; @@ -450,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, - ), - ), - ], - ), + ], ), - ], - ), + ), + ], ), ), ), @@ -613,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, @@ -621,6 +656,7 @@ class _RedisConnectionTile extends StatefulWidget { }); final ConnectionRow connection; + final bool isSelected; final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; @@ -766,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, - ), - ), - ], - ), + ], ), - ], - ), + ), + ], ), ), ), @@ -924,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, @@ -932,6 +967,7 @@ class _MongoConnectionTile extends StatefulWidget { }); final ConnectionRow connection; + final bool isSelected; final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; @@ -1098,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, - ), - ), - ], - ), + ], ), - ], - ), + ), + ], ), ), ), @@ -1272,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, @@ -1281,6 +1316,7 @@ class _PostgresConnectionTile extends StatefulWidget { }); final ConnectionRow connection; + final bool isSelected; final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; @@ -1407,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, - ), - ), - ], - ), + ], ), - ], - ), + ), + ], ), ), ), @@ -1509,6 +1543,7 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { class _MysqlConnectionTile extends StatefulWidget { const _MysqlConnectionTile({ required this.connection, + this.isSelected = false, required this.icon, this.iconAsset, required this.onRemove, @@ -1518,6 +1553,7 @@ class _MysqlConnectionTile extends StatefulWidget { }); final ConnectionRow connection; + final bool isSelected; final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; @@ -1648,49 +1684,47 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { ), ), 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, - ), - ), - ], - ), + ], ), - ], - ), + ), + ], ), ), ), From ee6a28e89e85f462748aa6f36a6d818fa33711b2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 16/29] ui: title bar accent, borders, hero new connection flow --- lib/features/main_screen/main_screen.dart | 30 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index e9882cf0..58c3fae1 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -4,9 +4,10 @@ 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'; @@ -21,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). @@ -142,6 +146,13 @@ class _MainScreenState extends State { }); } + 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; @@ -150,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) { @@ -178,6 +189,8 @@ class _MainScreenState extends State { SizedBox( width: leftW, child: ConnectionsPanel( + key: _connectionsPanelKey, + selectedConnectionId: _activeConnection?.id, onConnectionSelected: _onConnectionSelected, onRedisDatabaseSelected: _onRedisDatabaseSelected, onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, @@ -216,6 +229,9 @@ class _MainScreenState extends State { postgresSqlTabRequestToken: _postgresSqlTabRequestToken, selectedMysqlObject: _selectedMysqlObject, mysqlSqlTabRequestToken: _mysqlSqlTabRequestToken, + onRequestNewConnection: () { + _openNewConnectionFromHero(); + }, ), ), ], @@ -292,6 +308,12 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { child: Row( children: [ const SizedBox(width: 16), + material.Icon( + material.Icons.search_rounded, + size: 18, + color: QueryaColors.accentCyan, + ), + const Gap(8), const Text('Querya').semiBold().small(), const Gap(24), Menubar( From 40f6bce48d9e72efb30173485b197c2ae7e8967c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 17/29] ui: show empty hero when no connection selected --- lib/features/main_screen/workspace_panel.dart | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 1cbad5c3..beee2950 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -1,4 +1,4 @@ -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'; @@ -17,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, @@ -122,6 +123,7 @@ class WorkspacePanel extends StatefulWidget { this.postgresSqlTabRequestToken = 0, this.selectedMysqlObject, this.mysqlSqlTabRequestToken = 0, + this.onRequestNewConnection, }); /// Currently selected connection from the sidebar. @@ -150,6 +152,9 @@ class WorkspacePanel extends StatefulWidget { /// 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(); } @@ -163,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') { From 91c0d375555be87fdaa2716fc5715e16a4961dd2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 18/29] test: workspace panel layout uses stub connection for splitter --- .../main_screen/workspace_panel_layout_test.dart | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index b92fac09..850aa696 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,15 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/layout_overflow.dart'; void main() { + const stubRedisConnection = ConnectionRow( + id: 1, + type: 'redis', + name: 'layout-stub', + host: '127.0.0.1', + port: 6379, + createdAt: '0', + ); + group('WorkspacePanel layout (no connection)', () { final sizes = { 'narrow_tall': const material.Size(320, 720), @@ -46,7 +56,7 @@ void main() { darkTheme: AppTheme.dark, themeMode: ThemeMode.dark, home: const material.SizedBox.expand( - child: WorkspacePanel(), + child: WorkspacePanel(activeConnection: stubRedisConnection), ), ), ); From e83e6e23a27cec9627640ad17f1a3afb3f7bff3d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 19/29] ui: adaptive database picker dialog and grid --- .../connections/new_connection_dialog.dart | 123 +++++++++++++----- 1 file changed, 88 insertions(+), 35 deletions(-) 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(), ], ), ), From 3bf80b6260cc399d820fa3156e0c3504c05fc112 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 20/29] ui: adaptive dialog insets for driver manager --- lib/features/connections/driver_manager_dialog.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index dc3ece3c..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'; @@ -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(), ), ); } From b3b1f6f6fb668f6ffaf041ccee93f8136b095057 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:50 +0300 Subject: [PATCH 21/29] ui: adaptive dialog insets for new folder --- lib/features/connections/new_folder_dialog.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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(), ), ); } From 487afb85fa7fa683b15ab69b20dae43ffc5cfd58 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:51 +0300 Subject: [PATCH 22/29] ui: adaptive dialog insets for PostgreSQL form --- lib/features/postgresql/postgresql_connection_form.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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), ), ); From 9015d22106df73c7597bc3824d2b6fb7c986c03f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:51 +0300 Subject: [PATCH 23/29] ui: adaptive dialog insets for MySQL form --- lib/features/mysql/mysql_connection_form.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 92ba4cee..8227115f 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -2,6 +2,7 @@ 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'; @@ -15,8 +16,7 @@ Future showMysqlConnectionForm( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, - insetPadding: - const material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), + insetPadding: WindowLayout.dialogSymmetricInsets(context), child: _MysqlConnectionFormContent(folderId: folderId), ), ); From b26e9fbd5510bb2bcab9d03a1d5ac1a8ee53b0f0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:51 +0300 Subject: [PATCH 24/29] ui: adaptive dialog insets for MongoDB form --- lib/features/mongodb/mongodb_connection_form.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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), ), ); From f4c30110e8612c07edf87e2da31f9b0926d6acb8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:51 +0300 Subject: [PATCH 25/29] ui: adaptive dialog insets for Redis form --- lib/features/redis/redis_connection_form.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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), ), ); From 61a0d341257f234f7cec40667e0bb775fc2ea4ef Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:51 +0300 Subject: [PATCH 26/29] ui: adaptive dialog insets for create MongoDB database --- lib/features/mongodb/mongo_database_dialog.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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(), ), ); } From 0a25ebc6d5bbd02e41662c5ac4cfb22414190d3c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:51 +0300 Subject: [PATCH 27/29] ui: adaptive dialog insets for table privileges --- lib/features/postgresql/postgres_table_privileges_dialog.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, From 27035d5405465ec920239b4228fba66005e4b25e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 12:42:51 +0300 Subject: [PATCH 28/29] chore: update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 04b9d12c2a2c717aab8f8f7f7197e5ee0085e5dd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Apr 2026 13:07:40 +0300 Subject: [PATCH 29/29] fix --- .flutter-plugins-dependencies | 2 +- lib/core/layout/window_layout.dart | 6 ++-- .../connections/connection_creation_flow.dart | 21 +++++------ .../connections/connections_panel.dart | 4 +-- lib/features/main_screen/main_screen.dart | 2 +- .../main_screen/workspace_empty_hero.dart | 36 ++++++++++++------- .../workspace_panel_layout_test.dart | 10 +++--- 7 files changed, 49 insertions(+), 32 deletions(-) diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index c00bcc79..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":"/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-11 12:30:47.798124","version":"3.41.6","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/lib/core/layout/window_layout.dart b/lib/core/layout/window_layout.dart index fd17520e..a926d607 100644 --- a/lib/core/layout/window_layout.dart +++ b/lib/core/layout/window_layout.dart @@ -61,9 +61,11 @@ abstract class WindowLayout { }; } - /// Empty workspace hero content max width. + /// Empty workspace hero content max width (stays within viewport minus padding). static double heroContentMaxWidth(double viewportWidth) { - return math.min(560, math.max(260.0, viewportWidth * 0.92)); + final pad = heroHorizontalPadding(viewportWidth) * 2; + final inner = math.max(0.0, viewportWidth - pad); + return math.min(560, inner); } static double heroHorizontalPadding(double viewportWidth) { diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index 184c5909..935ff472 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -14,14 +14,15 @@ Future promptCreateConnection( }) async { final type = await showNewConnectionDialog(context); if (type == null) return null; - return switch (type) { - ConnectionType.postgresql => - await showPostgresConnectionForm(context, folderId: folderId), - ConnectionType.mysql => - await showMysqlConnectionForm(context, folderId: folderId), - ConnectionType.mongodb => - await showMongoConnectionForm(context, folderId: folderId), - ConnectionType.redis => - await showRedisConnectionForm(context, folderId: folderId), - }; + 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 4a32e54e..10ad02c2 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,4 +1,4 @@ -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, FontWeight, VoidCallback; +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'; @@ -376,7 +376,7 @@ class ConnectionsPanelState extends State { leading: material.Icon(material.Icons.settings_ethernet_rounded, size: 18, color: theme.colorScheme.mutedForeground), onPressed: (menuContext) async { await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; + if (!mounted || !menuContext.mounted) return; await _createConnection(dialogContext: menuContext); }, child: const Text('New Connection'), diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 58c3fae1..0b424b2f 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -308,7 +308,7 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { child: Row( children: [ const SizedBox(width: 16), - material.Icon( + const material.Icon( material.Icons.search_rounded, size: 18, color: QueryaColors.accentCyan, diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index 2c71b215..7c0828f6 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -38,21 +38,29 @@ class WorkspaceEmptyHero extends StatelessWidget { material.SizedBox(height: compact ? 16 : 20), material.Padding( padding: const material.EdgeInsets.symmetric(horizontal: 4), - child: Text( + child: material.Text( 'A lightweight desktop client for every database you ship', textAlign: material.TextAlign.center, - ) - .large() - .semiBold(), + 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: Text( + child: material.Text( 'Connect to PostgreSQL, MySQL, Redis and MongoDB from a single ' 'focused app with a calm dark interface.', textAlign: material.TextAlign.center, - ).muted().small(), + style: material.TextStyle( + fontSize: 14, + height: 1.4, + color: cs.mutedForeground, + ), + ), ), const material.SizedBox(height: 10), Text( @@ -118,7 +126,7 @@ class _HeroBadge extends StatelessWidget { ), ), child: material.Row( - mainAxisSize: material.MainAxisSize.min, + mainAxisSize: material.MainAxisSize.max, children: [ material.Icon( material.Icons.auto_awesome_rounded, @@ -126,11 +134,15 @@ class _HeroBadge extends StatelessWidget { color: colorScheme.primary, ), material.SizedBox(width: compact ? 6 : 8), - Text( - 'One app for SQL and NoSQL', - style: material.TextStyle( - fontSize: compact ? 11 : 12, - color: colorScheme.foreground.withValues(alpha: 0.92), + 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), + ), ), ), ], diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index 850aa696..d65f43e1 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -8,12 +8,14 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/layout_overflow.dart'; void main() { - const stubRedisConnection = ConnectionRow( + /// 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: 'redis', + type: '_layout_test_split', name: 'layout-stub', host: '127.0.0.1', - port: 6379, + port: 0, createdAt: '0', ); @@ -56,7 +58,7 @@ void main() { darkTheme: AppTheme.dark, themeMode: ThemeMode.dark, home: const material.SizedBox.expand( - child: WorkspacePanel(activeConnection: stubRedisConnection), + child: WorkspacePanel(activeConnection: stubSplitWorkspaceConnection), ), ), );