diff --git a/.cursor/rules/gitflow.md b/.cursor/rules/gitflow.md deleted file mode 100644 index 28f9a773..00000000 --- a/.cursor/rules/gitflow.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -description: Querya Desktop GitFlow, branching, commits, and PR workflow -alwaysApply: true ---- - -# Querya Desktop — agent workflow - -Desktop SQL/NoSQL client (PostgreSQL, MySQL, Redis, MongoDB). -Стек: **Flutter** (Dart 3.5+), **shadcn_flutter** (vendored в `third_party/`), локальный SQLite + OS secure storage для секретов. - -Remote: `git@github.com:QueryaHub/Querya-Desktop.git` - -## Sync with remote (always) - -Before checkout, branch, push, or PR — and **after** merge to `dev`: - -```bash -git fetch --all --prune -git checkout dev -git pull --ff-only origin dev -``` - -Before push on a feature branch: `git fetch origin`, then rebase or merge remote. - -**Full checks** before PR (локально, как в CI): - -```bash -flutter pub get -flutter analyze -flutter test -``` - -Опционально перед релизом / крупным UI-PR: - -```bash -flutter build linux --release # или windows / macos -``` - -На Linux при `errno = 24` на analyze: `ulimit -n 8192` (см. [CONTRIBUTING.md](CONTRIBUTING.md)). - -## Git branches - -- **`main`**: production-ready; релизные теги (`X.Y.Z`) ставятся на коммиты, готовые к бинарникам. -- **`dev`**: интеграция; **все feature PR → `dev`**. -- **Feature**: `issue/-` или `feat/` от актуального `dev`. - -```bash -git fetch --all --prune && git checkout dev && git pull --ff-only origin dev -git checkout -b issue/38-querya-workbench-theme-models -# или: git checkout -b feat/postgres-sql-workspace-toolbar -``` - -Долгоживущие ветки по подсистемам (если согласовано с командой): `postgres-ench`, `mongo-ench`, `my_sql`, `design` — не создавать без необходимости; предпочитать короткие `issue/*` / `feat/*`. - -**Hotfix** только по явному запросу: `hotfix/` от `main` → PR в `main`, затем back-merge в `dev`. - -**Release**: версия в `pubspec.yaml`, тег на нужном коммите, workflow Release — см. [docs/tags-and-releases.md](docs/tags-and-releases.md), [docs/release-checklist.md](docs/release-checklist.md). - -## Issue priority & roadmap - -Живой roadmap: [docs/roadmap.md](docs/roadmap.md). Крупные темы (пример — epic **#37** theming): дочерние issues #38–#60. - -| Область | Фокус | -|---------|--------| -| Connections | Tree, new connection, drivers, secure storage | -| PostgreSQL / MySQL | Browser, SQL workspace, grids, timeouts | -| Redis / MongoDB | Explorer, keys/collections, document editor | -| Theme / UI | `lib/core/theme/`, shadcn tokens, SQL editor (#37 epic) | -| CI / release | `.github/workflows/`, Linux deps, signing (macOS) | - -Один issue → одна ветка → один PR. Scope = issue only — без drive-by рефакторинга. - -## GitHub labels & milestones - -### Milestone: **Theme system** - -Epic [#37](https://github.com/QueryaHub/Querya-Desktop/issues/37) и дочерние issues **#38–#60** (кроме закрытого #56) — milestone [Theme system](https://github.com/QueryaHub/Querya-Desktop/milestone/1). - -Новые theme-issues: label `theme` → workflow [issue-theme-milestone.yml](.github/workflows/issue-theme-milestone.yml) проставит milestone автоматически. Шаблон: [.github/ISSUE_TEMPLATE/theme_task.yml](.github/ISSUE_TEMPLATE/theme_task.yml). - -### PR: labels + milestone (автоматика) - -1. Ветка **`issue/-`** (рекомендуется), например `issue/38-workbench-theme-models`. -2. В PR body: **`Closes #38`** (или Fixes/Resolves). -3. В title опционально: `feat(theme): … (#38)`. - -Workflow [pr-linked-issue-metadata.yml](.github/workflows/pr-linked-issue-metadata.yml) копирует **все labels** и **milestone** с linked issue на PR при open/edit/sync. - -Шаблон PR: [.github/pull_request_template.md](.github/pull_request_template.md). - -### Ручное создание PR (если автоматика не сработала) - -```bash -gh pr create --base dev \ - --milestone "Theme system" \ - --label "theme,enhancement" \ - --title "feat(theme): QueryaWorkbenchTheme models (#38)" \ - --body "$(cat <<'EOF' -## Summary -… - -Closes #38 -EOF -)" -``` - -Для editor-задач добавь label `editor`: `--label "theme,editor,enhancement"`. - -### Issues - -```bash -gh issue edit 38 --milestone "Theme system" --add-label "theme,enhancement" -``` - -Labels: `bug`, `enhancement`, `documentation`, `theme`, `editor`, `epic`. - -После merge: issue `CLOSED`; `git fetch` + `git pull --ff-only` на `dev`. - -## Commits and PRs - -- [Conventional Commits](https://www.conventionalcommits.org/): `feat`, `fix`, `perf`, `docs`, `test`, `ci`, `chore`, `refactor` + scope. -- Scopes (примеры): `postgresql`, `mysql`, `mongodb`, `redis`, `connections`, `theme`, `editor`, `settings`, `ci`, `deps`, `ui`. -- Атомарные коммиты; не коммитить без явной просьбы пользователя. -- PR body: `Closes #N` когда применимо. -- **Не коммитить:** `.env`, ключи, `credentials.json`, экспортированные connection secrets. - -Примеры: - -``` -feat(theme): add QueryaWorkbenchTheme and editor tokens -fix(postgresql): pass right-clicked table into Open in SQL -feat(mysql): SQL workspace statement timeout from settings -test(connections): panel layout with in-memory secrets -chore(release): bump version to 0.2.2+4 -``` - -## Layout & checks - -| Area | Path | -|------|------| -| Entry | `lib/main.dart`, `lib/app/app.dart` | -| Theme | `lib/core/theme/` | -| DB / pools | `lib/core/database/` | -| Storage / settings | `lib/core/storage/` | -| Features | `lib/features//` | -| Shared UI | `lib/shared/widgets/` | -| Vendored UI | `third_party/shadcn_flutter/` (override в `pubspec.yaml`) | -| Tests | `test/` (mirror `lib/` where possible) | -| Docs | `docs/` | -| CI / release | `.github/workflows/` | - -Зависимости: `pubspec.yaml`; lockfile **не** в git (см. `.gitignore`). - -## Flutter conventions - -- UI: **shadcn** `Theme.of(context).colorScheme`, не смешивать с Material без нужды (`material.` prefix где уже есть). -- Патчи shadcn — только в `third_party/shadcn_flutter`, с комментарием в `pubspec.yaml` `dependency_overrides`. -- Секреты: `flutter_secure_storage` / `connection_secrets_store` — без паролей в SQLite и логах ([docs/security.md](docs/security.md)). -- SQL editor: пока `TextField` / `QueryEditorTab`; подсветка — по issues #47–#50, не раздувать `TextField` ad hoc. -- Новый код: `flutter analyze` clean, тесты для нетривиальной логики (парсеры, storage, SQL helpers). - -## Out of scope (unless issue says otherwise) - -- Backend-сервисы, облачный sync аккаунтов. -- JDBC-драйверы (только встроенные Dart/native пути). -- Полная VS Code theme compatibility в одном PR (см. epic #37, поэтапно). -- Force-push на `main` / `dev` без явного запроса. diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies deleted file mode 100644 index e58e7712..00000000 --- a/.flutter-plugins-dependencies +++ /dev/null @@ -1 +0,0 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_ios","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_ios-0.5.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","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},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"android":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_android-0.5.2+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","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":"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},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"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":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","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},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"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":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","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},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"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":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","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},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"web":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","dependencies":[],"dev_dependency":false},{"name":"file_selector_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_web-0.9.4+2/","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","dependencies":["device_info_plus"],"dev_dependency":false}]},"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":"device_info_plus","dependencies":[]},{"name":"file_selector","dependencies":["file_selector_android","file_selector_ios","file_selector_linux","file_selector_macos","file_selector_web","file_selector_windows"]},{"name":"file_selector_android","dependencies":[]},{"name":"file_selector_ios","dependencies":[]},{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_web","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"irondash_engine_context","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":[]},{"name":"super_native_extensions","dependencies":["irondash_engine_context","device_info_plus"]}],"date_created":"2026-05-28 10:58:45.738032","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 22dd42e8..1960cc1d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,11 +15,9 @@ linux/flutter/generated_* windows/flutter/generated_* macos/Flutter/ephemeral/ -# IDE — track Cursor rules, ignore the rest +# IDE .idea/ -.cursor/* -!.cursor/rules/ -!.cursor/rules/** +.cursor/ # OS .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 50972bd1..5a7c4d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.1] - 2026-06-13 + +Performance and UX release ([#93](https://github.com/QueryaHub/Querya-Desktop/issues/93)). Git tag **`0.4.1`**. + +### Added + +- **MySQL server stats** — full dashboard (connections, queries, network, databases table) via `SHOW GLOBAL STATUS` / `information_schema`. +- **Virtual SQL result grid** — `VirtualResultGrid` with fixed column widths, cell copy, and `RepaintBoundary` instead of a materialized `Table`. +- **Lazy connection tree** — `lazyConnectionTreeList` / `ListView.builder` for large PostgreSQL and MySQL object lists; `ValueKey` on leaf rows. +- **Vertical split pane** — `ValueNotifier`-driven splitter drag without rebuilding entire workspace panels. +- **Docker dev stack** — `docker/` compose with PostgreSQL, MySQL, MongoDB, Redis and seed data for local testing. +- **`deep_collection_equals`** — shared snapshot diff helper for stats polling. + +### Changed + +- **UI scale preview** — decoupled from app-wide theme rebuilds; scale commits only on slider release. +- **Syntax highlighting** — debounced updates, worker isolate for all buffer sizes, highlighter pair cache by theme key. +- **Stats dashboards** — Redis, MongoDB, and PostgreSQL skip `setState` when polled data is unchanged; Redis/Mongo layouts redesigned; MySQL replaces version-only stub. +- **Connection forms** — `FormValidityNotifier` narrows rebuild scope to action buttons. +- **SQL workspace settings** — `SqlWorkspaceSettingsRevision` so theme/scale changes do not reload SQL tabs. +- **Connections panel** — rebuilds only when selected connection id changes. +- **MySQL results** — row string conversion moved to a worker isolate (`mysql_result_utils`). +- **Redis key editor** — virtualized hash/list/set/zset member lists. +- **Mongo documents** — lazy pretty-JSON cache; document cards without hover `setState`. +- **Explorer list rows** — Redis/Mongo database/key/collection rows use `InkWell` hover instead of hover `setState`. +- **QueryaDropdown** — caches menu children; ellipsizes trigger label in fixed-width layouts. + +### Fixed + +- **Connection menu** — new connection form opens after picking type from the menu ([#93](https://github.com/QueryaHub/Querya-Desktop/issues/93) follow-up). +- **Stats UI overflow** — Redis, MongoDB, and PostgreSQL stats cards no longer overflow in debug/profile layouts. +- **Redis TTL dialog** — disposes `TextEditingController` on close. + ## [0.4.0] - 2026-05-28 Theme system milestone (epic #37). Git tag **`0.4.0`** — use this release for binaries; earlier tag `0.3.0` was a pre-PR snapshot. @@ -16,7 +49,7 @@ Theme system milestone (epic #37). Git tag **`0.4.0`** — use this release for - **Syntax highlighting** — SQL and JSON in `QueryaCodeEditor` via `syntax_highlight`; `tokenColors` mapped to TextMate scopes; isolate highlight for large buffers. - **Editor** — `QueryaCodeEditor` abstraction, `SqlEditorChrome` from theme tokens, `QueryaThemeScope` for workbench/editor tokens. - **Samples** — `themes/samples/cyberpunk-neon.json` (+ JSONC) for manual import testing. -- **Docs** — [docs/theme.md](docs/theme.md), [docs/theme-import.md](docs/theme-import.md), [docs/editor-spike-report.md](docs/editor-spike-report.md), [docs/code-forge-evaluation.md](docs/code-forge-evaluation.md). +- **Docs** — [docs/theme.md](docs/theme.md), [docs/theme-import.md](docs/theme-import.md), [docs/archive/editor-spike-report.md](docs/archive/editor-spike-report.md), [docs/archive/code-forge-evaluation.md](docs/archive/code-forge-evaluation.md). ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index adc18b20..a9b216d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,32 +1,94 @@ # Contributing -## Flutter version +Thanks for your interest in improving Querya Desktop. This guide covers the +workflow, branch/commit conventions, and the checks we expect before a PR. -CI pins a **stable** Flutter version in [`.github/workflows/ci.yml`](.github/workflows/ci.yml) and [`.github/workflows/release.yml`](.github/workflows/release.yml). Prefer matching that version locally to avoid “works on my machine” drift. When bumping the pin, run `flutter test` and a release smoke build before merging. +New to the codebase? Start with [docs/getting-started.md](docs/getting-started.md) +and [docs/architecture.md](docs/architecture.md). -## Linux: `flutter analyze` and “Too many open files” +## Workflow -On some Linux setups the Dart analysis server hits the process **open file limit** (`errno = 24`). Try: +We follow a simple GitFlow: feature work branches off `dev` and merges back into +`dev` via PR; `main` holds production-ready code and release tags. ```bash -ulimit -n 8192 -flutter analyze +# 1. Sync +git fetch --all --prune +git checkout dev +git pull --ff-only origin dev + +# 2. Branch (one issue → one branch → one PR) +git checkout -b issue/- # or feat/ + +# 3. ...make changes, then run the checks below... + +# 4. Open a PR into `dev` with "Closes #" in the body ``` -## Git tags and release commits +Keep each PR scoped to a single issue — avoid drive-by refactoring. -A **tag points at one commit**. Release artifacts are built from the tree at that commit. If you fix something **after** pushing a release tag, either: +## Commits -- move the tag to the new commit (only if the team agrees and the release is not yet consumed), or -- ship a **new** semver (update `pubspec.yaml` / `CHANGELOG.md`) and push a **new** tag. +We use [Conventional Commits](https://www.conventionalcommits.org/): +`feat`, `fix`, `perf`, `docs`, `test`, `ci`, `chore`, `refactor`, with a scope +where helpful (`postgresql`, `mysql`, `mongodb`, `redis`, `connections`, `theme`, +`editor`, `settings`, `ui`, `ci`, `deps`). -See [docs/tags-and-releases.md](docs/tags-and-releases.md). +``` +feat(theme): add QueryaWorkbenchTheme and editor tokens +fix(postgresql): pass right-clicked table into Open in SQL +docs: restructure README and docs index +``` -## Tests +**Never commit** secrets — `.env`, keys, `credentials.json`, or exported +connection secrets. + +## Checks before a PR + +Run the same checks CI runs: ```bash flutter pub get +flutter analyze flutter test ``` -Widget tests that use SQLite or `path_provider` follow patterns in `test/features/connections/connections_panel_layout_test.dart` and `test/flutter_test_config.dart` (in-memory secrets backend). +For release or large UI PRs, optionally smoke-test a build: + +```bash +flutter build linux --release # or windows / macos +``` + +### Linux: `flutter analyze` and "Too many open files" + +On some Linux setups the Dart analysis server hits the process **open file +limit** (`errno = 24`). Raise it for the session: + +```bash +ulimit -n 8192 +flutter analyze +``` + +## Flutter version + +CI pins a **stable** Flutter version in +[`.github/workflows/ci.yml`](.github/workflows/ci.yml) and +[`.github/workflows/release.yml`](.github/workflows/release.yml). Match that +version locally to avoid "works on my machine" drift. When bumping the pin, run +`flutter test` and a release smoke build before merging. + +## Tests + +Widget tests that use SQLite or `path_provider` follow patterns in +`test/features/connections/connections_panel_layout_test.dart` and +`test/flutter_test_config.dart` (in-memory secrets backend). Mirror `lib/` layout +under `test/` where possible. + +## Releases and tags + +A **tag points at one commit**, and release artifacts are built from that tree. +If you fix something **after** pushing a release tag, either move the tag (only if +the team agrees and the release is not yet consumed) or ship a **new** semver +(update `pubspec.yaml` / `CHANGELOG.md`) and push a new tag. See +[docs/tags-and-releases.md](docs/tags-and-releases.md) and +[docs/release-checklist.md](docs/release-checklist.md). diff --git a/README.md b/README.md index e8d34c75..e7f4e94b 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,98 @@ -# Querya Desktop +
-A lightweight desktop client for SQL and NoSQL databases. Connect to PostgreSQL, MySQL, Redis, and MongoDB from a single app with a clean, dark UI inspired by tools like pgAdmin. +# Querya Desktop -## What it is +**A lightweight, cross-platform desktop client for SQL and NoSQL databases.** -- **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” (or **Connection → New Database Connection**) → pick database type → configure and save. Metadata is stored in local SQLite; **passwords and connection strings** use the OS secure store (see [docs/security.md](docs/security.md)). +Connect to PostgreSQL, MySQL/MariaDB, Redis, and MongoDB from a single app with +a clean, dark UI inspired by tools like pgAdmin. -## Database drivers +[![CI](https://github.com/QueryaHub/Querya-Desktop/actions/workflows/ci.yml/badge.svg)](https://github.com/QueryaHub/Querya-Desktop/actions/workflows/ci.yml) +[![Release](https://github.com/QueryaHub/Querya-Desktop/actions/workflows/release.yml/badge.svg)](https://github.com/QueryaHub/Querya-Desktop/actions/workflows/release.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Flutter](https://img.shields.io/badge/Flutter-Desktop-02569B?logo=flutter)](https://flutter.dev) +[![Platforms](https://img.shields.io/badge/Platforms-Linux%20%7C%20Windows%20%7C%20macOS-555)](#) -- **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 +--- -- **Flutter** (Dart) with desktop support -- **shadcn_flutter** for UI (buttons, inputs, theme) -- **bitsdojo_window** for custom frame and window sizing +## Highlights -## Prerequisites +- **Multi-database** — PostgreSQL, MySQL/MariaDB, Redis, and MongoDB, with + built-in Dart drivers (no JDBC JARs to download). +- **Cross-platform** — Linux, Windows, and macOS from one Flutter codebase. +- **SQL workspace** — query editor with syntax highlighting, configurable + statement timeouts, query history, and CSV/JSON export. +- **Object browsing** — connection tree with databases, tables, views, and + server stats. +- **Themeable** — runtime dark/light/system modes and **VS Code theme import**. +- **Scalable UI** — global interface scaling for high-DPI and accessibility. +- **Secure by default** — passwords and connection strings live in the OS secure + store, never in plaintext. -- [Flutter SDK](https://docs.flutter.dev/get-started/install) (stable) with desktop enabled: - ```bash - flutter config --enable-linux-desktop # or windows, macos - ``` -- **Linux builds** also need desktop headers used by Flutter and plugins (GTK, etc.). For `flutter_secure_storage` on Linux you typically need **`libsecret-1-dev`** (Debian/Ubuntu: `sudo apt install libsecret-1-dev`; Fedora: `libsecret-devel`). Match this to your distro’s Flutter desktop docs. +## Screenshots -## Setup +> _Add screenshots/GIFs to `docs/assets/` and reference them here._ -From the project root: +## Quick start ```bash -flutter pub get -``` - -If platform folders are missing: +# 1. Enable Flutter desktop for your platform +flutter config --enable-linux-desktop # or --enable-windows-desktop / --enable-macos-desktop -```bash -flutter create . --project-name querya_desktop --platforms=linux,windows,macos +# 2. Fetch dependencies flutter pub get + +# 3. Run +flutter run -d linux # or windows / macos ``` -## Run +Linux builds also need keyring headers (`libsecret-1-dev` on Debian/Ubuntu). +See **[Getting started](docs/getting-started.md)** for the full setup, including +prerequisites, release builds, and your first connection. -```bash -# Linux (on Wayland, use X11 to avoid Gdk warnings when the pointer leaves the window) -GDK_BACKEND=x11 flutter run -d linux +## Tech stack -# Or -flutter run -d linux +- **[Flutter](https://flutter.dev)** (Dart) with desktop support. +- **[shadcn_flutter](https://pub.dev/packages/shadcn_flutter)** for UI components and theming. +- **[bitsdojo_window](https://pub.dev/packages/bitsdojo_window)** for the custom window frame. +- Built-in Dart drivers: `postgres`, `mysql_client`, `redis`, `mongo_dart`. -# Windows -flutter run -d windows +## Project structure -# macOS -flutter run -d macos -``` +| Path | Description | +|------|-------------| +| `lib/main.dart` | App entry, window setup. | +| `lib/app/` | App shell and theme wiring. | +| `lib/core/` | Database clients, storage, theme, layout, editor helpers. | +| `lib/features/` | Feature modules (main screen, connections, per-engine UI, settings). | +| `lib/shared/` | Reusable widgets shared across features. | +| `assets/` | Database type icons and other bundled assets. | +| `linux/`, `windows/`, `macos/` | Native runners. | +| `third_party/` | Vendored components (e.g. `shadcn_flutter`), under their own licenses. | -Or use the helper script on Linux: +For a deeper map of modules and their responsibilities, see +**[Architecture](docs/architecture.md)**. -```bash -./run_linux.sh -``` +## Documentation -## Build release +Full documentation lives in **[`docs/`](docs/README.md)**: -```bash -flutter build linux -flutter build windows -flutter build macos -``` +- **[Getting started](docs/getting-started.md)** — install and first run. +- **[User guide](docs/user-guide.md)** — connections, preferences, drivers. +- **[Architecture](docs/architecture.md)** — codebase layout. +- **[Security](docs/security.md)** — local data and secrets. +- **[Theme system](docs/theme.md)** · **[Theme import](docs/theme-import.md)**. +- **[Roadmap](docs/roadmap.md)** · **[Releases](docs/tags-and-releases.md)**. -## Project structure +## Contributing -| Path | Description | -|------|-------------| -| `lib/main.dart` | App entry, window setup (bitsdojo_window) | -| `lib/app/` | App shell and theme | -| `lib/features/main_screen/` | Main layout, workspace panel, query/results tabs | -| `lib/features/connections/` | Connection tree, new connection / folder flows, driver manager | -| `lib/features/postgresql/`, `mysql/`, `redis/`, `mongodb/` | Per-engine workspace and browser UI | -| `lib/shared/widgets/` | Shared UI (shadcn re-exports, app dialog) | -| `lib/core/` | Database clients, local storage, theme, editor helpers | -| `assets/images/` | Database type icons (PostgreSQL, MySQL, Redis, MongoDB) | -| `linux/`, `windows/`, `macos/` | Native runners (custom frame on Linux/Windows) | +Contributions are welcome. Please read **[CONTRIBUTING.md](CONTRIBUTING.md)** for +the workflow, CI expectations, and the pre-PR checklist, and see the +[Changelog](CHANGELOG.md) for release history. ## License -[MIT](LICENSE). Third-party components (e.g. vendored UI under `third_party/`) retain their own licenses. - -## More documentation - -- [Security / local data](docs/security.md) -- [Theme system](docs/theme.md) -- [User guide](docs/user-guide.md) -- [Releases](docs/tags-and-releases.md) -- [Release checklist](docs/release-checklist.md) -- [Contributing](CONTRIBUTING.md) (Flutter pin, tags, local analyze) -- [Roadmap](docs/roadmap.md) +[MIT](LICENSE). Third-party components (e.g. vendored UI under `third_party/`) +retain their own licenses. diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 00000000..d3e34c34 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,19 @@ +# Copy to .env and adjust if host ports conflict with local services. +# cp .env.example .env + +POSTGRES_PORT=5432 +MYSQL_PORT=3306 +REDIS_PORT=6379 +MONGO_PORT=27017 + +POSTGRES_USER=querya +POSTGRES_PASSWORD=querya +POSTGRES_DB=querya + +MYSQL_ROOT_PASSWORD=querya +MYSQL_DATABASE=querya +MYSQL_USER=querya +MYSQL_PASSWORD=querya + +MONGO_INITDB_ROOT_USERNAME=querya +MONGO_INITDB_ROOT_PASSWORD=querya diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..fd0ad3d1 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,142 @@ +# Querya Desktop — local database stack for manual testing. +# +# Start: +# cd docker && docker compose up -d +# +# Stop and remove volumes (re-run init scripts on next up): +# docker compose down -v +# +# ── Connection cheat sheet (host: localhost) ───────────────────────────── +# PostgreSQL port 5432 db querya user querya password querya +# schemas: shop.* + database analytics +# MySQL port 3306 db querya user querya password querya +# extra database: analytics +# Redis port 6379 no auth keys prefix querya:* +# MongoDB port 27017 db querya user querya password querya +# auth source: admin collections: users, products, orders +# ───────────────────────────────────────────────────────────────────────── + +name: querya-dev + +services: + postgres: + image: postgres:16-alpine + container_name: querya-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-querya} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-querya} + POSTGRES_DB: ${POSTGRES_DB:-querya} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-querya} -d ${POSTGRES_DB:-querya}", + ] + interval: 5s + timeout: 5s + retries: 12 + + mysql: + image: mysql:8.4 + container_name: querya-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-querya} + MYSQL_DATABASE: ${MYSQL_DATABASE:-querya} + MYSQL_USER: ${MYSQL_USER:-querya} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-querya} + ports: + - "${MYSQL_PORT:-3306}:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./mysql/init:/docker-entrypoint-initdb.d:ro + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + healthcheck: + test: + [ + "CMD", + "mysqladmin", + "ping", + "-h", + "127.0.0.1", + "-u${MYSQL_USER:-querya}", + "-p${MYSQL_PASSWORD:-querya}", + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 30s + + redis: + image: redis:7-alpine + container_name: querya-redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 12 + + redis-seed: + image: redis:7-alpine + container_name: querya-redis-seed + depends_on: + redis: + condition: service_healthy + environment: + REDIS_HOST: redis + REDIS_PORT: "6379" + volumes: + - ./redis/seed.sh:/seed.sh:ro + entrypoint: ["/bin/sh", "/seed.sh"] + restart: "no" + + mongo: + image: mongo:7 + container_name: querya-mongo + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME:-querya} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:-querya} + ports: + - "${MONGO_PORT:-27017}:27017" + volumes: + - mongo_data:/data/db + - ./mongo/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: + [ + "CMD", + "mongosh", + "--quiet", + "-u", + "${MONGO_INITDB_ROOT_USERNAME:-querya}", + "-p", + "${MONGO_INITDB_ROOT_PASSWORD:-querya}", + "--authenticationDatabase", + "admin", + "--eval", + "db.adminCommand('ping').ok", + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 30s + +volumes: + postgres_data: + mysql_data: + redis_data: + mongo_data: diff --git a/docker/mongo/init/01_seed.js b/docker/mongo/init/01_seed.js new file mode 100644 index 00000000..b7dd34a8 --- /dev/null +++ b/docker/mongo/init/01_seed.js @@ -0,0 +1,83 @@ +// Demo data for Querya manual testing (MongoDB). +const appDb = db.getSiblingDB('querya'); + +appDb.users.drop(); +appDb.products.drop(); +appDb.orders.drop(); + +appDb.users.insertMany([ + { + name: 'Alice Martin', + email: 'alice@example.com', + role: 'admin', + city: 'Berlin', + tags: ['staff', 'beta'], + active: true, + }, + { + name: 'Bob Smith', + email: 'bob@example.com', + role: 'customer', + city: 'London', + tags: ['beta'], + active: true, + }, + { + name: 'Carla Ruiz', + email: 'carla@example.com', + role: 'customer', + city: 'Madrid', + tags: [], + active: false, + }, +]); + +appDb.products.insertMany([ + { sku: 'SKU-001', title: 'Wireless Mouse', price: 29.99, stock: 120 }, + { sku: 'SKU-002', title: 'Mechanical Keyboard', price: 89.0, stock: 45 }, + { sku: 'SKU-003', title: 'USB-C Hub', price: 45.5, stock: 80 }, + { sku: 'SKU-004', title: '27" Monitor', price: 329.0, stock: 15 }, +]); + +appDb.orders.insertMany([ + { + customerEmail: 'alice@example.com', + status: 'paid', + total: 164.49, + placedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), + lines: [ + { sku: 'SKU-001', qty: 1, unitPrice: 29.99 }, + { sku: 'SKU-003', qty: 1, unitPrice: 45.5 }, + { sku: 'SKU-002', qty: 1, unitPrice: 89.0 }, + ], + }, + { + customerEmail: 'bob@example.com', + status: 'shipped', + total: 404.0, + placedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), + lines: [{ sku: 'SKU-004', qty: 1, unitPrice: 329.0 }], + }, + { + customerEmail: 'carla@example.com', + status: 'new', + total: 29.99, + placedAt: new Date(), + lines: [{ sku: 'SKU-001', qty: 1, unitPrice: 29.99 }], + }, +]); + +appDb.users.createIndex({ email: 1 }, { unique: true }); +appDb.products.createIndex({ sku: 1 }, { unique: true }); +appDb.orders.createIndex({ status: 1, placedAt: -1 }); + +const analyticsDb = db.getSiblingDB('analytics'); +analyticsDb.metrics.drop(); +analyticsDb.metrics.insertMany([ + { day: new Date(), orders: 4, revenue: 203.99 }, + { + day: new Date(Date.now() - 24 * 60 * 60 * 1000), + orders: 9, + revenue: 615.0, + }, +]); diff --git a/docker/mysql/init/01_shop.sql b/docker/mysql/init/01_shop.sql new file mode 100644 index 00000000..3cd98457 --- /dev/null +++ b/docker/mysql/init/01_shop.sql @@ -0,0 +1,87 @@ +-- Demo schema for Querya manual testing (MySQL / MariaDB-compatible). +USE querya; + +CREATE TABLE customers ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + email VARCHAR(160) NOT NULL UNIQUE, + city VARCHAR(80), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +CREATE TABLE products ( + id INT AUTO_INCREMENT PRIMARY KEY, + sku VARCHAR(32) NOT NULL UNIQUE, + title VARCHAR(160) NOT NULL, + price DECIMAL(10, 2) NOT NULL +) ENGINE=InnoDB; + +CREATE TABLE orders ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_id INT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'new', + total DECIMAL(10, 2) NOT NULL DEFAULT 0, + placed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id) +) ENGINE=InnoDB; + +CREATE TABLE order_lines ( + order_id INT NOT NULL, + product_id INT NOT NULL, + qty INT NOT NULL, + unit_price DECIMAL(10, 2) NOT NULL, + PRIMARY KEY (order_id, product_id), + CONSTRAINT fk_lines_order FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE, + CONSTRAINT fk_lines_product FOREIGN KEY (product_id) REFERENCES products (id) +) ENGINE=InnoDB; + +INSERT INTO customers (name, email, city) VALUES + ('Alice Martin', 'alice@example.com', 'Berlin'), + ('Bob Smith', 'bob@example.com', 'London'), + ('Carla Ruiz', 'carla@example.com', 'Madrid'); + +INSERT INTO products (sku, title, price) VALUES + ('SKU-001', 'Wireless Mouse', 29.99), + ('SKU-002', 'Mechanical Keyboard', 89.00), + ('SKU-003', 'USB-C Hub', 45.50), + ('SKU-004', '27 inch Monitor', 329.00); + +INSERT INTO orders (customer_id, status, total, placed_at) VALUES + (1, 'paid', 164.49, NOW() - INTERVAL 2 DAY), + (2, 'shipped', 404.49, NOW() - INTERVAL 1 DAY), + (3, 'new', 29.99, NOW()); + +INSERT INTO order_lines (order_id, product_id, qty, unit_price) VALUES + (1, 1, 1, 29.99), + (1, 3, 1, 45.50), + (1, 2, 1, 89.00), + (2, 4, 1, 329.00), + (2, 1, 1, 29.99), + (2, 3, 1, 45.50), + (3, 1, 1, 29.99); + +CREATE VIEW customer_spending AS +SELECT + c.id, + c.name, + c.city, + COUNT(o.id) AS order_count, + COALESCE(SUM(o.total), 0) AS lifetime_total +FROM customers c +LEFT JOIN orders o ON o.customer_id = c.id +GROUP BY c.id, c.name, c.city; + +CREATE DATABASE IF NOT EXISTS analytics; + +USE analytics; + +CREATE TABLE daily_sales ( + day DATE PRIMARY KEY, + orders INT NOT NULL, + revenue DECIMAL(12, 2) NOT NULL +) ENGINE=InnoDB; + +INSERT INTO daily_sales (day, orders, revenue) VALUES + (CURDATE() - INTERVAL 2 DAY, 12, 842.50), + (CURDATE() - INTERVAL 1 DAY, 9, 615.00), + (CURDATE(), 4, 203.99); diff --git a/docker/postgres/init/01_shop.sql b/docker/postgres/init/01_shop.sql new file mode 100644 index 00000000..bc540a53 --- /dev/null +++ b/docker/postgres/init/01_shop.sql @@ -0,0 +1,77 @@ +-- Demo schema for Querya manual testing (PostgreSQL). +CREATE SCHEMA IF NOT EXISTS shop; + +CREATE TABLE shop.customers ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + city TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE shop.products ( + id SERIAL PRIMARY KEY, + sku TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + price NUMERIC(10, 2) NOT NULL CHECK (price >= 0) +); + +CREATE TABLE shop.orders ( + id SERIAL PRIMARY KEY, + customer_id INT NOT NULL REFERENCES shop.customers (id), + status TEXT NOT NULL DEFAULT 'new', + total NUMERIC(10, 2) NOT NULL DEFAULT 0, + placed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE shop.order_lines ( + order_id INT NOT NULL REFERENCES shop.orders (id) ON DELETE CASCADE, + product_id INT NOT NULL REFERENCES shop.products (id), + qty INT NOT NULL CHECK (qty > 0), + unit_price NUMERIC(10, 2) NOT NULL, + PRIMARY KEY (order_id, product_id) +); + +INSERT INTO shop.customers (name, email, city) VALUES + ('Alice Martin', 'alice@example.com', 'Berlin'), + ('Bob Smith', 'bob@example.com', 'London'), + ('Carla Ruiz', 'carla@example.com', 'Madrid'); + +INSERT INTO shop.products (sku, title, price) VALUES + ('SKU-001', 'Wireless Mouse', 29.99), + ('SKU-002', 'Mechanical Keyboard', 89.00), + ('SKU-003', 'USB-C Hub', 45.50), + ('SKU-004', '27" Monitor', 329.00); + +INSERT INTO shop.orders (customer_id, status, total, placed_at) VALUES + (1, 'paid', 164.49, NOW() - INTERVAL '2 days'), + (2, 'shipped', 404.49, NOW() - INTERVAL '1 day'), + (3, 'new', 29.99, NOW()); + +INSERT INTO shop.order_lines (order_id, product_id, qty, unit_price) VALUES + (1, 1, 1, 29.99), + (1, 3, 1, 45.50), + (1, 2, 1, 89.00), + (2, 4, 1, 329.00), + (2, 1, 1, 29.99), + (2, 3, 1, 45.50), + (3, 1, 1, 29.99); + +CREATE OR REPLACE VIEW shop.customer_spending AS +SELECT + c.id, + c.name, + c.city, + COUNT(o.id) AS order_count, + COALESCE(SUM(o.total), 0) AS lifetime_total +FROM shop.customers c +LEFT JOIN shop.orders o ON o.customer_id = c.id +GROUP BY c.id, c.name, c.city; + +CREATE OR REPLACE FUNCTION shop.order_count_for_customer(p_customer_id INT) +RETURNS INT +LANGUAGE sql +STABLE +AS $$ + SELECT COUNT(*)::INT FROM shop.orders WHERE customer_id = p_customer_id; +$$; diff --git a/docker/postgres/init/02_analytics.sql b/docker/postgres/init/02_analytics.sql new file mode 100644 index 00000000..9aa868f1 --- /dev/null +++ b/docker/postgres/init/02_analytics.sql @@ -0,0 +1,17 @@ +-- Second database to exercise PostgreSQL tree / database switching. +CREATE DATABASE analytics; + +\connect analytics + +CREATE SCHEMA metrics; + +CREATE TABLE metrics.daily_sales ( + day DATE PRIMARY KEY, + orders INT NOT NULL, + revenue NUMERIC(12, 2) NOT NULL +); + +INSERT INTO metrics.daily_sales (day, orders, revenue) VALUES + (CURRENT_DATE - 2, 12, 842.50), + (CURRENT_DATE - 1, 9, 615.00), + (CURRENT_DATE, 4, 203.99); diff --git a/docker/redis/seed.sh b/docker/redis/seed.sh new file mode 100755 index 00000000..0066be9c --- /dev/null +++ b/docker/redis/seed.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +HOST="${REDIS_HOST:-redis}" +PORT="${REDIS_PORT:-6379}" + +echo "Waiting for Redis at ${HOST}:${PORT}..." +until redis-cli -h "$HOST" -p "$PORT" ping | grep -q PONG; do + sleep 1 +done + +if redis-cli -h "$HOST" -p "$PORT" EXISTS querya:seed:marker | grep -q 1; then + echo "Redis seed marker present — skipping." + exit 0 +fi + +echo "Seeding Redis demo keys..." + +redis-cli -h "$HOST" -p "$PORT" <<'EOF' +SET querya:demo:string "Hello from Querya Docker stack" +SET querya:config:version "1" +HSET querya:user:1 name "Alice Martin" email "alice@example.com" city "Berlin" +HSET querya:user:2 name "Bob Smith" email "bob@example.com" city "London" +RPUSH querya:tasks:open "Review PR" "Write docs" "Test Redis key editor" +SADD querya:tags:popular redis docker mongodb postgresql mysql +ZADD querya:leaderboard 980 "player_alpha" 875 "player_beta" 640 "player_gamma" +SET querya:seed:marker "1" +EOF + +echo "Redis seed complete." diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..2d38f3ea --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# Documentation + +Index of Querya Desktop documentation, grouped by audience. + +## For users + +- [Getting started](getting-started.md) — prerequisites, install, first run. +- [User guide](user-guide.md) — connections, preferences, driver manager. +- [Security / local data](security.md) — where metadata and secrets are stored. + +## For contributors + +- [Contributing](../CONTRIBUTING.md) — workflow, CI, pre-PR checklist. +- [Architecture](architecture.md) — `lib/` layout and module responsibilities. +- [Theme system](theme.md) — runtime theming and VS Code theme tokens. +- [Theme import](theme-import.md) — supported `colors` keys and merge behavior. +- [Performance baseline](perf-baseline.md) — per-milestone DevTools checklist. + +## For release managers + +- [Tags and releases](tags-and-releases.md) — tag/release policy. +- [Release checklist](release-checklist.md) — step-by-step release flow. +- [macOS signing](macos-signing.md) — signing and notarization track. + +## Planning + +- [Roadmap](roadmap.md) — current direction and follow-ups. + +## Archive + +Historical design notes and one-off spikes, kept for context but no longer +maintained — see [`archive/`](archive/): + +- [Repository audit (#91)](archive/AUDIT.md) +- [Editor package spike (#48)](archive/editor-spike-report.md) +- [`code_forge` + LSP evaluation (#52)](archive/code-forge-evaluation.md) +- [Theme research (RU)](archive/research_theme.md) +- [MySQL implementation plan](archive/mysql-implementation-plan.md) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..5817736a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,71 @@ +# Architecture + +A high-level map of the codebase for contributors. The app is a Flutter desktop +client with a custom window frame ([bitsdojo_window](https://pub.dev/packages/bitsdojo_window)) +and [shadcn_flutter](https://pub.dev/packages/shadcn_flutter) UI components. + +## Layered layout + +``` +lib/ +├── main.dart App entry: window setup, controller bootstrap +├── app/ App shell, theme wiring, global scopes +├── core/ Cross-cutting infrastructure (no feature UI) +├── features/ Feature modules, one folder per area / engine +└── shared/ Reusable widgets shared across features +``` + +The dependency direction is **features → core/shared**, never the reverse. +`core/` and `shared/` must not import from `features/`. + +## `core/` + +| Path | Responsibility | +|------|----------------| +| `core/database/` | Engine clients and query execution (PostgreSQL, MySQL, Redis, MongoDB). | +| `core/storage/` | Local SQLite metadata (`local_db.dart`), app settings, secure secrets store. | +| `core/theme/` | Theme tokens, VS Code theme import, color resolution. | +| `core/editor/` | Code editor helpers and syntax highlighting glue. | +| `core/layout/` | Window/dialog sizing and the global UI-scale system. | +| `core/csv/`, `core/json/` | Result export helpers (CSV / JSON). | + +## `features/` + +| Path | Responsibility | +|------|----------------| +| `features/main_screen/` | Main layout, workspace panel, query/results tabs, history. | +| `features/connections/` | Connection tree, new connection / folder flows, driver manager. | +| `features/postgresql/` | PostgreSQL workspace, object browser, SQL editor dialog. | +| `features/mysql/` | MySQL / MariaDB workspace, browser, SQL editor dialog. | +| `features/redis/` | Redis connection form and workspace. | +| `features/mongodb/` | MongoDB connection form, database dialog, stats view. | +| `features/settings/` | Preferences dialog, controls, interface scale slider. | + +## `shared/` + +| Path | Responsibility | +|------|----------------| +| `shared/widgets/` | shadcn re-exports, `QueryaDropdown`, app dialog, shared tokens. | + +## Cross-cutting systems + +- **Theme system** — runtime dark/light/system modes plus VS Code theme import. + See [theme.md](theme.md) and [theme-import.md](theme-import.md). +- **UI scaling** — a global scale factor propagated via an inherited scope and + applied to text (`TextScaler`) and dialog dimensions. Lives in `core/layout/`. +- **Secrets** — passwords and connection strings go to the OS secure store, not + SQLite. See [security.md](security.md). + +## Data and secrets + +- **Connection metadata + preferences**: local SQLite (`querya.db` in the app + support directory). +- **Passwords / connection strings**: OS secure store via `flutter_secure_storage` + (Keychain / Credential Manager / libsecret). + +## Tests + +Widget tests that touch SQLite or `path_provider` follow the patterns in +`test/features/connections/connections_panel_layout_test.dart` and +`test/flutter_test_config.dart` (in-memory secrets backend, no desktop keyring +required). Run them with `flutter test`. diff --git a/docs/archive/AUDIT.md b/docs/archive/AUDIT.md new file mode 100644 index 00000000..a65176e0 --- /dev/null +++ b/docs/archive/AUDIT.md @@ -0,0 +1,47 @@ +# Repository audit (#91) + +Inventory of docs and tracked artifacts with a disposition decision. Goal: keep +the repository professional and easy to navigate without losing technically +valuable history. + +Legend: **keep** = active, maintained · **archive** = historical, moved to +`docs/archive/` · **untrack** = remove from git, keep locally · **delete** = removed. + +## Documentation + +| Path | Disposition | Rationale | +|------|-------------|-----------| +| `README.md` | keep (rewritten) | Project front page — restructured with badges, features, quick start, docs index. | +| `CONTRIBUTING.md` | keep (expanded) | GitFlow, CI expectations, pre-PR checklist. | +| `CHANGELOG.md` | keep | Release history (Keep a Changelog). Links updated for moved files. | +| `docs/README.md` | new | Documentation index (User / Developer / Release / Theme / Archive). | +| `docs/getting-started.md` | new | Install + first run, extracted from README. | +| `docs/architecture.md` | new | `lib/` layout and module responsibilities for contributors. | +| `docs/user-guide.md` | keep | End-user guide. Kept current with the shipped UI. | +| `docs/security.md` | keep | Local-data and secrets model. | +| `docs/theme.md` | keep | Theme system reference (epic #37). | +| `docs/theme-import.md` | keep | VS Code theme import details. | +| `docs/roadmap.md` | keep (synced) | Living roadmap; synced with closed issues. | +| `docs/perf-baseline.md` | keep | Reusable per-milestone DevTools checklist. | +| `docs/tags-and-releases.md` | keep | Release/tag policy. | +| `docs/release-checklist.md` | keep | Release steps. | +| `docs/macos-signing.md` | keep | Signing/notarize track. | +| `docs/editor-spike-report.md` | archive | One-off editor package spike (#48); decision shipped in 0.4.0. | +| `docs/code-forge-evaluation.md` | archive | `code_forge`/LSP go/no-go spike (#52); NO-GO recorded. | +| `docs/research_theme.md` | archive | Background research (RU) behind the theme epic. | +| `docs/mysql-implementation-plan.md` | archive | Historical plan; MySQL is implemented (`lib/features/mysql/`). | + +## Tracked artifacts + +| Path | Disposition | Rationale | +|------|-------------|-----------| +| `.flutter-plugins-dependencies` | untrack | Generated per-machine; already in `.gitignore`. Caused recurring "local changes". | +| `.metadata` | keep | Standard Flutter project metadata. | +| `third_party/` | keep (untouched) | Vendored `shadcn_flutter`; retains its own license/docs. | +| `test/`, `.github/workflows/` | keep (untouched) | Behavior and CI unchanged by this cleanup. | + +## Not in scope + +- Rewriting `third_party/shadcn_flutter` documentation. +- Full RU+EN localization of docs (separate issue if needed). +- Marketing site / landing outside the repository. diff --git a/docs/code-forge-evaluation.md b/docs/archive/code-forge-evaluation.md similarity index 100% rename from docs/code-forge-evaluation.md rename to docs/archive/code-forge-evaluation.md diff --git a/docs/editor-spike-report.md b/docs/archive/editor-spike-report.md similarity index 100% rename from docs/editor-spike-report.md rename to docs/archive/editor-spike-report.md diff --git a/docs/mysql-implementation-plan.md b/docs/archive/mysql-implementation-plan.md similarity index 100% rename from docs/mysql-implementation-plan.md rename to docs/archive/mysql-implementation-plan.md diff --git a/docs/research_theme.md b/docs/archive/research_theme.md similarity index 100% rename from docs/research_theme.md rename to docs/archive/research_theme.md diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..191c3c69 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,88 @@ +# Getting started + +How to build and run Querya Desktop from source. + +## Prerequisites + +- [Flutter SDK](https://docs.flutter.dev/get-started/install) (stable channel) + with desktop enabled: + + ```bash + flutter config --enable-linux-desktop # or --enable-windows-desktop / --enable-macos-desktop + ``` + +- **Linux** also needs the desktop headers used by Flutter and its plugins + (GTK, etc.). For `flutter_secure_storage` you typically need + **`libsecret-1-dev`**: + + ```bash + sudo apt install libsecret-1-dev # Debian / Ubuntu + sudo dnf install libsecret-devel # Fedora + ``` + + CI pins a specific stable Flutter version; matching it locally avoids + "works on my machine" drift (see [Contributing](../CONTRIBUTING.md)). + +## Install + +From the project root: + +```bash +flutter pub get +``` + +If the platform folders are missing: + +```bash +flutter create . --project-name querya_desktop --platforms=linux,windows,macos +flutter pub get +``` + +## Run + +```bash +# Linux +flutter run -d linux +# On Wayland, force X11 to avoid Gdk pointer warnings: +GDK_BACKEND=x11 flutter run -d linux +# Or use the helper script: +./run_linux.sh + +# Windows +flutter run -d windows + +# macOS +flutter run -d macos +``` + +## Build release + +```bash +flutter build linux +flutter build windows +flutter build macos +``` + +## First connection + +1. Launch the app. +2. **Connection → New Database Connection** (or right-click **Servers** in the tree). +3. Pick **PostgreSQL**, **MySQL**, **Redis**, or **MongoDB** and fill in host, + port, and credentials. +4. Saved connections appear in the left tree. + +See the [User guide](user-guide.md) for preferences, the driver manager, and +day-to-day usage, and [Security](security.md) for how credentials are stored. + +## Local dev databases (optional) + +The repo includes a Docker Compose stack under [`docker/`](../docker/) with +PostgreSQL, MySQL, MongoDB, and Redis plus seed data: + +```bash +cp docker/.env.example docker/.env # optional overrides +cd docker && docker compose up -d +``` + +Default credentials: user/password **`querya`**, database **`querya`** +(MongoDB auth source: **`admin`**). Stop with `docker compose down`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 30a36187..b0a48dbe 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,9 +6,10 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **Shipped in 0.4.0 (epic #37):** runtime themes, VS Code `colors` + `tokenColors` import, SQL/JSON highlighting, P0 workbench migration, Preferences, tests, docs — [theme.md](theme.md). +- **Shipped in 0.4.1 ([#93](https://github.com/QueryaHub/Querya-Desktop/issues/93)):** UI performance — virtual result grid, lazy connection tree, decoupled scale preview, stats polling, MySQL stats dashboard, local `docker/` dev stack — [perf-baseline.md](perf-baseline.md). - **Optional:** Preferences → **Animate theme changes** (off by default). - **Later:** P2 Mongo/Redis token colors; `re_editor` if perf gap; LSP epic per - [code-forge-evaluation.md](code-forge-evaluation.md) (**NO-GO** on `code_forge` for 0.3). + [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md) (**NO-GO** on `code_forge` for 0.3). ## Query history and favorites diff --git a/docs/theme.md b/docs/theme.md index f310fbf5..4fbb4550 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -13,7 +13,7 @@ import (#43–45), `tokenColors` → syntax highlight (#46–47, #49–50), P0 w animation (#57), editor package spikes (#48, #52). **Follow-up (not blocking):** P2 surfaces (Mongo/Redis explorer semantic hues), `re_editor` -if large-buffer benchmarks fail — see [code-forge-evaluation.md](code-forge-evaluation.md). +if large-buffer benchmarks fail — see [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md). ## Architecture @@ -180,11 +180,11 @@ Manual QA (with animation enabled): | SQL/JSON syntax highlighting | Done | | `tokenColors` → highlighter | Done | | Theme transition animation | Preferences → **Animate theme changes** (default off) | -| `code_forge` / LSP editor | **NO-GO** for 0.3 — [code-forge-evaluation.md](code-forge-evaluation.md) | +| `code_forge` / LSP editor | **NO-GO** for 0.3 — [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md) | ## Related docs - [theme-import.md](theme-import.md) — supported `colors` keys and merge behavior -- [research_theme.md](research_theme.md) — background research (RU) -- [editor-spike-report.md](editor-spike-report.md) — code editor package evaluation (#48) -- [code-forge-evaluation.md](code-forge-evaluation.md) — `code_forge` + LSP go/no-go (#52) +- [archive/research_theme.md](archive/research_theme.md) — background research (RU) +- [archive/editor-spike-report.md](archive/editor-spike-report.md) — code editor package evaluation (#48) +- [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md) — `code_forge` + LSP go/no-go (#52) diff --git a/lib/app/app.dart b/lib/app/app.dart index 06b6fdfa..75009613 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,4 +1,5 @@ -import 'package:querya_desktop/core/theme/querya_material_theme.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -12,29 +13,48 @@ class QueryaApp extends StatelessWidget { @override Widget build(BuildContext context) { final themeController = ThemeController.instance; + final uiScaleController = UiScaleController.instance; return ListenableBuilder( listenable: themeController, builder: (context, _) { final queryaTheme = themeController.activeTheme; final colorScheme = queryaTheme.colorScheme; - return ShadcnApp( - title: 'Querya', - theme: themeController.lightShadcnTheme, - darkTheme: themeController.darkShadcnTheme, - themeMode: themeController.themeMode, - materialTheme: materialThemeFromQuerya(colorScheme), - debugShowCheckedModeBanner: false, - enableThemeAnimation: themeController.themeAnimationEnabled, - enableScrollInterception: false, - // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. - builder: (context, child) => QueryaThemeScope( - data: queryaTheme, - child: child ?? const SizedBox.shrink(), - ), - home: const AppLifecycleCleanup( - child: MainScreen(), - ), + + return ListenableBuilder( + listenable: uiScaleController, + builder: (context, _) { + final scale = uiScaleController.scale; + return ShadcnApp( + title: 'Querya', + theme: themeController.lightShadcnTheme, + darkTheme: themeController.darkShadcnTheme, + themeMode: themeController.themeMode, + materialTheme: themeController.materialThemeFor(colorScheme), + debugShowCheckedModeBanner: false, + enableThemeAnimation: themeController.themeAnimationEnabled, + enableScrollInterception: false, + // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. + builder: (context, child) { + final mq = MediaQuery.maybeOf(context); + return QueryaUiScaleScope( + scale: scale, + child: MediaQuery( + data: (mq ?? const MediaQueryData()).copyWith( + textScaler: TextScaler.linear(scale), + ), + child: QueryaThemeScope( + data: queryaTheme, + child: child ?? const SizedBox.shrink(), + ), + ), + ); + }, + home: const AppLifecycleCleanup( + child: MainScreen(), + ), + ); + }, ); }, ); diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 68657f16..ce6ee2ba 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -330,4 +330,76 @@ class MysqlConnection { final rs = await execute('SELECT VERSION()'); return rs.rows.first.colAt(0) ?? ''; } + + /// Key metrics for the MySQL stats dashboard (`SHOW GLOBAL STATUS` / `VARIABLES`). + Future> serverStats() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final stats = {}; + + stats['version'] = await serverVersion(); + + final status = await _showKeyValueRows( + "SHOW GLOBAL STATUS WHERE Variable_name IN (" + "'Uptime','Threads_connected','Threads_running','Max_used_connections'," + "'Questions','Slow_queries','Bytes_received','Bytes_sent','Connections'," + "'Open_tables','Opened_tables','Aborted_connects'" + ')', + ); + stats['status'] = status; + stats['uptime_seconds'] = int.tryParse(status['Uptime'] ?? '') ?? 0; + + stats['variables'] = await _showKeyValueRows( + "SHOW GLOBAL VARIABLES WHERE Variable_name IN (" + "'max_connections','port','datadir','character_set_server'," + "'collation_server','innodb_buffer_pool_size','version'" + ')', + ); + + const systemSchemas = { + 'information_schema', + 'mysql', + 'performance_schema', + 'sys', + }; + final dbRs = await execute( + 'SELECT table_schema, ' + 'COALESCE(SUM(data_length + index_length), 0) AS size_bytes, ' + 'COUNT(*) AS table_count ' + 'FROM information_schema.tables ' + "WHERE table_schema NOT IN ('information_schema','mysql'," + "'performance_schema','sys') " + 'GROUP BY table_schema ' + 'ORDER BY table_schema', + ); + final databases = >[]; + for (final row in dbRs.rows) { + final name = row.colAt(0); + if (name == null || systemSchemas.contains(name.toLowerCase())) { + continue; + } + databases.add({ + 'name': name, + 'size': int.tryParse(row.colAt(1) ?? '') ?? 0, + 'tables': int.tryParse(row.colAt(2) ?? '') ?? 0, + }); + } + stats['databases'] = databases; + + return stats; + } + + Future> _showKeyValueRows(String sql) async { + final rs = await execute(sql); + final out = {}; + for (final row in rs.rows) { + final key = row.colAt(0); + final value = row.colAt(1); + if (key != null && value != null) { + out[key] = value; + } + } + return out; + } } diff --git a/lib/core/editor/querya_highlight_controller.dart b/lib/core/editor/querya_highlight_controller.dart index ae92387b..eb46c8e0 100644 --- a/lib/core/editor/querya_highlight_controller.dart +++ b/lib/core/editor/querya_highlight_controller.dart @@ -1,9 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:syntax_highlight/syntax_highlight.dart'; import 'syntax_highlight_isolate.dart'; +/// Debounce delay before scheduling syntax highlight work. +const Duration kSyntaxHighlightDebounce = Duration(milliseconds: 100); + /// [TextEditingController] that applies [Highlighter] in [buildTextSpan]. class QueryaHighlightController extends TextEditingController { QueryaHighlightController({ @@ -29,6 +34,7 @@ class QueryaHighlightController extends TextEditingController { String? _cachedText; Brightness? _cachedBrightness; int _highlightGeneration = 0; + Timer? _debounceTimer; @override TextSpan buildTextSpan({ @@ -37,20 +43,10 @@ class QueryaHighlightController extends TextEditingController { required bool withComposing, }) { final brightness = Theme.of(context).brightness; - final highlighter = brightness == Brightness.light - ? lightHighlighter - : darkHighlighter; final themeConfig = brightness == Brightness.light ? lightThemeConfig : darkThemeConfig; - if (text.length < kSyntaxHighlightIsolateThreshold) { - return TextSpan( - style: style, - children: [highlighter.highlight(text)], - ); - } - if (_cachedText == text && _cachedBrightness == brightness && _cachedSpan != null) { @@ -64,7 +60,9 @@ class QueryaHighlightController extends TextEditingController { style: style, ); - if (_cachedSpan != null && _cachedText == text) { + if (_cachedSpan != null && + _cachedText == text && + _cachedBrightness == brightness) { return TextSpan(style: style, children: [_cachedSpan!]); } @@ -76,6 +74,23 @@ class QueryaHighlightController extends TextEditingController { required Brightness brightness, required String themeConfig, required TextStyle? style, + }) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(kSyntaxHighlightDebounce, () { + _runIsolateHighlight( + text: text, + brightness: brightness, + themeConfig: themeConfig, + style: style, + ); + }); + } + + void _runIsolateHighlight({ + required String text, + required Brightness brightness, + required String themeConfig, + required TextStyle? style, }) { final generation = ++_highlightGeneration; final lang = switch (language) { @@ -103,6 +118,7 @@ class QueryaHighlightController extends TextEditingController { @override void dispose() { + _debounceTimer?.cancel(); _highlightGeneration++; super.dispose(); } diff --git a/lib/core/editor/syntax_highlight_isolate.dart b/lib/core/editor/syntax_highlight_isolate.dart index 4e1aa7ed..a0fece91 100644 --- a/lib/core/editor/syntax_highlight_isolate.dart +++ b/lib/core/editor/syntax_highlight_isolate.dart @@ -119,12 +119,9 @@ TextStyle? _styleFromSegment(HighlightSegment s, TextStyle? base) { ); } -/// Runs [syntaxHighlightInIsolate] off the UI thread when [code] is large. +/// Runs [syntaxHighlightInIsolate] off the UI thread. Future> highlightOffMainThread( SyntaxHighlightJob job, ) { - if (job.code.length < kSyntaxHighlightIsolateThreshold) { - return Future.value(syntaxHighlightInIsolate(job)); - } return compute(syntaxHighlightInIsolate, job); } diff --git a/lib/core/editor/syntax_highlight_service.dart b/lib/core/editor/syntax_highlight_service.dart index 14a20118..62a3b2a9 100644 --- a/lib/core/editor/syntax_highlight_service.dart +++ b/lib/core/editor/syntax_highlight_service.dart @@ -59,6 +59,29 @@ abstract final class SyntaxHighlightService { static HighlighterPair createPair({ required QueryaCodeLanguage language, required QueryaTheme queryaTheme, + }) { + final cacheKey = Object.hash( + language, + queryaTheme.editor, + Object.hashAll(queryaTheme.tokenColors), + ); + final cached = _pairCache[cacheKey]; + if (cached != null) return cached; + + final pair = _buildPair(language: language, queryaTheme: queryaTheme); + if (_pairCache.length >= _maxPairCacheEntries) { + _pairCache.remove(_pairCache.keys.first); + } + _pairCache[cacheKey] = pair; + return pair; + } + + static const _maxPairCacheEntries = 12; + static final Map _pairCache = {}; + + static HighlighterPair _buildPair({ + required QueryaCodeLanguage language, + required QueryaTheme queryaTheme, }) { final tokenColors = queryaTheme.tokenColors; return HighlighterPair( diff --git a/lib/core/layout/ui_scale.dart b/lib/core/layout/ui_scale.dart new file mode 100644 index 00000000..b290b8b2 --- /dev/null +++ b/lib/core/layout/ui_scale.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; + +/// App-wide UI scale factor (Preferences → Appearance). +class QueryaUiScaleScope extends InheritedWidget { + const QueryaUiScaleScope({ + super.key, + required this.scale, + required super.child, + }); + + final double scale; + + static double of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.scale ?? + 1.0; + } + + @override + bool updateShouldNotify(QueryaUiScaleScope oldWidget) => + oldWidget.scale != scale; +} + +extension QueryaUiScaleContext on BuildContext { + double get uiScale => QueryaUiScaleScope.of(this); + + double scaled(double logicalPixels) => logicalPixels * uiScale; +} diff --git a/lib/core/layout/ui_scale_controller.dart b/lib/core/layout/ui_scale_controller.dart new file mode 100644 index 00000000..39142046 --- /dev/null +++ b/lib/core/layout/ui_scale_controller.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; + +/// Loads and broadcasts [AppSettings] UI scale for the widget tree. +class UiScaleController extends ChangeNotifier { + UiScaleController._(); + static final UiScaleController instance = UiScaleController._(); + + double _scale = kDefaultUiScale; + double get scale => _scale; + + Future load() async { + _scale = await AppSettings.instance.getUiScale(); + notifyListeners(); + } + + /// Persist scale to SQLite (called on slider release). + Future commitScale(double value, {bool fine = false}) async { + await AppSettings.instance.setUiScale(value, fine: fine); + _scale = await AppSettings.instance.getUiScale(); + notifyListeners(); + } + + Future setScale(double value, {bool fine = false}) => + commitScale(value, fine: fine); + + /// Normalizes a raw scale value (preset snap or 1% fine steps). + static double normalize(double value, {bool fine = false}) { + final clamped = value.clamp(kMinUiScale, kMaxUiScale); + if (fine) { + final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); + return (kMinUiScale + steps * kUiScaleStep) + .clamp(kMinUiScale, kMaxUiScale); + } + return snapUiScaleToPreset(clamped); + } +} diff --git a/lib/core/layout/vertical_split_pane.dart b/lib/core/layout/vertical_split_pane.dart new file mode 100644 index 00000000..36dc3cb2 --- /dev/null +++ b/lib/core/layout/vertical_split_pane.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Holds top/bottom panes for [VerticalSplitPane] [ValueListenableBuilder.child]. +class SplitPanePair extends StatelessWidget { + const SplitPanePair({super.key, required this.top, required this.bottom}); + + final Widget top; + final Widget bottom; + + @override + Widget build(BuildContext context) => top; +} + +/// Vertical split whose drag updates [fraction] without rebuilding [top]/[bottom]. +class VerticalSplitPane extends StatelessWidget { + const VerticalSplitPane({ + super.key, + required this.fraction, + required this.top, + required this.bottom, + this.minFraction = 0.2, + this.maxFraction = 0.8, + this.handleKey, + }); + + final ValueNotifier fraction; + final Widget top; + final Widget bottom; + final double minFraction; + final double maxFraction; + final Key? handleKey; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final totalHeight = constraints.maxHeight; + return ValueListenableBuilder( + valueListenable: fraction, + builder: (context, value, panes) { + final pair = panes! as SplitPanePair; + final topFlex = (value * 100).round().clamp(20, 80).toInt(); + final bottomFlex = 100 - topFlex; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: topFlex, child: pair.top), + _VerticalSplitHandle( + key: handleKey, + onDrag: (dy) { + if (totalHeight <= 0) return; + fraction.value = (fraction.value + dy / totalHeight) + .clamp(minFraction, maxFraction); + }, + ), + Expanded(flex: bottomFlex, child: pair.bottom), + ], + ); + }, + child: SplitPanePair(top: top, bottom: bottom), + ); + }, + ); + } +} + +class _VerticalSplitHandle extends StatelessWidget { + const _VerticalSplitHandle({super.key, required this.onDrag}); + + final void Function(double dy) onDrag; + + @override + Widget build(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/core/layout/window_layout.dart b/lib/core/layout/window_layout.dart index a926d607..7491d6d8 100644 --- a/lib/core/layout/window_layout.dart +++ b/lib/core/layout/window_layout.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; /// Breakpoints and sizes derived from window / overlay size (desktop adaptive UI). abstract class WindowLayout { @@ -18,6 +19,36 @@ abstract class WindowLayout { return (screenHeight * 0.04).clamp(12.0, 40.0); } + /// Scaled [BoxConstraints] for modal dialogs (respects [QueryaUiScaleScope]). + static BoxConstraints dialogConstraints( + BuildContext context, { + double? maxWidth, + double? minWidth, + double? maxHeight, + double? minHeight, + }) { + return BoxConstraints( + maxWidth: maxWidth != null ? context.scaled(maxWidth) : double.infinity, + minWidth: minWidth != null ? context.scaled(minWidth) : 0, + maxHeight: maxHeight != null ? context.scaled(maxHeight) : double.infinity, + minHeight: minHeight != null ? context.scaled(minHeight) : 0, + ); + } + + /// Fits a base dialog dimension into the viewport, then applies UI scale. + static double scaledDialogExtent( + BuildContext context, { + required double screenExtent, + required double insetTotal, + required double baseMax, + required double baseMin, + double viewportFactor = 1.0, + }) { + final available = math.max(0.0, screenExtent - insetTotal); + final base = math.min(baseMax, math.max(baseMin, available * viewportFactor)); + return math.min(context.scaled(base), available); + } + /// Use for [Dialog.insetPadding] / modal margins on small windows. static EdgeInsets dialogSymmetricInsets(BuildContext context) { final mq = MediaQuery.sizeOf(context); @@ -28,15 +59,30 @@ abstract class WindowLayout { } /// "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 newConnectionDialogMaxWidth(BuildContext context) { + final mq = MediaQuery.sizeOf(context); + final inset = dialogHorizontalInset(mq.width) * 2; + return scaledDialogExtent( + context, + screenExtent: mq.width, + insetTotal: inset, + baseMax: 740, + baseMin: 280, + viewportFactor: 1.0, + ); } - 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 newConnectionDialogHeight(BuildContext context) { + final mq = MediaQuery.sizeOf(context); + final inset = dialogVerticalInset(mq.height) * 2; + return scaledDialogExtent( + context, + screenExtent: mq.height, + insetTotal: inset, + baseMax: 580, + baseMin: 320, + viewportFactor: 0.78, + ); } static double newConnectionSidebarWidth(double dialogWidth) { @@ -53,12 +99,13 @@ abstract class WindowLayout { return 1; } - static double dbTypeCardHeight(int crossAxisCount) { - return switch (crossAxisCount) { - 4 => 144, - 2 => 138, - _ => 132, + static double dbTypeCardHeight(BuildContext context, int crossAxisCount) { + final base = switch (crossAxisCount) { + 4 => 144.0, + 2 => 138.0, + _ => 132.0, }; + return context.scaled(base); } /// Empty workspace hero content max width (stays within viewport minus padding). diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 77c537bf..9530b38d 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -22,6 +22,56 @@ const List kSqlResultMaxRowsPresets = [ /// Default monospace size in the SQL editor (logical pixels). const double kDefaultSqlEditorFontSize = 13; +/// Default interface scale (1.0 = 100%). +const double kDefaultUiScale = 1.0; + +/// Minimum interface scale (Telegram Desktop supports 75%). +const double kMinUiScale = 0.75; + +/// Maximum interface scale (Telegram goes to 300%; 200% is enough for Querya). +const double kMaxUiScale = 2.0; + +/// Slider step — 1% increments when Shift is held (fine control). +const double kUiScaleStep = 0.01; + +/// Fixed tick marks on the interface scale slider (75% … 200%). +const List kUiScalePresets = [ + 0.75, + 0.85, + 0.9, + 1.0, + 1.1, + 1.25, + 1.5, + 1.75, + 2.0, +]; + +int nearestUiScalePresetIndex(double scale) { + var best = 0; + var bestDist = double.infinity; + for (var i = 0; i < kUiScalePresets.length; i++) { + final dist = (kUiScalePresets[i] - scale).abs(); + if (dist < bestDist) { + bestDist = dist; + best = i; + } + } + return best; +} + +double snapUiScaleToPreset(double scale) => + kUiScalePresets[nearestUiScalePresetIndex(scale)]; + +double _normalizeUiScaleContinuous(double value) { + final clamped = value.clamp(kMinUiScale, kMaxUiScale); + final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); + return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); +} + +double _normalizeUiScale(double value, {bool fine = false}) => + fine ? _normalizeUiScaleContinuous(value) : snapUiScaleToPreset(value); + /// Default cap on stored SQL history entries per connection + database. const int kDefaultSqlHistoryMaxEntries = 100; @@ -57,15 +107,23 @@ abstract final class AppSettingsKeys { static const themeImportName = 'theme_import_name'; static const themeImportedColorsJson = 'theme_imported_colors_json'; static const themeAnimationEnabled = 'theme_animation_enabled'; + static const uiScale = 'ui_scale'; } -/// Bumps [listenable] when any preference is persisted so open screens can reload. +/// Bumps [listenable] when any preference is persisted (theme, legacy listeners). abstract final class AppSettingsRevision { static final ValueNotifier listenable = ValueNotifier(0); static void bump() => listenable.value++; } +/// Bumps when SQL workspace preferences change (timeouts, grid, editor font, history). +abstract final class SqlWorkspaceSettingsRevision { + static final ValueNotifier listenable = ValueNotifier(0); + + static void bump() => listenable.value++; +} + /// User preferences backed by [LocalDb] (SQLite). class AppSettings { AppSettings._(); @@ -91,7 +149,7 @@ class AppSettings { seconds.toString(), ); } - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// `null` = use driver default for statement duration. @@ -114,7 +172,7 @@ class AppSettings { seconds.toString(), ); } - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// Max rows loaded into the result grid for PostgreSQL / MySQL workspaces. @@ -136,7 +194,7 @@ class AppSettings { AppSettingsKeys.sqlResultMaxRows, preset.toString(), ); - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// Editor font size in logical pixels. @@ -156,7 +214,24 @@ class AppSettings { AppSettingsKeys.sqlEditorFontSizePoints, clamped.toString(), ); - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); + } + + /// Global interface scale for typography and compact controls. + Future getUiScale() async { + final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.uiScale); + if (v == null || v.isEmpty) return kDefaultUiScale; + final n = double.tryParse(v); + if (n == null) return kDefaultUiScale; + return _normalizeUiScaleContinuous(n); + } + + Future setUiScale(double scale, {bool fine = false}) async { + final normalized = _normalizeUiScale(scale, fine: fine); + await LocalDb.instance.setAppSetting( + AppSettingsKeys.uiScale, + normalized.toStringAsFixed(2), + ); } /// Max SQL history rows kept per connection + database (oldest trimmed). @@ -178,7 +253,7 @@ class AppSettings { AppSettingsKeys.sqlHistoryMaxEntries, preset.toString(), ); - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// UI theme mode (dark / light / system). diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index ac562091..8abdb638 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,4 +1,6 @@ +import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'parser/apply_token_colors_to_editor.dart'; @@ -25,6 +27,14 @@ class ThemeController extends ChangeNotifier { bool _loaded = false; bool _themeAnimationEnabled = false; + QueryaTheme? _cachedLightTheme; + QueryaTheme? _cachedDarkTheme; + QueryaTheme? _cachedActiveTheme; + ThemeData? _cachedLightShadcnTheme; + ThemeData? _cachedDarkShadcnTheme; + material.ThemeData? _cachedMaterialTheme; + ColorScheme? _cachedMaterialThemeScheme; + ThemeMode get themeMode => _themeMode; /// When true, [QueryaApp] enables ShadcnAnimatedTheme transitions. @@ -70,13 +80,41 @@ class ThemeController extends ChangeNotifier { } /// Workbench + editor tokens for the current preset/mode and overrides. - QueryaTheme get activeTheme => _themeForBrightness(_effectiveBrightness()); + QueryaTheme get activeTheme => + _cachedActiveTheme ??= _themeForBrightness(_effectiveBrightness()); + + ThemeData get lightShadcnTheme => _cachedLightShadcnTheme ??= + (_cachedLightTheme ??= _themeForBrightness(Brightness.light)) + .toShadcnThemeData(); + + ThemeData get darkShadcnTheme => _cachedDarkShadcnTheme ??= + (_cachedDarkTheme ??= _themeForBrightness(Brightness.dark)) + .toShadcnThemeData(); + + /// Cached Material theme for dialogs/dropdowns (avoids rebuild churn). + material.ThemeData materialThemeFor(ColorScheme scheme) { + if (_cachedMaterialTheme != null && + _cachedMaterialThemeScheme == scheme) { + return _cachedMaterialTheme!; + } + _cachedMaterialThemeScheme = scheme; + return _cachedMaterialTheme = materialThemeFromQuerya(scheme); + } - ThemeData get lightShadcnTheme => - _themeForBrightness(Brightness.light).toShadcnThemeData(); + void _invalidateThemeCache() { + _cachedLightTheme = null; + _cachedDarkTheme = null; + _cachedActiveTheme = null; + _cachedLightShadcnTheme = null; + _cachedDarkShadcnTheme = null; + _cachedMaterialTheme = null; + _cachedMaterialThemeScheme = null; + } - ThemeData get darkShadcnTheme => - _themeForBrightness(Brightness.dark).toShadcnThemeData(); + void _notifyThemeChanged() { + _invalidateThemeCache(); + notifyListeners(); + } Future load() async { final mode = await AppSettings.instance.getThemeMode(); @@ -106,13 +144,13 @@ class ThemeController extends ChangeNotifier { _themeAnimationEnabled = await AppSettings.instance.getThemeAnimationEnabled(); _loaded = true; - notifyListeners(); + _notifyThemeChanged(); } Future setThemeAnimationEnabled(bool enabled) async { _themeAnimationEnabled = enabled; await AppSettings.instance.setThemeAnimationEnabled(enabled); - notifyListeners(); + _notifyThemeChanged(); } Future setThemeMode(ThemeMode mode) async { @@ -124,7 +162,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemePreset(_preset); } await AppSettings.instance.setThemeMode(mode); - notifyListeners(); + _notifyThemeChanged(); } Future setPreset(QueryaThemePreset preset) async { @@ -141,7 +179,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemePreset(preset); await AppSettings.instance.setThemeMode(_themeMode); } - notifyListeners(); + _notifyThemeChanged(); } /// Parses a VS Code theme file, persists it, and activates the imported preset. @@ -165,7 +203,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemeImportPath(storedPath); await AppSettings.instance.setThemePreset(QueryaThemePreset.imported); await AppSettings.instance.setThemeMode(_themeMode); - notifyListeners(); + _notifyThemeChanged(); return result; case ThemeImportFailure(): return result; @@ -182,14 +220,14 @@ class ThemeController extends ChangeNotifier { } _userOverrides = Map.unmodifiable(next); await AppSettings.instance.setThemeColorOverrides(next); - notifyListeners(); + _notifyThemeChanged(); } /// Removes only the user override layer (keeps preset/imported theme). Future clearColorOverrides() async { _userOverrides = const {}; await AppSettings.instance.clearThemeColorOverrides(); - notifyListeners(); + _notifyThemeChanged(); } /// Clears imported theme file and settings; falls back to Querya Dark. @@ -205,7 +243,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemePreset(_preset); await AppSettings.instance.setThemeMode(_themeMode); } - notifyListeners(); + _notifyThemeChanged(); } Future resetToDefaults() async { @@ -218,7 +256,7 @@ class ThemeController extends ChangeNotifier { _userOverrides = const {}; _importedThemeName = null; _themeAnimationEnabled = false; - notifyListeners(); + _notifyThemeChanged(); } Brightness _effectiveBrightness() { diff --git a/lib/core/util/deep_collection_equals.dart b/lib/core/util/deep_collection_equals.dart new file mode 100644 index 00000000..99f7a273 --- /dev/null +++ b/lib/core/util/deep_collection_equals.dart @@ -0,0 +1,27 @@ +/// Deep equality for JSON-like trees (maps, lists, primitives). +bool deepCollectionEquals(Object? a, Object? b) { + if (identical(a, b)) return true; + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!b.containsKey(key)) return false; + if (!deepCollectionEquals(a[key], b[key])) return false; + } + return true; + } + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!deepCollectionEquals(a[i], b[i])) return false; + } + return true; + } + return a == b; +} + +/// Updates [current] when [next] differs; returns true if changed. +bool replaceIfChanged(T? current, T? next, void Function(T? value) apply) { + if (deepCollectionEquals(current, next)) return false; + apply(next); + return true; +} diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index 935ff472..9a43338e 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -7,22 +7,32 @@ 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'; +/// Context that stays mounted after menu overlays close (multi-step dialog flow). +material.BuildContext _dialogAnchorContext(material.BuildContext context) { + final navigator = material.Navigator.maybeOf(context, rootNavigator: true); + if (navigator != null && navigator.context.mounted) { + return navigator.context; + } + return context; +} + /// 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); + final dialogContext = _dialogAnchorContext(context); + final type = await showNewConnectionDialog(dialogContext); if (type == null) return null; - if (!context.mounted) return null; + if (!dialogContext.mounted) return null; switch (type) { case ConnectionType.postgresql: - return await showPostgresConnectionForm(context, folderId: folderId); + return await showPostgresConnectionForm(dialogContext, folderId: folderId); case ConnectionType.mysql: - return await showMysqlConnectionForm(context, folderId: folderId); + return await showMysqlConnectionForm(dialogContext, folderId: folderId); case ConnectionType.mongodb: - return await showMongoConnectionForm(context, folderId: folderId); + return await showMongoConnectionForm(dialogContext, folderId: folderId); case ConnectionType.redis: - return await showRedisConnectionForm(context, folderId: folderId); + return await showRedisConnectionForm(dialogContext, folderId: folderId); } } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 59e4c43c..5cc49806 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, 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, RepaintBoundary; +import 'package:flutter/material.dart' as material show AlertDialog, BoxConstraints, BuildContext, Column, ConstrainedBox, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, EdgeInsetsGeometry, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, TextStyle, CustomScrollView, SliverFillRemaining, SliverPadding, SliverList, SliverChildBuilderDelegate, GestureDetector, HitTestBehavior, SizedBox, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color, SelectableText, Padding, Widget, Navigator, ValueKey, FontWeight, VoidCallback, RepaintBoundary, ListView, ClampingScrollPhysics; 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'; @@ -33,6 +33,54 @@ typedef OnPostgresOpenSqlWorkspace = void Function( PostgresObjectKind? kind, }); +/// Typical height of a compact tree row (object leaf / table name). +const double kConnectionTreeRowExtent = 28; + +/// Build all rows inline when the list is short. +const int kConnectionTreeEagerThreshold = 24; + +/// Max rows visible before nested list scrolls (virtualized via [ListView.builder]). +const int kConnectionTreeMaxVisibleRows = 14; + +/// Builds a short [Column] or a height-capped [ListView.builder] for large lists. +material.Widget lazyConnectionTreeList({ + required material.BuildContext context, + required int itemCount, + required material.Widget Function(material.BuildContext context, int index) + itemBuilder, + double? itemExtent, + int eagerThreshold = kConnectionTreeEagerThreshold, + int maxVisibleRows = kConnectionTreeMaxVisibleRows, + material.EdgeInsetsGeometry? padding, +}) { + if (itemCount == 0) { + return const material.SizedBox.shrink(); + } + if (itemCount <= eagerThreshold) { + return material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < itemCount; i++) itemBuilder(context, i), + ], + ); + } + final rowExtent = itemExtent ?? kConnectionTreeRowExtent; + return material.ConstrainedBox( + constraints: material.BoxConstraints( + maxHeight: maxVisibleRows * rowExtent, + ), + child: material.ListView.builder( + padding: padding ?? material.EdgeInsets.zero, + shrinkWrap: true, + physics: const material.ClampingScrollPhysics(), + itemCount: itemCount, + itemExtent: itemExtent, + itemBuilder: itemBuilder, + ), + ); +} + /// Left panel: Browser tree (pgAdmin-style). Uses shadcn layout widgets. class ConnectionsPanel extends StatefulWidget { const ConnectionsPanel({ @@ -268,6 +316,9 @@ class ConnectionsPanelState extends State { // Connections without a folder final rootConnections = _connections.where((c) => c.folderId == null).toList(); + final showEmptyState = _connections.isEmpty && _folders.isEmpty; + final topLevelCount = + _folders.length + rootConnections.length + (showEmptyState ? 1 : 0); return material.Container( decoration: material.BoxDecoration( @@ -302,14 +353,20 @@ class ConnectionsPanelState extends State { slivers: [ material.SliverPadding( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 12), - sliver: material.SliverToBoxAdapter( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - // Folders - for (final name in _folders) - _FolderTile( + sliver: material.SliverList( + delegate: material.SliverChildBuilderDelegate( + (context, index) { + if (showEmptyState && index == 0) { + return const material.Padding( + padding: material.EdgeInsets.only(top: 8), + child: _EmptyState(message: 'No connections yet'), + ); + } + final folderOffset = showEmptyState ? 1 : 0; + final folderIndex = index - folderOffset; + if (folderIndex < _folders.length) { + final name = _folders[folderIndex]; + return _FolderTile( name: name, initiallyExpanded: _expandedFolders.contains(name), onExpansionCommitted: (folderName, expanded) { @@ -337,17 +394,12 @@ class ConnectionsPanelState extends State { onRedisDatabaseTap: widget.onRedisDatabaseSelected, onMongoDBDatabaseTap: widget.onMongoDBDatabaseSelected, buildConnectionTile: _buildConnectionTile, - ), - // Root connections (no folder) - for (final conn in rootConnections) - _buildConnectionTile(conn), - // Empty state - if (_connections.isEmpty && _folders.isEmpty) - const material.Padding( - padding: material.EdgeInsets.only(top: 8), - child: _EmptyState(message: 'No connections yet'), - ), - ], + ); + } + final connIndex = folderIndex - _folders.length; + return _buildConnectionTile(rootConnections[connIndex]); + }, + childCount: topLevelCount, ), ), ), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 61684b78..f6045237 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -289,14 +289,20 @@ class _MysqlDatabasesNode extends material.StatelessWidget { : (c, {database, schema, name, kind}) => onMysqlOpenSqlWorkspace!(c), ), - for (final db in databases) - _MysqlDatabaseNode( - key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), - connection: connection, - databaseName: db, - onMysqlObjectSelected: onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, - ), + lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _MysqlDatabaseNode( + key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), + connection: connection, + databaseName: db, + onMysqlObjectSelected: onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, + ); + }, + ), ], ), ); @@ -444,10 +450,17 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { onContextRefresh: _loadTables, onOpenSqlWorkspace: null, ), - for (final t in _tables) - material.Padding( - padding: const material.EdgeInsets.only(left: 12), - child: _PgTreeRow( + lazyConnectionTreeList( + context: context, + itemCount: _tables.length, + itemExtent: kConnectionTreeRowExtent, + padding: const material.EdgeInsets.only(left: 12), + itemBuilder: (context, index) { + final t = _tables[index]; + return _PgTreeRow( + key: material.ValueKey( + 'mysql-table-${widget.connection.id ?? 0}-${widget.databaseName}-$t', + ), label: t, icon: material.Icons.grid_on_rounded, iconSize: 12, @@ -468,8 +481,9 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { connection: widget.connection, onContextRefresh: null, onOpenSqlWorkspace: null, - ), - ), + ); + }, + ), ], if (_views.isNotEmpty) ...[ _PgTreeRow( @@ -487,10 +501,17 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { onContextRefresh: _loadTables, onOpenSqlWorkspace: null, ), - for (final v in _views) - material.Padding( - padding: const material.EdgeInsets.only(left: 12), - child: _PgTreeRow( + lazyConnectionTreeList( + context: context, + itemCount: _views.length, + itemExtent: kConnectionTreeRowExtent, + padding: const material.EdgeInsets.only(left: 12), + itemBuilder: (context, index) { + final v = _views[index]; + return _PgTreeRow( + key: material.ValueKey( + 'mysql-view-${widget.connection.id ?? 0}-${widget.databaseName}-$v', + ), label: v, icon: material.Icons.view_week_rounded, iconSize: 12, @@ -511,8 +532,9 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { connection: widget.connection, onContextRefresh: null, onOpenSqlWorkspace: null, - ), - ), + ); + }, + ), ], ], ), diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 2ff0fc92..d79fe824 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -25,7 +25,7 @@ class _PgDatabasesNode extends StatefulWidget { State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); } -/// Ellipsis label; tooltip only when text overflows (intrinsic width > slot). +/// Ellipsis label; tooltip when the name is long enough to likely truncate. class _PgTreeRowLabel extends material.StatelessWidget { const _PgTreeRowLabel({ required this.label, @@ -35,30 +35,21 @@ class _PgTreeRowLabel extends material.StatelessWidget { final String label; final material.TextStyle textStyle; + static const int _tooltipMinLength = 28; + @override material.Widget build(material.BuildContext context) { - return material.LayoutBuilder( - builder: (context, constraints) { - final tp = material.TextPainter( - text: material.TextSpan(text: label, style: textStyle), - maxLines: 1, - textDirection: material.TextDirection.ltr, - ); - tp.layout(maxWidth: double.infinity); - final overflow = tp.width > constraints.maxWidth + 0.5; - final text = material.Text( - label, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: textStyle, - ); - if (!overflow) return text; - return material.Tooltip( - message: label, - waitDuration: const Duration(milliseconds: 450), - child: text, - ); - }, + final text = material.Text( + label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: textStyle, + ); + if (label.length < _tooltipMinLength) return text; + return material.Tooltip( + message: label, + waitDuration: const Duration(milliseconds: 450), + child: text, ); } } @@ -66,6 +57,7 @@ class _PgTreeRowLabel extends material.StatelessWidget { /// Shared tree row: consistent ink hover, optional context menu, tooltips when truncated. class _PgTreeRow extends material.StatelessWidget { const _PgTreeRow({ + super.key, required this.label, this.leading, this.icon, @@ -242,14 +234,20 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) - for (final db in widget.databases) - _PgDatabaseNode( - key: material.ValueKey('pg-db-${widget.connection.id ?? 0}-$db'), - connection: widget.connection, - databaseName: db, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), + lazyConnectionTreeList( + context: context, + itemCount: widget.databases.length, + itemBuilder: (context, index) { + final db = widget.databases[index]; + return _PgDatabaseNode( + key: material.ValueKey('pg-db-${widget.connection.id ?? 0}-$db'), + connection: widget.connection, + databaseName: db, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ); + }, + ), ], ), ); @@ -531,17 +529,23 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) - for (final schema in widget.schemas) - _PgSchemaNode( - key: material.ValueKey( - 'pg-schema-${widget.connection.id ?? 0}-${widget.databaseName}-$schema', - ), - connection: widget.connection, - databaseName: widget.databaseName, - schemaName: schema, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), + lazyConnectionTreeList( + context: context, + itemCount: widget.schemas.length, + itemBuilder: (context, index) { + final schema = widget.schemas[index]; + return _PgSchemaNode( + key: material.ValueKey( + 'pg-schema-${widget.connection.id ?? 0}-${widget.databaseName}-$schema', + ), + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: schema, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ); + }, + ), ], ), ); @@ -955,10 +959,17 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) - for (final item in widget.items) - material.Padding( - padding: const material.EdgeInsets.only(left: 22), - child: _PgTreeRow( + lazyConnectionTreeList( + context: context, + itemCount: widget.items.length, + itemExtent: kConnectionTreeRowExtent, + padding: const material.EdgeInsets.only(left: 22), + itemBuilder: (context, index) { + final item = widget.items[index]; + return _PgTreeRow( + key: material.ValueKey( + 'pg-${widget.objectKind.name}-${widget.databaseName}-${widget.schemaName}-$item', + ), label: item, icon: widget.icon, iconSize: 12, @@ -979,8 +990,9 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { openSqlSchema: widget.schemaName, openSqlName: item, openSqlKind: widget.objectKind, - ), - ), + ); + }, + ), ], ), ); diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 0aae05be..f1994158 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -67,6 +67,7 @@ class _EmptyState extends StatelessWidget { /// Tile for a single connection in the sidebar. class _ConnectionTile extends StatelessWidget { const _ConnectionTile({ + super.key, required this.connection, this.isSelected = false, required this.icon, @@ -271,19 +272,28 @@ class _FolderTileState extends State<_FolderTile> { ), ), if (_expanded) - for (final conn in widget.connections) - material.Padding( - padding: const material.EdgeInsets.only(left: 24), - child: widget.buildConnectionTile != null - ? widget.buildConnectionTile!(conn) - : _ConnectionTile( - connection: conn, - icon: widget.iconForType(conn.type), - iconAsset: ConnectionsPanelState._iconAssetForType(conn.type), - onRemove: () => widget.onRemoveConnection(conn.id!), - onTap: () => widget.onConnectionTap?.call(conn), - ), + material.Padding( + padding: const material.EdgeInsets.only(left: 24), + child: lazyConnectionTreeList( + context: context, + itemCount: widget.connections.length, + itemBuilder: (context, index) { + final conn = widget.connections[index]; + return widget.buildConnectionTile != null + ? widget.buildConnectionTile!(conn) + : _ConnectionTile( + key: material.ValueKey('folder-conn-${conn.id}'), + connection: conn, + icon: widget.iconForType(conn.type), + iconAsset: ConnectionsPanelState._iconAssetForType( + conn.type, + ), + onRemove: () => widget.onRemoveConnection(conn.id!), + onTap: () => widget.onConnectionTap?.call(conn), + ); + }, ), + ), ], ), ), diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index 8b77c30f..b3e18e19 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -51,7 +51,11 @@ class _DriverManagerDialogContent extends material.StatelessWidget { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 520, minWidth: 400), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 520, + minWidth: 400, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 62b9bb61..9cf93f7e 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -89,11 +89,10 @@ 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 dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(context); + final dialogH = WindowLayout.newConnectionDialogHeight(context); final headerPadH = dialogMaxW < 420 ? 16.0 : 24.0; + final stackFilters = dialogMaxW < 520; return material.Container( width: dialogMaxW, @@ -145,74 +144,61 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial child: TextField( controller: _searchController, placeholder: const Text('Search...'), - onChanged: (v) => setState(() => _searchQuery = v), + onChanged: (v) => setState(() { + _searchQuery = v; + if (_selectedType != null && + !_filteredTypes.contains(_selectedType)) { + _selectedType = null; + } + }), ), ), ], ), ), + const material.SizedBox(height: 12), + _FilterDropdowns( + stackVertically: stackFilters, + category: _category, + selectedType: _selectedType, + filteredTypes: _filteredTypes, + onCategoryChanged: (category) { + setState(() { + _category = category; + if (_selectedType != null && + !_categoryTypes.contains(_selectedType)) { + _selectedType = null; + } + }); + }, + onTypeChanged: (type) => setState(() => _selectedType = type), + ), ], ), ), material.Expanded( - child: material.Row( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Container( - width: sidebarW, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide( - color: theme.border.withValues(alpha: 0.4), - ), - ), - ), - child: material.ListView( - padding: const material.EdgeInsets.symmetric(vertical: 8), - children: [ - _CategoryTile( - label: 'All', - icon: material.Icons.dns_rounded, - selected: _category == _Category.all, - onTap: () => setState(() => _category = _Category.all), - theme: theme, - ), - _CategoryTile( - label: 'SQL', - icon: material.Icons.table_chart_rounded, - selected: _category == _Category.sql, - onTap: () => setState(() => _category = _Category.sql), - theme: theme, - ), - _CategoryTile( - label: 'NoSQL', - icon: material.Icons.memory_rounded, - selected: _category == _Category.nosql, - onTap: () => setState(() => _category = _Category.nosql), - theme: theme, - ), - ], - ), - ), - material.Expanded( - child: material.LayoutBuilder( - builder: (context, constraints) { - const spacing = 12.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: material.EdgeInsets.all(gridPad), - child: material.GridView.count( + child: material.LayoutBuilder( + builder: (context, constraints) { + const spacing = 12.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(context, crossAxisCount); + final cardWidth = crossAxisCount > 0 + ? (innerW - spacing * (crossAxisCount - 1)) / crossAxisCount + : innerW; + final aspect = cardHeight > 0 ? cardWidth / cardHeight : 1.0; + return material.Padding( + padding: material.EdgeInsets.all(gridPad), + child: _filteredTypes.isEmpty + ? material.Center( + child: const Text('No databases match your search.') + .muted() + .small(), + ) + : material.GridView.count( crossAxisCount: crossAxisCount, mainAxisSpacing: spacing, crossAxisSpacing: spacing, @@ -229,11 +215,8 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial ), ], ), - ); - }, - ), - ), - ], + ); + }, ), ), material.Container( @@ -271,38 +254,100 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial } } -class _CategoryTile extends StatelessWidget { - const _CategoryTile({ - required this.label, - required this.icon, - required this.selected, - required this.onTap, - required this.theme, +class _FilterDropdowns extends StatelessWidget { + const _FilterDropdowns({ + required this.stackVertically, + required this.category, + required this.selectedType, + required this.filteredTypes, + required this.onCategoryChanged, + required this.onTypeChanged, }); - final String label; - final material.IconData icon; - final bool selected; - final VoidCallback onTap; - final ColorScheme theme; + final bool stackVertically; + final _Category category; + final ConnectionType? selectedType; + final List filteredTypes; + final void Function(_Category category) onCategoryChanged; + final void Function(ConnectionType? type) onTypeChanged; + + static const _categoryItems = [ + QueryaDropdownItem( + value: _Category.all, + label: 'All databases', + leading: material.Icon(material.Icons.dns_rounded, size: 18), + ), + QueryaDropdownItem( + value: _Category.sql, + label: 'SQL', + leading: material.Icon(material.Icons.table_chart_rounded, size: 18), + ), + QueryaDropdownItem( + value: _Category.nosql, + label: 'NoSQL', + leading: material.Icon(material.Icons.memory_rounded, size: 18), + ), + ]; @override material.Widget build(material.BuildContext context) { - return material.Material( - color: selected ? theme.muted.withValues(alpha: 0.35) : material.Colors.transparent, - child: material.InkWell( - onTap: onTap, - child: material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: material.Row( - children: [ - material.Icon(icon, size: 20, color: theme.mutedForeground), - const material.SizedBox(width: 14), - selected ? Text(label).semiBold().small() : Text(label).small(), - ], - ), + final categoryField = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Category').small().muted(), + const material.SizedBox(height: 4), + QueryaDropdown<_Category>( + value: category, + expandToParent: true, + items: _categoryItems, + onSelected: (value) { + if (value != null) onCategoryChanged(value); + }, ), - ), + ], + ); + + final typeField = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Database type').small().muted(), + const material.SizedBox(height: 4), + QueryaDropdown( + value: selectedType, + hint: filteredTypes.isEmpty ? 'No matches' : 'Select database…', + enabled: filteredTypes.isNotEmpty, + expandToParent: true, + items: [ + for (final type in filteredTypes) + QueryaDropdownItem( + value: type, + label: type.label, + leading: material.Icon(type.icon, size: 18), + ), + ], + onSelected: onTypeChanged, + ), + ], + ); + + if (stackVertically) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + categoryField, + const material.SizedBox(height: 10), + typeField, + ], + ); + } + + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded(child: categoryField), + const material.SizedBox(width: 12), + material.Expanded(child: typeField), + ], ); } } diff --git a/lib/features/connections/new_folder_dialog.dart b/lib/features/connections/new_folder_dialog.dart index 15c95a5d..db316e78 100644 --- a/lib/features/connections/new_folder_dialog.dart +++ b/lib/features/connections/new_folder_dialog.dart @@ -37,7 +37,11 @@ class _NewFolderDialogContentState extends material.State<_NewFolderDialogConten final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 440, minWidth: 360), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 440, + minWidth: 360, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index a7461e5e..2250f616 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -117,9 +117,11 @@ class _MainScreenState extends State { await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); } - Future _onNewDatabaseConnectionFromMenu( - material.BuildContext menuContext) async { - final row = await promptCreateConnection(menuContext, folderId: null); + Future _onNewDatabaseConnectionFromMenu() async { + // Menu overlay context is torn down before the connection form opens. + await Future.delayed(const Duration(milliseconds: 100)); + if (!mounted) return; + final row = await promptCreateConnection(context, folderId: null); if (!mounted || row == null) return; await LocalDb.instance.addConnection(row); await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); @@ -207,67 +209,57 @@ class _MainContentSplitState extends State<_MainContentSplit> { static const double _maxLeftWidth = 500; static const double _minWorkspaceWidth = 64; static const double _resizeHandleWidth = 6; - double _leftPanelWidth = 260; + final ValueNotifier _leftPanelWidth = ValueNotifier(260); + + @override + void dispose() { + _leftPanelWidth.dispose(); + super.dispose(); + } + + double _clampLeftWidth(double raw, double maxWidth) { + final maxLeft = maxWidth - _resizeHandleWidth - _minWorkspaceWidth; + if (maxLeft <= 0) return 0; + if (maxLeft < _minLeftWidth) return raw.clamp(0, maxLeft); + return raw.clamp(_minLeftWidth, math.min(_maxLeftWidth, maxLeft)); + } @override material.Widget build(material.BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - final maxLeft = - constraints.maxWidth - _resizeHandleWidth - _minWorkspaceWidth; - double leftW; - if (maxLeft <= 0) { - leftW = 0; - } else if (maxLeft < _minLeftWidth) { - leftW = maxLeft; - } else { - leftW = _leftPanelWidth.clamp( - _minLeftWidth, - math.min(_maxLeftWidth, maxLeft), - ); - } return Row( children: [ - SizedBox( - width: leftW, + ValueListenableBuilder( + valueListenable: _leftPanelWidth, + builder: (context, rawWidth, connectionsPanel) { + final leftW = _clampLeftWidth(rawWidth, constraints.maxWidth); + return SizedBox(width: leftW, child: connectionsPanel); + }, child: material.RepaintBoundary( - child: ValueListenableBuilder( - valueListenable: widget.workspace, - builder: (context, ws, _) { - return ConnectionsPanel( - key: widget.connectionsPanelKey, - selectedConnectionId: ws.activeConnection?.id, - onConnectionSelected: widget.onConnectionSelected, - onRedisDatabaseSelected: widget.onRedisDatabaseSelected, - onMongoDBDatabaseSelected: - widget.onMongoDBDatabaseSelected, - onPostgresObjectSelected: - widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: - widget.onPostgresOpenSqlWorkspace, - onMysqlObjectSelected: widget.onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, - ); - }, + child: _ConnectionsPanelSlot( + connectionsPanelKey: widget.connectionsPanelKey, + workspace: widget.workspace, + onConnectionSelected: widget.onConnectionSelected, + onRedisDatabaseSelected: widget.onRedisDatabaseSelected, + onMongoDBDatabaseSelected: widget.onMongoDBDatabaseSelected, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, ), ), ), _VerticalResizeHandle( onDrag: (dx) { - setState(() { - final w = MediaQuery.sizeOf(context).width; - final ml = w - _resizeHandleWidth - _minWorkspaceWidth; - if (ml <= 0) return; - final next = _leftPanelWidth + dx; - if (ml < _minLeftWidth) { - _leftPanelWidth = next.clamp(0, ml); - } else { - _leftPanelWidth = next.clamp( - _minLeftWidth, - math.min(_maxLeftWidth, ml), - ); - } - }); + final ml = constraints.maxWidth - + _resizeHandleWidth - + _minWorkspaceWidth; + if (ml <= 0) return; + _leftPanelWidth.value = _clampLeftWidth( + _leftPanelWidth.value + dx, + constraints.maxWidth, + ); }, ), Expanded( @@ -300,6 +292,84 @@ class _MainContentSplitState extends State<_MainContentSplit> { } } +/// Rebuilds [ConnectionsPanel] only when the selected connection id changes. +class _ConnectionsPanelSlot extends StatefulWidget { + const _ConnectionsPanelSlot({ + required this.connectionsPanelKey, + required this.workspace, + required this.onConnectionSelected, + required this.onPostgresObjectSelected, + required this.onMysqlObjectSelected, + required this.onRedisDatabaseSelected, + required this.onMongoDBDatabaseSelected, + required this.onPostgresOpenSqlWorkspace, + required this.onMysqlOpenSqlWorkspace, + }); + + final GlobalKey connectionsPanelKey; + final ValueNotifier workspace; + final void Function(ConnectionRow) onConnectionSelected; + final void Function( + ConnectionRow, + String database, + String schema, + String name, + PostgresObjectKind kind, + ) onPostgresObjectSelected; + final void Function( + ConnectionRow, + String database, + String name, + MysqlObjectKind kind, + ) onMysqlObjectSelected; + final void Function(ConnectionRow, int) onRedisDatabaseSelected; + final void Function(ConnectionRow, String) onMongoDBDatabaseSelected; + final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; + final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; + + @override + State<_ConnectionsPanelSlot> createState() => _ConnectionsPanelSlotState(); +} + +class _ConnectionsPanelSlotState extends State<_ConnectionsPanelSlot> { + int? _selectedConnectionId; + + @override + void initState() { + super.initState(); + _selectedConnectionId = widget.workspace.value.activeConnection?.id; + widget.workspace.addListener(_onWorkspaceChanged); + } + + @override + void dispose() { + widget.workspace.removeListener(_onWorkspaceChanged); + super.dispose(); + } + + void _onWorkspaceChanged() { + final next = widget.workspace.value.activeConnection?.id; + if (next != _selectedConnectionId) { + setState(() => _selectedConnectionId = next); + } + } + + @override + material.Widget build(material.BuildContext context) { + return ConnectionsPanel( + key: widget.connectionsPanelKey, + selectedConnectionId: _selectedConnectionId, + onConnectionSelected: widget.onConnectionSelected, + onRedisDatabaseSelected: widget.onRedisDatabaseSelected, + onMongoDBDatabaseSelected: widget.onMongoDBDatabaseSelected, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + ); + } +} + class _VerticalResizeHandle extends StatelessWidget { const _VerticalResizeHandle({required this.onDrag}); @@ -329,8 +399,7 @@ class _CustomTitleBar extends StatefulWidget { }); final ColorScheme theme; - final Future Function(material.BuildContext context) - onNewDatabaseConnection; + final Future Function() onNewDatabaseConnection; @override State<_CustomTitleBar> createState() => _CustomTitleBarState(); @@ -414,8 +483,8 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { material.Icons.add_link_rounded, size: 18), trailing: const Text('Shift+Ctrl+N').xSmall().muted(), - onPressed: (ctx) => - widget.onNewDatabaseConnection(ctx), + onPressed: (_) => + widget.onNewDatabaseConnection(), child: const Text('New Database Connection'), ), MenuButton( diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart new file mode 100644 index 00000000..eedeba8a --- /dev/null +++ b/lib/features/main_screen/result_grid_view.dart @@ -0,0 +1,318 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Layout metrics for [VirtualResultGrid]. +abstract final class ResultGridMetrics { + static const double rowHeight = 36; + static const double headerHeight = 36; + static const double minColumnWidth = 120; + static const double maxColumnWidth = 280; + static const int columnWidthSampleRows = 40; + static const int tooltipMinLength = 48; +} + +/// Computes fixed column widths from headers and a sample of [rows]. +List computeResultGridColumnWidths({ + required List columns, + required List> rows, + double minWidth = ResultGridMetrics.minColumnWidth, + double maxWidth = ResultGridMetrics.maxColumnWidth, + int sampleRowCount = ResultGridMetrics.columnWidthSampleRows, +}) { + if (columns.isEmpty) return const []; + + final widths = List.filled(columns.length, minWidth); + final sample = rows.length < sampleRowCount ? rows.length : sampleRowCount; + + for (var c = 0; c < columns.length; c++) { + var maxChars = columns[c].length; + for (var r = 0; r < sample; r++) { + if (c < rows[r].length && rows[r][c].length > maxChars) { + maxChars = rows[r][c].length; + } + } + widths[c] = (maxChars * 7.5 + 24).clamp(minWidth, maxWidth); + } + return widths; +} + +/// Virtualized read-only grid for SQL query results. +class VirtualResultGrid extends material.StatefulWidget { + const VirtualResultGrid({ + super.key, + required this.columns, + required this.rows, + }); + + final List columns; + final List> rows; + + @override + material.State createState() => _VirtualResultGridState(); +} + +class _VirtualResultGridState extends material.State { + final _horizontalController = material.ScrollController(); + final _verticalController = material.ScrollController(); + + List _columnWidths = const []; + bool _widthsNeedUpdate = true; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_widthsNeedUpdate) { + _columnWidths = _computeColumnWidths(); + _widthsNeedUpdate = false; + } + } + + @override + void didUpdateWidget(VirtualResultGrid oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.columns != widget.columns || + oldWidget.rows != widget.rows) { + _widthsNeedUpdate = true; + } + } + + @override + void dispose() { + _horizontalController.dispose(); + _verticalController.dispose(); + super.dispose(); + } + + List _computeColumnWidths() { + return computeResultGridColumnWidths( + columns: widget.columns, + rows: widget.rows, + minWidth: context.scaled(ResultGridMetrics.minColumnWidth), + maxWidth: context.scaled(ResultGridMetrics.maxColumnWidth), + ); + } + + double get _tableWidth { + if (_columnWidths.isEmpty) return 0; + return _columnWidths.reduce((a, b) => a + b); + } + + double _scaledRowHeight(material.BuildContext context) => + context.scaled(ResultGridMetrics.rowHeight); + + double _scaledHeaderHeight(material.BuildContext context) => + context.scaled(ResultGridMetrics.headerHeight); + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final colCount = widget.columns.length; + final rowHeight = _scaledRowHeight(context); + final headerHeight = _scaledHeaderHeight(context); + + return material.RepaintBoundary( + child: material.LayoutBuilder( + builder: (context, constraints) { + final tableWidth = _tableWidth > constraints.maxWidth + ? _tableWidth + : constraints.maxWidth; + + return material.Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + notificationPredicate: (_) => true, + child: material.SingleChildScrollView( + controller: _horizontalController, + scrollDirection: material.Axis.horizontal, + child: material.SizedBox( + width: tableWidth, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _HeaderRow( + columns: widget.columns, + columnWidths: _columnWidths, + height: headerHeight, + colorScheme: cs, + ), + material.Expanded( + child: material.Scrollbar( + controller: _verticalController, + thumbVisibility: true, + child: material.ListView.builder( + controller: _verticalController, + itemCount: widget.rows.length, + itemExtent: rowHeight, + itemBuilder: (context, rowIndex) { + final row = widget.rows[rowIndex]; + final isEven = rowIndex.isEven; + return _DataRow( + key: ValueKey('result-row-$rowIndex'), + row: row, + columnWidths: _columnWidths, + columnCount: colCount, + height: rowHeight, + colorScheme: cs, + striped: !isEven, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} + +class _HeaderRow extends material.StatelessWidget { + const _HeaderRow({ + required this.columns, + required this.columnWidths, + required this.height, + required this.colorScheme, + }); + + final List columns; + final List columnWidths; + final double height; + final ColorScheme colorScheme; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + height: height, + decoration: material.BoxDecoration( + color: colorScheme.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.5), + ), + ), + ), + child: material.Row( + children: [ + for (var i = 0; i < columns.length; i++) + _GridCell( + text: columns[i], + width: columnWidths[i], + isHeader: true, + colorScheme: colorScheme, + ), + ], + ), + ); + } +} + +class _DataRow extends material.StatelessWidget { + const _DataRow({ + super.key, + required this.row, + required this.columnWidths, + required this.columnCount, + required this.height, + required this.colorScheme, + required this.striped, + }); + + final List row; + final List columnWidths; + final int columnCount; + final double height; + final ColorScheme colorScheme; + final bool striped; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + height: height, + decoration: material.BoxDecoration( + color: striped + ? colorScheme.muted.withValues(alpha: 0.12) + : material.Colors.transparent, + border: material.Border( + bottom: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.15), + ), + ), + ), + child: material.Row( + children: [ + for (var c = 0; c < columnCount; c++) + _GridCell( + text: c < row.length ? row[c] : '', + width: columnWidths[c], + colorScheme: colorScheme, + ), + ], + ), + ); + } +} + +class _GridCell extends material.StatelessWidget { + const _GridCell({ + required this.text, + required this.width, + required this.colorScheme, + this.isHeader = false, + }); + + final String text; + final double width; + final ColorScheme colorScheme; + final bool isHeader; + + @override + material.Widget build(material.BuildContext context) { + final isNull = !isHeader && text == 'NULL'; + final style = material.TextStyle( + fontSize: isHeader ? 12 : 12, + fontWeight: + isHeader ? material.FontWeight.w600 : material.FontWeight.normal, + fontFamily: isHeader ? null : 'monospace', + color: isNull + ? colorScheme.mutedForeground.withValues(alpha: 0.5) + : (isHeader ? colorScheme.foreground : colorScheme.foreground), + fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, + ); + + final cell = material.Container( + width: width, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Text( + text, + style: style, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ); + + if (isHeader) return cell; + + return material.Tooltip( + message: text.length >= ResultGridMetrics.tooltipMinLength ? text : '', + waitDuration: const Duration(milliseconds: 400), + child: material.GestureDetector( + onSecondaryTap: () => Clipboard.setData(ClipboardData(text: text)), + child: cell, + ), + ); + } +} diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index 36675758..c7f2fd47 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/csv/result_grid_csv.dart'; import 'package:querya_desktop/core/csv/save_result_grid_csv.dart'; import 'package:querya_desktop/core/json/result_grid_json.dart'; import 'package:querya_desktop/core/json/save_result_grid_json.dart'; +import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Query output: grid, loading, error, or placeholder. @@ -148,58 +149,7 @@ class ResultsTab extends StatelessWidget { ), ), material.Expanded( - child: material.Scrollbar( - child: material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.SingleChildScrollView( - child: material.Table( - border: material.TableBorder.all( - color: Theme.of(context) - .colorScheme - .border - .withValues(alpha: 0.35), - ), - defaultColumnWidth: const material.IntrinsicColumnWidth(), - children: [ - material.TableRow( - decoration: material.BoxDecoration( - color: Theme.of(context) - .colorScheme - .muted - .withValues(alpha: 0.35), - ), - children: columns - .map( - (c) => material.Padding( - padding: const material.EdgeInsets.all(8), - child: Text(c).semiBold().small(), - ), - ) - .toList(), - ), - ...rows.map( - (r) => material.TableRow( - children: r - .map( - (cell) => material.Padding( - padding: const material.EdgeInsets.all(8), - child: material.SelectableText( - cell, - style: const material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - ), - ), - ), - ) - .toList(), - ), - ), - ], - ), - ), - ), - ), + child: VirtualResultGrid(columns: columns, rows: rows), ), ], ); diff --git a/lib/features/main_screen/sql_query_history_dialog.dart b/lib/features/main_screen/sql_query_history_dialog.dart index c9e01a18..2a8aa146 100644 --- a/lib/features/main_screen/sql_query_history_dialog.dart +++ b/lib/features/main_screen/sql_query_history_dialog.dart @@ -69,8 +69,10 @@ class _SqlQueryHistoryDialogContentState }); } + static final _whitespacePattern = RegExp(r'\s+'); + static String _previewOneLine(String sql) { - final collapsed = sql.replaceAll(RegExp(r'\s+'), ' ').trim(); + final collapsed = sql.replaceAll(_whitespacePattern, ' ').trim(); if (collapsed.length <= 96) return collapsed; return '${collapsed.substring(0, 93)}…'; } @@ -125,7 +127,8 @@ class _SqlQueryHistoryDialogContentState final scheme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints( + constraints: WindowLayout.dialogConstraints( + context, maxWidth: 520, minWidth: 320, maxHeight: 440, diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 7d777f3f..b61720cd 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -1,4 +1,5 @@ -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:flutter/material.dart' as material show Alignment, Axis, Container, EdgeInsets, BoxDecoration, GestureDetector, Padding, BorderRadius, Center, Icon, Icons, MouseRegion, AnimatedContainer, AnimatedScale, Curves, SystemMouseCursors, SizedBox, SingleChildScrollView, Row, MainAxisSize; +import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -73,7 +74,13 @@ class WorkspacePanel extends StatefulWidget { class _WorkspacePanelState extends State { int _editorTabIndex = 0; int _outputTabIndex = 0; - double _topFraction = 0.7; + final ValueNotifier _topFraction = ValueNotifier(0.7); + + @override + void dispose() { + _topFraction.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -186,104 +193,54 @@ class _WorkspacePanelState extends State { ); } - final topFlex = (_topFraction * 100).round().clamp(20, 80); - final bottomFlex = 100 - topFlex; - return material.Container( color: theme.colorScheme.background, - child: material.LayoutBuilder( - builder: (context, constraints) { - final totalHeight = constraints.maxHeight; - return Column( - children: [ - Expanded( - flex: topFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SectionBar( - title: 'Query', - tabs: const ['Query Editor', 'Query History'], - index: _editorTabIndex, - onTabChanged: (v) => setState(() => _editorTabIndex = v), - trailing: const _RunButton(), - ), - const Divider(height: 1), - Expanded( - child: IndexedStack( - index: _editorTabIndex, - children: const [ - QueryEditorTab(), - _PlaceholderTab(message: 'Query history'), - ], - ), - ), - ], - ), - ), - _HorizontalResizeHandle( - totalHeight: totalHeight, - onDrag: (dy) { - if (totalHeight <= 0) return; - setState(() { - _topFraction = (_topFraction + dy / totalHeight).clamp(0.2, 0.8); - }); - }, + child: VerticalSplitPane( + fraction: _topFraction, + handleKey: const Key('workspace_panel_resize_handle'), + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SectionBar( + title: 'Query', + tabs: const ['Query Editor', 'Query History'], + index: _editorTabIndex, + onTabChanged: (v) => setState(() => _editorTabIndex = v), + trailing: const _RunButton(), + ), + const Divider(height: 1), + Expanded( + child: IndexedStack( + index: _editorTabIndex, + children: const [ + QueryEditorTab(), + _PlaceholderTab(message: 'Query history'), + ], ), - Expanded( - flex: bottomFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SectionBar( - title: 'Output', - tabs: const ['Data Output', 'Messages', 'Notifications'], - index: _outputTabIndex, - onTabChanged: (v) => setState(() => _outputTabIndex = v), - ), - const Divider(height: 1), - Expanded( - child: IndexedStack( - index: _outputTabIndex, - children: const [ - ResultsTab(), - _PlaceholderTab(message: 'Messages'), - _PlaceholderTab(message: 'Notifications'), - ], - ), - ), - ], - ), + ), + ], + ), + bottom: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SectionBar( + title: 'Output', + tabs: const ['Data Output', 'Messages', 'Notifications'], + index: _outputTabIndex, + onTabChanged: (v) => setState(() => _outputTabIndex = v), + ), + const Divider(height: 1), + Expanded( + child: IndexedStack( + index: _outputTabIndex, + children: const [ + ResultsTab(), + _PlaceholderTab(message: 'Messages'), + _PlaceholderTab(message: 'Notifications'), + ], ), - ], - ); - }, - ), - ); - } -} - -class _HorizontalResizeHandle extends StatelessWidget { - const _HorizontalResizeHandle({ - required this.totalHeight, - required this.onDrag, - }); - - final double totalHeight; - final void Function(double dy) onDrag; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: material.SystemMouseCursors.resizeRow, - child: material.GestureDetector( - key: const Key('workspace_panel_resize_handle'), - 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/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 76f907ed..0bb4cb33 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -362,7 +362,7 @@ class _MongoCollectionsViewState extends material.State { // ─── Row widget ────────────────────────────────────────────────────────────── -class _CollectionRow extends StatefulWidget { +class _CollectionRow extends StatelessWidget { const _CollectionRow({ required this.collection, required this.colorScheme, @@ -375,41 +375,31 @@ class _CollectionRow extends StatefulWidget { final VoidCallback onView; final VoidCallback onDrop; - @override - State<_CollectionRow> createState() => _CollectionRowState(); -} - -class _CollectionRowState extends State<_CollectionRow> { - bool _hovered = false; - @override Widget build(BuildContext context) { - final cs = widget.colorScheme; - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, - color: _hovered - ? cs.muted.withValues(alpha: 0.15) - : Colors.transparent, - padding: const material.EdgeInsets.symmetric( - horizontal: 20, vertical: 10), - child: Row( - children: [ - _ActionButton( - label: 'View', - icon: material.Icons.visibility_rounded, - color: const Color(0xFF4CAF50), - onTap: widget.onView, - ), - const Gap(16), - material.Expanded( - child: material.InkWell( - onTap: widget.onView, + final cs = colorScheme; + return material.Material( + color: Colors.transparent, + child: material.InkWell( + onTap: onView, + hoverColor: cs.muted.withValues(alpha: 0.15), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + child: Row( + children: [ + _ActionButton( + label: 'View', + icon: material.Icons.visibility_rounded, + color: const Color(0xFF4CAF50), + onTap: onView, + ), + const Gap(16), + Expanded( child: Text( - widget.collection.name, + collection.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, style: material.TextStyle( color: cs.primary, fontSize: 14, @@ -417,26 +407,24 @@ class _CollectionRowState extends State<_CollectionRow> { ), ), ), - ), - material.SizedBox( - width: 100, - child: Text(widget.collection.documentCount?.toString() ?? '—') - .muted() - .small(), - ), - material.SizedBox( - width: 100, - child: Text(_formatSize(widget.collection.size)) - .muted() - .small(), - ), - _ActionButton( - label: 'Del', - icon: material.Icons.delete_rounded, - color: const Color(0xFFEF5350), - onTap: widget.onDrop, - ), - ], + SizedBox( + width: 100, + child: Text(collection.documentCount?.toString() ?? '—') + .muted() + .small(), + ), + SizedBox( + width: 100, + child: Text(_formatSize(collection.size)).muted().small(), + ), + _ActionButton( + label: 'Del', + icon: material.Icons.delete_rounded, + color: const Color(0xFFEF5350), + onTap: onDrop, + ), + ], + ), ), ), ); @@ -455,7 +443,7 @@ class _CollectionRowState extends State<_CollectionRow> { } } -class _ActionButton extends StatefulWidget { +class _ActionButton extends StatelessWidget { const _ActionButton({ required this.label, required this.icon, @@ -468,43 +456,29 @@ class _ActionButton extends StatefulWidget { final Color color; final VoidCallback onTap; - @override - State<_ActionButton> createState() => _ActionButtonState(); -} - -class _ActionButtonState extends State<_ActionButton> { - bool _hovered = false; - @override Widget build(BuildContext context) { - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + return material.Material( + color: Colors.transparent, child: material.InkWell( - onTap: widget.onTap, + onTap: onTap, borderRadius: material.BorderRadius.circular(6), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, + child: material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 12, vertical: 6), decoration: material.BoxDecoration( - color: _hovered - ? widget.color.withValues(alpha: 0.9) - : widget.color.withValues(alpha: 0.75), + color: color.withValues(alpha: 0.8), borderRadius: material.BorderRadius.circular(6), ), child: Row( mainAxisSize: material.MainAxisSize.min, children: [ - material.Icon(widget.icon, - size: 14, color: material.Colors.white), + material.Icon(icon, size: 14, color: Colors.white), const Gap(5), Text( - widget.label, + label, style: const material.TextStyle( - color: material.Colors.white, + color: Colors.white, fontSize: 12, fontWeight: material.FontWeight.w500, ), diff --git a/lib/features/mongodb/mongo_database_dialog.dart b/lib/features/mongodb/mongo_database_dialog.dart index dcf15582..620de32f 100644 --- a/lib/features/mongodb/mongo_database_dialog.dart +++ b/lib/features/mongodb/mongo_database_dialog.dart @@ -52,7 +52,7 @@ class _CreateMongoDBDialogContentState extends material.State<_CreateMongoDBDial final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 500), + constraints: WindowLayout.dialogConstraints(context, maxWidth: 500), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/mongodb/mongo_databases_view.dart b/lib/features/mongodb/mongo_databases_view.dart index 8630995b..087ac0dc 100644 --- a/lib/features/mongodb/mongo_databases_view.dart +++ b/lib/features/mongodb/mongo_databases_view.dart @@ -349,7 +349,7 @@ class _MongoDatabasesViewState extends State { // ─── Helper widgets ────────────────────────────────────────────────────────── -class _DatabaseRow extends StatefulWidget { +class _DatabaseRow extends StatelessWidget { const _DatabaseRow({ required this.database, required this.colorScheme, @@ -362,43 +362,31 @@ class _DatabaseRow extends StatefulWidget { final VoidCallback onView; final VoidCallback onDrop; - @override - State<_DatabaseRow> createState() => _DatabaseRowState(); -} - -class _DatabaseRowState extends State<_DatabaseRow> { - bool _hovered = false; - @override Widget build(BuildContext context) { - final cs = widget.colorScheme; - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, - color: _hovered - ? cs.muted.withValues(alpha: 0.15) - : Colors.transparent, - padding: const material.EdgeInsets.symmetric( - horizontal: 20, vertical: 10), - child: Row( - children: [ - // View button - _ActionButton( - label: 'View', - icon: material.Icons.visibility_rounded, - color: const Color(0xFF4CAF50), - onTap: widget.onView, - ), - const Gap(16), - // Database name - material.Expanded( - child: material.InkWell( - onTap: widget.onView, + final cs = colorScheme; + return material.Material( + color: Colors.transparent, + child: material.InkWell( + onTap: onView, + hoverColor: cs.muted.withValues(alpha: 0.15), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + child: Row( + children: [ + _ActionButton( + label: 'View', + icon: material.Icons.visibility_rounded, + color: const Color(0xFF4CAF50), + onTap: onView, + ), + const Gap(16), + Expanded( child: Text( - widget.database.name, + database.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, style: material.TextStyle( color: cs.primary, fontSize: 14, @@ -406,22 +394,18 @@ class _DatabaseRowState extends State<_DatabaseRow> { ), ), ), - ), - // Size - material.SizedBox( - width: 120, - child: Text(_formatSize(widget.database.sizeOnDisk)) - .muted() - .small(), - ), - // Delete button - _ActionButton( - label: 'Del', - icon: material.Icons.delete_rounded, - color: const Color(0xFFEF5350), - onTap: widget.onDrop, - ), - ], + SizedBox( + width: 120, + child: Text(_formatSize(database.sizeOnDisk)).muted().small(), + ), + _ActionButton( + label: 'Del', + icon: material.Icons.delete_rounded, + color: const Color(0xFFEF5350), + onTap: onDrop, + ), + ], + ), ), ), ); @@ -440,7 +424,7 @@ class _DatabaseRowState extends State<_DatabaseRow> { } } -class _ActionButton extends StatefulWidget { +class _ActionButton extends StatelessWidget { const _ActionButton({ required this.label, required this.icon, @@ -453,43 +437,29 @@ class _ActionButton extends StatefulWidget { final Color color; final VoidCallback onTap; - @override - State<_ActionButton> createState() => _ActionButtonState(); -} - -class _ActionButtonState extends State<_ActionButton> { - bool _hovered = false; - @override Widget build(BuildContext context) { - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + return material.Material( + color: Colors.transparent, child: material.InkWell( - onTap: widget.onTap, + onTap: onTap, borderRadius: material.BorderRadius.circular(6), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, + child: material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 12, vertical: 6), decoration: material.BoxDecoration( - color: _hovered - ? widget.color.withValues(alpha: 0.9) - : widget.color.withValues(alpha: 0.75), + color: color.withValues(alpha: 0.8), borderRadius: material.BorderRadius.circular(6), ), child: Row( mainAxisSize: material.MainAxisSize.min, children: [ - material.Icon(widget.icon, - size: 14, color: material.Colors.white), + material.Icon(icon, size: 14, color: Colors.white), const Gap(5), Text( - widget.label, + label, style: const material.TextStyle( - color: material.Colors.white, + color: Colors.white, fontSize: 12, fontWeight: material.FontWeight.w500, ), diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index e1d1a3f2..a5b98262 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _defaultLimit = 25; +const _prettyJsonEncoder = JsonEncoder.withIndent(' '); /// Paginated document browser for a MongoDB collection. class MongoDocumentsView extends material.StatefulWidget { @@ -393,92 +394,111 @@ class _DocumentCard extends StatefulWidget { } class _DocumentCardState extends State<_DocumentCard> { - bool _hovered = false; bool _expanded = false; + late String _keysPreviewText; + String? _prettyJsonCache; + + @override + void initState() { + super.initState(); + _keysPreviewText = _computeKeysPreview(widget.document); + } + + @override + void didUpdateWidget(covariant _DocumentCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.document, widget.document)) { + _keysPreviewText = _computeKeysPreview(widget.document); + _prettyJsonCache = null; + } + } + + void _toggleExpanded() { + setState(() { + _expanded = !_expanded; + if (_expanded && _prettyJsonCache == null) { + _prettyJsonCache = _encodePrettyJson(widget.document); + } + }); + } @override Widget build(BuildContext context) { final cs = widget.colorScheme; final scs = widget.shadcnCs; final idStr = widget.document['_id']?.toString() ?? '—'; - final keysPreview = _keysPreview(widget.document); - - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - decoration: material.BoxDecoration( - color: _hovered - ? scs.muted.withValues(alpha: 0.15) - : cs.card, - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.3), width: 1), - ), + + return material.Container( + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.3), width: 1), + ), + clipBehavior: material.Clip.antiAlias, + child: material.Material( + color: cs.card, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, mainAxisSize: material.MainAxisSize.min, children: [ - // Header row - material.InkWell( - onTap: widget.onView, - borderRadius: material.BorderRadius.circular(8), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 16, vertical: 10), - child: Row( - children: [ - material.Icon(material.Icons.description_rounded, - size: 16, color: scs.mutedForeground), - const Gap(8), - Text( - idStr, - style: material.TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: material.FontWeight.w500), - ), - const Spacer(), - // Expand toggle - material.InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon( - _expanded - ? material.Icons.expand_less_rounded - : material.Icons.expand_more_rounded, - size: 18, - color: scs.mutedForeground, + material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: widget.onView, + hoverColor: scs.muted.withValues(alpha: 0.15), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + child: Row( + children: [ + material.Icon(material.Icons.description_rounded, + size: 16, color: scs.mutedForeground), + const Gap(8), + Text( + idStr, + style: material.TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: material.FontWeight.w500), + ), + const Spacer(), + material.InkWell( + onTap: _toggleExpanded, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon( + _expanded + ? material.Icons.expand_less_rounded + : material.Icons.expand_more_rounded, + size: 18, + color: scs.mutedForeground, + ), ), ), - ), - const Gap(8), - // View - _SmallActionButton( - icon: material.Icons.edit_rounded, - color: const Color(0xFF42A5F5), - onTap: widget.onView, - ), - const Gap(4), - // Delete - _SmallActionButton( - icon: material.Icons.delete_rounded, - color: const Color(0xFFEF5350), - onTap: widget.onDelete, - ), - ], + const Gap(8), + _SmallActionButton( + icon: material.Icons.edit_rounded, + color: const Color(0xFF42A5F5), + onTap: widget.onView, + ), + const Gap(4), + _SmallActionButton( + icon: material.Icons.delete_rounded, + color: const Color(0xFFEF5350), + onTap: widget.onDelete, + ), + ], + ), ), ), ), - // Preview / expanded JSON material.Padding( padding: const material.EdgeInsets.only( left: 16, right: 16, bottom: 10), child: _expanded ? material.SelectableText( - _prettyJson(widget.document), + _prettyJsonCache ?? '', style: material.TextStyle( fontSize: 12, fontFamily: 'monospace', @@ -486,7 +506,7 @@ class _DocumentCardState extends State<_DocumentCard> { ), ) : Text( - keysPreview, + _keysPreviewText, overflow: TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( @@ -502,16 +522,15 @@ class _DocumentCardState extends State<_DocumentCard> { ); } - /// Returns a compact list of top-level keys (excluding _id). - String _keysPreview(Map doc) { + static String _computeKeysPreview(Map doc) { final keys = doc.keys.where((k) => k != '_id').toList(); if (keys.isEmpty) return '{ }'; return keys.join(', '); } - String _prettyJson(Map doc) { + static String _encodePrettyJson(Map doc) { try { - return const JsonEncoder.withIndent(' ').convert(doc); + return _prettyJsonEncoder.convert(doc); } catch (_) { return doc.toString(); } @@ -520,7 +539,7 @@ class _DocumentCardState extends State<_DocumentCard> { // ─── Small icon-only action button ────────────────────────────────────────── -class _SmallActionButton extends StatefulWidget { +class _SmallActionButton extends StatelessWidget { const _SmallActionButton({ required this.icon, required this.color, @@ -531,34 +550,14 @@ class _SmallActionButton extends StatefulWidget { final Color color; final VoidCallback onTap; - @override - State<_SmallActionButton> createState() => _SmallActionButtonState(); -} - -class _SmallActionButtonState extends State<_SmallActionButton> { - bool _hovered = false; - @override Widget build(BuildContext context) { - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.InkWell( - onTap: widget.onTap, - borderRadius: material.BorderRadius.circular(4), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - padding: const material.EdgeInsets.all(5), - decoration: material.BoxDecoration( - color: _hovered - ? widget.color.withValues(alpha: 0.15) - : material.Colors.transparent, - borderRadius: material.BorderRadius.circular(4), - ), - child: material.Icon(widget.icon, size: 15, color: widget.color), - ), - ), + return material.IconButton( + onPressed: onTap, + icon: material.Icon(icon, size: 15, color: color), + padding: const material.EdgeInsets.all(5), + constraints: const material.BoxConstraints(minWidth: 28, minHeight: 28), + splashRadius: 18, ); } } diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index 70d465ec..b7baea71 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -419,7 +419,7 @@ class _BreadcrumbBar extends StatelessWidget { } } -class _CrumbChip extends StatefulWidget { +class _CrumbChip extends material.StatelessWidget { const _CrumbChip({ required this.label, required this.isLast, @@ -428,44 +428,37 @@ class _CrumbChip extends StatefulWidget { final String label; final bool isLast; - final VoidCallback? onTap; - - @override - material.State<_CrumbChip> createState() => _CrumbChipState(); -} - -class _CrumbChipState extends material.State<_CrumbChip> { - bool _hovered = false; + final material.VoidCallback? onTap; @override material.Widget build(material.BuildContext context) { final cs = shadcn.Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: widget.onTap != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.GestureDetector( - onTap: widget.onTap, - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + final child = isLast + ? Text(label).semiBold().small() + : Text(label, + style: material.TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: material.FontWeight.w500)) + .small(); + + if (onTap == null) { + return material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: child, + ); + } + + return material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + hoverColor: cs.primary.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: material.BoxDecoration( - color: _hovered && widget.onTap != null - ? cs.primary.withValues(alpha: 0.1) - : material.Colors.transparent, - borderRadius: material.BorderRadius.circular(4), - ), - child: widget.isLast - ? Text(widget.label).semiBold().small() - : Text(widget.label, - style: material.TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: material.FontWeight.w500)) - .small(), + child: child, ), ), ); diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index 0a029b5e..d8418cbf 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -8,8 +9,8 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _defaultAutoRefresh = Duration(seconds: 3); -const _summaryChipHeight = 72.0; -const _gridCardHeight = 220.0; +const _summaryChipHeight = 88.0; +const _gridCardMinHeight = 220.0; class MongoStatsView extends material.StatefulWidget { const MongoStatsView({ @@ -117,10 +118,12 @@ class _MongoStatsViewState extends material.State { {'serverStatus': 1}, ); if (!mounted) return; + final changed = + replaceIfChanged(_serverStatus, status, (v) => _serverStatus = v); + if (!changed && !_loading) return; setState(() { - _serverStatus = status; _loading = false; - _lastFetchedAt = DateTime.now(); + if (changed) _lastFetchedAt = DateTime.now(); }); } catch (e) { if (mounted) { @@ -157,10 +160,10 @@ class _MongoStatsViewState extends material.State { {'serverStatus': 1}, ); if (!mounted) return; - setState(() { - _serverStatus = status; - _lastFetchedAt = DateTime.now(); - }); + if (!replaceIfChanged(_serverStatus, status, (v) => _serverStatus = v)) { + return; + } + setState(() => _lastFetchedAt = DateTime.now()); } catch (_) { // Keep last good snapshot on transient errors during auto-refresh. } @@ -245,29 +248,47 @@ class _MongoStatsViewState extends material.State { const Gap(24), _summaryChips(context, status), const Gap(24), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded(child: _memoryCard(context, status)), - const Gap(16), - material.Expanded(child: _operationsCard(context, status)), - ], + _gridRow( + _memoryCard(context, status), + _operationsCard(context, status), ), const Gap(16), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded(child: _connectionsCard(context, status)), - const Gap(16), - material.Expanded(child: _networkCard(context, status)), - ], + _gridRow( + _connectionsCard(context, status), + _networkCard(context, status), ), const Gap(24), _sectionCard(context, 'Server', _extractServerInfo(status)), const Gap(12), - _sectionCard(context, 'Storage', _extractStorageInfo(status)), - const Gap(12), - _sectionCard(context, 'Replication', _extractReplicationInfo(status)), + material.LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 900; + final storage = _extractStorageInfo(status); + final replication = _extractReplicationInfo(status); + if (!wide) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _sectionCard(context, 'Storage', storage), + const Gap(12), + _sectionCard(context, 'Replication', replication), + ], + ); + } + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: _sectionCard(context, 'Storage', storage), + ), + const Gap(16), + material.Expanded( + child: _sectionCard(context, 'Replication', replication), + ), + ], + ); + }, + ), const Gap(12), _sectionCard(context, 'WiredTiger', _extractWiredTigerInfo(status)), ], @@ -281,107 +302,124 @@ class _MongoStatsViewState extends material.State { material.Widget _header(material.BuildContext context) { final cs = shadcn.Theme.of(context).colorScheme; final last = _lastFetchedAt; - return material.Wrap( - crossAxisAlignment: material.WrapCrossAlignment.center, - spacing: 8, - runSpacing: 10, + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - material.Container( - padding: const material.EdgeInsets.all(10), - decoration: material.BoxDecoration( - color: cs.primary.withValues(alpha: 0.12), - borderRadius: material.BorderRadius.circular(12), - ), - child: material.Icon(material.Icons.eco_rounded, size: 28, color: cs.primary), - ), - material.ConstrainedBox( - constraints: const material.BoxConstraints(minWidth: 160, maxWidth: 400), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - Text(widget.connectionRow.name).large().semiBold(), - const Gap(4), - Text('${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 27017}') - .muted() - .small(), - if (last != null) ...[ - const Gap(4), - Text('Last updated ${_formatClock(last)}').muted().xSmall(), - ], - ], - ), - ), - if (widget.onBack != null) - OutlineButton( - onPressed: widget.onBack, - leading: const material.Icon( - material.Icons.grid_view_rounded, - size: 18), - child: const Text('Explorer'), - ), - OutlineButton( - onPressed: _manualRefreshing ? null : _refreshNow, - leading: _manualRefreshing - ? const material.SizedBox( - width: 16, - height: 16, - child: material.CircularProgressIndicator(strokeWidth: 2), - ) - : const material.Icon(material.Icons.refresh_rounded, size: 18), - child: const Text('Refresh now'), - ), - material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 4), - child: material.DropdownButton( - value: _autoRefreshInterval, - underline: const material.SizedBox.shrink(), - isDense: true, - borderRadius: material.BorderRadius.circular(8), - items: const [ - material.DropdownMenuItem( - value: null, - child: material.Text('Auto: off'), - ), - material.DropdownMenuItem( - value: Duration(seconds: 3), - child: material.Text('Auto: 3 s'), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(12), ), - material.DropdownMenuItem( - value: Duration(seconds: 10), - child: material.Text('Auto: 10 s'), + child: material.Icon(material.Icons.eco_rounded, size: 28, color: cs.primary), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(widget.connectionRow.name).large().semiBold(), + const Gap(4), + Text('${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 27017}') + .muted() + .small(), + if (last != null) ...[ + const Gap(4), + Text('Last updated ${_formatClock(last)}').muted().xSmall(), + ], + ], ), - material.DropdownMenuItem( - value: Duration(seconds: 30), - child: material.Text('Auto: 30 s'), + ), + ], + ), + const Gap(12), + material.Wrap( + spacing: 8, + runSpacing: 8, + alignment: material.WrapAlignment.end, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + if (widget.onBack != null) + OutlineButton( + onPressed: widget.onBack, + leading: const material.Icon( + material.Icons.grid_view_rounded, + size: 18), + child: const Text('Explorer'), ), - material.DropdownMenuItem( - value: Duration(seconds: 60), - child: material.Text('Auto: 60 s'), + OutlineButton( + onPressed: _manualRefreshing ? null : _refreshNow, + leading: _manualRefreshing + ? const material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator(strokeWidth: 2), + ) + : const material.Icon(material.Icons.refresh_rounded, size: 18), + child: const Text('Refresh now'), + ), + material.SizedBox( + width: 132, + child: QueryaDropdown( + expandToParent: true, + value: _autoRefreshInterval, + items: const [ + QueryaDropdownItem(value: null, label: 'Auto: off'), + QueryaDropdownItem(value: Duration(seconds: 3), label: 'Auto: 3 s'), + QueryaDropdownItem(value: Duration(seconds: 10), label: 'Auto: 10 s'), + QueryaDropdownItem(value: Duration(seconds: 30), label: 'Auto: 30 s'), + QueryaDropdownItem(value: Duration(seconds: 60), label: 'Auto: 60 s'), + ], + onSelected: (value) { + setState(() => _autoRefreshInterval = value); + _startTimer(); + }, ), - ], - onChanged: (value) { - setState(() => _autoRefreshInterval = value); - _startTimer(); - }, - ), + ), + ], ), ], ); } + material.Widget _gridRow(material.Widget left, material.Widget right) { + return material.IntrinsicHeight( + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Expanded(child: left), + const Gap(16), + material.Expanded(child: right), + ], + ), + ); + } + + String _formatUptime(int seconds) { + if (seconds <= 0) return '0m'; + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + if (days > 0) return '${days}d ${hours}h'; + if (hours > 0) return '${hours}h ${minutes}m'; + return '${minutes}m'; + } + material.Widget _summaryChips(material.BuildContext context, Map status) { final cs = shadcn.Theme.of(context).colorScheme; final version = _getString(status, 'version') ?? '—'; - final uptime = _getInt(status, 'uptime') ?? 0; - final uptimeDays = (uptime / 86400).toStringAsFixed(1); + final uptime = _formatUptime(_getInt(status, 'uptime') ?? 0); final connections = _getNestedInt(status, 'connections', 'current') ?? 0; - final maxConnections = _getNestedInt(status, 'connections', 'available') ?? 0; + final available = _getNestedInt(status, 'connections', 'available') ?? 0; final ops = _getNestedInt(status, 'opcounters', 'query') ?? 0; material.Widget chip(String label, String value, material.IconData icon) { return material.Expanded( - child: material.SizedBox( - height: _summaryChipHeight, + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(minHeight: _summaryChipHeight), child: material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: material.BoxDecoration( @@ -390,8 +428,12 @@ class _MongoStatsViewState extends material.State { border: material.Border.all(color: cs.border.withValues(alpha: 0.5)), ), child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - material.Icon(icon, size: 20, color: cs.primary), + material.Padding( + padding: const material.EdgeInsets.only(top: 2), + child: material.Icon(icon, size: 20, color: cs.primary), + ), const Gap(12), material.Expanded( child: material.Column( @@ -401,7 +443,17 @@ class _MongoStatsViewState extends material.State { children: [ Text(label).muted().xSmall(), const Gap(2), - Text(value).semiBold().small(), + material.Text( + value, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), ], ), ), @@ -412,23 +464,32 @@ class _MongoStatsViewState extends material.State { ); } return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, children: [ chip('Version', version, material.Icons.tag_rounded), const Gap(12), - chip('Uptime', '$uptimeDays days', material.Icons.schedule_rounded), + chip('Uptime', uptime, material.Icons.schedule_rounded), const Gap(12), - chip('Connections', '$connections / $maxConnections', material.Icons.people_outline_rounded), + chip('Connections', '$connections / $available', material.Icons.people_outline_rounded), const Gap(12), chip('Queries', '$ops', material.Icons.speed_rounded), ], ); } - material.Widget _card(material.BuildContext context, String title, material.Widget body, {double? height}) { + material.Widget _card( + material.BuildContext context, + String title, + material.Widget body, { + double? minHeight, + bool stretchBody = false, + }) { final cs = shadcn.Theme.of(context).colorScheme; return material.Container( width: double.infinity, - height: height, + constraints: minHeight != null + ? material.BoxConstraints(minHeight: minHeight) + : const material.BoxConstraints(), padding: const material.EdgeInsets.all(20), decoration: material.BoxDecoration( color: cs.card, @@ -436,17 +497,35 @@ class _MongoStatsViewState extends material.State { border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), ), child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.start, + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Text(title).semiBold(), const Gap(12), - body, + if (stretchBody) material.Expanded(child: body) else body, ], ), ); } + material.Widget _metricList( + material.BuildContext context, + List rows, { + bool stretch = false, + }) { + final column = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: rows, + ); + if (!stretch) return column; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + column, + const material.Spacer(), + ], + ); + } + material.Widget _memoryCard(material.BuildContext context, Map status) { final mem = status['mem'] as Map?; final resident = _getInt(mem, 'resident') ?? 0; @@ -456,17 +535,18 @@ class _MongoStatsViewState extends material.State { return _card( context, 'Memory', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Resident', _formatBytes(resident)), - _row(context, 'Virtual', _formatBytes(virtual)), - _row(context, 'Mapped', _formatBytes(mapped)), - _row(context, 'Mapped + Journal', _formatBytes(mappedWithJournal)), + _metricList( + context, + [ + _metricRow(context, 'Resident', _formatBytes(resident)), + _metricRow(context, 'Virtual', _formatBytes(virtual)), + _metricRow(context, 'Mapped', _formatBytes(mapped)), + _metricRow(context, 'Mapped + Journal', _formatBytes(mappedWithJournal)), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -479,17 +559,18 @@ class _MongoStatsViewState extends material.State { return _card( context, 'Operations', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Inserts', '$inserts'), - _row(context, 'Queries', '$queries'), - _row(context, 'Updates', '$updates'), - _row(context, 'Deletes', '$deletes'), + _metricList( + context, + [ + _metricRow(context, 'Inserts', '$inserts'), + _metricRow(context, 'Queries', '$queries'), + _metricRow(context, 'Updates', '$updates'), + _metricRow(context, 'Deletes', '$deletes'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -501,16 +582,17 @@ class _MongoStatsViewState extends material.State { return _card( context, 'Connections', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Current', '$current'), - _row(context, 'Available', '$available'), - _row(context, 'Active clients', '$active'), + _metricList( + context, + [ + _metricRow(context, 'Current', '$current'), + _metricRow(context, 'Available', '$available'), + _metricRow(context, 'Active clients', '$active'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -522,16 +604,17 @@ class _MongoStatsViewState extends material.State { return _card( context, 'Network', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Bytes in', _formatBytes(bytesIn)), - _row(context, 'Bytes out', _formatBytes(bytesOut)), - _row(context, 'Requests', '$numRequests'), + _metricList( + context, + [ + _metricRow(context, 'Bytes in', _formatBytes(bytesIn)), + _metricRow(context, 'Bytes out', _formatBytes(bytesOut)), + _metricRow(context, 'Requests', '$numRequests'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -540,23 +623,76 @@ class _MongoStatsViewState extends material.State { return _card( context, title, - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [for (final e in data.entries) _row(context, e.key, e.value)], + _twoColumnMetrics( + context, + data.entries.map((e) => MapEntry(e.key, e.value)).toList(), ), ); } - material.Widget _row(material.BuildContext context, String key, String value) { + material.Widget _twoColumnMetrics( + material.BuildContext context, + List> entries, + ) { + if (entries.length <= 4) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in entries) _metricRow(context, e.key, e.value)], + ); + } + final mid = (entries.length / 2).ceil(); + final left = entries.sublist(0, mid); + final right = entries.sublist(mid); + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in left) _metricRow(context, e.key, e.value)], + ), + ), + const Gap(24), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in right) _metricRow(context, e.key, e.value)], + ), + ), + ], + ); + } + + material.Widget _metricRow(material.BuildContext context, String label, String value) { final cs = shadcn.Theme.of(context).colorScheme; return material.Padding( - padding: const material.EdgeInsets.symmetric(vertical: 4), + padding: const material.EdgeInsets.symmetric(vertical: 6), child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - material.SizedBox(width: 160, child: Text(key).muted().small()), - material.Expanded(child: material.SelectableText(value, style: material.TextStyle(fontSize: 13, color: cs.foreground))), + material.Expanded( + flex: 3, + child: Text( + label, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ).muted().small(), + ), + const Gap(12), + material.Flexible( + flex: 2, + child: material.SelectableText( + value, + textAlign: material.TextAlign.right, + maxLines: 2, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ), ], ), ); @@ -599,8 +735,7 @@ class _MongoStatsViewState extends material.State { if (status['process'] != null) result['Process'] = status['process'].toString(); final uptime = _getInt(status, 'uptime'); if (uptime != null) { - final days = (uptime / 86400).toStringAsFixed(1); - result['Uptime'] = '$days days ($uptime seconds)'; + result['Uptime'] = '${_formatUptime(uptime)} ($uptime s)'; } return result; } diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index f0526ece..ab5e0362 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -4,6 +4,7 @@ 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/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// MongoDB connection form data. @@ -79,28 +80,35 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo bool _isTesting = false; String? _testResult; Timer? _dismissTimer; + late final FormValidityNotifier _formValidNotifier; @override void initState() { super.initState(); - // Rebuild on every keystroke so Save button reacts to validity - _nameController.addListener(_onFieldChanged); - _hostController.addListener(_onFieldChanged); - _portController.addListener(_onFieldChanged); - _connectionStringController.addListener(_onFieldChanged); - } - - void _onFieldChanged() { - setState(() {}); + _formValidNotifier = FormValidityNotifier(() => _formData.isValid); + for (final c in [ + _nameController, + _hostController, + _portController, + _connectionStringController, + ]) { + _formValidNotifier.listenTo(c); + } + _formValidNotifier.seed(); } @override void dispose() { _dismissTimer?.cancel(); - _nameController.removeListener(_onFieldChanged); - _hostController.removeListener(_onFieldChanged); - _portController.removeListener(_onFieldChanged); - _connectionStringController.removeListener(_onFieldChanged); + for (final c in [ + _nameController, + _hostController, + _portController, + _connectionStringController, + ]) { + _formValidNotifier.unlistenFrom(c); + } + _formValidNotifier.dispose(); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); @@ -207,7 +215,11 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 600, maxHeight: 700), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 600, + maxHeight: 700, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), @@ -251,7 +263,10 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo children: [ material.Checkbox( value: _useConnectionString, - onChanged: (v) => setState(() => _useConnectionString = v ?? false), + onChanged: (v) { + setState(() => _useConnectionString = v ?? false); + _formValidNotifier.seed(); + }, ), const Gap(8), const Text('Use connection string').small(), @@ -465,43 +480,53 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo ), material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: material.Row( - children: [ - OutlineButton( - onPressed: _formData.isValid && !_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: _formData.isValid ? theme.primary : theme.mutedForeground, + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Row( + 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, ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: _formData.isValid ? theme.primary : theme.mutedForeground, + ), ), - ), - ), - const material.Spacer(), - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( - onPressed: _formData.isValid ? _save : null, - child: const Text('Save'), - ), - ], + const material.Spacer(), + 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_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 8227115f..a143e511 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -4,6 +4,7 @@ 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/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows MySQL / MariaDB connection form dialog. @@ -47,26 +48,31 @@ class _MysqlConnectionFormContentState bool _isTesting = false; String? _testResult; Timer? _dismissTimer; + late final FormValidityNotifier _formValidNotifier; @override void initState() { super.initState(); - _nameController.addListener(_onFieldChanged); - _hostController.addListener(_onFieldChanged); - _portController.addListener(_onFieldChanged); - _databaseController.addListener(_onFieldChanged); - _usernameController.addListener(_onFieldChanged); - _connectionStringController.addListener(_onFieldChanged); + _formValidNotifier = FormValidityNotifier(_computeFormValid); + for (final c in [ + _nameController, + _hostController, + _portController, + _databaseController, + _usernameController, + _connectionStringController, + ]) { + _formValidNotifier.listenTo(c); + } + _formValidNotifier.seed(); } - void _onFieldChanged() => setState(() {}); - bool _looksLikeMysqlUri(String s) { final t = s.trim().toLowerCase(); return t.startsWith('mysql://') || t.startsWith('mariadb://'); } - bool get _formValid { + bool _computeFormValid() { final uri = _connectionStringController.text.trim(); if (uri.isNotEmpty) { return _looksLikeMysqlUri(uri); @@ -93,7 +99,7 @@ class _MysqlConnectionFormContentState } Future _testConnection() async { - if (!_formValid) return; + if (!_formValidNotifier.value) return; _dismissTimer?.cancel(); _dismissTimer = null; setState(() { @@ -128,7 +134,7 @@ class _MysqlConnectionFormContentState } void _save() { - if (!_formValid) return; + if (!_formValidNotifier.value) return; final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 3306; @@ -163,12 +169,17 @@ class _MysqlConnectionFormContentState @override void dispose() { _dismissTimer?.cancel(); - _nameController.removeListener(_onFieldChanged); - _hostController.removeListener(_onFieldChanged); - _portController.removeListener(_onFieldChanged); - _databaseController.removeListener(_onFieldChanged); - _usernameController.removeListener(_onFieldChanged); - _connectionStringController.removeListener(_onFieldChanged); + for (final c in [ + _nameController, + _hostController, + _portController, + _databaseController, + _usernameController, + _connectionStringController, + ]) { + _formValidNotifier.unlistenFrom(c); + } + _formValidNotifier.dispose(); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); @@ -186,7 +197,11 @@ class _MysqlConnectionFormContentState return material.Container( constraints: - const material.BoxConstraints(maxWidth: 600, maxHeight: 640), + WindowLayout.dialogConstraints( + context, + maxWidth: 600, + maxHeight: 640, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), @@ -427,54 +442,61 @@ class _MysqlConnectionFormContentState 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 + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return 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, ), - 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'), + 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_result_utils.dart b/lib/features/mysql/mysql_result_utils.dart new file mode 100644 index 00000000..98e71dee --- /dev/null +++ b/lib/features/mysql/mysql_result_utils.dart @@ -0,0 +1,19 @@ +/// Serializable row batch for [convertMysqlResultRowsToStrings] in a worker isolate. +class MysqlResultConvertJob { + const MysqlResultConvertJob({ + required this.rowValues, + }); + + final List> rowValues; +} + +/// Converts MySQL result cell values to display strings off the UI thread. +List> convertMysqlResultRowsToStrings(MysqlResultConvertJob job) { + return job.rowValues + .map( + (row) => row + .map((value) => value == null ? 'NULL' : value.toString()) + .toList(), + ) + .toList(); +} diff --git a/lib/features/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart index 6fbdcda3..3f653e3d 100644 --- a/lib/features/mysql/mysql_sql_editor_dialog.dart +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -78,7 +78,8 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), child: material.Container( - constraints: const material.BoxConstraints( + constraints: WindowLayout.dialogConstraints( + context, maxWidth: 720, minWidth: 480, maxHeight: 520, diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 42386bdc..580c93b6 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; 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/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; @@ -12,6 +14,7 @@ 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/features/main_screen/sql_editor_chrome.dart'; import 'package:querya_desktop/features/main_screen/sql_query_history_dialog.dart'; +import 'package:querya_desktop/features/mysql/mysql_result_utils.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Ad-hoc SQL editor + results for MySQL / MariaDB. @@ -30,7 +33,7 @@ class MysqlSqlWorkspace extends material.StatefulWidget { class _MysqlSqlWorkspaceState extends material.State { final _sqlController = material.TextEditingController(); - double _topFractionState = 0.65; + final ValueNotifier _topFraction = ValueNotifier(0.65); MysqlLease? _lease; @@ -55,7 +58,7 @@ class _MysqlSqlWorkspaceState extends material.State { _appSettingsListener = () { unawaited(_loadWorkspaceSettings()); }; - AppSettingsRevision.listenable.addListener(_appSettingsListener); + SqlWorkspaceSettingsRevision.listenable.addListener(_appSettingsListener); material.WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_loadWorkspaceSettings()); }); @@ -103,7 +106,8 @@ class _MysqlSqlWorkspaceState extends material.State { @override void dispose() { - AppSettingsRevision.listenable.removeListener(_appSettingsListener); + SqlWorkspaceSettingsRevision.listenable.removeListener(_appSettingsListener); + _topFraction.dispose(); if (_running) { MysqlService.instance.interrupt( widget.connectionRow, @@ -152,20 +156,22 @@ class _MysqlSqlWorkspaceState extends material.State { cols.add(c.name.isNotEmpty ? c.name : 'col_${cols.length}'); } - final outRows = >[]; + final rawRows = >[]; var n = 0; final cap = _resultMaxRows; for (final row in rs.rows) { if (n >= cap) break; - outRows.add( - List.generate( - row.numOfColumns, - (i) => row.colAt(i) ?? 'NULL', - ), + rawRows.add( + List.generate(row.numOfColumns, (i) => row.colAt(i)), ); n++; } + final outRows = await compute( + convertMysqlResultRowsToStrings, + MysqlResultConvertJob(rowValues: rawRows), + ); + int? affected; if (cols.isEmpty && outRows.isEmpty) { affected = _affectedInt(rs.affectedRows); @@ -224,12 +230,9 @@ class _MysqlSqlWorkspaceState extends material.State { @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): () { @@ -240,85 +243,65 @@ class _MysqlSqlWorkspaceState extends material.State { }, 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, - onOpenPreferences: () => - showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && - !_running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: - widget.connectionRow.databaseName, - sqlController: _sqlController, - ); - } - : null, - ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab( - controller: _sqlController, - fontSize: _editorFontSize, - ), - ), - ], + child: VerticalSplitPane( + fraction: _topFraction, + maxFraction: 0.85, + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MysqlSqlToolbar( + onExecute: _running ? null : _execute, + running: _running, + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, + onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: widget.connectionRow.databaseName, + sqlController: _sqlController, + ); + } + : null, ), - ), - _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, - ), - ), - ], + const Divider(height: 1), + Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), - ), - ], + ], + ), + bottom: 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, + ), + ), + ], + ), ), ), ); @@ -423,29 +406,3 @@ class _MysqlSqlToolbar extends material.StatelessWidget { ); } } - -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 index b04729b8..5617644a 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -1,9 +1,18 @@ +import 'dart:async'; + import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; 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'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +const _pollInterval = Duration(seconds: 5); +const _summaryChipHeight = 88.0; +const _gridCardMinHeight = 220.0; +final _mysqlVersionPattern = RegExp(r'(\d+\.\d+(?:\.\d+)?)'); -/// Summary when a MySQL connection is selected without a tree object. +/// Server dashboard when a MySQL connection is selected without a tree object. class MysqlStatsView extends material.StatefulWidget { const MysqlStatsView({ super.key, @@ -18,10 +27,10 @@ class MysqlStatsView extends material.StatefulWidget { class _MysqlStatsViewState extends material.State { MysqlLease? _lease; - String? _version; - int? _databaseCount; + Map? _stats; bool _loading = true; String? _error; + Timer? _timer; @override void initState() { @@ -33,6 +42,7 @@ class _MysqlStatsViewState extends material.State { void didUpdateWidget(covariant MysqlStatsView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _timer?.cancel(); _disconnect(); _load(); } @@ -40,6 +50,7 @@ class _MysqlStatsViewState extends material.State { @override void dispose() { + _timer?.cancel(); _disconnect(); super.dispose(); } @@ -50,13 +61,13 @@ class _MysqlStatsViewState extends material.State { } Future _load() async { + _timer?.cancel(); _disconnect(); if (!mounted) return; setState(() { _loading = true; _error = null; - _version = null; - _databaseCount = null; + _stats = null; }); try { final lease = await MysqlService.instance.acquire( @@ -69,14 +80,25 @@ class _MysqlStatsViewState extends material.State { return; } _lease = lease; - final v = await lease.connection.serverVersion(); - final dbs = await lease.connection.listDatabases(); + await _fetch(); + if (mounted) _startTimer(); + } catch (e) { if (!mounted) return; setState(() { - _version = v; - _databaseCount = dbs.length; + _error = e.toString(); _loading = false; }); + } + } + + Future _fetch() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) return; + try { + final stats = await conn.serverStats(); + if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() => _loading = false); } catch (e) { if (!mounted) return; setState(() { @@ -86,32 +108,44 @@ class _MysqlStatsViewState extends material.State { } } + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(_pollInterval, (_) async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) return; + try { + final stats = await conn.serverStats(); + if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() {}); + } catch (_) {} + }); + } + @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; + final cs = Theme.of(context).colorScheme; + final width = material.MediaQuery.sizeOf(context).width; 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 material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), ), - const Gap(12), - const Text('Loading server info...').muted().small(), + const Gap(16), + const Text('Connecting...').muted().small(), ], ), ); } - if (_error != null) { + final err = _error; + if (err != null) { return material.Center( child: material.Padding( padding: const material.EdgeInsets.all(32), @@ -124,22 +158,16 @@ class _MysqlStatsViewState extends material.State { color: cs.destructive, ), const Gap(16), - const Text('Could not load server info').large().semiBold(), + const Text('Connection Error').large().semiBold(), const Gap(8), material.SelectableText( - _error!, - style: material.TextStyle( - color: cs.mutedForeground, - fontSize: 13, - ), + err, + style: material.TextStyle(color: cs.mutedForeground, fontSize: 13), ), const Gap(24), OutlineButton( onPressed: _load, - leading: const material.Icon( - material.Icons.refresh_rounded, - size: 18, - ), + leading: const material.Icon(material.Icons.refresh_rounded, size: 18), child: const Text('Retry'), ), ], @@ -148,49 +176,433 @@ class _MysqlStatsViewState extends material.State { ); } - return material.SingleChildScrollView( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, + final stats = _stats; + if (stats == null) return material.Container(color: cs.background); + + return material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: width, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, stats), + const Gap(24), + _gridRow( + _connectionsCard(context, stats), + _queriesCard(context, stats), + ), + const Gap(16), + _gridRow( + _networkCard(context, stats), + _settingsCard(context, stats), + ), + const Gap(24), + _databasesCard(context, stats), + ], + ), + ), + ), + ), + ); + } + + material.Widget _header(material.BuildContext context) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(12), + ), + child: material.SizedBox( + width: 28, + height: 28, + child: material.Image.asset( + 'assets/images/mysql_icon.png', + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + material.Icons.storage_rounded, + size: 28, + color: cs.primary, + ), + ), + ), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(widget.connectionRow.name).large().semiBold(), + const Gap(4), + Text( + '${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 3306}', + ).muted().small(), + ], + ), + ), + OutlineButton( + onPressed: _load, + leading: const material.Icon(material.Icons.refresh_rounded, size: 18), + child: const Text('Refresh'), + ), + ], + ); + } + + material.Widget _gridRow(material.Widget left, material.Widget right) { + return material.IntrinsicHeight( + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - const Text('Server').large().semiBold(), + material.Expanded(child: left), const Gap(16), - material.Container( - width: double.infinity, - padding: const material.EdgeInsets.all(16), + material.Expanded(child: right), + ], + ), + ); + } + + String _formatUptime(int seconds) { + if (seconds <= 0) return '0m'; + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + if (days > 0) return '${days}d ${hours}h'; + if (hours > 0) return '${hours}h ${minutes}m'; + return '${minutes}m'; + } + + String _status(Map stats, String key) => + (stats['status'] as Map?)?[key] ?? '—'; + + String _variable(Map stats, String key) => + (stats['variables'] as Map?)?[key] ?? '—'; + + material.Widget _summaryChips( + material.BuildContext context, Map stats) { + final cs = shadcn.Theme.of(context).colorScheme; + final versionFull = stats['version'] as String? ?? '—'; + final versionShort = _extractMysqlVersion(versionFull); + final uptimeSec = stats['uptime_seconds'] as int? ?? 0; + final connected = _status(stats, 'Threads_connected'); + final maxConn = _variable(stats, 'max_connections'); + final questions = _status(stats, 'Questions'); + + material.Widget chip(String label, String value, material.IconData icon) { + return material.Expanded( + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(minHeight: _summaryChipHeight), + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), 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)), + color: cs.card, + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all(color: cs.border.withValues(alpha: 0.5)), ), - child: material.Column( + child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - const Text('Version').small().muted(), - const Gap(4), - material.SelectableText( - _version ?? '—', - style: material.TextStyle( - fontSize: 13, - color: cs.foreground, - ), + material.Padding( + padding: const material.EdgeInsets.only(top: 2), + child: material.Icon(icon, size: 20, color: cs.primary), ), - 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, + const Gap(12), + material.Expanded( + child: material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(label).muted().xSmall(), + const Gap(2), + material.Text( + value, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ], ), ), ], ), ), + ), + ); + } + + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + chip('Version', versionShort, material.Icons.tag_rounded), + const Gap(12), + chip('Uptime', _formatUptime(uptimeSec), material.Icons.schedule_rounded), + const Gap(12), + chip('Connections', '$connected / $maxConn', material.Icons.people_outline_rounded), + const Gap(12), + chip('Queries', questions, material.Icons.speed_rounded), + ], + ); + } + + material.Widget _card( + material.BuildContext context, + String title, + material.Widget body, { + double? minHeight, + bool stretchBody = false, + }) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Container( + width: double.infinity, + constraints: minHeight != null + ? material.BoxConstraints(minHeight: minHeight) + : const material.BoxConstraints(), + padding: const material.EdgeInsets.all(20), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(12), + border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text(title).semiBold(), + const Gap(12), + if (stretchBody) material.Expanded(child: body) else body, + ], + ), + ); + } + + material.Widget _metricList( + material.BuildContext context, + List rows, { + bool stretch = false, + }) { + final column = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: rows, + ); + if (!stretch) return column; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + column, + const material.Spacer(), + ], + ); + } + + material.Widget _connectionsCard( + material.BuildContext context, Map stats) { + return _card( + context, + 'Connections', + _metricList( + context, + [ + _metricRow(context, 'Connected', _status(stats, 'Threads_connected')), + _metricRow(context, 'Running', _status(stats, 'Threads_running')), + _metricRow(context, 'Max used', _status(stats, 'Max_used_connections')), + _metricRow(context, 'Max allowed', _variable(stats, 'max_connections')), + ], + stretch: true, + ), + minHeight: _gridCardMinHeight, + stretchBody: true, + ); + } + + material.Widget _queriesCard( + material.BuildContext context, Map stats) { + return _card( + context, + 'Queries', + _metricList( + context, + [ + _metricRow(context, 'Questions', _status(stats, 'Questions')), + _metricRow(context, 'Slow queries', _status(stats, 'Slow_queries')), + _metricRow(context, 'Open tables', _status(stats, 'Open_tables')), + _metricRow(context, 'Aborted connects', _status(stats, 'Aborted_connects')), + ], + stretch: true, + ), + minHeight: _gridCardMinHeight, + stretchBody: true, + ); + } + + material.Widget _networkCard( + material.BuildContext context, Map stats) { + final bytesIn = int.tryParse(_status(stats, 'Bytes_received')) ?? 0; + final bytesOut = int.tryParse(_status(stats, 'Bytes_sent')) ?? 0; + return _card( + context, + 'Network', + _metricList( + context, + [ + _metricRow(context, 'Bytes in', _formatBytes(bytesIn)), + _metricRow(context, 'Bytes out', _formatBytes(bytesOut)), + _metricRow(context, 'Total connects', _status(stats, 'Connections')), + ], + stretch: true, + ), + minHeight: _gridCardMinHeight, + stretchBody: true, + ); + } + + material.Widget _settingsCard( + material.BuildContext context, Map stats) { + final pool = int.tryParse(_variable(stats, 'innodb_buffer_pool_size')) ?? 0; + return _card( + context, + 'Server', + _metricList( + context, + [ + _metricRow(context, 'InnoDB buffer pool', _formatBytes(pool)), + _metricRow(context, 'Charset', _variable(stats, 'character_set_server')), + _metricRow(context, 'Collation', _variable(stats, 'collation_server')), + _metricRow(context, 'Port', _variable(stats, 'port')), + ], + stretch: true, + ), + minHeight: _gridCardMinHeight, + stretchBody: true, + ); + } + + material.Widget _databasesCard( + material.BuildContext context, Map stats) { + final databases = + stats['databases'] as List>? ?? []; + if (databases.isEmpty) return const material.SizedBox.shrink(); + + final cs = shadcn.Theme.of(context).colorScheme; + return _card( + context, + 'Databases', + material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.only(bottom: 8), + child: material.Row( + children: [ + material.SizedBox( + width: 180, + child: const Text('Name').muted().xSmall(), + ), + material.SizedBox( + width: 100, + child: const Text('Size').muted().xSmall(), + ), + material.Expanded( + child: const Text('Tables').muted().xSmall(), + ), + ], + ), + ), + material.Divider(height: 1, color: cs.border.withValues(alpha: 0.3)), + for (final db in databases) + material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 5), + child: material.Row( + children: [ + material.SizedBox( + width: 180, + child: material.Text( + '${db['name']}', + style: material.TextStyle(fontSize: 13, color: cs.foreground), + overflow: material.TextOverflow.ellipsis, + ), + ), + material.SizedBox( + width: 100, + child: Text(_formatBytes((db['size'] as int?) ?? 0)) + .muted() + .xSmall(), + ), + material.Expanded( + child: Text('${db['tables'] ?? 0}').muted().xSmall(), + ), + ], + ), + ), + ], + ), + ); + } + + material.Widget _metricRow( + material.BuildContext context, String label, String value) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 6), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + flex: 3, + child: Text( + label, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ).muted().small(), + ), + const Gap(12), + material.Flexible( + flex: 2, + child: material.SelectableText( + value, + textAlign: material.TextAlign.right, + maxLines: 2, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ), ], ), ); } + + String _extractMysqlVersion(String full) { + final match = _mysqlVersionPattern.firstMatch(full); + return match?.group(1) ?? full; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } } diff --git a/lib/features/postgresql/postgres_sql_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index 8c05490f..f0e5a84a 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -105,7 +105,8 @@ class _PostgresSqlEditorDialogState extends material.State<_PostgresSqlEditorDia backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), child: material.Container( - constraints: const material.BoxConstraints( + constraints: WindowLayout.dialogConstraints( + context, maxWidth: 720, minWidth: 480, maxHeight: 520, diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index f870a4c7..f4c5fd1d 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:postgres/postgres.dart' as pg; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; +import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; @@ -54,7 +55,7 @@ class PostgresSqlWorkspace extends material.StatefulWidget { class _PostgresSqlWorkspaceState extends material.State { final _sqlController = material.TextEditingController(); - double _topFractionState = 0.65; + final ValueNotifier _topFraction = ValueNotifier(0.65); PgLease? _lease; @@ -92,7 +93,7 @@ class _PostgresSqlWorkspaceState extends material.State { _appSettingsListener = () { unawaited(_loadWorkspaceSettings()); }; - AppSettingsRevision.listenable.addListener(_appSettingsListener); + SqlWorkspaceSettingsRevision.listenable.addListener(_appSettingsListener); material.WidgetsBinding.instance.addPostFrameCallback((_) { _syncPostgresSqlTreeContext(); unawaited(_loadWorkspaceSettings()); @@ -243,7 +244,8 @@ class _PostgresSqlWorkspaceState extends material.State { @override void dispose() { - AppSettingsRevision.listenable.removeListener(_appSettingsListener); + SqlWorkspaceSettingsRevision.listenable.removeListener(_appSettingsListener); + _topFraction.dispose(); if (_running) { PostgresService.instance.interrupt( widget.connectionRow, @@ -366,12 +368,9 @@ class _PostgresSqlWorkspaceState extends material.State { @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): () { @@ -380,98 +379,76 @@ class _PostgresSqlWorkspaceState extends material.State { }, child: material.Focus( autofocus: true, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: topFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SqlToolbar( - sessionDatabase: _effectiveSessionDatabase(), - onExecute: _running ? null : _execute, - running: _running, - autocommit: _autocommit, - onAutocommitChanged: (v) => - setState(() => _autocommit = v), - queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: _onStmtTimeoutChanged, - onOpenPreferences: () => - showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && - !_running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: _effectiveSessionDatabase(), - sqlController: _sqlController, - ); - } - : null, - txOpen: _txOpen, - onBegin: _running - ? null - : () => _runTxCommand('BEGIN'), - onCommit: _running - ? null - : () => _runTxCommand('COMMIT'), - onRollback: _running - ? null - : () => _runTxCommand('ROLLBACK'), - ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab( - controller: _sqlController, - fontSize: _editorFontSize, - ), - ), - ], + child: VerticalSplitPane( + fraction: _topFraction, + maxFraction: 0.85, + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SqlToolbar( + sessionDatabase: _effectiveSessionDatabase(), + onExecute: _running ? null : _execute, + running: _running, + autocommit: _autocommit, + onAutocommitChanged: (v) => + setState(() => _autocommit = v), + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, + onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: _effectiveSessionDatabase(), + sqlController: _sqlController, + ); + } + : null, + txOpen: _txOpen, + onBegin: + _running ? null : () => _runTxCommand('BEGIN'), + onCommit: + _running ? null : () => _runTxCommand('COMMIT'), + onRollback: + _running ? null : () => _runTxCommand('ROLLBACK'), ), - ), - _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: material.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, - ), - ), - ], + const Divider(height: 1), + Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), - ), - ], + ], + ), + bottom: 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, + ), + ), + ], + ), ), ), ); @@ -623,29 +600,3 @@ class _SqlToolbar extends material.StatelessWidget { ); } } - -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/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 4c8483ae..31c253ba 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -10,6 +11,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _pollInterval = Duration(seconds: 5); const _summaryChipHeight = 88.0; const _gridCardMinHeight = 220.0; +final _pgVersionPattern = RegExp(r'PostgreSQL\s+([\d.]+)'); class PostgresStatsView extends material.StatefulWidget { const PostgresStatsView({ @@ -105,8 +107,8 @@ class _PostgresStatsViewState extends material.State { try { final stats = await c.serverStats(); if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; setState(() { - _stats = stats; _loading = false; }); } catch (e) { @@ -127,7 +129,8 @@ class _PostgresStatsViewState extends material.State { try { final stats = await c.serverStats(); if (!mounted) return; - setState(() => _stats = stats); + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() {}); } catch (_) {} }); } @@ -553,7 +556,7 @@ class _PostgresStatsViewState extends material.State { } String _extractPgVersion(String full) { - final match = RegExp(r'PostgreSQL\s+([\d.]+)').firstMatch(full); + final match = _pgVersionPattern.firstMatch(full); return match?.group(1) ?? full; } diff --git a/lib/features/postgresql/postgres_table_privileges_dialog.dart b/lib/features/postgresql/postgres_table_privileges_dialog.dart index b66dbc2a..c5234604 100644 --- a/lib/features/postgresql/postgres_table_privileges_dialog.dart +++ b/lib/features/postgresql/postgres_table_privileges_dialog.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - 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'; @@ -81,10 +79,23 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; final mq = material.MediaQuery.sizeOf(context); - final dialogHeight = - math.min(480.0, math.max(260.0, mq.height * 0.72)).toDouble(); - final dialogWidth = - math.min(560.0, math.max(300.0, mq.width - 48)).toDouble(); + final hInset = WindowLayout.dialogVerticalInset(mq.height) * 2; + final wInset = WindowLayout.dialogHorizontalInset(mq.width) * 2; + final dialogHeight = WindowLayout.scaledDialogExtent( + context, + screenExtent: mq.height, + insetTotal: hInset, + baseMax: 480, + baseMin: 260, + viewportFactor: 0.72, + ); + final dialogWidth = WindowLayout.scaledDialogExtent( + context, + screenExtent: mq.width, + insetTotal: wInset, + baseMax: 560, + baseMin: 300, + ); return material.Dialog( backgroundColor: material.Colors.transparent, diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 30ab6670..e89f990f 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -4,6 +4,7 @@ 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/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows PostgreSQL connection form dialog. @@ -47,26 +48,26 @@ class _PostgresConnectionFormContentState bool _isTesting = false; String? _testResult; Timer? _dismissTimer; + late final FormValidityNotifier _formValidNotifier; @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 _looksLikePostgresUri(String s) { - final t = s.trim().toLowerCase(); - return t.startsWith('postgres://') || t.startsWith('postgresql://'); + _formValidNotifier = FormValidityNotifier(_computeFormValid); + for (final c in [ + _nameController, + _hostController, + _portController, + _databaseController, + _usernameController, + _connectionStringController, + ]) { + _formValidNotifier.listenTo(c); + } + _formValidNotifier.seed(); } - bool get _formValid { + bool _computeFormValid() { final uri = _connectionStringController.text.trim(); if (uri.isNotEmpty) { return _looksLikePostgresUri(uri); @@ -76,6 +77,11 @@ class _PostgresConnectionFormContentState return host.isNotEmpty && db.isNotEmpty; } + bool _looksLikePostgresUri(String s) { + final t = s.trim().toLowerCase(); + return t.startsWith('postgres://') || t.startsWith('postgresql://'); + } + void _showTestResult(String result) { _dismissTimer?.cancel(); setState(() { @@ -94,7 +100,7 @@ class _PostgresConnectionFormContentState } Future _testConnection() async { - if (!_formValid) return; + if (!_formValidNotifier.value) return; _dismissTimer?.cancel(); _dismissTimer = null; setState(() { @@ -130,7 +136,7 @@ class _PostgresConnectionFormContentState } void _save() { - if (!_formValid) return; + if (!_formValidNotifier.value) return; final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 5432; @@ -163,12 +169,17 @@ class _PostgresConnectionFormContentState @override void dispose() { _dismissTimer?.cancel(); - _nameController.removeListener(_onFieldChanged); - _hostController.removeListener(_onFieldChanged); - _portController.removeListener(_onFieldChanged); - _databaseController.removeListener(_onFieldChanged); - _usernameController.removeListener(_onFieldChanged); - _connectionStringController.removeListener(_onFieldChanged); + for (final c in [ + _nameController, + _hostController, + _portController, + _databaseController, + _usernameController, + _connectionStringController, + ]) { + _formValidNotifier.unlistenFrom(c); + } + _formValidNotifier.dispose(); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); @@ -186,7 +197,11 @@ class _PostgresConnectionFormContentState return material.Container( constraints: - const material.BoxConstraints(maxWidth: 600, maxHeight: 640), + WindowLayout.dialogConstraints( + context, + maxWidth: 600, + maxHeight: 640, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), @@ -439,54 +454,61 @@ class _PostgresConnectionFormContentState 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 + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return 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, ), - 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'), + 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/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index 798b3a2c..2e4cceca 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -4,6 +4,7 @@ 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/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows Redis connection form dialog. Returns ConnectionRow if saved, null if cancelled. @@ -41,20 +42,22 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo bool _isTesting = false; String? _testResult; Timer? _dismissTimer; + late final FormValidityNotifier _formValidNotifier; @override void initState() { super.initState(); - _nameController.addListener(_onFieldChanged); - _hostController.addListener(_onFieldChanged); - _portController.addListener(_onFieldChanged); + _formValidNotifier = FormValidityNotifier(_computeFormValid); + for (final c in [_nameController, _hostController, _portController]) { + _formValidNotifier.listenTo(c); + } + _formValidNotifier.seed(); } - void _onFieldChanged() => setState(() {}); - - bool get _formValid { + bool _computeFormValid() { final host = _hostController.text.trim(); - return host.isNotEmpty && (_nameController.text.trim().isNotEmpty || host.isNotEmpty); + return host.isNotEmpty && + (_nameController.text.trim().isNotEmpty || host.isNotEmpty); } void _showTestResult(String result) { @@ -75,7 +78,7 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo } Future _testConnection() async { - if (!_formValid) return; + if (!_formValidNotifier.value) return; _dismissTimer?.cancel(); _dismissTimer = null; setState(() { @@ -99,7 +102,7 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo } void _save() { - if (!_formValid) return; + if (!_formValidNotifier.value) return; final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 6379; @@ -120,9 +123,10 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo @override void dispose() { _dismissTimer?.cancel(); - _nameController.removeListener(_onFieldChanged); - _hostController.removeListener(_onFieldChanged); - _portController.removeListener(_onFieldChanged); + for (final c in [_nameController, _hostController, _portController]) { + _formValidNotifier.unlistenFrom(c); + } + _formValidNotifier.dispose(); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); @@ -137,7 +141,11 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 600, maxHeight: 560), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 600, + maxHeight: 560, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), @@ -323,43 +331,53 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo ), material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: material.Row( - 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: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Row( + 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, ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: _formValid ? theme.primary : theme.mutedForeground, + ), ), - ), - ), - const material.Spacer(), - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( - onPressed: _formValid ? _save : null, - child: const Text('Save'), - ), - ], + const material.Spacer(), + 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/redis/redis_databases_view.dart b/lib/features/redis/redis_databases_view.dart index 5d35d6bd..3c4b46de 100644 --- a/lib/features/redis/redis_databases_view.dart +++ b/lib/features/redis/redis_databases_view.dart @@ -237,7 +237,7 @@ class _DbInfo { // ─── Tile widget ──────────────────────────────────────────────────────────── -class _DatabaseTile extends StatefulWidget { +class _DatabaseTile extends material.StatelessWidget { const _DatabaseTile({ required this.db, required this.colorScheme, @@ -248,34 +248,21 @@ class _DatabaseTile extends StatefulWidget { final _DbInfo db; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; - final VoidCallback onTap; - - @override - material.State<_DatabaseTile> createState() => _DatabaseTileState(); -} - -class _DatabaseTileState extends material.State<_DatabaseTile> { - bool _hovered = false; + final material.VoidCallback onTap; @override material.Widget build(material.BuildContext context) { - final cs = widget.colorScheme; - final scs = widget.shadcnCs; - final db = widget.db; + final cs = colorScheme; + final db = this.db; - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + return material.Material( + color: material.Colors.transparent, child: material.InkWell( - onTap: widget.onTap, - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + onTap: onTap, + hoverColor: shadcnCs.primary.withValues(alpha: 0.06), + child: material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 20, vertical: 10), - color: _hovered - ? scs.primary.withValues(alpha: 0.06) - : material.Colors.transparent, child: material.Row( children: [ material.Icon( @@ -283,7 +270,7 @@ class _DatabaseTileState extends material.State<_DatabaseTile> { ? material.Icons.dns_rounded : material.Icons.dns_outlined, size: 18, - color: db.hasData ? scs.primary : scs.mutedForeground, + color: db.hasData ? shadcnCs.primary : shadcnCs.mutedForeground, ), const Gap(12), material.Expanded( @@ -302,7 +289,7 @@ class _DatabaseTileState extends material.State<_DatabaseTile> { '${db.keys} keys • ${db.expires} with TTL', style: material.TextStyle( fontSize: 12, - color: scs.mutedForeground, + color: shadcnCs.mutedForeground, ), ), ], @@ -313,7 +300,7 @@ class _DatabaseTileState extends material.State<_DatabaseTile> { padding: const material.EdgeInsets.symmetric( horizontal: 8, vertical: 3), decoration: material.BoxDecoration( - color: scs.primary.withValues(alpha: 0.12), + color: shadcnCs.primary.withValues(alpha: 0.12), borderRadius: material.BorderRadius.circular(10), ), child: Text( @@ -321,13 +308,13 @@ class _DatabaseTileState extends material.State<_DatabaseTile> { style: material.TextStyle( fontSize: 11, fontWeight: material.FontWeight.w600, - color: scs.primary, + color: shadcnCs.primary, ), ), ), const Gap(8), material.Icon(material.Icons.chevron_right_rounded, - size: 18, color: scs.mutedForeground), + size: 18, color: shadcnCs.mutedForeground), ], ), ), diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index a8a7e35c..08c960f9 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -349,7 +349,7 @@ class _BreadcrumbBar extends StatelessWidget { } } -class _CrumbChip extends StatefulWidget { +class _CrumbChip extends material.StatelessWidget { const _CrumbChip({ required this.label, required this.isLast, @@ -358,44 +358,37 @@ class _CrumbChip extends StatefulWidget { final String label; final bool isLast; - final VoidCallback? onTap; - - @override - material.State<_CrumbChip> createState() => _CrumbChipState(); -} - -class _CrumbChipState extends material.State<_CrumbChip> { - bool _hovered = false; + final material.VoidCallback? onTap; @override material.Widget build(material.BuildContext context) { final cs = shadcn.Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: widget.onTap != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.GestureDetector( - onTap: widget.onTap, - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + final child = isLast + ? Text(label).semiBold().small() + : Text(label, + style: material.TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: material.FontWeight.w500)) + .small(); + + if (onTap == null) { + return material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: child, + ); + } + + return material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + hoverColor: cs.primary.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: material.BoxDecoration( - color: _hovered && widget.onTap != null - ? cs.primary.withValues(alpha: 0.1) - : material.Colors.transparent, - borderRadius: material.BorderRadius.circular(4), - ), - child: widget.isLast - ? Text(widget.label).semiBold().small() - : Text(widget.label, - style: material.TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: material.FontWeight.w500)) - .small(), + child: child, ), ), ); diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index a3d9acef..5987b07d 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -312,9 +312,13 @@ class _RedisKeyEditorState extends material.State { const Divider(height: 1), // Content material.Expanded( - child: material.SingleChildScrollView( + child: material.Padding( padding: const material.EdgeInsets.all(16), - child: _buildContent(cs, shadcnCs), + child: widget.keyType == 'string' + ? material.SingleChildScrollView( + child: _buildContent(cs, shadcnCs), + ) + : _buildContent(cs, shadcnCs), ), ), ], @@ -499,7 +503,6 @@ class _RedisKeyEditorState extends material.State { children: [ Text('Hash fields (${entries.length})').semiBold(), const Gap(8), - // Add field row material.Row( children: [ material.Expanded( @@ -531,16 +534,24 @@ class _RedisKeyEditorState extends material.State { ], ), const Gap(12), - for (final entry in entries) ...[ - _FieldRow( - field: entry.key, - value: entry.value, - onDelete: () => _hashDel(entry.key), - colorScheme: cs, - shadcnCs: scs, - ), - const Gap(4), - ], + material.Expanded( + child: entries.isEmpty + ? material.Center(child: const Text('No fields').muted()) + : material.ListView.separated( + itemCount: entries.length, + separatorBuilder: (_, __) => const Gap(4), + itemBuilder: (context, index) { + final entry = entries[index]; + return _FieldRow( + field: entry.key, + value: entry.value, + onDelete: () => _hashDel(entry.key), + colorScheme: cs, + shadcnCs: scs, + ); + }, + ), + ), ], ); } @@ -576,15 +587,20 @@ class _RedisKeyEditorState extends material.State { ], ), const Gap(12), - for (var i = 0; i < _listValue.length; i++) ...[ - _IndexedValueRow( - index: i, - value: _listValue[i], - colorScheme: cs, - shadcnCs: scs, - ), - const Gap(4), - ], + material.Expanded( + child: _listValue.isEmpty + ? material.Center(child: const Text('No items').muted()) + : material.ListView.separated( + itemCount: _listValue.length, + separatorBuilder: (_, __) => const Gap(4), + itemBuilder: (context, i) => _IndexedValueRow( + index: i, + value: _listValue[i], + colorScheme: cs, + shadcnCs: scs, + ), + ), + ), ], ); } @@ -620,15 +636,20 @@ class _RedisKeyEditorState extends material.State { ], ), const Gap(12), - for (final member in _setValue) ...[ - _MemberRow( - member: member, - onDelete: () => _setRemove(member), - colorScheme: cs, - shadcnCs: scs, - ), - const Gap(4), - ], + material.Expanded( + child: _setValue.isEmpty + ? material.Center(child: const Text('No members').muted()) + : material.ListView.separated( + itemCount: _setValue.length, + separatorBuilder: (_, __) => const Gap(4), + itemBuilder: (context, index) => _MemberRow( + member: _setValue[index], + onDelete: () => _setRemove(_setValue[index]), + colorScheme: cs, + shadcnCs: scs, + ), + ), + ), ], ); } @@ -675,16 +696,24 @@ class _RedisKeyEditorState extends material.State { ], ), const Gap(12), - for (final (member, score) in _zsetValue) ...[ - _ScoredMemberRow( - member: member, - score: score, - onDelete: () => _zsetRemove(member), - colorScheme: cs, - shadcnCs: scs, - ), - const Gap(4), - ], + material.Expanded( + child: _zsetValue.isEmpty + ? material.Center(child: const Text('No members').muted()) + : material.ListView.separated( + itemCount: _zsetValue.length, + separatorBuilder: (_, __) => const Gap(4), + itemBuilder: (context, index) { + final (member, score) = _zsetValue[index]; + return _ScoredMemberRow( + member: member, + score: score, + onDelete: () => _zsetRemove(member), + colorScheme: cs, + shadcnCs: scs, + ); + }, + ), + ), ], ); } @@ -692,43 +721,12 @@ class _RedisKeyEditorState extends material.State { // ─── TTL dialog ───────────────────────────────────────────────────────── void _showTtlDialog() { - final controller = - material.TextEditingController(text: _ttl > 0 ? '$_ttl' : ''); - showAppDialog( + showAppDialog( context: context, - builder: (ctx) { - return AlertDialog( - title: const Text('Set TTL'), - content: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Enter TTL in seconds (0 to remove)').muted().small(), - const Gap(8), - TextField( - controller: controller, - placeholder: const Text('Seconds'), - ), - ], - ), - actions: [ - GhostButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Cancel'), - ), - PrimaryButton( - onPressed: () { - final val = int.tryParse(controller.text.trim()); - if (val != null) { - _setTtl(val); - } - Navigator.of(ctx).pop(); - }, - child: const Text('Apply'), - ), - ], - ); - }, + builder: (ctx) => _RedisTtlDialogContent( + initialTtl: _ttl, + onApply: _setTtl, + ), ); } @@ -769,6 +767,73 @@ class _RedisKeyEditorState extends material.State { } } +class _RedisTtlDialogContent extends material.StatefulWidget { + const _RedisTtlDialogContent({ + required this.initialTtl, + required this.onApply, + }); + + final int initialTtl; + final Future Function(int seconds) onApply; + + @override + material.State<_RedisTtlDialogContent> createState() => + _RedisTtlDialogContentState(); +} + +class _RedisTtlDialogContentState extends material.State<_RedisTtlDialogContent> { + late final material.TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController( + text: widget.initialTtl > 0 ? '${widget.initialTtl}' : '', + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + return AlertDialog( + title: const Text('Set TTL'), + content: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Enter TTL in seconds (0 to remove)').muted().small(), + const Gap(8), + TextField( + controller: _controller, + placeholder: const Text('Seconds'), + ), + ], + ), + actions: [ + GhostButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + PrimaryButton( + onPressed: () { + final val = int.tryParse(_controller.text.trim()); + if (val != null) { + widget.onApply(val); + } + Navigator.of(context).pop(); + }, + child: const Text('Apply'), + ), + ], + ); + } +} + // ─── Shared row widgets ───────────────────────────────────────────────────── class _FieldRow extends StatelessWidget { diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 8cbcf4ba..afaedb4b 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -320,7 +320,7 @@ class _KeyInfo { // ─── Key tile widget ──────────────────────────────────────────────────────── -class _KeyTile extends StatefulWidget { +class _KeyTile extends material.StatelessWidget { const _KeyTile({ required this.keyInfo, required this.colorScheme, @@ -332,34 +332,27 @@ class _KeyTile extends StatefulWidget { final _KeyInfo keyInfo; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; - final VoidCallback onTap; - final VoidCallback onDelete; + final material.VoidCallback onTap; + final material.VoidCallback onDelete; - @override - material.State<_KeyTile> createState() => _KeyTileState(); -} - -class _KeyTileState extends material.State<_KeyTile> { - bool _hovered = false; - - Color _typeColor(String type) { + static Color _typeColor(String type, shadcn.ColorScheme scs) { switch (type) { case 'string': - return const Color(0xFF42A5F5); + return const material.Color(0xFF42A5F5); case 'hash': - return const Color(0xFFAB47BC); + return const material.Color(0xFFAB47BC); case 'list': - return const Color(0xFF66BB6A); + return const material.Color(0xFF66BB6A); case 'set': - return const Color(0xFFFFA726); + return const material.Color(0xFFFFA726); case 'zset': - return const Color(0xFFEF5350); + return const material.Color(0xFFEF5350); default: - return widget.shadcnCs.mutedForeground; + return scs.mutedForeground; } } - material.IconData _typeIcon(String type) { + static material.IconData _typeIcon(String type) { switch (type) { case 'string': return material.Icons.text_fields_rounded; @@ -376,7 +369,7 @@ class _KeyTileState extends material.State<_KeyTile> { } } - String _formatTtl(int ttl) { + static String _formatTtl(int ttl) { if (ttl == -1) return 'No TTL'; if (ttl == -2) return 'Missing'; if (ttl < 60) return '${ttl}s'; @@ -387,35 +380,25 @@ class _KeyTileState extends material.State<_KeyTile> { @override material.Widget build(material.BuildContext context) { - final cs = widget.colorScheme; - final scs = widget.shadcnCs; - final ki = widget.keyInfo; - final typeCol = _typeColor(ki.type); - - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + final cs = colorScheme; + final ki = keyInfo; + final typeCol = _typeColor(ki.type, shadcnCs); + + return material.Material( + color: cs.card, + borderRadius: material.BorderRadius.circular(8), + clipBehavior: material.Clip.antiAlias, child: material.InkWell( - onTap: widget.onTap, + onTap: onTap, + hoverColor: shadcnCs.muted.withValues(alpha: 0.15), borderRadius: material.BorderRadius.circular(8), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + child: material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 16, vertical: 10), - decoration: material.BoxDecoration( - color: _hovered - ? scs.muted.withValues(alpha: 0.15) - : cs.card, - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.3), width: 1), - ), child: material.Row( children: [ material.Icon(_typeIcon(ki.type), size: 16, color: typeCol), const Gap(10), - // Type badge material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 6, vertical: 2), @@ -423,7 +406,7 @@ class _KeyTileState extends material.State<_KeyTile> { color: typeCol.withValues(alpha: 0.12), borderRadius: material.BorderRadius.circular(4), ), - child: Text( + child: material.Text( ki.type.toUpperCase(), style: material.TextStyle( fontSize: 10, @@ -434,7 +417,6 @@ class _KeyTileState extends material.State<_KeyTile> { ), ), const Gap(10), - // Key name material.Expanded( child: material.Text( ki.name, @@ -448,40 +430,35 @@ class _KeyTileState extends material.State<_KeyTile> { ), ), const Gap(8), - // TTL if (ki.ttl >= 0) material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: material.BoxDecoration( - color: scs.muted.withValues(alpha: 0.3), + color: shadcnCs.muted.withValues(alpha: 0.3), borderRadius: material.BorderRadius.circular(4), ), - child: Text( + child: material.Text( 'TTL ${_formatTtl(ki.ttl)}', style: material.TextStyle( fontSize: 10, - color: scs.mutedForeground, + color: shadcnCs.mutedForeground, ), ), ), - const Gap(8), - // Delete button (only on hover) - material.AnimatedOpacity( - opacity: _hovered ? 1.0 : 0.0, - duration: const Duration(milliseconds: 120), - child: material.InkWell( - onTap: widget.onDelete, - borderRadius: material.BorderRadius.circular(4), - child: const material.Padding( - padding: material.EdgeInsets.all(4), - child: material.Icon(material.Icons.delete_rounded, - size: 15, color: Color(0xFFEF5350)), - ), - ), + const Gap(4), + material.IconButton( + onPressed: onDelete, + icon: const material.Icon(material.Icons.delete_rounded, + size: 15, color: material.Color(0xFFEF5350)), + padding: const material.EdgeInsets.all(4), + constraints: + const material.BoxConstraints(minWidth: 28, minHeight: 28), + splashRadius: 18, + tooltip: 'Delete key', ), material.Icon(material.Icons.chevron_right_rounded, - size: 18, color: scs.mutedForeground), + size: 18, color: shadcnCs.mutedForeground), ], ), ), diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 2d6023a4..1e100dbd 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; @@ -9,8 +10,30 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _pollInterval = Duration(seconds: 3); -const _summaryChipHeight = 72.0; -const _gridCardHeight = 220.0; +const _summaryChipHeight = 88.0; +const _gridCardMinHeight = 220.0; + +const _redisFieldLabels = { + 'redis_version': 'Version', + 'redis_mode': 'Mode', + 'os': 'OS', + 'tcp_port': 'Port', + 'uptime_in_days': 'Uptime (days)', + 'config_file': 'Config file', + 'connected_clients': 'Connected', + 'blocked_clients': 'Blocked', + 'maxclients': 'Max clients', + 'client_recent_max_input_buffer': 'Max input buffer', + 'client_recent_max_output_buffer': 'Max output buffer', + 'rdb_bgsave_in_progress': 'BGSAVE in progress', + 'rdb_last_save_time': 'Last RDB save', + 'rdb_last_bgsave_status': 'Last BGSAVE status', + 'aof_enabled': 'AOF enabled', + 'aof_last_rewrite_time_sec': 'Last AOF rewrite', + 'role': 'Role', + 'connected_slaves': 'Connected replicas', + 'master_repl_offset': 'Repl offset', +}; class RedisView extends material.StatefulWidget { const RedisView({ @@ -119,10 +142,9 @@ class _RedisViewState extends material.State { final raw = await c.info(); final info = parseRedisInfo(raw); if (!mounted) return; - setState(() { - _info = info; - _loading = false; - }); + final changed = replaceIfChanged(_info, info, (v) => _info = v); + if (!changed && !_loading) return; + setState(() => _loading = false); } void _startTimer() { @@ -134,7 +156,8 @@ class _RedisViewState extends material.State { final raw = await c.info(); final info = parseRedisInfo(raw); if (!mounted) return; - setState(() => _info = info); + if (!replaceIfChanged(_info, info, (v) => _info = v)) return; + setState(() {}); } catch (_) {} }); } @@ -206,40 +229,57 @@ class _RedisViewState extends material.State { const Gap(24), _summaryChips(context, info), const Gap(24), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded(child: _memoryCard(context, info)), - const Gap(16), - material.Expanded(child: _statsCard(context, info)), - ], + _gridRow( + _memoryCard(context, info), + _statsCard(context, info), ), const Gap(16), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded(child: _keyspaceCard(context, info)), - const Gap(16), - material.Expanded(child: _cpuCard(context, info)), - ], + _gridRow( + _cpuCard(context, info), + _keyspaceCard(context, info), ), const Gap(24), _sectionCard(context, 'Server', info['Server'], keys: const [ 'redis_version', 'redis_mode', 'os', 'tcp_port', 'uptime_in_days', 'config_file', ]), const Gap(12), - _sectionCard(context, 'Clients', info['Clients']), - const Gap(12), - _sectionCard(context, 'Persistence', info['Persistence'], keys: const [ - 'rdb_bgsave_in_progress', 'rdb_last_save_time', 'rdb_last_bgsave_status', - 'aof_enabled', 'aof_last_rewrite_time_sec', - ]), + material.LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 900; + if (!wide) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _sectionCard(context, 'Clients', info['Clients']), + const Gap(12), + _sectionCard(context, 'Persistence', info['Persistence'], keys: const [ + 'rdb_bgsave_in_progress', 'rdb_last_save_time', 'rdb_last_bgsave_status', + 'aof_enabled', 'aof_last_rewrite_time_sec', + ]), + ], + ); + } + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: _sectionCard(context, 'Clients', info['Clients']), + ), + const Gap(16), + material.Expanded( + child: _sectionCard(context, 'Persistence', info['Persistence'], keys: const [ + 'rdb_bgsave_in_progress', 'rdb_last_save_time', 'rdb_last_bgsave_status', + 'aof_enabled', 'aof_last_rewrite_time_sec', + ]), + ), + ], + ); + }, + ), const Gap(12), _sectionCard(context, 'Replication', info['Replication']), const Gap(12), _errorStatsCard(context, info), - const Gap(12), - _sectionCard(context, 'Keyspace', info['Keyspace']), ], ), ), @@ -292,17 +332,43 @@ class _RedisViewState extends material.State { ); } + material.Widget _gridRow(material.Widget left, material.Widget right) { + return material.IntrinsicHeight( + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Expanded(child: left), + const Gap(16), + material.Expanded(child: right), + ], + ), + ); + } + + String _formatUptime(RedisInfoSections info) { + final seconds = sectionInt(info, 'Server', 'uptime_in_seconds'); + if (seconds == null || seconds <= 0) return '0m'; + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + if (days > 0) return '${days}d ${hours}h'; + if (hours > 0) return '${hours}h ${minutes}m'; + return '${minutes}m'; + } + + String _labelFor(String key) => _redisFieldLabels[key] ?? key.replaceAll('_', ' '); + material.Widget _summaryChips(material.BuildContext context, RedisInfoSections info) { final cs = shadcn.Theme.of(context).colorScheme; final version = sectionValue(info, 'Server', 'redis_version') ?? '—'; - final uptime = sectionInt(info, 'Server', 'uptime_in_days') ?? 0; + final uptime = _formatUptime(info); final clients = sectionInt(info, 'Clients', 'connected_clients') ?? 0; final maxClients = sectionInt(info, 'Clients', 'maxclients') ?? 0; final ops = sectionInt(info, 'Stats', 'instantaneous_ops_per_sec') ?? 0; material.Widget chip(String label, String value, material.IconData icon) { return material.Expanded( - child: material.SizedBox( - height: _summaryChipHeight, + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(minHeight: _summaryChipHeight), child: material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: material.BoxDecoration( @@ -311,8 +377,12 @@ class _RedisViewState extends material.State { border: material.Border.all(color: cs.border.withValues(alpha: 0.5)), ), child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - material.Icon(icon, size: 20, color: cs.primary), + material.Padding( + padding: const material.EdgeInsets.only(top: 2), + child: material.Icon(icon, size: 20, color: cs.primary), + ), const Gap(12), material.Expanded( child: material.Column( @@ -322,7 +392,17 @@ class _RedisViewState extends material.State { children: [ Text(label).muted().xSmall(), const Gap(2), - Text(value).semiBold().small(), + material.Text( + value, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), ], ), ), @@ -333,10 +413,11 @@ class _RedisViewState extends material.State { ); } return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, children: [ chip('Version', version, material.Icons.tag_rounded), const Gap(12), - chip('Uptime', '$uptime days', material.Icons.schedule_rounded), + chip('Uptime', uptime, material.Icons.schedule_rounded), const Gap(12), chip('Clients', '$clients / $maxClients', material.Icons.people_outline_rounded), const Gap(12), @@ -345,11 +426,19 @@ class _RedisViewState extends material.State { ); } - material.Widget _card(material.BuildContext context, String title, material.Widget body, {double? height}) { + material.Widget _card( + material.BuildContext context, + String title, + material.Widget body, { + double? minHeight, + bool stretchBody = false, + }) { final cs = shadcn.Theme.of(context).colorScheme; return material.Container( width: double.infinity, - height: height, + constraints: minHeight != null + ? material.BoxConstraints(minHeight: minHeight) + : const material.BoxConstraints(), padding: const material.EdgeInsets.all(20), decoration: material.BoxDecoration( color: cs.card, @@ -357,17 +446,35 @@ class _RedisViewState extends material.State { border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), ), child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.start, + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Text(title).semiBold(), const Gap(12), - body, + if (stretchBody) material.Expanded(child: body) else body, ], ), ); } + material.Widget _metricList( + material.BuildContext context, + List rows, { + bool stretch = false, + }) { + final column = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: rows, + ); + if (!stretch) return column; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + column, + const material.Spacer(), + ], + ); + } + material.Widget _memoryCard(material.BuildContext context, RedisInfoSections info) { final usedHuman = sectionValue(info, 'Memory', 'used_memory_human') ?? '—'; final peakHuman = sectionValue(info, 'Memory', 'used_memory_peak_human') ?? '—'; @@ -376,17 +483,18 @@ class _RedisViewState extends material.State { return _card( context, 'Memory', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Used memory', usedHuman), - _row(context, 'Peak', peakHuman), - _row(context, 'RSS', rss), - _row(context, 'Fragmentation', '${frag.toStringAsFixed(2)}x'), + _metricList( + context, + [ + _metricRow(context, 'Used', usedHuman), + _metricRow(context, 'Peak', peakHuman), + _metricRow(context, 'RSS', rss), + _metricRow(context, 'Fragmentation', '${frag.toStringAsFixed(2)}×'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -399,58 +507,96 @@ class _RedisViewState extends material.State { final hitRate = total > 0 ? (keyspaceHits / total * 100).toStringAsFixed(1) : '—'; return _card( context, - 'Stats', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Ops/s', '$ops'), - if (totalOps != null) _row(context, 'Total commands', '$totalOps'), - _row(context, 'Keyspace hits', '$keyspaceHits'), - _row(context, 'Keyspace misses', '$keyspaceMisses'), - _row(context, 'Hit rate', '$hitRate%'), + 'Performance', + _metricList( + context, + [ + _metricRow(context, 'Ops/s', '$ops'), + _metricRow(context, 'Total commands', totalOps?.toString() ?? '—'), + _metricRow(context, 'Hits / misses', '$keyspaceHits / $keyspaceMisses'), + _metricRow(context, 'Hit rate', hitRate == '—' ? hitRate : '$hitRate%'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } material.Widget _keyspaceCard(material.BuildContext context, RedisInfoSections info) { - final hits = sectionInt(info, 'Stats', 'keyspace_hits') ?? 0; - final misses = sectionInt(info, 'Stats', 'keyspace_misses') ?? 0; - final total = hits + misses; - final hitPct = total > 0 ? (hits / total * 100).toStringAsFixed(1) : '—'; + final keyspace = info['Keyspace']; + final rows = []; + if (keyspace != null && keyspace.isNotEmpty) { + final entries = keyspace.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + for (final entry in entries) { + rows.add(_metricRow(context, entry.key, _formatKeyspaceEntry(entry.value))); + } + } else { + rows.add( + material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 8), + child: const Text('No keys in any database').muted().small(), + ), + ); + } return _card( context, - 'Keyspace hits / misses', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Hits', '$hits'), - _row(context, 'Misses', '$misses'), - _row(context, 'Hit rate', '$hitPct%'), - ], - ), - height: _gridCardHeight, + 'Keyspace', + _metricList(context, rows, stretch: true), + minHeight: _gridCardMinHeight, + stretchBody: true, ); } + String _formatKeyspaceEntry(String raw) { + var keys = 0; + var expires = 0; + int? avgTtlMs; + for (final part in raw.split(',')) { + final kv = part.split('='); + if (kv.length != 2) continue; + final name = kv[0].trim(); + final value = kv[1].trim(); + switch (name) { + case 'keys': + keys = int.tryParse(value) ?? 0; + case 'expires': + expires = int.tryParse(value) ?? 0; + case 'avg_ttl': + avgTtlMs = int.tryParse(value); + } + } + final ttlPart = (avgTtlMs != null && avgTtlMs > 0) + ? ' · avg TTL ${_formatDurationMs(avgTtlMs)}' + : ''; + return '$keys keys · $expires with TTL$ttlPart'; + } + + String _formatDurationMs(int ms) { + final seconds = ms ~/ 1000; + if (seconds < 60) return '${seconds}s'; + if (seconds < 3600) return '${seconds ~/ 60}m'; + if (seconds < 86400) return '${(seconds / 3600).toStringAsFixed(1)}h'; + return '${(seconds / 86400).toStringAsFixed(1)}d'; + } + material.Widget _cpuCard(material.BuildContext context, RedisInfoSections info) { final sys = sectionDouble(info, 'CPU', 'used_cpu_sys_main_thread'); final user = sectionDouble(info, 'CPU', 'used_cpu_user_main_thread'); return _card( context, 'CPU (main thread)', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - if (sys != null) _row(context, 'System', sys.toStringAsFixed(2)), - if (user != null) _row(context, 'User', user.toStringAsFixed(2)), + _metricList( + context, + [ + _metricRow(context, 'System', sys?.toStringAsFixed(2) ?? '—'), + _metricRow(context, 'User', user?.toStringAsFixed(2) ?? '—'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -463,14 +609,46 @@ class _RedisViewState extends material.State { return _card( context, title, - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [for (final e in entries) _row(context, e.key, e.value)], + _twoColumnMetrics( + context, + entries.map((e) => MapEntry(_labelFor(e.key), e.value)).toList(), ), ); } + material.Widget _twoColumnMetrics( + material.BuildContext context, + List> entries, + ) { + if (entries.length <= 4) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in entries) _metricRow(context, e.key, e.value)], + ); + } + final mid = (entries.length / 2).ceil(); + final left = entries.sublist(0, mid); + final right = entries.sublist(mid); + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in left) _metricRow(context, e.key, e.value)], + ), + ), + const Gap(24), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in right) _metricRow(context, e.key, e.value)], + ), + ), + ], + ); + } + material.Widget _errorStatsCard(material.BuildContext context, RedisInfoSections info) { final data = info['Errorstats']; if (data == null || data.isEmpty) return const material.SizedBox.shrink(); @@ -478,22 +656,45 @@ class _RedisViewState extends material.State { context, 'Error stats', material.Column( - mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [for (final e in data.entries) _row(context, e.key, e.value)], + children: [ + for (final e in data.entries) + _metricRow(context, _labelFor(e.key), e.value), + ], ), ); } - material.Widget _row(material.BuildContext context, String key, String value) { + material.Widget _metricRow(material.BuildContext context, String label, String value) { final cs = shadcn.Theme.of(context).colorScheme; return material.Padding( - padding: const material.EdgeInsets.symmetric(vertical: 4), + padding: const material.EdgeInsets.symmetric(vertical: 6), child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - material.SizedBox(width: 160, child: Text(key).muted().small()), - material.Expanded(child: material.SelectableText(value, style: material.TextStyle(fontSize: 13, color: cs.foreground))), + material.Expanded( + flex: 3, + child: Text( + label, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ).muted().small(), + ), + const Gap(12), + material.Flexible( + flex: 2, + child: material.SelectableText( + value, + textAlign: material.TextAlign.right, + maxLines: 2, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ), ], ), ); diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 84fe0961..c5706448 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -7,7 +7,6 @@ import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Appearance / theme controls for [PreferencesDialog]. class PreferencesAppearanceSection extends material.StatefulWidget { @@ -101,77 +100,79 @@ class _PreferencesAppearanceSectionState children: [ const Text('Appearance').semiBold().small().foreground(), const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Theme mode').small().foreground(), - const material.SizedBox(width: 12), - PreferencesDropdownMenu( - value: c.themeMode, - onSelected: (v) { - if (v != null) unawaited(_setThemeMode(v)); - }, - entries: const [ - material.DropdownMenuEntry( - value: ThemeMode.dark, - label: 'Dark', - ), - material.DropdownMenuEntry( - value: ThemeMode.light, - label: 'Light', - ), - material.DropdownMenuEntry( - value: ThemeMode.system, - label: 'System', - ), - ], - ), - ], + PreferencesFieldRow( + label: 'Theme mode', + control: PreferencesDropdownMenu( + value: c.themeMode, + onSelected: (v) { + if (v != null) unawaited(_setThemeMode(v)); + }, + entries: const [ + material.DropdownMenuEntry( + value: ThemeMode.dark, + label: 'Dark', + ), + material.DropdownMenuEntry( + value: ThemeMode.light, + label: 'Light', + ), + material.DropdownMenuEntry( + value: ThemeMode.system, + label: 'System', + ), + ], + ), ), const material.SizedBox(height: 12), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Padding( - padding: const material.EdgeInsets.only(top: 10), - child: const Text('Color preset').small().foreground(), - ), - const material.SizedBox(width: 12), - material.Expanded( - child: PreferencesDropdownMenu( - value: c.preset, - expandToParent: true, - onSelected: (v) { - if (v != null) unawaited(_setPreset(v)); - }, - entries: [ - const material.DropdownMenuEntry( - value: QueryaThemePreset.queryaDark, - label: 'Querya Dark', - ), - const material.DropdownMenuEntry( - value: QueryaThemePreset.queryaLight, - label: 'Querya Light', - ), - material.DropdownMenuEntry( - value: QueryaThemePreset.imported, - enabled: c.hasImportedTheme, - label: importedLabel, - ), - ], + PreferencesFieldRow( + label: 'Color preset', + control: PreferencesDropdownMenu( + value: c.preset, + onSelected: (v) { + if (v != null) unawaited(_setPreset(v)); + }, + entries: [ + const material.DropdownMenuEntry( + value: QueryaThemePreset.queryaDark, + label: 'Querya Dark', ), - ), - ], + const material.DropdownMenuEntry( + value: QueryaThemePreset.queryaLight, + label: 'Querya Light', + ), + material.DropdownMenuEntry( + value: QueryaThemePreset.imported, + enabled: c.hasImportedTheme, + label: importedLabel, + ), + ], + ), ), const material.SizedBox(height: 12), - material.Row( - children: [ - const Text('Animate theme changes').small().foreground(), - const material.SizedBox(width: 12), - material.Switch( - value: c.themeAnimationEnabled, - onChanged: (v) => unawaited(_setThemeAnimation(v)), - ), - ], + const PreferencesFieldRow( + label: 'Interface scale', + hint: + 'Snap to presets (75%, 85%, 90%, 100% …). Hold Shift for 1% fine control.', + control: InterfaceScaleSlider(), + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Animate theme changes', + control: material.Builder( + builder: (context) { + final h = QueryaDropdownTokens.scaledTriggerHeight(context); + return material.SizedBox( + height: h, + child: material.Align( + alignment: material.Alignment.centerLeft, + child: material.Switch( + value: c.themeAnimationEnabled, + onChanged: (v) => unawaited(_setThemeAnimation(v)), + ), + ), + ); + }, + ), ), const material.SizedBox(height: 4), const PreferencesHint( diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index 0029c287..ad30a932 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -1,4 +1,10 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Helper / hint copy in Preferences — readable on imported themes. @@ -21,7 +27,195 @@ class PreferencesHint extends StatelessWidget { } } -/// Material 3 dropdown anchored to the field (stable inside scroll views). +/// Label + full-width control row for Preferences (uniform dropdown width). +class PreferencesFieldRow extends StatelessWidget { + const PreferencesFieldRow({ + super.key, + required this.label, + required this.control, + this.hint, + this.labelWidth = kPreferencesLabelWidth, + }); + + final String label; + final material.Widget control; + final String? hint; + final double labelWidth; + + @override + material.Widget build(material.BuildContext context) { + final triggerHeight = QueryaDropdownTokens.scaledTriggerHeight(context); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SizedBox( + width: labelWidth, + height: triggerHeight, + child: material.Align( + alignment: material.Alignment.centerLeft, + child: Text(label).small().foreground(), + ), + ), + const material.SizedBox(width: 12), + material.Expanded(child: control), + ], + ), + if (hint != null) ...[ + const material.SizedBox(height: 4), + material.Padding( + padding: material.EdgeInsets.only(left: labelWidth + 12), + child: PreferencesHint(hint!), + ), + ], + ], + ); + } +} + +/// Interface scale slider: fixed presets by default; hold Shift for 1% fine steps. +/// +/// Drag updates the label locally; [UiScaleController.commitScale] runs on release +/// so the rest of the app rebuilds once, not on every slider tick. +class InterfaceScaleSlider extends material.StatefulWidget { + const InterfaceScaleSlider({super.key, this.scale}); + + /// When set (e.g. in tests), overrides [UiScaleController.instance.scale]. + final double? scale; + + @override + material.State createState() => + _InterfaceScaleSliderState(); +} + +class _InterfaceScaleSliderState extends material.State { + bool _fineControl = false; + double? _dragScale; + + static int get _fineDivisions => + ((kMaxUiScale - kMinUiScale) / kUiScaleStep).round(); + + bool get _shiftHeld => HardwareKeyboard.instance.isShiftPressed; + + double get _committedScale => + widget.scale ?? UiScaleController.instance.scale; + + double get _displayScale => _dragScale ?? _committedScale; + + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_onKeyEvent); + if (widget.scale == null) { + UiScaleController.instance.addListener(_onCommittedScaleChanged); + } + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_onKeyEvent); + if (widget.scale == null) { + UiScaleController.instance.removeListener(_onCommittedScaleChanged); + } + super.dispose(); + } + + @override + void didUpdateWidget(InterfaceScaleSlider oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.scale != widget.scale) { + _dragScale = null; + } + } + + void _onCommittedScaleChanged() { + if (_dragScale != null || !mounted) return; + setState(() {}); + } + + bool _onKeyEvent(KeyEvent event) { + final fine = _shiftHeld; + if (fine != _fineControl) { + setState(() => _fineControl = fine); + } + return false; + } + + double _sliderPosition(double scale, {required bool fine}) { + if (fine) return scale; + return nearestUiScalePresetIndex(scale).toDouble(); + } + + double _scaleFromSlider(double position, {required bool fine}) { + if (fine) return position; + final index = position.round().clamp(0, kUiScalePresets.length - 1); + return kUiScalePresets[index]; + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final displayScale = _displayScale; + final pct = (displayScale * 100).round(); + final fine = _fineControl; + + return material.Row( + children: [ + material.Expanded( + child: Slider( + value: SliderValue.single( + _sliderPosition(displayScale, fine: fine), + ), + min: fine ? kMinUiScale : 0, + max: fine + ? kMaxUiScale + : (kUiScalePresets.length - 1).toDouble(), + divisions: fine ? _fineDivisions : kUiScalePresets.length - 1, + hintValue: const SliderValue.single(kDefaultUiScale), + onChanged: (value) { + final next = _scaleFromSlider( + value.value, + fine: _shiftHeld, + ); + setState(() => _dragScale = next); + }, + onChangeEnd: (value) { + final next = _scaleFromSlider( + value.value, + fine: _shiftHeld, + ); + setState(() => _dragScale = null); + unawaited( + UiScaleController.instance.commitScale( + next, + fine: _shiftHeld, + ), + ); + }, + ), + ), + const material.SizedBox(width: 8), + material.SizedBox( + width: 44, + child: material.Text( + '$pct%', + textAlign: material.TextAlign.right, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.popoverForeground, + ), + ), + ), + ], + ); + } +} + +/// Preferences dropdown backed by [QueryaDropdown] ([MenuAnchor]). class PreferencesDropdownMenu extends StatelessWidget { const PreferencesDropdownMenu({ super.key, @@ -29,63 +223,39 @@ class PreferencesDropdownMenu extends StatelessWidget { required this.entries, required this.onSelected, this.width, - this.expandToParent = false, + this.expandToParent = true, this.enabled = true, }); final T value; final List> entries; - final ValueChanged onSelected; + final material.ValueChanged onSelected; - /// Fixed width for field + menu. Do not pass [double.infinity] — menu glitches. + /// Fixed width when [expandToParent] is false. final double? width; - /// Fill [Expanded] parent width without stretching the popup to screen width. + /// Fill parent — use inside [PreferencesFieldRow]. final bool expandToParent; final bool enabled; @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - final mTheme = material.Theme.of(context); - final textStyle = material.TextStyle( - color: cs.popoverForeground, - fontSize: 14, - ); - - return material.Theme( - data: mTheme.copyWith( - canvasColor: cs.popover, - colorScheme: mTheme.colorScheme.copyWith( - surface: cs.popover, - onSurface: cs.popoverForeground, + final items = [ + for (final entry in entries) + QueryaDropdownItem( + value: entry.value, + label: entry.label, + enabled: entry.enabled, ), - ), - child: material.DropdownMenu( - enabled: enabled, - width: width, - expandedInsets: - expandToParent ? material.EdgeInsets.zero : null, - initialSelection: value, - onSelected: enabled ? onSelected : null, - dropdownMenuEntries: entries, - textStyle: textStyle, - inputDecorationTheme: material.InputDecorationTheme( - isDense: true, - contentPadding: const material.EdgeInsets.symmetric(vertical: 6), - enabledBorder: material.UnderlineInputBorder( - borderSide: material.BorderSide(color: cs.border), - ), - focusedBorder: material.UnderlineInputBorder( - borderSide: material.BorderSide(color: cs.ring, width: 2), - ), - ), - menuStyle: material.MenuStyle( - backgroundColor: material.WidgetStatePropertyAll(cs.popover), - surfaceTintColor: material.WidgetStatePropertyAll(cs.popover), - elevation: const material.WidgetStatePropertyAll(8), - ), - ), + ]; + + return QueryaDropdown( + value: value, + items: items, + enabled: enabled, + width: width, + expandToParent: expandToParent, + onSelected: onSelected, ); } } diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 1a84d5f1..78fc0762 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -94,7 +94,8 @@ class _PreferencesDialogContentState child: material.IconTheme( data: material.IconThemeData(color: onPopover), child: material.Container( - constraints: const material.BoxConstraints( + constraints: WindowLayout.dialogConstraints( + context, maxWidth: 480, minWidth: 360, maxHeight: 640, @@ -144,17 +145,13 @@ class _PreferencesDialogContentState .small() .foreground(), const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Statement timeout') - .small() - .foreground(), - const material.SizedBox(width: 12), - SqlStatementTimeoutDropdown( - value: _pgTimeout, - onChanged: (v) => unawaited(_setPg(v)), - ), - ], + PreferencesFieldRow( + label: 'Statement timeout', + control: SqlStatementTimeoutDropdown( + value: _pgTimeout, + expandToParent: true, + onChanged: (v) => unawaited(_setPg(v)), + ), ), const material.SizedBox(height: 24), const Text('SQL — MySQL / MariaDB') @@ -162,17 +159,13 @@ class _PreferencesDialogContentState .small() .foreground(), const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Statement timeout') - .small() - .foreground(), - const material.SizedBox(width: 12), - SqlStatementTimeoutDropdown( - value: _mysqlTimeout, - onChanged: (v) => unawaited(_setMysql(v)), - ), - ], + PreferencesFieldRow( + label: 'Statement timeout', + control: SqlStatementTimeoutDropdown( + value: _mysqlTimeout, + expandToParent: true, + onChanged: (v) => unawaited(_setMysql(v)), + ), ), const material.SizedBox(height: 24), const Text('SQL editor') @@ -180,109 +173,78 @@ class _PreferencesDialogContentState .small() .foreground(), const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Max rows in results') - .small() - .foreground(), - const material.SizedBox(width: 12), - PreferencesDropdownMenu( - value: _maxRows, - onSelected: (v) { - if (v != null) unawaited(_setMaxRows(v)); - }, - entries: [ - for (final n in kSqlResultMaxRowsPresets) - material.DropdownMenuEntry( - value: n, - label: '$n', - ), - ], - ), - ], - ), - const material.SizedBox(height: 12), - material.Row( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - material.Padding( - padding: - const material.EdgeInsets.only(top: 8), - child: const Text('Query history limit') - .small() - .foreground(), - ), - const material.SizedBox(width: 12), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - PreferencesDropdownMenu( - value: _historyMax, - expandToParent: true, - onSelected: (v) { - if (v != null) { - unawaited(_setHistoryMax(v)); - } - }, - entries: [ - for (final n - in kSqlHistoryMaxEntriesPresets) - material.DropdownMenuEntry( - value: n, - label: '$n entries', - ), - ], - ), - const material.SizedBox(height: 4), - const PreferencesHint( - 'Per connection and database; oldest queries are dropped.', - ), - ], - ), - ), - ], - ), - const material.SizedBox(height: 12), - material.Row( - children: [ - const Text('Font size').small().foreground(), - const material.SizedBox(width: 12), - PreferencesDropdownMenu( - value: _fontSize, - onSelected: (v) { - if (v != null) unawaited(_setFont(v)); - }, - entries: const [ - material.DropdownMenuEntry( - value: 11.0, - label: '11 pt', - ), - material.DropdownMenuEntry( - value: 12.0, - label: '12 pt', - ), + PreferencesFieldRow( + label: 'Max rows in results', + control: PreferencesDropdownMenu( + value: _maxRows, + onSelected: (v) { + if (v != null) unawaited(_setMaxRows(v)); + }, + entries: [ + for (final n in kSqlResultMaxRowsPresets) material.DropdownMenuEntry( - value: 13.0, - label: '13 pt', - ), - material.DropdownMenuEntry( - value: 14.0, - label: '14 pt', - ), - material.DropdownMenuEntry( - value: 16.0, - label: '16 pt', + value: n, + label: '$n', ), + ], + ), + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Query history limit', + hint: + 'Per connection and database; oldest queries are dropped.', + control: PreferencesDropdownMenu( + value: _historyMax, + onSelected: (v) { + if (v != null) { + unawaited(_setHistoryMax(v)); + } + }, + entries: [ + for (final n in kSqlHistoryMaxEntriesPresets) material.DropdownMenuEntry( - value: 18.0, - label: '18 pt', + value: n, + label: '$n entries', ), - ], - ), - ], + ], + ), + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Font size', + control: PreferencesDropdownMenu( + value: _fontSize, + onSelected: (v) { + if (v != null) unawaited(_setFont(v)); + }, + entries: const [ + material.DropdownMenuEntry( + value: 11.0, + label: '11 pt', + ), + material.DropdownMenuEntry( + value: 12.0, + label: '12 pt', + ), + material.DropdownMenuEntry( + value: 13.0, + label: '13 pt', + ), + material.DropdownMenuEntry( + value: 14.0, + label: '14 pt', + ), + material.DropdownMenuEntry( + value: 16.0, + label: '16 pt', + ), + material.DropdownMenuEntry( + value: 18.0, + label: '18 pt', + ), + ], + ), ), const material.SizedBox(height: 16), const PreferencesHint( diff --git a/lib/features/settings/sql_statement_timeout_dropdown.dart b/lib/features/settings/sql_statement_timeout_dropdown.dart index fa0848f0..129bb956 100644 --- a/lib/features/settings/sql_statement_timeout_dropdown.dart +++ b/lib/features/settings/sql_statement_timeout_dropdown.dart @@ -1,12 +1,20 @@ import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; /// Shared dropdown values for SQL statement timeouts (PostgreSQL / MySQL). +const List> kSqlStatementTimeoutMenuItems = [ + QueryaDropdownItem(value: null, label: 'No limit'), + QueryaDropdownItem(value: 10, label: '10 s'), + QueryaDropdownItem(value: 30, label: '30 s'), + QueryaDropdownItem(value: 60, label: '60 s'), + QueryaDropdownItem(value: 120, label: '2 min'), + QueryaDropdownItem(value: 300, label: '5 min'), + QueryaDropdownItem(value: 600, label: '10 min'), +]; + +/// Legacy alias for tests and call sites that still reference menu entries. const List> kSqlStatementTimeoutMenuEntries = [ - material.DropdownMenuEntry( - value: null, - label: 'No limit', - ), + material.DropdownMenuEntry(value: null, label: 'No limit'), material.DropdownMenuEntry(value: 10, label: '10 s'), material.DropdownMenuEntry(value: 30, label: '30 s'), material.DropdownMenuEntry(value: 60, label: '60 s'), @@ -22,19 +30,22 @@ class SqlStatementTimeoutDropdown extends material.StatelessWidget { required this.value, required this.onChanged, this.enabled = true, + this.expandToParent = false, }); final int? value; final void Function(int?) onChanged; final bool enabled; + final bool expandToParent; @override material.Widget build(material.BuildContext context) { - return PreferencesDropdownMenu( + return QueryaDropdown( value: value, enabled: enabled, + expandToParent: expandToParent, onSelected: onChanged, - entries: kSqlStatementTimeoutMenuEntries, + items: kSqlStatementTimeoutMenuItems, ); } } diff --git a/lib/main.dart b/lib/main.dart index 14dbd026..887d9a07 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'app/app.dart'; import 'core/editor/syntax_highlight_service.dart'; +import 'core/layout/ui_scale_controller.dart'; import 'core/storage/local_db.dart'; import 'core/theme/theme_controller.dart'; @@ -11,6 +12,7 @@ void main() async { await LocalDb.initFfi(); await SyntaxHighlightService.ensureInitialized(); await ThemeController.instance.load(); + await UiScaleController.instance.load(); runApp(const QueryaApp()); doWhenWindowReady(() { final win = appWindow; diff --git a/lib/shared/widgets/form_validity_notifier.dart b/lib/shared/widgets/form_validity_notifier.dart new file mode 100644 index 00000000..24d2297e --- /dev/null +++ b/lib/shared/widgets/form_validity_notifier.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' as material; + +/// Notifies when a derived form-valid flag changes (avoids full-form [setState]). +class FormValidityNotifier { + FormValidityNotifier(this._compute); + + final bool Function() _compute; + final ValueNotifier listenable = ValueNotifier(false); + + bool get value => listenable.value; + + void listenTo(material.TextEditingController controller) { + controller.addListener(_onChanged); + } + + void unlistenFrom(material.TextEditingController controller) { + controller.removeListener(_onChanged); + } + + void _onChanged() { + final next = _compute(); + if (next != listenable.value) { + listenable.value = next; + } + } + + void seed() => _onChanged(); + + void dispose() { + listenable.dispose(); + } +} diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart new file mode 100644 index 00000000..cfcd5860 --- /dev/null +++ b/lib/shared/widgets/querya_dropdown.dart @@ -0,0 +1,388 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +export 'querya_dropdown_tokens.dart'; + +/// One selectable row in [QueryaDropdown]. +class QueryaDropdownItem { + const QueryaDropdownItem({ + required this.value, + required this.label, + this.enabled = true, + this.leading, + }); + + final T value; + final String label; + final bool enabled; + final material.Widget? leading; +} + +/// Stable dropdown built on [material.MenuAnchor] (no Overlay portal). +/// +/// Visual metrics: [QueryaDropdownTokens]. Colors from shadcn [ColorScheme]. +class QueryaDropdown extends material.StatefulWidget { + const QueryaDropdown({ + super.key, + required this.value, + required this.items, + required this.onSelected, + this.controller, + this.enabled = true, + this.width, + this.expandToParent = false, + this.alignmentOffset = QueryaDropdownTokens.menuAlignmentOffset, + this.menuMaxHeight = QueryaDropdownTokens.menuMaxHeight, + this.hint, + }); + + final T value; + final List> items; + final material.ValueChanged onSelected; + final material.MenuController? controller; + final bool enabled; + final double? width; + final bool expandToParent; + final material.Offset alignmentOffset; + final double menuMaxHeight; + final String? hint; + + @override + material.State> createState() => _QueryaDropdownState(); +} + +class _QueryaDropdownState extends material.State> { + late material.MenuController _controller; + bool _triggerHovered = false; + List? _cachedMenuChildren; + List>? _cachedMenuItems; + T? _cachedMenuValue; + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? material.MenuController(); + } + + @override + void didUpdateWidget(covariant QueryaDropdown oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + _controller = widget.controller ?? material.MenuController(); + } + if (!listEquals(oldWidget.items, widget.items) || + oldWidget.value != widget.value) { + _cachedMenuChildren = null; + } + } + + List _menuChildren(ColorScheme cs) { + if (_cachedMenuChildren != null && + listEquals(_cachedMenuItems, widget.items) && + _cachedMenuValue == widget.value) { + return _cachedMenuChildren!; + } + _cachedMenuItems = List>.from(widget.items); + _cachedMenuValue = widget.value; + _cachedMenuChildren = + widget.items.map((item) => _menuItem(item, cs)).toList(); + return _cachedMenuChildren!; + } + + material.Widget _triggerLabelText({ + required material.BuildContext context, + required String label, + required ColorScheme cs, + required bool expand, + }) { + final textColor = + widget.enabled ? cs.popoverForeground : cs.mutedForeground; + final text = material.Text( + label, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: QueryaDropdownTokens.triggerTextStyle(context, textColor), + ); + if (expand) { + return material.Expanded(child: text); + } + return text; + } + + String _labelFor(T value) { + for (final item in widget.items) { + if (item.value == value) return item.label; + } + return widget.hint ?? ''; + } + + material.Widget _menuItem(QueryaDropdownItem item, ColorScheme cs) { + return _QueryaDropdownMenuItem( + item: item, + selected: item.value == widget.value, + enabled: widget.enabled && item.enabled, + colorScheme: cs, + onPick: () { + widget.onSelected(item.value); + _controller.close(); + }, + ); + } + + material.Widget _buildTrigger({ + required material.BuildContext context, + required material.MenuController controller, + required ColorScheme cs, + required String label, + required double? fieldWidth, + }) { + final borderColor = widget.enabled + ? (_triggerHovered ? cs.ring : cs.border) + : cs.border.withValues(alpha: 0.4); + final triggerHeight = QueryaDropdownTokens.scaledTriggerHeight(context); + final chevronGap = context.scaled(QueryaDropdownTokens.triggerChevronGap); + final chevronSize = context.scaled(QueryaDropdownTokens.triggerChevronSize); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + + final triggerBody = material.MouseRegion( + cursor: widget.enabled + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + onEnter: widget.enabled ? (_) => setState(() => _triggerHovered = true) : null, + onExit: widget.enabled ? (_) => setState(() => _triggerHovered = false) : null, + child: material.AnimatedContainer( + duration: const Duration( + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), + curve: material.Curves.easeOut, + height: triggerHeight, + padding: QueryaDropdownTokens.scaledTriggerPadding(context), + decoration: material.BoxDecoration( + color: _triggerHovered + ? cs.muted.withValues(alpha: 0.28) + : cs.muted.withValues(alpha: 0.14), + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: borderColor), + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + mainAxisSize: (widget.expandToParent || fieldWidth != null) + ? material.MainAxisSize.max + : material.MainAxisSize.min, + children: [ + _triggerLabelText( + context: context, + label: label, + cs: cs, + expand: widget.expandToParent || fieldWidth != null, + ), + material.SizedBox(width: chevronGap), + material.Icon( + material.Icons.keyboard_arrow_down_rounded, + size: chevronSize, + color: widget.enabled + ? cs.mutedForeground + : cs.mutedForeground.withValues(alpha: 0.5), + ), + ], + ), + ), + ); + + return material.Material( + type: material.MaterialType.transparency, + child: material.InkWell( + onTap: widget.enabled + ? () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + } + : null, + borderRadius: material.BorderRadius.circular(radius), + child: fieldWidth != null + ? material.SizedBox(width: fieldWidth, child: triggerBody) + : triggerBody, + ), + ); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final label = _labelFor(widget.value); + final fieldWidth = widget.expandToParent + ? null + : (widget.width != null ? context.scaled(widget.width!) : null); + final menuChildren = _menuChildren(cs); + final scaledMaxHeight = context.scaled(widget.menuMaxHeight); + final effectiveMaxHeight = + widget.items.length > QueryaDropdownTokens.menuScrollItemThreshold + ? scaledMaxHeight + : double.infinity; + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + + final anchor = material.MenuAnchor( + controller: _controller, + crossAxisUnconstrained: false, + alignmentOffset: material.Offset( + widget.alignmentOffset.dx, + context.scaled(widget.alignmentOffset.dy), + ), + consumeOutsideTap: true, + style: material.MenuStyle( + backgroundColor: material.WidgetStatePropertyAll(cs.popover), + surfaceTintColor: material.WidgetStatePropertyAll(cs.popover), + elevation: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuElevation, + ), + shadowColor: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuShadowColor, + ), + maximumSize: material.WidgetStatePropertyAll( + material.Size(double.infinity, effectiveMaxHeight), + ), + padding: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuPadding, + ), + shape: material.WidgetStatePropertyAll( + material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(radius), + side: material.BorderSide(color: cs.border), + ), + ), + ), + menuChildren: menuChildren, + builder: (context, controller, child) { + final trigger = _buildTrigger( + context: context, + controller: controller, + cs: cs, + label: label, + fieldWidth: fieldWidth, + ); + if (widget.expandToParent) { + return material.SizedBox(width: double.infinity, child: trigger); + } + return trigger; + }, + ); + + if (fieldWidth != null && !widget.expandToParent) { + return material.SizedBox(width: fieldWidth, child: anchor); + } + return anchor; + } +} + +class _QueryaDropdownMenuItem extends material.StatefulWidget { + const _QueryaDropdownMenuItem({ + required this.item, + required this.selected, + required this.enabled, + required this.colorScheme, + required this.onPick, + }); + + final QueryaDropdownItem item; + final bool selected; + final bool enabled; + final ColorScheme colorScheme; + final material.VoidCallback onPick; + + @override + material.State<_QueryaDropdownMenuItem> createState() => + _QueryaDropdownMenuItemState(); +} + +class _QueryaDropdownMenuItemState extends material.State<_QueryaDropdownMenuItem> { + bool _hovered = false; + + material.Widget _leading(material.BuildContext context, ColorScheme cs) { + final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); + final checkSize = context.scaled(QueryaDropdownTokens.selectedCheckSize); + if (widget.selected) { + return material.Icon( + material.Icons.check_rounded, + size: checkSize, + color: cs.primary, + ); + } + if (widget.item.leading != null) { + return widget.item.leading!; + } + return material.SizedBox(width: slot); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = widget.colorScheme; + final bg = _hovered + ? cs.accent.withValues(alpha: 0.14) + : widget.selected + ? cs.muted.withValues(alpha: 0.32) + : material.Colors.transparent; + final itemHeight = QueryaDropdownTokens.scaledMenuItemHeight(context); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); + + return material.MouseRegion( + cursor: widget.enabled + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + onEnter: widget.enabled ? (_) => setState(() => _hovered = true) : null, + onExit: widget.enabled ? (_) => setState(() => _hovered = false) : null, + child: material.MenuItemButton( + style: material.MenuItemButton.styleFrom( + minimumSize: material.Size(double.infinity, itemHeight), + padding: material.EdgeInsets.zero, + foregroundColor: cs.popoverForeground, + disabledForegroundColor: cs.mutedForeground.withValues(alpha: 0.5), + overlayColor: material.Colors.transparent, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(radius), + ), + ), + onPressed: widget.enabled ? widget.onPick : null, + child: material.AnimatedContainer( + duration: const Duration( + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), + curve: material.Curves.easeOut, + constraints: material.BoxConstraints(minHeight: itemHeight), + padding: material.EdgeInsets.symmetric( + horizontal: context.scaled(QueryaDropdownTokens.menuItemPadding.horizontal), + vertical: context.scaled(QueryaDropdownTokens.menuItemPadding.vertical), + ), + decoration: material.BoxDecoration( + color: bg, + borderRadius: material.BorderRadius.circular(radius), + ), + child: material.Row( + children: [ + material.SizedBox(width: slot, child: _leading(context, cs)), + material.SizedBox(width: context.scaled(6)), + material.Expanded( + child: material.Text( + widget.item.label, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: QueryaDropdownTokens.menuItemTextStyle( + context, + cs.popoverForeground, + selected: widget.selected, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/querya_dropdown_tokens.dart b/lib/shared/widgets/querya_dropdown_tokens.dart new file mode 100644 index 00000000..367bbaaf --- /dev/null +++ b/lib/shared/widgets/querya_dropdown_tokens.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; + +/// Fixed metrics for [QueryaDropdown] — single source of truth for dropdown UI. +abstract final class QueryaDropdownTokens { + /// Compact desktop trigger height (content is vertically centered). + static const double triggerHeight = 36.0; + + static const double triggerPaddingHorizontal = 12.0; + + static const double triggerChevronGap = 8.0; + + static const double triggerChevronSize = 18.0; + + static const material.Offset menuAlignmentOffset = material.Offset(0, 4.0); + + static const double menuMaxHeight = 300.0; + + static const int menuScrollItemThreshold = 8; + + static const double menuBorderRadius = 6.0; + + static const double menuElevation = 8.0; + + static const material.Color menuShadowColor = material.Color(0x42000000); + + static const material.EdgeInsets menuPadding = + material.EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0); + + static const double menuItemHeight = 32.0; + + static const material.EdgeInsets menuItemPadding = + material.EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0); + + static const double fontSize = 14.0; + + static const double lineHeight = 1.25; + + static const double selectedCheckSize = 16.0; + + static const double selectedCheckSlotWidth = 18.0; + + static const int hoverAnimationMs = 120; + + static double scaledTriggerHeight(material.BuildContext context) => + context.scaled(triggerHeight); + + static double scaledFontSize(material.BuildContext context) => + context.scaled(fontSize); + + static double scaledMenuItemHeight(material.BuildContext context) => + context.scaled(menuItemHeight); + + static double scaledMenuMaxHeight(material.BuildContext context) => + context.scaled(menuMaxHeight); + + static material.EdgeInsets scaledTriggerPadding(material.BuildContext context) => + material.EdgeInsets.symmetric( + horizontal: context.scaled(triggerPaddingHorizontal), + ); + + static material.TextStyle triggerTextStyle( + material.BuildContext context, + material.Color color, + ) { + final size = scaledFontSize(context); + return material.TextStyle( + fontSize: size, + height: lineHeight, + fontWeight: material.FontWeight.w500, + color: color, + ); + } + + static material.TextStyle menuItemTextStyle( + material.BuildContext context, + material.Color color, { + required bool selected, + }) { + final size = scaledFontSize(context); + return material.TextStyle( + fontSize: size, + height: lineHeight, + fontWeight: selected ? material.FontWeight.w600 : material.FontWeight.w400, + color: color, + ); + } +} + +/// Uniform label column width in [PreferencesFieldRow]. +const double kPreferencesLabelWidth = 152.0; diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 1cd9ec8c..4c4619cf 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -9,4 +9,6 @@ library; export 'app_dialog.dart'; +export 'querya_dropdown.dart' + show QueryaDropdown, QueryaDropdownItem, QueryaDropdownTokens, kPreferencesLabelWidth; export 'package:shadcn_flutter/shadcn_flutter.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 3f764c8a..2e8cecdb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.1+7 +version: 0.4.0+6 diff --git a/test/core/editor/querya_code_editor_test.dart b/test/core/editor/querya_code_editor_test.dart index 33f1dc22..4c376dab 100644 --- a/test/core/editor/querya_code_editor_test.dart +++ b/test/core/editor/querya_code_editor_test.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import '../../support/pump_syntax_highlight.dart'; import '../../support/querya_theme_test_shell.dart'; void main() { @@ -74,11 +75,14 @@ void main() { ), ); await tester.pumpAndSettle(); + await pumpSyntaxHighlightDebounce(tester); await tester.enterText(find.byType(material.EditableText), 'SELECT 1'); await tester.pump(); + await pumpSyntaxHighlightDebounce(tester); expect(external.text, 'SELECT 1'); external.text = 'UPDATE x'; await tester.pump(); + await pumpSyntaxHighlightDebounce(tester); expect( tester.widget(find.byType(material.EditableText)).controller.text, 'UPDATE x', diff --git a/test/core/layout/window_layout_scale_test.dart b/test/core/layout/window_layout_scale_test.dart new file mode 100644 index 00000000..f064d83e --- /dev/null +++ b/test/core/layout/window_layout_scale_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; + +void main() { + testWidgets('dialogConstraints scales with QueryaUiScaleScope', (tester) async { + await tester.pumpWidget( + QueryaUiScaleScope( + scale: 1.25, + child: MaterialApp( + home: Builder( + builder: (context) { + final c = WindowLayout.dialogConstraints( + context, + maxWidth: 480, + minWidth: 360, + maxHeight: 640, + ); + expect(c.maxWidth, 600); + expect(c.minWidth, 450); + expect(c.maxHeight, 800); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + }); + + testWidgets('scaledDialogExtent applies scale before viewport clamp', (tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => QueryaUiScaleScope( + scale: 1.25, + child: child ?? const SizedBox.shrink(), + ), + home: Builder( + builder: (context) { + final extent = WindowLayout.scaledDialogExtent( + context, + screenExtent: 2000, + insetTotal: 100, + baseMax: 480, + baseMin: 200, + ); + expect(extent, 600); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); +} diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 5b1a0d99..62e6b05d 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -229,6 +229,26 @@ void main() { }); }); + group('ui scale', () { + test('defaults to 1.0 and stores continuous 1% steps', () async { + expect(await AppSettings.instance.getUiScale(), kDefaultUiScale); + + await AppSettings.instance.setUiScale(1.12, fine: true); + expect(await AppSettings.instance.getUiScale(), closeTo(1.12, 0.001)); + + await AppSettings.instance.setUiScale(0.75); + expect(await AppSettings.instance.getUiScale(), kMinUiScale); + + await AppSettings.instance.setUiScale(2.5); + expect(await AppSettings.instance.getUiScale(), kMaxUiScale); + + await AppSettings.instance.setUiScale(1.12, fine: false); + expect(await AppSettings.instance.getUiScale(), 1.1); + + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.uiScale); + }); + }); + group('AppSettingsRevision', () { test('bump increments listenable value', () { final start = AppSettingsRevision.listenable.value; @@ -236,16 +256,30 @@ void main() { expect(AppSettingsRevision.listenable.value, start + 1); }); - test('mutating AppSettings notifies listenable', () async { + test('mutating SQL workspace settings notifies SqlWorkspaceSettingsRevision', + () async { var calls = 0; void listener() => calls++; - AppSettingsRevision.listenable.addListener(listener); - final before = AppSettingsRevision.listenable.value; + SqlWorkspaceSettingsRevision.listenable.addListener(listener); + final before = SqlWorkspaceSettingsRevision.listenable.value; await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(45); - expect(AppSettingsRevision.listenable.value, greaterThan(before)); + expect(SqlWorkspaceSettingsRevision.listenable.value, greaterThan(before)); expect(calls, greaterThan(0)); - AppSettingsRevision.listenable.removeListener(listener); + SqlWorkspaceSettingsRevision.listenable.removeListener(listener); + }); + + test('mutating theme settings does not notify SqlWorkspaceSettingsRevision', + () async { + var sqlCalls = 0; + void listener() => sqlCalls++; + + SqlWorkspaceSettingsRevision.listenable.addListener(listener); + final before = SqlWorkspaceSettingsRevision.listenable.value; + await AppSettings.instance.setThemeMode(ThemeMode.light); + expect(SqlWorkspaceSettingsRevision.listenable.value, before); + expect(sqlCalls, 0); + SqlWorkspaceSettingsRevision.listenable.removeListener(listener); }); }); } diff --git a/test/core/util/deep_collection_equals_test.dart b/test/core/util/deep_collection_equals_test.dart new file mode 100644 index 00000000..69938137 --- /dev/null +++ b/test/core/util/deep_collection_equals_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; +import 'package:querya_desktop/features/mysql/mysql_result_utils.dart'; + +void main() { + group('deepCollectionEquals', () { + test('compares nested maps and lists', () { + const a = { + 'x': 1, + 'y': [1, 2, {'z': 'ok'}], + }; + const b = { + 'x': 1, + 'y': [1, 2, {'z': 'ok'}], + }; + const c = { + 'x': 1, + 'y': [1, 2, {'z': 'nope'}], + }; + expect(deepCollectionEquals(a, b), isTrue); + expect(deepCollectionEquals(a, c), isFalse); + }); + + test('replaceIfChanged skips identical snapshots', () { + var value = {'a': 1}; + var applyCount = 0; + expect( + replaceIfChanged(value, {'a': 1}, (v) { + applyCount++; + value = v!; + }), + isFalse, + ); + expect(applyCount, 0); + }); + }); + + group('convertMysqlResultRowsToStrings', () { + test('null cells become NULL', () { + final out = convertMysqlResultRowsToStrings( + const MysqlResultConvertJob( + rowValues: [ + [1, null, 'x'], + ], + ), + ); + expect(out, [ + ['1', 'NULL', 'x'], + ]); + }); + }); +} diff --git a/test/features/connections/lazy_connection_tree_list_test.dart b/test/features/connections/lazy_connection_tree_list_test.dart new file mode 100644 index 00000000..8feed90e --- /dev/null +++ b/test/features/connections/lazy_connection_tree_list_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart'; + +void main() { + testWidgets('lazyConnectionTreeList uses ListView for large lists', (tester) async { + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: material.Builder( + builder: (context) => lazyConnectionTreeList( + context: context, + itemCount: 50, + itemExtent: kConnectionTreeRowExtent, + itemBuilder: (context, index) => material.SizedBox( + height: kConnectionTreeRowExtent, + child: material.Text('item $index'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsOneWidget); + }); + + testWidgets('lazyConnectionTreeList uses Column for small lists', (tester) async { + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: material.Builder( + builder: (context) => lazyConnectionTreeList( + context: context, + itemCount: 5, + itemBuilder: (context, index) => material.Text('item $index'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsNothing); + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item 4'), findsOneWidget); + }); +} diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart new file mode 100644 index 00000000..647f9c16 --- /dev/null +++ b/test/features/main_screen/results_tab_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; +import 'package:querya_desktop/features/main_screen/results_tab.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('computeResultGridColumnWidths', () { + test('returns empty list for no columns', () { + expect( + computeResultGridColumnWidths(columns: const [], rows: const []), + isEmpty, + ); + }); + + test('respects min and max width bounds', () { + final widths = computeResultGridColumnWidths( + columns: const ['id', 'note'], + rows: [ + ['1', 'x'], + ['2', 'y'], + ], + minWidth: 100, + maxWidth: 150, + ); + expect(widths, hasLength(2)); + for (final w in widths) { + expect(w, inInclusiveRange(100, 150)); + } + }); + + test('widens columns for long sampled values', () { + final short = computeResultGridColumnWidths( + columns: const ['payload'], + rows: [ + ['a'], + ], + ).single; + final long = computeResultGridColumnWidths( + columns: const ['payload'], + rows: [ + ['x' * 80], + ], + ).single; + expect(long, greaterThan(short)); + }); + }); + + group('ResultsTab', () { + testWidgets('uses virtualized grid instead of Table', (tester) async { + final rows = List.generate( + 120, + (i) => ['$i', 'value-$i'], + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ResultsTab( + columns: const ['id', 'name'], + rows: rows, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsOneWidget); + expect(find.byType(material.Table), findsNothing); + expect(find.byType(VirtualResultGrid), findsOneWidget); + }); + + testWidgets('virtualizes rows — does not build all row widgets at once', + (tester) async { + final rows = List.generate( + 500, + (i) => ['$i', 'value-$i'], + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + height: 400, + width: 600, + child: VirtualResultGrid( + columns: const ['id', 'name'], + rows: rows, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Header + only visible rows (not all 500). + final dataRowWidgets = tester.widgetList(find.byType(material.Row)).length; + expect(dataRowWidgets, lessThan(80)); + }); + }); +} diff --git a/test/features/mongodb/mongo_document_editor_test.dart b/test/features/mongodb/mongo_document_editor_test.dart index 64f23985..6915c118 100644 --- a/test/features/mongodb/mongo_document_editor_test.dart +++ b/test/features/mongodb/mongo_document_editor_test.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/mongodb/mongo_document_editor.dart'; +import '../../support/pump_syntax_highlight.dart'; import '../../support/querya_theme_test_shell.dart'; void main() { @@ -37,6 +38,7 @@ void main() { ), ); await tester.pumpAndSettle(); + await pumpSyntaxHighlightDebounce(tester); } testWidgets('Format pretty-prints valid JSON', (tester) async { @@ -48,6 +50,7 @@ void main() { await tester.pump(); await tester.tap(find.text('Format')); await tester.pump(); + await pumpSyntaxHighlightDebounce(tester); final editable = tester.widget( find.byType(material.EditableText), @@ -63,6 +66,7 @@ void main() { await tester.pump(); await tester.tap(find.text('Format')); await tester.pump(); + await pumpSyntaxHighlightDebounce(tester); expect(find.textContaining('Invalid JSON'), findsOneWidget); expect(find.byType(material.EditableText), findsOneWidget); diff --git a/test/features/settings/interface_scale_slider_test.dart b/test/features/settings/interface_scale_slider_test.dart new file mode 100644 index 00000000..300c9c6d --- /dev/null +++ b/test/features/settings/interface_scale_slider_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('InterfaceScaleSlider', () { + testWidgets('shows percentage and slider', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: InterfaceScaleSlider(scale: 1.0), + ), + ), + ); + await tester.pump(); + + expect(find.text('100%'), findsOneWidget); + expect(find.byType(Slider), findsOneWidget); + }); + + test('UiScaleController.normalize snaps to presets unless fine mode', () { + expect(UiScaleController.normalize(1.15), 1.1); + expect(UiScaleController.normalize(1.15, fine: true), closeTo(1.15, 0.001)); + expect(UiScaleController.normalize(0.88), 0.9); + }); + }); + + group('ui scale range', () { + test('matches Telegram-style 75–200 percent bounds', () { + expect(kMinUiScale, 0.75); + expect(kMaxUiScale, 2.0); + expect(kUiScaleStep, 0.01); + }); + + test('presets snap to fixed ticks', () { + expect(kUiScalePresets, [ + 0.75, + 0.85, + 0.9, + 1.0, + 1.1, + 1.25, + 1.5, + 1.75, + 2.0, + ]); + expect(snapUiScaleToPreset(1.12), 1.1); + expect(snapUiScaleToPreset(0.88), 0.9); + expect(nearestUiScalePresetIndex(1.0), 3); + }); + }); +} diff --git a/test/features/settings/sql_statement_timeout_dropdown_test.dart b/test/features/settings/sql_statement_timeout_dropdown_test.dart index cde436bb..cdd6a208 100644 --- a/test/features/settings/sql_statement_timeout_dropdown_test.dart +++ b/test/features/settings/sql_statement_timeout_dropdown_test.dart @@ -4,17 +4,16 @@ import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown. import '../../support/querya_theme_test_shell.dart'; void main() { - group('kSqlStatementTimeoutMenuEntries', () { + group('kSqlStatementTimeoutMenuItems', () { test('has seven entries with expected values', () { - expect(kSqlStatementTimeoutMenuEntries.length, 7); - final values = - kSqlStatementTimeoutMenuEntries.map((e) => e.value).toList(); + expect(kSqlStatementTimeoutMenuItems.length, 7); + final values = kSqlStatementTimeoutMenuItems.map((e) => e.value).toList(); expect(values, [null, 10, 30, 60, 120, 300, 600]); }); }); group('SqlStatementTimeoutDropdown', () { - testWidgets('builds DropdownMenu with current value', (tester) async { + testWidgets('builds MenuAnchor with current value', (tester) async { await tester.pumpWidget( queryaThemeTestShell( child: material.Scaffold( @@ -27,15 +26,11 @@ void main() { ); await tester.pump(); - expect(find.byType(material.DropdownMenu), findsOneWidget); - final menu = tester.widget>( - find.byType(material.DropdownMenu), - ); - expect(menu.initialSelection, 60); - expect(menu.onSelected, isNotNull); + expect(find.byType(material.MenuAnchor), findsOneWidget); + expect(find.text('60 s'), findsOneWidget); }); - testWidgets('disables changes when enabled is false', (tester) async { + testWidgets('disables menu when enabled is false', (tester) async { await tester.pumpWidget( queryaThemeTestShell( child: material.Scaffold( @@ -49,10 +44,10 @@ void main() { ); await tester.pump(); - final menu = tester.widget>( - find.byType(material.DropdownMenu), - ); - expect(menu.enabled, isFalse); + await tester.tap(find.text('30 s')); + await tester.pumpAndSettle(); + + expect(find.text('10 s'), findsNothing); }); }); } diff --git a/test/shared/querya_dropdown_test.dart b/test/shared/querya_dropdown_test.dart new file mode 100644 index 00000000..ec2fa07f --- /dev/null +++ b/test/shared/querya_dropdown_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; + +import '../support/querya_theme_test_shell.dart'; + +void main() { + group('QueryaDropdownTokens', () { + test('uses design-system defaults from issue #89', () { + expect(QueryaDropdownTokens.triggerHeight, 36.0); + expect(QueryaDropdownTokens.menuAlignmentOffset, const material.Offset(0, 4)); + expect(QueryaDropdownTokens.menuMaxHeight, 300.0); + expect(QueryaDropdownTokens.menuBorderRadius, 6.0); + expect(QueryaDropdownTokens.fontSize, 14.0); + }); + }); + + group('QueryaDropdown', () { + testWidgets('builds MenuAnchor with current label and chevron', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaDropdown( + value: 'b', + items: const [ + QueryaDropdownItem(value: 'a', label: 'Alpha'), + QueryaDropdownItem(value: 'b', label: 'Beta'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(material.MenuAnchor), findsOneWidget); + expect(find.text('Beta'), findsOneWidget); + expect(find.byIcon(material.Icons.keyboard_arrow_down_rounded), findsOneWidget); + + final box = tester.getSize(find.byType(material.AnimatedContainer).first); + expect(box.height, QueryaDropdownTokens.triggerHeight); + }); + + testWidgets('opens menu and reports selection', (tester) async { + int? picked; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaDropdown( + value: 1, + items: const [ + QueryaDropdownItem(value: 1, label: 'One'), + QueryaDropdownItem(value: 2, label: 'Two'), + ], + onSelected: (v) => picked = v, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Two')); + await tester.pumpAndSettle(); + + expect(picked, 2); + }); + + testWidgets('menu anchor constrains width to trigger', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 240, + child: QueryaDropdown( + value: 'a', + expandToParent: true, + items: const [ + QueryaDropdownItem(value: 'a', label: 'Alpha'), + QueryaDropdownItem(value: 'b', label: 'Beta'), + ], + onSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final anchor = tester.widget( + find.byType(material.MenuAnchor), + ); + expect(anchor.crossAxisUnconstrained, isFalse); + }); + + testWidgets('shows check on selected menu item', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaDropdown( + value: 'b', + items: const [ + QueryaDropdownItem(value: 'a', label: 'Alpha'), + QueryaDropdownItem(value: 'b', label: 'Beta'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Beta')); + await tester.pumpAndSettle(); + + expect(find.byIcon(material.Icons.check_rounded), findsOneWidget); + }); + + testWidgets('shows hint when value is null', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaDropdown( + value: null, + hint: 'Pick one', + items: const [ + QueryaDropdownItem(value: 'x', label: 'X'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Pick one'), findsOneWidget); + }); + }); +} diff --git a/test/support/pump_syntax_highlight.dart b/test/support/pump_syntax_highlight.dart new file mode 100644 index 00000000..da1c7970 --- /dev/null +++ b/test/support/pump_syntax_highlight.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/editor/querya_highlight_controller.dart'; + +/// Flushes the syntax-highlight debounce timer and pending isolate work. +Future pumpSyntaxHighlightDebounce(WidgetTester tester) async { + await tester.pump(kSyntaxHighlightDebounce); + await tester.pump(); +} diff --git a/test/support/querya_theme_test_shell.dart b/test/support/querya_theme_test_shell.dart index 32f13e6b..11090d9b 100644 --- a/test/support/querya_theme_test_shell.dart +++ b/test/support/querya_theme_test_shell.dart @@ -1,3 +1,4 @@ +import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -13,9 +14,12 @@ Widget queryaThemeTestShell({ theme: td, darkTheme: td, themeMode: ThemeMode.dark, - builder: (context, appChild) => QueryaThemeScope( - data: data, - child: appChild ?? const SizedBox.shrink(), + builder: (context, appChild) => QueryaUiScaleScope( + scale: 1.0, + child: QueryaThemeScope( + data: data, + child: appChild ?? const SizedBox.shrink(), + ), ), home: child, );