diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 7d469f53..d3e25e73 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-04-19 12:51:07.216547","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"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":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"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":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"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":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"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":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"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":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"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":"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":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-04-24 10:36:12.197519","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff2c9c6e..a1f2044a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,11 @@ jobs: channel: 'stable' cache: true + - name: Install Linux dependencies (plugins) + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev + - name: Get dependencies run: flutter pub get @@ -60,7 +65,7 @@ jobs: - name: Install Linux dependencies run: | sudo apt-get update - sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev - name: Setup Flutter uses: subosito/flutter-action@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8584512..6a6bf0b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,10 @@ -# Release: сборка артефактов → единая публикация с git-cliff и SHA256SUMS. -# Триггер: git tag v0.1.0 && git push origin v0.1.0 -# -# Рекомендуемые коммиты: Conventional Commits (feat:, fix:, …) — см. cliff.toml +# Release: ручной запуск сборки артефактов и публикация релиза с тегом v. +# Триггер: Actions -> Release -> Run workflow name: Release on: - push: - tags: - - 'v*' + workflow_dispatch: permissions: contents: write @@ -21,9 +17,24 @@ jobs: build-windows: name: Build Windows runs-on: windows-latest + outputs: + version: ${{ steps.version.outputs.version }} + full_version: ${{ steps.version.outputs.full_version }} + build_number: ${{ steps.version.outputs.build_number }} steps: - uses: actions/checkout@v4 + - name: Get version from pubspec.yaml + id: version + shell: bash + run: | + FULL_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //') + VERSION=$(echo "$FULL_VERSION" | sed 's/+.*//') + BUILD_NUMBER=$(echo "$FULL_VERSION" | sed 's/.*+//') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "full_version=$FULL_VERSION" >> "$GITHUB_OUTPUT" + echo "build_number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT" + - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -39,26 +50,35 @@ jobs: - name: Zip Windows artifact run: | - $tag = "${{ github.ref_name }}" + $tag = "v${{ steps.version.outputs.version }}" $dir = "build\windows\x64\runner\Release" Compress-Archive -Path "$dir\*" -DestinationPath "Querya-Desktop-$tag-windows.zip" - uses: actions/upload-artifact@v4 with: name: bundle-windows - path: Querya-Desktop-${{ github.ref_name }}-windows.zip + path: Querya-Desktop-v${{ steps.version.outputs.version }}-windows.zip if-no-files-found: error build-linux: name: Build Linux runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@v4 + - name: Get version from pubspec.yaml + id: version + run: | + FULL_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //') + VERSION=$(echo "$FULL_VERSION" | sed 's/+.*//') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Install Linux dependencies run: | sudo apt-get update - sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -76,12 +96,12 @@ jobs: - name: Zip Linux artifact run: | cd build/linux/x64/release/bundle - zip -r "${GITHUB_WORKSPACE}/Querya-Desktop-${{ github.ref_name }}-linux.zip" . + zip -r "${GITHUB_WORKSPACE}/Querya-Desktop-v${{ steps.version.outputs.version }}-linux.zip" . - uses: actions/upload-artifact@v4 with: name: bundle-linux - path: Querya-Desktop-${{ github.ref_name }}-linux.zip + path: Querya-Desktop-v${{ steps.version.outputs.version }}-linux.zip if-no-files-found: error publish: @@ -93,10 +113,6 @@ jobs: with: fetch-depth: 0 - - uses: taiki-e/install-action@v2 - with: - tool: git-cliff@2.11.0 - - uses: actions/download-artifact@v4 with: path: artifacts @@ -112,37 +128,25 @@ jobs: cd dist sha256sum *.zip | tee SHA256SUMS.txt - - name: Generate changelog (git-cliff + GitHub API) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPO: ${{ github.repository }} - run: | - set -euo pipefail - TAG="${{ github.ref_name }}" - # Текст страницы релиза: без глобального header из cliff.toml - git-cliff --latest \ - --config cliff.toml \ - --strip header \ - --github-token "$GITHUB_TOKEN" \ - --github-repo "$GITHUB_REPO" \ - -o release-body.md - # Вложение: полный фрагмент с header (удобно архивировать / копировать в docs) - git-cliff --latest \ - --config cliff.toml \ - --github-token "$GITHUB_TOKEN" \ - --github-repo "$GITHUB_REPO" \ - -o "CHANGELOG-${TAG}.md" - - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref_name }} - name: Release ${{ github.ref_name }} - body_path: release-body.md + tag_name: v${{ needs.build-windows.outputs.version }} + name: Release v${{ needs.build-windows.outputs.version }} + body: | + ## Querya Desktop v${{ needs.build-windows.outputs.version }} + + ### Downloads + - **Linux**: Querya-Desktop-v${{ needs.build-windows.outputs.version }}-linux.zip + - **Windows**: Querya-Desktop-v${{ needs.build-windows.outputs.version }}-windows.zip + + ### Build Info + - Version: ${{ needs.build-windows.outputs.full_version }} + - Build Number: ${{ needs.build-windows.outputs.build_number }} + - Commit: ${{ github.sha }} fail_on_unmatched_files: true files: | dist/*.zip dist/SHA256SUMS.txt - CHANGELOG-${{ github.ref_name }}.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 00000000..e3382367 --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -0,0 +1,76 @@ +name: Auto Version Bump + +on: + pull_request: + types: + - closed + branches: + - main + +jobs: + bump-version: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Check if PR was merged + id: check + run: | + if [ "${{ github.event.pull_request.merged }}" = "true" ]; then + echo "merged=true" >> "$GITHUB_OUTPUT" + else + echo "merged=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout main branch + if: steps.check.outputs.merged == 'true' + uses: actions/checkout@v4 + with: + ref: main + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get current version + if: steps.check.outputs.merged == 'true' + id: current_version + run: | + CURRENT_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | sed 's/+.*//') + BUILD_NUMBER=$(grep "^version:" pubspec.yaml | sed 's/.*+//') + echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + echo "current_build=$BUILD_NUMBER" >> "$GITHUB_OUTPUT" + + - name: Bump patch version + if: steps.check.outputs.merged == 'true' + id: bump + run: | + CURRENT_VERSION="${{ steps.current_version.outputs.current_version }}" + CURRENT_BUILD="${{ steps.current_version.outputs.current_build }}" + MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1) + MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2) + PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) + NEW_PATCH=$((PATCH + 1)) + NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH" + if [ -z "$CURRENT_BUILD" ]; then + NEW_BUILD=1 + else + NEW_BUILD=$((CURRENT_BUILD + 1)) + fi + echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "new_build=$NEW_BUILD" >> "$GITHUB_OUTPUT" + + - name: Update pubspec.yaml + if: steps.check.outputs.merged == 'true' + run: | + NEW_VERSION="${{ steps.bump.outputs.new_version }}" + NEW_BUILD="${{ steps.bump.outputs.new_build }}" + sed -i "s/^version: .*/version: ${NEW_VERSION}+${NEW_BUILD}/" pubspec.yaml + grep "^version:" pubspec.yaml + + - name: Commit version bump + if: steps.check.outputs.merged == 'true' + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add pubspec.yaml + git commit -m "Bump version to ${{ steps.bump.outputs.new_version }}+${{ steps.bump.outputs.new_build }}" + git push origin main diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a3cc7264 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +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.1.0] - 2026-04-24 + +### Added + +- **Preferences** — **Edit → Preferences** dialog: global SQL statement timeout (shared dropdown), max result rows presets, editor font size, and `AppSettingsRevision` so open SQL workspaces refresh when settings change. +- **Security** — connection passwords and sensitive connection material stored via **`flutter_secure_storage`** (OS keychain/credential store); local SQLite schema migration for non-secret fields only (see `docs/security.md`). +- **MIT LICENSE** at repository root. +- **Documentation** — `docs/security.md`, `docs/user-guide.md`, `docs/release-checklist.md`; release/tag process updates in `docs/tags-and-releases.md`. +- **Linux** — `run_linux.sh` checks for `libsecret` via `pkg-config` before `flutter run`. +- **UI** — empty-workspace hints (menu path to new connection and docs); **Connection → New Database Connection** wired in the menu. +- **Tests** — coverage for `AppSettingsRevision`, `SqlStatementTimeoutDropdown`, and `QueryEditorTab` font size behavior. + +### Changed + +- **Connections / drivers** — removed JDBC driver download UI and related storage/URL helpers; MySQL/Postgres use Dart clients only. +- **CI / release** — Linux runners install **`libsecret-1-dev`**; release workflow aligned with version tags from `pubspec`. +- **Linux build** — CMake install prefix forced so the Flutter bundle installs under **`build/`** instead of system paths such as `/usr/local`. + +### Fixed + +- Linux desktop build/install layout no longer targets `/usr/local` when building the app bundle. + +[0.1.0]: https://github.com/QueryaHub/Querya-Desktop/compare/0.0.1...0.1.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..9f4a37a8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Querya contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4edbf11d..8ae9356c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A lightweight desktop client for SQL and NoSQL databases. Connect to PostgreSQL, - **Cross-platform:** Windows, Linux, macOS (Flutter desktop). - **Multi-database:** PostgreSQL, MySQL, Redis, MongoDB (more can be added). - **UI:** Custom window (no system title bar), resizable left panel (connection tree) and bottom split (query editor / results), dark theme, [shadcn_flutter](https://pub.dev/packages/shadcn_flutter) components. -- **Flow:** Right-click “Servers” → “New connection” → pick database type → configure and save (stored in local SQLite). +- **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)). ## Database drivers @@ -27,6 +27,7 @@ A lightweight desktop client for SQL and NoSQL databases. Connect to PostgreSQL, ```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. ## Setup @@ -87,4 +88,11 @@ flutter build macos ## License -See repository license if present. +[MIT](LICENSE). Third-party components (e.g. vendored UI under `third_party/`) retain their own licenses. + +## More documentation + +- [Security / local data](docs/security.md) +- [User guide](docs/user-guide.md) +- [Releases](docs/tags-and-releases.md) +- [Release checklist](docs/release-checklist.md) diff --git a/docs/mysql-implementation-plan.md b/docs/mysql-implementation-plan.md index 70edf84d..1c503f4d 100644 --- a/docs/mysql-implementation-plan.md +++ b/docs/mysql-implementation-plan.md @@ -1,5 +1,7 @@ # План: MySQL / MariaDB по образцу PostgreSQL +> **Статус:** MySQL / MariaDB уже интегрированы в приложение (`lib/features/mysql/`, встроенный драйвер `mysql_client`). Разделы про «coming soon» в Driver Manager и отдельные JDBC-артефакты относятся к **историческому** плану; актуальный список драйверов — только встроенные Dart-пакеты (см. **Connection → Driver Manager**). + Документ описывает текущую реализацию PostgreSQL в Querya Desktop, проверяет каждый пункт плана по MySQL и детализирует рекомендуемые практики, компромиссы и порядок работ. --- @@ -71,7 +73,7 @@ ### 1.7 Заглушки и драйвер-менеджер - **Stub:** в `connections_panel.dart` удаляются записи с `type == 'mysql' && name == 'MySQL connection'`. -- **Driver Manager:** `lib/features/connections/driver_manager_dialog.dart` — у MySQL `fixedStatus: DriverStatus.comingSoon`, без `DownloadableDriver`. +- **Driver Manager:** `lib/features/connections/driver_manager_dialog.dart` — список **встроенных** Dart-драйверов (без загрузки JDBC/JAR); MySQL отображается как поддерживаемый тип. --- @@ -282,7 +284,7 @@ ## 10. Документация и статус драйвера -- **`driver_manager_dialog.dart`:** заменить `comingSoon` на `installed` или `available`, когда функциональность готова; при необходимости добавить `DownloadableDriver.mysql`, если появится отдельный артефакт (сейчас PG тянется отдельно — следовать той же модели, если она актуальна для MySQL). +- **`driver_manager_dialog.dart`:** актуальная версия только информирует о встроенных драйверах; отдельные загружаемые артефакты не используются. - **`README.md`:** кратко описать поддерживаемые версии MySQL/MariaDB и ограничения драйвера. --- diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 00000000..ebd5bbd0 --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,27 @@ +# Pre-release checklist (toward 1.0) + +Use this before tagging or running the **Release** workflow. + +## Product smoke (manual) + +- [ ] Fresh profile / empty state: create one connection per supported type (PostgreSQL, MySQL, Redis, MongoDB). +- [ ] Reopen the app: connections still appear; **connect** succeeds (secrets migrated or loaded from secure store). +- [ ] Remove a connection: it disappears and reconnect is impossible without re-entering credentials. +- [ ] **Connection → New Database Connection** from the menu saves and shows in the tree. +- [ ] **Driver Manager** shows only built-in drivers (no misleading JDBC requirement). + +## Automated + +- [ ] `flutter analyze` — clean. +- [ ] `flutter test` — all green. + +## Versioning and release + +- [ ] `pubspec.yaml` `version` matches the release you intend to ship. +- [ ] Run the **Release** workflow from GitHub Actions (see [tags-and-releases.md](tags-and-releases.md)). +- [ ] Verify **Linux** and **Windows** zip artifacts and `SHA256SUMS.txt` on the GitHub Release. + +## Docs + +- [ ] [security.md](security.md) still matches behavior if storage changed. +- [ ] [README.md](../README.md) prerequisites (e.g. Linux deps) still accurate. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 00000000..9b9d44a7 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,23 @@ +# Security model (local data) + +Querya Desktop keeps **non-secret** connection metadata (host, port, user name, labels, folder ids) in a local SQLite file under the application support directory (see `LocalDb` in `lib/core/storage/local_db.dart`). + +## Secrets (passwords and connection strings) + +As of the current design: + +- **Passwords** and **MongoDB-style connection strings** are **not** stored as plaintext in SQLite. +- They are written to the **platform secure store** via `flutter_secure_storage` (Keychain on macOS, Credential Manager / DPAPI on Windows, libsecret on typical Linux desktops). +- Keys are scoped per saved connection id (`ConnectionSecretsStore` in `lib/core/storage/connection_secrets_store.dart`). + +On upgrade from older databases, existing plaintext secrets in SQLite are **migrated** into the secure store and the SQLite columns are cleared (schema version 5). + +## Threat model (practical) + +- Anyone with **full access to your user session** can usually read app data and may extract secrets depending on OS protections. +- The app does **not** implement team features, audit logging, or network zero-trust controls. +- **SSH tunnels / jump hosts** are not built into the client today; use OS-level VPN or SSH forwarding if required. + +## Tests + +Automated tests use an **in-memory** secrets backend (see `test/flutter_test_config.dart`) so CI does not require a desktop keyring. diff --git a/docs/tags-and-releases.md b/docs/tags-and-releases.md index 48004555..09bbaf69 100644 --- a/docs/tags-and-releases.md +++ b/docs/tags-and-releases.md @@ -1,95 +1,56 @@ # Теги и релизы -Кратко: как помечать версии в Git и что при этом делает CI. +Кратко: как выставить версию и опубликовать бинарники через GitHub Actions. -## Зачем теги +## Как устроен релиз сейчас -- **Версия в Git** — неизменяемая метка на коммите (`v0.2.0` всегда указывает на один и тот же снимок кода). -- **Автосборка** — при пуше тега вида `v*` запускается workflow **Release** (см. `.github/workflows/release.yml`): сборки Linux и Windows, checksums, GitHub Release с текстом из [git-cliff](https://git-cliff.org/) и вложениями. +- Workflow **[Release](../.github/workflows/release.yml)** запускается **вручную** (**Actions → Release → Run workflow**), а не автоматически при пуше тега. +- Версия для артефактов и имени тега берётся из поля **`version`** в [pubspec.yaml](../pubspec.yaml) (например `0.9.0+42` → тег `v0.9.0`, build number в метаданных релиза). +- Собираются артефакты **Windows** и **Linux** (zip), публикуется **`SHA256SUMS.txt`**, создаётся **GitHub Release** с фиксированным телом (описание из workflow, не git-cliff в CI). -Теги **не заменяют** обычные коммиты в `main` / `dev`: сначала коммиты попадают в нужную ветку, затем на выбранный коммит вешается тег. +## Changelog (git-cliff) -## Имя тега +- В репозитории есть [cliff.toml](../cliff.toml) — его можно использовать **локально** для черновика release notes: -- Используйте префикс **`v`** и семантическую версию, например: `v0.1.0`, `v1.2.3`. -- В CI и [cliff.toml](../cliff.toml) ожидаются теги, попадающие под шаблон **`v*`** (например `v0.1.0`). Теги вроде `1.0.0` без `v` workflow **не** подхватит. -- Предрелизные суффиксы в имени тега возможны, но в `cliff.toml` для changelog **пропускаются** паттерны вроде `beta`, `alpha`, `rc` в `skip_tags` — ориентируйтесь на обычные релизные теги `v1.0.0`. + ```bash + git-cliff --latest --strip header + ``` -## Создать и отправить тег +- Генерация changelog **не подключена** к `release.yml`; при необходимости вставьте вывод git-cliff в описание релиза вручную на GitHub или расширьте workflow. -Убедитесь, что нужный коммит уже в удалённом репозитории (или запушьте ветку, затем тег). +## Имя тега -**Тег на текущий коммит:** +- Workflow создаёт тег вида **`v` + semver из pubspec** (например `v0.9.0`). +- Чтобы не путаться с другими схемами, придерживайтесь **`v*`** для релизных тегов. -```bash -git checkout main # или dev — как принято у вас -git pull -git tag v0.2.0 -git push origin v0.2.0 -``` +## Перед запуском Release -**Тег на конкретный коммит:** - -```bash -git tag v0.2.0 abc1234 -git push origin v0.2.0 -``` +1. Обновите `version` в `pubspec.yaml`, закоммитьте в ветку по вашему процессу. +2. Убедитесь, что **CI** (тесты + analyze) зелёные на этом коммите. +3. Запустите workflow **Release** на нужном коммите (обычно `main`). -**Удалить тег** (осторожно, если релиз уже опубликован): +## Локальная проверка changelog (опционально) ```bash -git tag -d v0.2.0 -git push origin :refs/tags/v0.2.0 +git-cliff --latest --strip header ``` -На GitHub вручную: **Releases → Draft** не заменяет пуш тега; для автоматизации ориентируйтесь на `git push origin `. - -## Что появится в GitHub после пуша тега - -1. Запускается workflow **Release**. -2. Собираются артефакты **Windows** и **Linux** (zip). -3. Генерируется **`SHA256SUMS.txt`** для проверки архивов. -4. Формируется описание релиза и файл **`CHANGELOG-<тег>.md`** через **git-cliff** (группировка по Conventional Commits, при наличии токена — данные с GitHub API). -5. Создаётся или обновляется **GitHub Release** с телом из changelog и вложениями (zip + checksums + markdown). - -Отдельно при пуше тега `v*` может выполняться **CI** из `.github/workflows/ci.yml` (тесты, анализ, при необходимости сборка Linux). - -## Коммиты и changelog - -Чтобы разделы релиза (Features, Bug Fixes и т.д.) выглядели аккуратно, сообщения коммитов лучше вести в стиле [Conventional Commits](https://www.conventionalcommits.org/), например: - -- `feat(connections): …` -- `fix(postgres): …` -- `docs: …` - -Остальные сообщения не пропадают: они попадают в группу **Other** (см. [cliff.toml](../cliff.toml)). - -## Версия в приложении - -Номер в **Git-теге** и поле **`version`** в [pubspec.yaml](../pubspec.yaml) — разные сущности. Для согласованности перед релизом обычно обновляют `pubspec.yaml`, коммитят, затем вешают тег на этот коммит (или наоборот — по договорённости в команде). +С токеном GitHub (PR, авторы) см. историческую секцию в предыдущих версиях этого файла или документацию git-cliff. ## Где смотреть настройки | Файл | Назначение | |------|------------| -| [.github/workflows/release.yml](../.github/workflows/release.yml) | Триггер по тегам `v*`, сборка, релиз | -| [.github/workflows/ci.yml](../.github/workflows/ci.yml) | CI при пуше веток и тегов `v*` | -| [cliff.toml](../cliff.toml) | Правила changelog (группы, теги, remote) | - -## Локальная проверка changelog (опционально) +| [.github/workflows/release.yml](../.github/workflows/release.yml) | Ручной релиз, сборка, GitHub Release | +| [.github/workflows/ci.yml](../.github/workflows/ci.yml) | Тесты, analyze, smoke-сборка Linux на тегах `v*` | +| [.github/workflows/version-bump.yml](../.github/workflows/version-bump.yml) | Автоподнятие patch/build при merge в `main` | +| [cliff.toml](../cliff.toml) | Правила changelog (локально) | -Установите [git-cliff](https://git-cliff.org/docs/installation), в корне репозитория: - -```bash -git-cliff --latest --strip header -``` +## Версия в приложении -Для расширенного контекста (PR, контрибьюторы) нужен токен GitHub: +Поле **`version`** в `pubspec.yaml` должно соответствовать тому, что вы ожидаете увидеть в имени zip и в GitHub Release. Тег создаётся workflow’ом из этой версии. -```bash -export GITHUB_TOKEN=ghp_... -export GITHUB_REPO=QueryaHub/Querya-Desktop -git-cliff --latest --github-token "$GITHUB_TOKEN" --github-repo "$GITHUB_REPO" --strip header -``` +## Платформы -(В форке подставьте свой `owner/repo`.) +- **Поставка через CI:** Windows и Linux (см. `release.yml`). +- **macOS:** локальная сборка возможна (`flutter build macos`); отдельного job подписи/notarize в этом workflow нет. diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 00000000..78cc25ee --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,38 @@ +# Querya Desktop — user guide + +## First run + +1. Start the app. +2. Create a connection: **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. + +## Where data is stored + +- **Connection list and settings**: local SQLite (`querya.db` under the app support directory). +- **Passwords / connection strings**: OS secure store (see [security.md](security.md)). + +## Preferences + +Open **Edit → Preferences…** to change options that apply across the app: + +- **SQL statement timeouts** (PostgreSQL and MySQL) — **global** defaults for every connection of that type, not per-server. You can still change the timeout from the SQL workspace toolbar; both places stay in sync. +- **Max rows in results** — how many rows are loaded into the grid after running a query (large results may be truncated with a status message). +- **SQL editor font size** — monospace size in the query editor. + +Preferences (except secrets) live in the same local SQLite file as connection metadata. A **settings** icon next to the statement timeout in the PostgreSQL/MySQL SQL toolbar opens the same dialog. + +## Driver manager + +**Connection → Driver Manager** lists **built-in** Dart drivers. You do **not** need to download a JDBC JAR to connect. + +## Linux notes + +- **Wayland**: if you see Gdk pointer warnings, try `GDK_BACKEND=x11 flutter run -d linux` or run via `./run_linux.sh` as documented in the README. +- **libsecret**: release builds on Linux link against the system keyring stack; distro packages such as `libsecret-1-dev` are required to **compile** the Linux desktop binary (CI installs these headers). + +## Supported capabilities + +High-level feature depth varies by database type. PostgreSQL and MySQL include rich object trees and SQL workspaces; Redis and MongoDB focus on data exploration and commands suitable for day-to-day development. + +For troubleshooting build/run issues, see the main [README.md](../README.md). diff --git a/lib/core/drivers/driver_storage.dart b/lib/core/drivers/driver_storage.dart deleted file mode 100644 index 43c0839a..00000000 --- a/lib/core/drivers/driver_storage.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -import 'driver_urls.dart'; - -export 'driver_urls.dart' show DownloadableDriver; - -/// Manages downloaded database driver files (e.g. PostgreSQL JDBC JAR). -/// Drivers are stored under [applicationSupport]/querya_desktop/drivers/. -class DriverStorage { - DriverStorage._(); - static final DriverStorage instance = DriverStorage._(); - - Directory? _driversDir; - - Future _getDriversDir() async { - if (_driversDir != null) return _driversDir!; - final appSupport = await getApplicationSupportDirectory(); - final dir = Directory(p.join(appSupport.path, 'querya_desktop', 'drivers')); - if (!await dir.exists()) await dir.create(recursive: true); - _driversDir = dir; - return dir; - } - - /// Path to the driver JAR file for [driver] (whether it exists or not). - Future getJarPath(DownloadableDriver driver) async { - final dir = await _getDriversDir(); - return p.join(dir.path, driver.jarFileName); - } - - /// Whether the driver JAR for [driver] is present on disk. - Future isInstalled(DownloadableDriver driver) async { - final path = await getJarPath(driver); - return File(path).exists(); - } - - /// Downloads the driver for [driver] from the official URL. - /// Overwrites existing file if present. - Future download( - DownloadableDriver driver, { - void Function(int received, int total)? onProgress, - }) async { - final dir = await _getDriversDir(); - final filePath = p.join(dir.path, driver.jarFileName); - final file = File(filePath); - - final response = await http.get(Uri.parse(driver.url)); - if (response.statusCode != 200) { - throw DriverDownloadException( - 'Failed to download ${driver.name} driver: HTTP ${response.statusCode}', - ); - } - await file.writeAsBytes(response.bodyBytes); - onProgress?.call(response.bodyBytes.length, response.bodyBytes.length); - } - - /// Deletes the downloaded driver JAR for [driver]. - Future delete(DownloadableDriver driver) async { - final path = await getJarPath(driver); - final file = File(path); - if (await file.exists()) await file.delete(); - } - - // --- Legacy PostgreSQL-only API (delegate to generic) --- - - Future getPostgresqlJarPath() => getJarPath(DownloadableDriver.postgresql); - Future isPostgresqlInstalled() => isInstalled(DownloadableDriver.postgresql); - Future downloadPostgresql({void Function(int received, int total)? onProgress}) => - download(DownloadableDriver.postgresql, onProgress: onProgress); - Future deletePostgresql() => delete(DownloadableDriver.postgresql); -} - -class DriverDownloadException implements Exception { - DriverDownloadException(this.message); - final String message; - @override - String toString() => message; -} diff --git a/lib/core/drivers/driver_urls.dart b/lib/core/drivers/driver_urls.dart deleted file mode 100644 index c8261088..00000000 --- a/lib/core/drivers/driver_urls.dart +++ /dev/null @@ -1,24 +0,0 @@ -/// Official download URLs and file names for database drivers. -/// Used by [DriverStorage] for download/install. -/// Redis and MongoDB use Dart packages (built-in); only JAR-based drivers are listed here. -library; - -/// PostgreSQL JDBC driver (official jdbc.postgresql.org). -const postgresqlDriverUrl = 'https://jdbc.postgresql.org/download/postgresql-42.7.10.jar'; - -/// Local file name for the PostgreSQL driver JAR. -const postgresqlJarFileName = 'postgresql-42.7.10.jar'; - -/// Identifies a driver that can be downloaded and uninstalled (JAR). -enum DownloadableDriver { - postgresql, -} - -extension DownloadableDriverUrls on DownloadableDriver { - String get url => switch (this) { - DownloadableDriver.postgresql => postgresqlDriverUrl, - }; - String get jarFileName => switch (this) { - DownloadableDriver.postgresql => postgresqlJarFileName, - }; -} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 5203626a..346e8aed 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -1,10 +1,45 @@ +import 'package:flutter/foundation.dart'; + import 'local_db.dart'; +/// Default cap on rows shown in SQL workspace result grids (full result may be larger). +const int kDefaultSqlResultMaxRows = 5000; + +/// Allowed values for [AppSettings.getSqlResultMaxRows] (nearest preset is used). +const List kSqlResultMaxRowsPresets = [ + 1000, + 2500, + 5000, + 10000, + 25000, + 50000, + 100000, +]; + +/// Default monospace size in the SQL editor (logical pixels). +const double kDefaultSqlEditorFontSize = 13; + +int _normalizeSqlResultMaxRows(int n) { + final c = n.clamp(100, 100000); + return kSqlResultMaxRowsPresets.reduce( + (a, b) => (c - a).abs() <= (c - b).abs() ? a : b, + ); +} + /// Typed keys for [LocalDb] app_settings. abstract final class AppSettingsKeys { static const postgresSqlStmtTimeoutSeconds = 'postgres_sql_stmt_timeout_seconds'; static const mysqlSqlStmtTimeoutSeconds = 'mysql_sql_stmt_timeout_seconds'; + static const sqlResultMaxRows = 'sql_result_max_rows'; + static const sqlEditorFontSizePoints = 'sql_editor_font_size_points'; +} + +/// Bumps [listenable] when any preference is persisted so open screens can reload. +abstract final class AppSettingsRevision { + static final ValueNotifier listenable = ValueNotifier(0); + + static void bump() => listenable.value++; } /// User preferences backed by [LocalDb] (SQLite). @@ -32,6 +67,7 @@ class AppSettings { seconds.toString(), ); } + AppSettingsRevision.bump(); } /// `null` = use driver default for statement duration. @@ -54,5 +90,48 @@ class AppSettings { seconds.toString(), ); } + AppSettingsRevision.bump(); + } + + /// Max rows loaded into the result grid for PostgreSQL / MySQL workspaces. + Future getSqlResultMaxRows() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.sqlResultMaxRows, + ); + if (v == null || v.isEmpty) return kDefaultSqlResultMaxRows; + final n = int.tryParse(v); + if (n == null) return kDefaultSqlResultMaxRows; + return _normalizeSqlResultMaxRows(n); + } + + Future setSqlResultMaxRows(int rows) async { + final preset = kSqlResultMaxRowsPresets.contains(rows) + ? rows + : _normalizeSqlResultMaxRows(rows); + await LocalDb.instance.setAppSetting( + AppSettingsKeys.sqlResultMaxRows, + preset.toString(), + ); + AppSettingsRevision.bump(); + } + + /// Editor font size in logical pixels. + Future getSqlEditorFontSize() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.sqlEditorFontSizePoints, + ); + if (v == null || v.isEmpty) return kDefaultSqlEditorFontSize; + final n = int.tryParse(v); + if (n == null) return kDefaultSqlEditorFontSize; + return n.clamp(10, 24).toDouble(); + } + + Future setSqlEditorFontSize(double sizePoints) async { + final clamped = sizePoints.round().clamp(10, 24); + await LocalDb.instance.setAppSetting( + AppSettingsKeys.sqlEditorFontSizePoints, + clamped.toString(), + ); + AppSettingsRevision.bump(); } } diff --git a/lib/core/storage/connection_secrets_store.dart b/lib/core/storage/connection_secrets_store.dart new file mode 100644 index 00000000..09f75315 --- /dev/null +++ b/lib/core/storage/connection_secrets_store.dart @@ -0,0 +1,66 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Backend for reading/writing per-connection secrets (password, connection string). +/// In unit/widget tests, set [ConnectionSecretsStore.backend] to a memory implementation. +abstract class SecretsStorageBackend { + Future read(String key); + Future write(String key, String? value); + Future delete(String key); +} + +class _FlutterSecureStorageBackend implements SecretsStorageBackend { + /// Desktop targets use the default platform options from the plugin. + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + + @override + Future read(String key) => _storage.read(key: key); + + @override + Future write(String key, String? value) async { + if (value == null || value.isEmpty) { + await _storage.delete(key: key); + } else { + await _storage.write(key: key, value: value); + } + } + + @override + Future delete(String key) => _storage.delete(key: key); +} + +/// Stores connection passwords and connection strings outside of the SQLite file +/// using the platform secure store (Keychain / Credential Manager / libsecret). +class ConnectionSecretsStore { + ConnectionSecretsStore._(); + + /// Production uses OS-backed storage; tests override [backend] (see `test/flutter_test_config.dart`). + static SecretsStorageBackend backend = _FlutterSecureStorageBackend(); + + static const _keyPrefix = 'querya.v1.conn'; + + static String _passwordKey(int connectionId) => '$_keyPrefix.$connectionId.password'; + static String _connectionStringKey(int connectionId) => + '$_keyPrefix.$connectionId.connection_string'; + + static Future writeForConnection( + int connectionId, { + String? password, + String? connectionString, + }) async { + await backend.write(_passwordKey(connectionId), password); + await backend.write(_connectionStringKey(connectionId), connectionString); + } + + static Future<({String? password, String? connectionString})> readForConnection( + int connectionId, + ) async { + final password = await backend.read(_passwordKey(connectionId)); + final connectionString = await backend.read(_connectionStringKey(connectionId)); + return (password: password, connectionString: connectionString); + } + + static Future deleteForConnection(int connectionId) async { + await backend.delete(_passwordKey(connectionId)); + await backend.delete(_connectionStringKey(connectionId)); + } +} diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 7a98f868..78af2007 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -2,10 +2,11 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const _dbName = 'querya.db'; -const _dbVersion = 4; +const _dbVersion = 5; int? _sqliteInt(Object? v) { if (v == null) return null; @@ -128,6 +129,23 @@ class LocalDb { ) '''); } + if (oldVersion < 5) { + final rows = await db.query('connections'); + for (final m in rows) { + final id = _sqliteInt(m['id']); + if (id == null) continue; + final pwd = m['password'] as String?; + final cs = m['connection_string'] as String?; + await ConnectionSecretsStore.writeForConnection( + id, + password: (pwd != null && pwd.isNotEmpty) ? pwd : null, + connectionString: (cs != null && cs.isNotEmpty) ? cs : null, + ); + } + await db.execute( + 'UPDATE connections SET password = NULL, connection_string = NULL', + ); + } } Future getAppSetting(String key) async { @@ -189,16 +207,49 @@ class LocalDb { Future> getConnections() async { final db = await _open(); final rows = await db.query('connections', orderBy: 'sort_order ASC, name ASC'); - return rows.map(ConnectionRow.fromMap).toList(); + final out = []; + for (final m in rows) { + out.add(await _hydrateConnection(ConnectionRow.fromMap(m))); + } + return out; + } + + static Future _hydrateConnection(ConnectionRow row) async { + if (row.id == null) return row; + final secrets = await ConnectionSecretsStore.readForConnection(row.id!); + return ConnectionRow( + id: row.id, + type: row.type, + name: row.name, + host: row.host, + port: row.port, + username: row.username, + password: secrets.password ?? row.password, + databaseName: row.databaseName, + authSource: row.authSource, + useSSL: row.useSSL, + connectionString: secrets.connectionString ?? row.connectionString, + folderId: row.folderId, + sortOrder: row.sortOrder, + createdAt: row.createdAt, + ); } /// Inserts a row and returns the SQLite row id. + /// Password and connection string are stored in the OS secure store, not in SQLite. Future addConnection(ConnectionRow row) async { final db = await _open(); - return db.insert('connections', row.toMap()); + final id = await db.insert('connections', row.toPersistenceMap()); + await ConnectionSecretsStore.writeForConnection( + id, + password: row.password, + connectionString: row.connectionString, + ); + return id; } Future removeConnection(int id) async { + await ConnectionSecretsStore.deleteForConnection(id); final db = await _open(); await db.delete('connections', where: 'id = ?', whereArgs: [id]); } @@ -258,6 +309,23 @@ class ConnectionRow { 'created_at': createdAt, }; + /// SQLite row without secrets ([password], [connectionString]); use the secure store for those. + Map toPersistenceMap() => { + 'type': type, + 'name': name, + 'host': host, + 'port': port, + 'username': username, + 'password': null, + 'database_name': databaseName, + 'auth_source': authSource, + 'use_ssl': useSSL ? 1 : 0, + 'connection_string': null, + 'folder_id': folderId, + 'sort_order': sortOrder, + 'created_at': createdAt, + }; + static ConnectionRow fromMap(Map m) => ConnectionRow( id: _sqliteInt(m['id']), type: m['type'] as String, diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 10ad02c2..c2c34439 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -179,10 +179,9 @@ class ConnectionsPanelState extends State { Future _createConnection({ int? folderId, - material.BuildContext? dialogContext, }) async { final row = await promptCreateConnection( - dialogContext ?? context, + context, folderId: folderId, ); if (row != null && mounted) { @@ -376,8 +375,8 @@ class ConnectionsPanelState extends State { leading: material.Icon(material.Icons.settings_ethernet_rounded, size: 18, color: theme.colorScheme.mutedForeground), onPressed: (menuContext) async { await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted || !menuContext.mounted) return; - await _createConnection(dialogContext: menuContext); + if (!mounted) return; + await _createConnection(); }, child: const Text('New Connection'), ), diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index fa386c67..8b77c30f 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -1,33 +1,37 @@ import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/drivers/driver_storage.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'new_connection_dialog.dart'; -/// Driver status shown in Driver Manager. -enum DriverStatus { - available, - installed, - comingSoon, -} - -/// One row in the driver list: [type] for icon/label, [description], optional [downloadable] for install state. +/// One row in the driver list. typedef _DriverInfo = ({ ConnectionType type, String description, - DriverStatus? fixedStatus, - DownloadableDriver? downloadable, }); +/// Built-in drivers (Dart packages). No separate JDBC/JAR install is required to connect. final _driverInfoList = <_DriverInfo>[ - (type: ConnectionType.postgresql, description: 'PostgreSQL server connection (JDBC driver)', fixedStatus: null, downloadable: DownloadableDriver.postgresql), - (type: ConnectionType.mysql, description: 'MySQL / MariaDB server connection', fixedStatus: DriverStatus.installed, downloadable: null), - (type: ConnectionType.redis, description: 'Redis server connection', fixedStatus: DriverStatus.installed, downloadable: null), - (type: ConnectionType.mongodb, description: 'MongoDB server connection', fixedStatus: DriverStatus.installed, downloadable: null), + ( + type: ConnectionType.postgresql, + description: + 'PostgreSQL — built-in Dart driver (`postgres`). Use Connection → New Database Connection.', + ), + ( + type: ConnectionType.mysql, + description: 'MySQL / MariaDB — built-in Dart driver (`mysql_client`).', + ), + ( + type: ConnectionType.redis, + description: 'Redis — built-in Dart client (`redis`).', + ), + ( + type: ConnectionType.mongodb, + description: 'MongoDB — built-in Dart driver (`mongo_dart`).', + ), ]; -/// Shows the Driver Manager dialog: list of built-in database drivers and their status. +/// Shows built-in database drivers shipped with the app. void showDriverManagerDialog(BuildContext context) { showAppDialog( context: context, @@ -39,66 +43,9 @@ void showDriverManagerDialog(BuildContext context) { ); } -class _DriverManagerDialogContent extends material.StatefulWidget { +class _DriverManagerDialogContent extends material.StatelessWidget { const _DriverManagerDialogContent(); - @override - material.State<_DriverManagerDialogContent> createState() => _DriverManagerDialogContentState(); -} - -class _DriverManagerDialogContentState extends material.State<_DriverManagerDialogContent> { - final Map _installed = {}; - DownloadableDriver? _loadingDriver; - final Map _errors = {}; - - @override - void initState() { - super.initState(); - _refreshAllStatuses(); - } - - Future _refreshAllStatuses() async { - for (final d in DownloadableDriver.values) { - final installed = await DriverStorage.instance.isInstalled(d); - if (mounted) setState(() => _installed[d] = installed); - } - } - - Future _download(DownloadableDriver driver) async { - setState(() { - _errors.remove(driver); - _loadingDriver = driver; - }); - try { - await DriverStorage.instance.download(driver); - if (mounted) setState(() => _installed[driver] = true); - } on DriverDownloadException catch (e) { - if (mounted) setState(() => _errors[driver] = e.message); - } catch (e) { - if (mounted) setState(() => _errors[driver] = e.toString()); - } finally { - if (mounted) setState(() => _loadingDriver = null); - } - } - - Future _delete(DownloadableDriver driver) async { - setState(() => _loadingDriver = driver); - try { - await DriverStorage.instance.delete(driver); - if (mounted) setState(() => _installed[driver] = false); - } finally { - if (mounted) setState(() => _loadingDriver = null); - } - } - - DriverStatus _statusFor(_DriverInfo info) { - if (info.fixedStatus != null) return info.fixedStatus!; - final driver = info.downloadable!; - final installed = _installed[driver]; - if (installed == null) return DriverStatus.comingSoon; - return installed ? DriverStatus.installed : DriverStatus.available; - } - @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; @@ -124,7 +71,7 @@ class _DriverManagerDialogContentState extends material.State<_DriverManagerDial const Text('Driver Manager').large().semiBold(), const material.SizedBox(height: 6), const Text( - 'Built-in database drivers. Add new connection types via Connection → New Database Connection.', + 'Querya connects using built-in Dart drivers. Add a server under Connection → New Database Connection.', ).muted().small(), ], ), @@ -147,20 +94,10 @@ class _DriverManagerDialogContentState extends material.State<_DriverManagerDial ), itemBuilder: (context, index) { final info = _driverInfoList[index]; - final status = _statusFor(info); - final driver = info.downloadable; - final loading = driver != null && _loadingDriver == driver; - final error = driver != null ? _errors[driver] : null; return _DriverRow( type: info.type, - status: status, description: info.description, theme: theme, - isBuiltIn: info.downloadable == null && status == DriverStatus.installed, - loading: loading, - error: error, - onDownload: driver != null ? () => _download(driver) : null, - onUninstall: driver != null ? () => _delete(driver) : null, ); }, ), @@ -193,30 +130,16 @@ class _DriverManagerDialogContentState extends material.State<_DriverManagerDial class _DriverRow extends material.StatelessWidget { const _DriverRow({ required this.type, - required this.status, required this.description, required this.theme, - this.isBuiltIn = false, - this.loading = false, - this.error, - this.onDownload, - this.onUninstall, }); final ConnectionType type; - final DriverStatus status; final String description; final ColorScheme theme; - final bool isBuiltIn; - final bool loading; - final String? error; - final VoidCallback? onDownload; - final VoidCallback? onUninstall; @override material.Widget build(material.BuildContext context) { - final showDownload = !isBuiltIn && status == DriverStatus.available && onDownload != null; - final showUninstall = !isBuiltIn && status == DriverStatus.installed && onUninstall != null; return material.Padding( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: material.Row( @@ -242,89 +165,28 @@ class _DriverRow extends material.StatelessWidget { Text(type.label).semiBold().small(), const material.SizedBox(height: 2), Text(description).muted().xSmall(), - if (error != null) ...[ - const material.SizedBox(height: 4), - Text( - error!, - style: material.TextStyle( - fontSize: 11, - color: theme.destructive, - ), - ), - ], ], ), ), - if (isBuiltIn) ...[ - const material.SizedBox(width: 8), - material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: material.BoxDecoration( - color: theme.primary.withValues(alpha: 0.12), - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: theme.primary.withValues(alpha: 0.35), - ), + const material.SizedBox(width: 8), + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: material.BoxDecoration( + color: theme.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: theme.primary.withValues(alpha: 0.35), ), - child: Text( - 'Built-in', - style: material.TextStyle( - fontSize: 11, - fontWeight: material.FontWeight.w600, - color: theme.primary, - ), + ), + child: Text( + 'Built-in', + style: material.TextStyle( + fontSize: 11, + fontWeight: material.FontWeight.w600, + color: theme.primary, ), ), - ] else if (showDownload || showUninstall) ...[ - const material.SizedBox(width: 8), - if (showDownload) - loading - ? material.SizedBox( - width: 32, - height: 32, - child: material.Center( - child: material.SizedBox( - width: 20, - height: 20, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, - ), - ), - ), - ) - : IconButton.ghost( - onPressed: onDownload, - icon: material.Icon( - material.Icons.download_rounded, - size: 20, - color: theme.primary, - ), - ), - if (showUninstall) - loading - ? material.SizedBox( - width: 32, - height: 32, - child: material.Center( - child: material.SizedBox( - width: 20, - height: 20, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, - ), - ), - ), - ) - : IconButton.destructive( - onPressed: onUninstall, - icon: const material.Icon( - material.Icons.delete_outline_rounded, - size: 20, - ), - ), - ], + ), ], ), ); diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 0b424b2f..ac394acd 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -1,7 +1,8 @@ import 'dart:math' as math; import 'package:bitsdojo_window/bitsdojo_window.dart'; -import 'package:flutter/material.dart' as material show Scaffold, Container, MainAxisSize, GestureDetector, MouseRegion, SystemMouseCursors, HitTestBehavior, Icons, Icon; +import 'package:flutter/material.dart' as material + show Scaffold, Container, MainAxisSize, GestureDetector, MouseRegion, SystemMouseCursors, HitTestBehavior, Icons, Icon, BuildContext, Widget; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/core/theme/querya_colors.dart'; @@ -11,7 +12,7 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; -import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'workspace_panel.dart'; class MainScreen extends StatefulWidget { @@ -153,8 +154,15 @@ class _MainScreenState extends State { await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); } + Future _onNewDatabaseConnectionFromMenu(material.BuildContext menuContext) async { + final row = await promptCreateConnection(menuContext, folderId: null); + if (!mounted || row == null) return; + await LocalDb.instance.addConnection(row); + await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } + @override - Widget build(BuildContext context) { + material.Widget build(material.BuildContext context) { final theme = AppTheme.dark.colorScheme; return material.Scaffold( backgroundColor: theme.background, @@ -165,7 +173,10 @@ class _MainScreenState extends State { width: 1, child: Column( children: [ - _CustomTitleBar(theme: theme), + _CustomTitleBar( + theme: theme, + onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, + ), Divider(height: 1, color: theme.border.withValues(alpha: 0.22)), Expanded( child: LayoutBuilder( @@ -253,7 +264,7 @@ class _VerticalResizeHandle extends StatelessWidget { final void Function(double dx) onDrag; @override - Widget build(BuildContext context) { + material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; return material.MouseRegion( cursor: material.SystemMouseCursors.resizeColumn, @@ -270,9 +281,13 @@ class _VerticalResizeHandle extends StatelessWidget { } class _CustomTitleBar extends StatefulWidget { - const _CustomTitleBar({required this.theme}); + const _CustomTitleBar({ + required this.theme, + required this.onNewDatabaseConnection, + }); final ColorScheme theme; + final Future Function(material.BuildContext context) onNewDatabaseConnection; @override State<_CustomTitleBar> createState() => _CustomTitleBarState(); @@ -280,7 +295,7 @@ class _CustomTitleBar extends StatefulWidget { class _CustomTitleBarState extends State<_CustomTitleBar> { @override - Widget build(BuildContext context) { + material.Widget build(material.BuildContext context) { final c = widget.theme; final buttonColors = WindowButtonColors( iconNormal: c.mutedForeground, @@ -330,17 +345,25 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { ], child: const Text('File'), ), + MenuButton( + subMenu: [ + MenuButton( + leading: const material.Icon( + material.Icons.tune_rounded, + size: 18, + ), + onPressed: (ctx) => showPreferencesDialog(ctx), + child: const Text('Preferences…'), + ), + ], + child: const Text('Edit'), + ), MenuButton( subMenu: [ MenuButton( leading: const material.Icon(material.Icons.add_link_rounded, size: 18), trailing: const Text('Shift+Ctrl+N').xSmall().muted(), - onPressed: (ctx) async { - final type = await showNewConnectionDialog(ctx); - if (type != null) { - // TODO: add connection (no folder) - } - }, + onPressed: (ctx) => widget.onNewDatabaseConnection(ctx), child: const Text('New Database Connection'), ), MenuButton( diff --git a/lib/features/main_screen/query_editor_tab.dart b/lib/features/main_screen/query_editor_tab.dart index 7272997e..ea76336a 100644 --- a/lib/features/main_screen/query_editor_tab.dart +++ b/lib/features/main_screen/query_editor_tab.dart @@ -8,21 +8,26 @@ class QueryEditorTab extends StatelessWidget { const QueryEditorTab({ super.key, this.controller, + this.fontSize = 13, }); /// When null, an internal controller is used (standalone workspace without PG). final material.TextEditingController? controller; + /// Monospace font size in logical pixels. + final double fontSize; + @override Widget build(BuildContext context) { - return _QueryEditorBody(controller: controller); + return _QueryEditorBody(controller: controller, fontSize: fontSize); } } class _QueryEditorBody extends StatefulWidget { - const _QueryEditorBody({this.controller}); + const _QueryEditorBody({this.controller, required this.fontSize}); final material.TextEditingController? controller; + final double fontSize; @override State<_QueryEditorBody> createState() => _QueryEditorBodyState(); @@ -46,6 +51,9 @@ class _QueryEditorBodyState extends State<_QueryEditorBody> { @override void didUpdateWidget(covariant _QueryEditorBody oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.fontSize != widget.fontSize) { + setState(() {}); + } if (oldWidget.controller != widget.controller) { if (_ownController) { _owned.dispose(); @@ -80,7 +88,7 @@ class _QueryEditorBodyState extends State<_QueryEditorBody> { expands: true, style: material.TextStyle( fontFamily: QueryaTypography.mono, - fontSize: 13, + fontSize: widget.fontSize, color: theme.colorScheme.foreground, ), placeholder: const Text('-- Enter SQL here…\nSELECT 1;'), diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index 7c0828f6..b97cbf6a 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -95,6 +95,21 @@ class WorkspaceEmptyHero extends StatelessWidget { ), ], ), + material.SizedBox(height: compact ? 14 : 18), + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 8), + child: material.Text( + 'You can also use Connection → New Database Connection. ' + 'Passwords are kept in your OS secure store (see docs/security.md). ' + 'Quick start: docs/user-guide.md.', + textAlign: material.TextAlign.center, + style: material.TextStyle( + fontSize: 12, + height: 1.35, + color: cs.mutedForeground.withValues(alpha: 0.9), + ), + ), + ), ], ), ), diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 4aad3a38..076fefc2 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -5,12 +5,12 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -const _maxDisplayRows = 5000; - /// Ad-hoc SQL editor + results for MySQL / MariaDB. class MysqlSqlWorkspace extends material.StatefulWidget { const MysqlSqlWorkspace({ @@ -40,18 +40,33 @@ class _MysqlSqlWorkspaceState extends material.State { int? _queryTimeoutSeconds; + int _resultMaxRows = kDefaultSqlResultMaxRows; + double _editorFontSize = kDefaultSqlEditorFontSize; + + late final VoidCallback _appSettingsListener; + @override void initState() { super.initState(); + _appSettingsListener = () { + unawaited(_loadWorkspaceSettings()); + }; + AppSettingsRevision.listenable.addListener(_appSettingsListener); material.WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(_loadStmtTimeoutSetting()); + unawaited(_loadWorkspaceSettings()); }); } - Future _loadStmtTimeoutSetting() async { + Future _loadWorkspaceSettings() async { final t = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); + final rows = await AppSettings.instance.getSqlResultMaxRows(); + final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; - setState(() => _queryTimeoutSeconds = t); + setState(() { + _queryTimeoutSeconds = t; + _resultMaxRows = rows; + _editorFontSize = font; + }); } void _onStmtTimeoutChanged(int? v) { @@ -82,6 +97,7 @@ class _MysqlSqlWorkspaceState extends material.State { @override void dispose() { + AppSettingsRevision.listenable.removeListener(_appSettingsListener); if (_running) { MysqlService.instance.interrupt( widget.connectionRow, @@ -132,8 +148,9 @@ class _MysqlSqlWorkspaceState extends material.State { final outRows = >[]; var n = 0; + final cap = _resultMaxRows; for (final row in rs.rows) { - if (n >= _maxDisplayRows) break; + if (n >= cap) break; outRows.add( List.generate( row.numOfColumns, @@ -158,9 +175,9 @@ class _MysqlSqlWorkspaceState extends material.State { : 'Command completed.'; } else { final total = rs.numOfRows; - final truncated = total > _maxDisplayRows; + final truncated = total > cap; _statusLine = truncated - ? 'Showing first $_maxDisplayRows of $total row(s).' + ? 'Showing first $cap of $total row(s).' : '$total row(s).'; } _running = false; @@ -219,10 +236,15 @@ class _MysqlSqlWorkspaceState extends material.State { running: _running, queryTimeoutSeconds: _queryTimeoutSeconds, onQueryTimeoutChanged: _onStmtTimeoutChanged, + onOpenPreferences: () => + showPreferencesDialog(context), ), const Divider(height: 1), Expanded( - child: QueryEditorTab(controller: _sqlController), + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), ], ), @@ -282,12 +304,14 @@ class _MysqlSqlToolbar extends material.StatelessWidget { required this.running, required this.queryTimeoutSeconds, required this.onQueryTimeoutChanged, + required this.onOpenPreferences, }); final Future Function()? onExecute; final bool running; final int? queryTimeoutSeconds; final void Function(int?) onQueryTimeoutChanged; + final VoidCallback onOpenPreferences; @override material.Widget build(material.BuildContext context) { @@ -335,39 +359,18 @@ class _MysqlSqlToolbar extends material.StatelessWidget { children: [ const Text('Stmt timeout').small(), const Gap(6), - material.DropdownButton( + SqlStatementTimeoutDropdown( value: queryTimeoutSeconds, - onChanged: running ? null : onQueryTimeoutChanged, - items: const [ - material.DropdownMenuItem( - value: null, - child: material.Text('No limit'), - ), - material.DropdownMenuItem( - value: 10, - child: material.Text('10 s'), - ), - material.DropdownMenuItem( - value: 30, - child: material.Text('30 s'), - ), - material.DropdownMenuItem( - value: 60, - child: material.Text('60 s'), - ), - material.DropdownMenuItem( - value: 120, - child: material.Text('2 min'), - ), - material.DropdownMenuItem( - value: 300, - child: material.Text('5 min'), - ), - material.DropdownMenuItem( - value: 600, - child: material.Text('10 min'), - ), - ], + onChanged: onQueryTimeoutChanged, + enabled: !running, + ), + const Gap(4), + IconButton.ghost( + onPressed: running ? null : onOpenPreferences, + icon: const material.Icon( + material.Icons.settings_rounded, + size: 20, + ), ), ], ), diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 8b450e56..e871a988 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -7,12 +7,12 @@ import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -const _maxDisplayRows = 5000; - /// Ad-hoc SQL editor + results for a PostgreSQL connection (pgAdmin-style). class PostgresSqlWorkspace extends material.StatefulWidget { const PostgresSqlWorkspace({ @@ -51,21 +51,36 @@ class _PostgresSqlWorkspaceState extends material.State { /// `null` = use connection / URI [query_timeout] default from driver. int? _queryTimeoutSeconds; + int _resultMaxRows = kDefaultSqlResultMaxRows; + double _editorFontSize = kDefaultSqlEditorFontSize; + /// `null` = unknown (older server or error). bool? _txOpen; + late final VoidCallback _appSettingsListener; + @override void initState() { super.initState(); + _appSettingsListener = () { + unawaited(_loadWorkspaceSettings()); + }; + AppSettingsRevision.listenable.addListener(_appSettingsListener); material.WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(_loadStmtTimeoutSetting()); + unawaited(_loadWorkspaceSettings()); }); } - Future _loadStmtTimeoutSetting() async { + Future _loadWorkspaceSettings() async { final t = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); + final rows = await AppSettings.instance.getSqlResultMaxRows(); + final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; - setState(() => _queryTimeoutSeconds = t); + setState(() { + _queryTimeoutSeconds = t; + _resultMaxRows = rows; + _editorFontSize = font; + }); } void _onStmtTimeoutChanged(int? v) { @@ -156,6 +171,7 @@ class _PostgresSqlWorkspaceState extends material.State { @override void dispose() { + AppSettingsRevision.listenable.removeListener(_appSettingsListener); if (_running) { PostgresService.instance.interrupt( widget.connectionRow, @@ -217,8 +233,9 @@ class _PostgresSqlWorkspaceState extends material.State { final outRows = >[]; var n = 0; + final cap = _resultMaxRows; for (final row in result) { - if (n >= _maxDisplayRows) break; + if (n >= cap) break; outRows.add(row.map(_cellText).toList()); n++; } @@ -231,9 +248,9 @@ class _PostgresSqlWorkspaceState extends material.State { _statusLine = 'Command completed. Rows affected: ${result.affectedRows}.'; } else { - final truncated = result.length > _maxDisplayRows; + final truncated = result.length > cap; _statusLine = truncated - ? 'Showing first $_maxDisplayRows of ${result.length} row(s).' + ? 'Showing first $cap of ${result.length} row(s).' : '${result.length} row(s).'; } _running = false; @@ -295,6 +312,8 @@ class _PostgresSqlWorkspaceState extends material.State { setState(() => _autocommit = v), queryTimeoutSeconds: _queryTimeoutSeconds, onQueryTimeoutChanged: _onStmtTimeoutChanged, + onOpenPreferences: () => + showPreferencesDialog(context), txOpen: _txOpen, onBegin: _running ? null @@ -308,7 +327,10 @@ class _PostgresSqlWorkspaceState extends material.State { ), const Divider(height: 1), Expanded( - child: QueryEditorTab(controller: _sqlController), + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), ], ), @@ -370,6 +392,7 @@ class _SqlToolbar extends material.StatelessWidget { required this.onAutocommitChanged, required this.queryTimeoutSeconds, required this.onQueryTimeoutChanged, + required this.onOpenPreferences, required this.txOpen, required this.onBegin, required this.onCommit, @@ -382,6 +405,7 @@ class _SqlToolbar extends material.StatelessWidget { final void Function(bool) onAutocommitChanged; final int? queryTimeoutSeconds; final void Function(int?) onQueryTimeoutChanged; + final VoidCallback onOpenPreferences; final bool? txOpen; final void Function()? onBegin; final void Function()? onCommit; @@ -451,39 +475,18 @@ class _SqlToolbar extends material.StatelessWidget { children: [ const Text('Stmt timeout').small(), const Gap(6), - material.DropdownButton( + SqlStatementTimeoutDropdown( value: queryTimeoutSeconds, - onChanged: running ? null : onQueryTimeoutChanged, - items: const [ - material.DropdownMenuItem( - value: null, - child: material.Text('No limit'), - ), - material.DropdownMenuItem( - value: 10, - child: material.Text('10 s'), - ), - material.DropdownMenuItem( - value: 30, - child: material.Text('30 s'), - ), - material.DropdownMenuItem( - value: 60, - child: material.Text('60 s'), - ), - material.DropdownMenuItem( - value: 120, - child: material.Text('2 min'), - ), - material.DropdownMenuItem( - value: 300, - child: material.Text('5 min'), - ), - material.DropdownMenuItem( - value: 600, - child: material.Text('10 min'), - ), - ], + onChanged: onQueryTimeoutChanged, + enabled: !running, + ), + const Gap(4), + IconButton.ghost( + onPressed: running ? null : onOpenPreferences, + icon: const material.Icon( + material.Icons.settings_rounded, + size: 20, + ), ), ], ), diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart new file mode 100644 index 00000000..0f98fde7 --- /dev/null +++ b/lib/features/settings/preferences_dialog.dart @@ -0,0 +1,240 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +void showPreferencesDialog(BuildContext context) { + showAppDialog( + context: context, + builder: (ctx) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(ctx), + child: const _PreferencesDialogContent(), + ), + ); +} + +class _PreferencesDialogContent extends material.StatefulWidget { + const _PreferencesDialogContent(); + + @override + material.State<_PreferencesDialogContent> createState() => + _PreferencesDialogContentState(); +} + +class _PreferencesDialogContentState extends material.State<_PreferencesDialogContent> { + bool _loading = true; + int? _pgTimeout; + int? _mysqlTimeout; + int _maxRows = kDefaultSqlResultMaxRows; + double _fontSize = kDefaultSqlEditorFontSize; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final pg = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); + final my = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); + final rows = await AppSettings.instance.getSqlResultMaxRows(); + final font = await AppSettings.instance.getSqlEditorFontSize(); + if (!mounted) return; + setState(() { + _pgTimeout = pg; + _mysqlTimeout = my; + _maxRows = rows; + _fontSize = font; + _loading = false; + }); + } + + Future _setPg(int? v) async { + setState(() => _pgTimeout = v); + await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(v); + } + + Future _setMysql(int? v) async { + setState(() => _mysqlTimeout = v); + await AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(v); + } + + Future _setMaxRows(int v) async { + setState(() => _maxRows = v); + await AppSettings.instance.setSqlResultMaxRows(v); + } + + Future _setFont(double v) async { + setState(() => _fontSize = v); + await AppSettings.instance.setSqlEditorFontSize(v); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + return material.Container( + constraints: const material.BoxConstraints( + maxWidth: 480, + minWidth: 360, + maxHeight: 560, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Preferences').large().semiBold(), + const material.SizedBox(height: 6), + // ignore: prefer_const_constructors — TextStyle via shadcn extensions + Text( + 'Changes apply immediately. SQL timeouts are global for all connections of that type.', + ).muted().small(), + ], + ), + ), + material.Expanded( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: _loading + ? const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(24), + child: material.CircularProgressIndicator(), + ), + ) + : material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('SQL — PostgreSQL').semiBold().small(), + const material.SizedBox(height: 8), + material.Row( + children: [ + const Text('Statement timeout').small(), + const material.SizedBox(width: 12), + SqlStatementTimeoutDropdown( + value: _pgTimeout, + onChanged: (v) => unawaited(_setPg(v)), + ), + ], + ), + const material.SizedBox(height: 24), + const Text('SQL — MySQL / MariaDB').semiBold().small(), + const material.SizedBox(height: 8), + material.Row( + children: [ + const Text('Statement timeout').small(), + const material.SizedBox(width: 12), + SqlStatementTimeoutDropdown( + value: _mysqlTimeout, + onChanged: (v) => unawaited(_setMysql(v)), + ), + ], + ), + const material.SizedBox(height: 24), + const Text('SQL editor').semiBold().small(), + const material.SizedBox(height: 8), + material.Row( + children: [ + const Text('Max rows in results').small(), + const material.SizedBox(width: 12), + material.DropdownButton( + value: _maxRows, + onChanged: (v) { + if (v != null) unawaited(_setMaxRows(v)); + }, + items: [ + for (final n in kSqlResultMaxRowsPresets) + material.DropdownMenuItem( + value: n, + child: material.Text('$n'), + ), + ], + ), + ], + ), + const material.SizedBox(height: 12), + material.Row( + children: [ + const Text('Font size').small(), + const material.SizedBox(width: 12), + material.DropdownButton( + value: _fontSize, + onChanged: (v) { + if (v != null) unawaited(_setFont(v)); + }, + items: const [ + material.DropdownMenuItem( + value: 11.0, + child: material.Text('11 pt'), + ), + material.DropdownMenuItem( + value: 12.0, + child: material.Text('12 pt'), + ), + material.DropdownMenuItem( + value: 13.0, + child: material.Text('13 pt'), + ), + material.DropdownMenuItem( + value: 14.0, + child: material.Text('14 pt'), + ), + material.DropdownMenuItem( + value: 16.0, + child: material.Text('16 pt'), + ), + material.DropdownMenuItem( + value: 18.0, + child: material.Text('18 pt'), + ), + ], + ), + ], + ), + const material.SizedBox(height: 16), + // ignore: prefer_const_constructors — TextStyle via shadcn extensions + Text( + 'Preferences are stored locally in SQLite (non-secret keys only).', + ).muted().xSmall(), + ], + ), + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide(color: theme.border.withValues(alpha: 0.3)), + ), + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/settings/sql_statement_timeout_dropdown.dart b/lib/features/settings/sql_statement_timeout_dropdown.dart new file mode 100644 index 00000000..80fc9c76 --- /dev/null +++ b/lib/features/settings/sql_statement_timeout_dropdown.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart' as material; + +/// Shared dropdown values for SQL statement timeouts (PostgreSQL / MySQL). +const List> kSqlStatementTimeoutMenuItems = [ + material.DropdownMenuItem( + value: null, + child: material.Text('No limit'), + ), + material.DropdownMenuItem(value: 10, child: material.Text('10 s')), + material.DropdownMenuItem(value: 30, child: material.Text('30 s')), + material.DropdownMenuItem(value: 60, child: material.Text('60 s')), + material.DropdownMenuItem(value: 120, child: material.Text('2 min')), + material.DropdownMenuItem(value: 300, child: material.Text('5 min')), + material.DropdownMenuItem(value: 600, child: material.Text('10 min')), +]; + +/// Statement timeout selector used in SQL toolbars and Preferences. +class SqlStatementTimeoutDropdown extends material.StatelessWidget { + const SqlStatementTimeoutDropdown({ + super.key, + required this.value, + required this.onChanged, + this.enabled = true, + }); + + final int? value; + final void Function(int?) onChanged; + final bool enabled; + + @override + material.Widget build(material.BuildContext context) { + return material.DropdownButton( + value: value, + onChanged: enabled ? onChanged : null, + items: kSqlStatementTimeoutMenuItems, + ); + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index e37f9c46..fab71312 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -76,12 +76,11 @@ include(flutter/generated_plugins.cmake) # === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. +# Always install into a relocatable bundle under build/linux/.../bundle. +# A stale CMakeCache can leave CMAKE_INSTALL_PREFIX at /usr/local/... and break +# `flutter run` with permission errors; Flutter never needs a system prefix here. set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() +set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "Bundle output directory" FORCE) # Start with a clean build bundle directory every time. install(CODE " diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index bc8dc593..a3eaab34 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) bitsdojo_window_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "BitsdojoWindowPlugin"); bitsdojo_window_plugin_register_with_registrar(bitsdojo_window_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index e7b0802d..869134d6 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST bitsdojo_window_linux + flutter_secure_storage_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 72034940..3a8b621f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import bitsdojo_window_macos +import flutter_secure_storage_macos import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.yaml b/pubspec.yaml index 212f020c..b41bccaf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: postgres: ^3.5.6 fl_chart: ^0.69.0 mysql_client: ^0.0.27 + flutter_secure_storage: ^9.2.4 dev_dependencies: flutter_test: diff --git a/run_linux.sh b/run_linux.sh index 2c068ab8..6b2ab7d6 100755 --- a/run_linux.sh +++ b/run_linux.sh @@ -6,5 +6,16 @@ # the Flutter snap ships an incomplete LLVM bin dir. Fix: install Flutter outside snap # (git clone https://github.com/flutter/flutter.git + add .../flutter/bin to PATH), or see # https://github.com/canonical/flutter-snap/issues/123 +# +# flutter_secure_storage_linux needs libsecret headers at compile time. +if ! pkg-config --exists libsecret-1 2>/dev/null; then + echo "Querya Linux build requires libsecret-1 (CMake: libsecret-1>=0.18.4)." >&2 + echo "Install development package, then retry:" >&2 + echo " Debian/Ubuntu: sudo apt-get install libsecret-1-dev" >&2 + echo " Fedora/RHEL: sudo dnf install libsecret-devel" >&2 + echo " Arch: sudo pacman -S libsecret" >&2 + exit 1 +fi + export GDK_BACKEND=x11 exec flutter run -d linux "$@" diff --git a/test/core/drivers/driver_urls_test.dart b/test/core/drivers/driver_urls_test.dart deleted file mode 100644 index 80d801d9..00000000 --- a/test/core/drivers/driver_urls_test.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/drivers/driver_urls.dart'; -import 'package:querya_desktop/core/drivers/driver_storage.dart'; - -void main() { - group('driver_urls constants', () { - test('postgresqlDriverUrl is valid HTTPS URL', () { - expect(postgresqlDriverUrl, startsWith('https://')); - expect(Uri.tryParse(postgresqlDriverUrl), isNotNull); - }); - - test('postgresqlJarFileName ends with .jar', () { - expect(postgresqlJarFileName, endsWith('.jar')); - expect(postgresqlJarFileName, contains('postgresql')); - }); - }); - - group('DownloadableDriver extension', () { - test('postgresql url matches constant', () { - expect(DownloadableDriver.postgresql.url, postgresqlDriverUrl); - }); - - test('postgresql jarFileName matches constant', () { - expect(DownloadableDriver.postgresql.jarFileName, postgresqlJarFileName); - }); - - test('all enum values have url and jarFileName', () { - for (final d in DownloadableDriver.values) { - expect(d.url, isNotEmpty); - expect(d.jarFileName, isNotEmpty); - expect(d.jarFileName, endsWith('.jar')); - } - }); - }); - - group('DriverDownloadException', () { - test('stores message and toString returns it', () { - final ex = DriverDownloadException('HTTP 404'); - expect(ex.message, 'HTTP 404'); - expect(ex.toString(), 'HTTP 404'); - }); - }); -} diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index bb803b20..4a0cd94a 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -60,6 +60,8 @@ void main() { tearDown(() async { await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(null); await AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(null); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.sqlResultMaxRows); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.sqlEditorFontSizePoints); }); group('AppSettings', () { @@ -98,5 +100,67 @@ void main() { ); expect(await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(), isNull); }); + + test('getSqlResultMaxRows defaults and normalizes to preset', () async { + expect(await AppSettings.instance.getSqlResultMaxRows(), kDefaultSqlResultMaxRows); + + await AppSettings.instance.setSqlResultMaxRows(10000); + expect(await AppSettings.instance.getSqlResultMaxRows(), 10000); + + await LocalDb.instance.setAppSetting(AppSettingsKeys.sqlResultMaxRows, '7777'); + expect(await AppSettings.instance.getSqlResultMaxRows(), 10000); + + await LocalDb.instance.setAppSetting(AppSettingsKeys.sqlResultMaxRows, 'not-int'); + expect(await AppSettings.instance.getSqlResultMaxRows(), kDefaultSqlResultMaxRows); + }); + + test('getSqlEditorFontSize roundtrip and invalid stored', () async { + expect(await AppSettings.instance.getSqlEditorFontSize(), kDefaultSqlEditorFontSize); + + await AppSettings.instance.setSqlEditorFontSize(16); + expect(await AppSettings.instance.getSqlEditorFontSize(), 16); + + await LocalDb.instance.setAppSetting(AppSettingsKeys.sqlEditorFontSizePoints, '99'); + expect(await AppSettings.instance.getSqlEditorFontSize(), 24); + + await LocalDb.instance.setAppSetting(AppSettingsKeys.sqlEditorFontSizePoints, 'x'); + expect(await AppSettings.instance.getSqlEditorFontSize(), kDefaultSqlEditorFontSize); + }); + + test('setSqlResultMaxRows snaps non-preset to nearest', () async { + await AppSettings.instance.setSqlResultMaxRows(3333); + expect(await AppSettings.instance.getSqlResultMaxRows(), 2500); + + await AppSettings.instance.setSqlResultMaxRows(8000); + expect(await AppSettings.instance.getSqlResultMaxRows(), 10000); + }); + + test('setSqlEditorFontSize clamps to 10–24', () async { + await AppSettings.instance.setSqlEditorFontSize(8); + expect(await AppSettings.instance.getSqlEditorFontSize(), 10); + + await AppSettings.instance.setSqlEditorFontSize(30); + expect(await AppSettings.instance.getSqlEditorFontSize(), 24); + }); + }); + + group('AppSettingsRevision', () { + test('bump increments listenable value', () { + final start = AppSettingsRevision.listenable.value; + AppSettingsRevision.bump(); + expect(AppSettingsRevision.listenable.value, start + 1); + }); + + test('mutating AppSettings notifies listenable', () async { + var calls = 0; + void listener() => calls++; + + AppSettingsRevision.listenable.addListener(listener); + final before = AppSettingsRevision.listenable.value; + await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(45); + expect(AppSettingsRevision.listenable.value, greaterThan(before)); + expect(calls, greaterThan(0)); + AppSettingsRevision.listenable.removeListener(listener); + }); }); } diff --git a/test/core/storage/connection_row_test.dart b/test/core/storage/connection_row_test.dart index 200aa166..3d3233cb 100644 --- a/test/core/storage/connection_row_test.dart +++ b/test/core/storage/connection_row_test.dart @@ -159,6 +159,25 @@ void main() { expect(row.sortOrder, 0); // null sort_order → 0 }); + test('toPersistenceMap omits secrets for SQLite persistence', () { + const row = ConnectionRow( + type: 'postgresql', + name: 'PG', + host: 'localhost', + port: 5432, + username: 'u', + password: 'secret', + databaseName: 'db', + connectionString: 'postgres://u:p@h/db', + createdAt: '2026-01-01T00:00:00Z', + ); + final map = row.toPersistenceMap(); + expect(map['password'], isNull); + expect(map['connection_string'], isNull); + expect(map['username'], 'u'); + expect(map['host'], 'localhost'); + }); + test('toMap does not include id field', () { const row = ConnectionRow( id: 123, diff --git a/test/core/storage/local_db_secrets_test.dart b/test/core/storage/local_db_secrets_test.dart new file mode 100644 index 00000000..1afa3908 --- /dev/null +++ b/test/core/storage/local_db_secrets_test.dart @@ -0,0 +1,118 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import '../../memory_secrets_backend.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; + + @override + Future getApplicationCachePath() async => _root; + + @override + Future getLibraryPath() async => _root; + + @override + Future getExternalStoragePath() async => _root; + + @override + Future?> getExternalCachePaths() async => [_root]; + + @override + Future?> getExternalStoragePaths({StorageDirectory? type}) async => + [_root]; + + @override + Future getDownloadsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_local_db_secrets_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + testMemorySecrets.clear(); + for (final c in await LocalDb.instance.getConnections()) { + if (c.id != null) await LocalDb.instance.removeConnection(c.id!); + } + }); + + group('LocalDb secrets', () { + test('addConnection leaves password out of SQLite', () async { + const row = ConnectionRow( + type: 'redis', + name: 'R1', + host: '127.0.0.1', + port: 6379, + password: 'redis-secret', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + + final dbFile = p.join(tempDir.path, 'querya_desktop', 'querya.db'); + await LocalDb.instance.close(); + sqfliteFfiInit(); + final raw = await databaseFactoryFfi.openDatabase( + dbFile, + options: OpenDatabaseOptions(readOnly: true), + ); + try { + final maps = await raw.query('connections', where: 'id = ?', whereArgs: [id]); + expect(maps.single['password'], isNull); + } finally { + await raw.close(); + } + + final list = await LocalDb.instance.getConnections(); + final loaded = list.singleWhere((c) => c.id == id); + expect(loaded.password, 'redis-secret'); + }); + + test('removeConnection deletes secure-store entries', () async { + const row = ConnectionRow( + type: 'redis', + name: 'R2', + host: '127.0.0.1', + port: 6379, + password: 'x', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + await LocalDb.instance.removeConnection(id); + + final s = await ConnectionSecretsStore.readForConnection(id); + expect(s.password, isNull); + expect(s.connectionString, isNull); + }); + }); +} diff --git a/test/features/main_screen/query_editor_tab_test.dart b/test/features/main_screen/query_editor_tab_test.dart new file mode 100644 index 00000000..1104700f --- /dev/null +++ b/test/features/main_screen/query_editor_tab_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + testWidgets('QueryEditorTab applies fontSize to EditableText', (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: const material.Scaffold( + body: SizedBox( + width: 600, + height: 400, + child: QueryEditorTab(fontSize: 17), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(material.EditableText), findsOneWidget); + final editable = tester.widget( + find.byType(material.EditableText), + ); + expect(editable.style.fontSize, 17); + }); + + testWidgets('QueryEditorTab default fontSize is 13', (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: const material.Scaffold( + body: SizedBox( + width: 600, + height: 400, + child: QueryEditorTab(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final editable = tester.widget( + find.byType(material.EditableText), + ); + expect(editable.style.fontSize, 13); + }); +} diff --git a/test/features/settings/sql_statement_timeout_dropdown_test.dart b/test/features/settings/sql_statement_timeout_dropdown_test.dart new file mode 100644 index 00000000..96a35117 --- /dev/null +++ b/test/features/settings/sql_statement_timeout_dropdown_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; + +void main() { + group('kSqlStatementTimeoutMenuItems', () { + test('has seven entries with expected values', () { + 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 DropdownButton with current value', (tester) async { + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: SqlStatementTimeoutDropdown( + value: 60, + onChanged: (_) {}, + ), + ), + ), + ); + + expect(find.byType(material.DropdownButton), findsOneWidget); + final dd = tester.widget>( + find.byType(material.DropdownButton), + ); + expect(dd.value, 60); + expect(dd.onChanged, isNotNull); + }); + + testWidgets('disables changes when enabled is false', (tester) async { + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: SqlStatementTimeoutDropdown( + value: 30, + onChanged: (_) {}, + enabled: false, + ), + ), + ), + ); + + final dd = tester.widget>( + find.byType(material.DropdownButton), + ); + expect(dd.onChanged, isNull); + }); + + testWidgets('onChanged receives new selection', (tester) async { + int? last; + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: SqlStatementTimeoutDropdown( + value: null, + onChanged: (v) => last = v, + ), + ), + ), + ); + + await tester.tap(find.byType(material.DropdownButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('30 s').last); + await tester.pumpAndSettle(); + + expect(last, 30); + }); + }); +} diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart new file mode 100644 index 00000000..97892e9c --- /dev/null +++ b/test/flutter_test_config.dart @@ -0,0 +1,12 @@ +import 'dart:async'; + +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; + +import 'memory_secrets_backend.dart'; + +/// Runs before all tests in this package (see `package:test` global configuration). +Future testExecutable(FutureOr Function() testMain) async { + ConnectionSecretsStore.backend = testMemorySecrets; + testMemorySecrets.clear(); + await testMain(); +} diff --git a/test/memory_secrets_backend.dart b/test/memory_secrets_backend.dart new file mode 100644 index 00000000..75a6250a --- /dev/null +++ b/test/memory_secrets_backend.dart @@ -0,0 +1,28 @@ +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; + +/// Shared in-memory backend wired from `test/flutter_test_config.dart`. +final MemorySecretsStorageBackend testMemorySecrets = MemorySecretsStorageBackend(); + +/// In-memory secrets backend for `flutter test` (no OS keychain / libsecret). +class MemorySecretsStorageBackend implements SecretsStorageBackend { + final Map _values = {}; + + @override + Future read(String key) async => _values[key]; + + @override + Future write(String key, String? value) async { + if (value == null || value.isEmpty) { + _values.remove(key); + } else { + _values[key] = value; + } + } + + @override + Future delete(String key) async { + _values.remove(key); + } + + void clear() => _values.clear(); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index ac4baddc..beebfba4 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { BitsdojoWindowPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("BitsdojoWindowPlugin")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 84cb9102..5181336b 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST bitsdojo_window_windows + flutter_secure_storage_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST