diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index fd5e346f..20cf0d3f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,12 +1,32 @@ -FROM ghcr.io/cirruslabs/flutter:3.41.6 +# Querya Desktop dev container: Flutter (pinned to the CI version) + the Linux +# desktop toolchain + the Docker CLI for the database stack. +FROM ubuntu:24.04 -# Linux desktop toolchain + libs the app links against. +# Keep in step with `flutter-version` in .github/workflows/ci.yml +# (test/guards/devcontainer_flutter_version_test.dart fails when they differ). +ARG FLUTTER_VERSION=3.47.1 + +ENV DEBIAN_FRONTEND=noninteractive \ + FLUTTER_HOME=/opt/flutter \ + PUB_CACHE=/root/.pub-cache +ENV PATH=/opt/flutter/bin:/opt/flutter/bin/cache/dart-sdk/bin:/root/.pub-cache/bin:$PATH + +# Linux desktop toolchain + libs the app links against; bubblewrap for the +# extension sandbox tests; socat to forward localhost ports to the databases. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev \ - libsqlite3-dev libglu1-mesa xvfb git curl ca-certificates \ + build-essential clang cmake ninja-build pkg-config libgtk-3-dev \ + libsecret-1-dev libsqlite3-dev liblzma-dev libglu1-mesa xvfb \ + git curl unzip xz-utils zip ca-certificates bubblewrap socat \ && rm -rf /var/lib/apt/lists/* +# Flutter at the pinned tag (the cirruslabs image registry has no 3.47.x tag). +RUN git config --system --add safe.directory /opt/flutter \ + && git clone --depth 1 --branch "${FLUTTER_VERSION}" \ + https://github.com/flutter/flutter.git /opt/flutter \ + && flutter config --no-analytics --enable-linux-desktop \ + && flutter precache --linux + # Docker CLI so the dev stack (docker/docker-compose.yml) can be started from inside the container. COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker COPY --from=docker:27-cli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index dbf02ce3..cca3c70d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,18 +1,23 @@ { "name": "Querya Desktop", - "build": { "dockerfile": "Dockerfile" }, + "build": { + "dockerfile": "Dockerfile", + "cacheFrom": "ghcr.io/queryahub/querya-devcontainer:latest" + }, "features": { "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} }, "postCreateCommand": "flutter config --enable-linux-desktop && flutter pub get", - "postStartCommand": "docker compose -f docker/docker-compose.yml up -d || echo 'Docker stack not started (run it manually: cd docker && docker compose up -d)'", - "forwardPorts": [5432, 3306, 6379, 27017, 8123, 9000], + "postStartCommand": "bash scripts/dev/devcontainer_start.sh", + "forwardPorts": [5432, 3306, 6379, 27017, 8123, 9000, 3307], "portsAttributes": { "5432": { "label": "PostgreSQL" }, "3306": { "label": "MySQL" }, "6379": { "label": "Redis" }, "27017": { "label": "MongoDB" }, - "8123": { "label": "ClickHouse HTTP" } + "8123": { "label": "ClickHouse HTTP" }, + "9000": { "label": "ClickHouse native" }, + "3307": { "label": "MySQL 8.0 (profile live-tests)" } }, "customizations": { "vscode": { diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..0840c30e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,60 @@ +name: Bug report +description: Something in Querya Desktop does not work as expected +labels: [bug, needs-triage] +body: + - type: markdown + attributes: + value: | + Thanks for the report. Please search existing issues first. + Do not include passwords, connection strings or private data. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you do, and what went wrong? + validations: + required: true + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: input + id: version + attributes: + label: Querya version + description: See Settings > About + placeholder: "0.4.19" + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + options: [Linux, Windows, macOS] + validations: + required: true + - type: dropdown + id: engine + attributes: + label: Database engine + options: [PostgreSQL, MySQL, MariaDB, SQLite, Redis, MongoDB, Extension driver, Not related to a database] + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs and screenshots + description: Paste relevant output. It is rendered as code. + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..bc791cd4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/QueryaHub/Querya-Desktop/security/advisories/new + about: Please report security problems privately, not as a public issue. + - name: Questions and ideas + url: https://github.com/QueryaHub/Querya-Desktop/discussions + about: Ask a question or discuss an idea before opening an issue. + - name: Documentation + url: https://github.com/QueryaHub/Querya-Desktop/tree/main/docs + about: Getting started, architecture, extensions and packaging guides. + - name: Website + url: https://queryahub.com/ + about: Product information and downloads. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..abc62e65 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,33 @@ +name: Feature request +description: Suggest an improvement or a new capability +labels: [enhancement, needs-triage] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do, and what gets in the way? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: How would you like it to work? + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: [SQL editor, Data grid, Schema explorer, Connections, Redis, MongoDB, Themes, Extensions, Packaging, Other] + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: checkboxes + id: contribute + attributes: + label: Contribution + options: + - label: I would be willing to work on this diff --git a/.github/actions/test-extension/action.yml b/.github/actions/test-extension/action.yml index 57a2bb3f..bb42d49f 100644 --- a/.github/actions/test-extension/action.yml +++ b/.github/actions/test-extension/action.yml @@ -34,6 +34,13 @@ inputs: description: Run the plugin inside the OS sandbox when available ("true" / "false"). required: false default: 'false' + strict: + description: >- + Fail on warnings too ("true" / "false"), for example when the OS sandbox + could not be applied. Needs a tester newer than 0.4.19; with an older + one the run fails because it does not know --strict. + required: false + default: 'false' junit-path: description: Where to write the JUnit XML report. required: false @@ -111,6 +118,7 @@ runs: QUERY: ${{ inputs.query }} TABLE: ${{ inputs.table }} SANDBOX: ${{ inputs.sandbox }} + STRICT: ${{ inputs.strict }} JUNIT_PATH: ${{ inputs.junit-path }} TAP_PATH: ${{ inputs.tap-path }} run: | @@ -122,6 +130,7 @@ runs: if [ -n "$CONNECT" ]; then ARGS+=(--connect "$CONNECT"); fi if [ -n "$TABLE" ]; then ARGS+=(--table "$TABLE"); fi if [ "$SANDBOX" = "true" ]; then ARGS+=(--sandbox); fi + if [ "$STRICT" = "true" ]; then ARGS+=(--strict); fi if [ -n "$JUNIT_PATH" ]; then ARGS+=(--junit "$JUNIT_PATH"); fi if [ -n "$TAP_PATH" ]; then ARGS+=(--tap "$TAP_PATH"); fi diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 3c71f017..a14dc034 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -137,3 +137,13 @@ jobs: python3 scripts/ci/app_bench_report.py "$RUNNER_TEMP/app.log" > "$RUNNER_TEMP/app_report.txt" cat "$RUNNER_TEMP/app_report.txt" tail -n +2 "$RUNNER_TEMP/app_report.txt" >> "$GITHUB_STEP_SUMMARY" + + # `flutter run` exits 0 whatever the app does; an aborted scenario run + # prints `BENCH FAILED`, so the job turns red instead of reporting only the + # scenarios that ran. The job stays informational (continue-on-error). + - name: Fail when the run aborted + run: | + if grep -q 'BENCH FAILED' "$RUNNER_TEMP/app.log"; then + grep -A3 'BENCH FAILED' "$RUNNER_TEMP/app.log" | head -n 8 + exit 1 + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40e5f62f..11ed99a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,21 @@ jobs: - uses: actions/checkout@v4 - run: ./scripts/ci/test_sections.sh check + # A pull request from a release branch (release/X.Y.Z) must be taggable as + # it is: pubspec, CHANGELOG section and SemVer form (#1498). + release-check: + name: Release branch check + if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: check_release.sh for the pubspec version + run: | + VERSION=$(grep '^version:' pubspec.yaml | sed 's/^version:[[:space:]]*//; s/+.*//') + ./scripts/release/check_release.sh "$VERSION" + analyze: name: Code Analysis timeout-minutes: 20 diff --git a/.github/workflows/community.yml b/.github/workflows/community.yml new file mode 100644 index 00000000..6d607c7c --- /dev/null +++ b/.github/workflows/community.yml @@ -0,0 +1,21 @@ +name: Community + +on: + issues: + types: [opened] + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + triage: + if: github.event_name == 'issues' + uses: QueryaHub/.github/.github/workflows/reusable-triage.yml@main + + stale: + if: github.event_name != 'issues' + uses: QueryaHub/.github/.github/workflows/reusable-stale.yml@main diff --git a/.github/workflows/devcontainer-feature.yml b/.github/workflows/devcontainer-feature.yml new file mode 100644 index 00000000..8d3aea1e --- /dev/null +++ b/.github/workflows/devcontainer-feature.yml @@ -0,0 +1,74 @@ +name: Dev Container Feature + +# Tests and publishes ghcr.io/queryahub/features/querya-ext-tester +# (devcontainer-features/src). The tester is a glibc binary, so only +# Debian-family bases are tested. Pull requests only test; the Feature is pushed +# from main/dev or by hand. +on: + push: + branches: [main, dev] + paths: + - devcontainer-features/** + - .github/workflows/devcontainer-feature.yml + pull_request: + paths: + - devcontainer-features/** + - .github/workflows/devcontainer-feature.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: devcontainer-feature-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test querya-ext-tester Feature (${{ matrix.base }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + base: + - mcr.microsoft.com/devcontainers/base:ubuntu + - mcr.microsoft.com/devcontainers/base:debian + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install the Dev Containers CLI + run: npm install -g @devcontainers/cli@0.80.0 + - name: Autogenerated tests (default options) + run: | + devcontainer features test \ + --features querya-ext-tester \ + --base-image "${{ matrix.base }}" \ + --project-folder devcontainer-features + - name: Scenarios + if: matrix.base == 'mcr.microsoft.com/devcontainers/base:ubuntu' + run: | + devcontainer features test \ + --features querya-ext-tester \ + --skip-autogenerated \ + --project-folder devcontainer-features + + publish: + name: Publish Feature + needs: test + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + - uses: devcontainers/action@v1 + with: + publish-features: "true" + base-path-to-features: ./devcontainer-features/src + features-namespace: queryahub/features + generate-docs: "false" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/devcontainer-image.yml b/.github/workflows/devcontainer-image.yml new file mode 100644 index 00000000..ff03605b --- /dev/null +++ b/.github/workflows/devcontainer-image.yml @@ -0,0 +1,94 @@ +name: Dev container image + +# Publishes ghcr.io/queryahub/querya-devcontainer (GitHub Packages) so the dev +# container and Codespaces start from a prebuilt layer cache instead of +# rebuilding the Flutter + Linux desktop toolchain from scratch. +# +# Pull requests only build and smoke-test the image, they never push. +on: + push: + branches: [main, dev] + paths: + - .devcontainer/Dockerfile + - .github/workflows/devcontainer-image.yml + pull_request: + paths: + - .devcontainer/Dockerfile + - .github/workflows/devcontainer-image.yml + schedule: + - cron: "0 5 * * 1" # weekly: pick up base-image and apt security updates + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: devcontainer-image-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE: ghcr.io/queryahub/querya-devcontainer + +jobs: + image: + name: Build and push querya-devcontainer + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Flutter version from the Dockerfile + id: flutter + run: | + v=$(sed -n 's#^ARG FLUTTER_VERSION=\(.*\)$#\1#p' .devcontainer/Dockerfile | head -n1) + test -n "$v" + echo "version=$v" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + if: github.event_name != 'pull_request' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build (loaded locally for the smoke test) + uses: docker/build-push-action@v6 + with: + context: .devcontainer + file: .devcontainer/Dockerfile + load: true + tags: querya-devcontainer:test + cache-from: type=gha + cache-to: type=gha,mode=max + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.description=Querya Desktop dev container (Flutter ${{ steps.flutter.outputs.version }} + Linux desktop toolchain) + org.opencontainers.image.licenses=MIT + + - name: Smoke test + run: | + set -euo pipefail + docker run --rm querya-devcontainer:test flutter --version | tee /dev/stderr | grep -q "Flutter ${{ steps.flutter.outputs.version }}" + docker run --rm querya-devcontainer:test bwrap --version + docker run --rm querya-devcontainer:test socat -V + docker run --rm querya-devcontainer:test docker --version + docker run --rm querya-devcontainer:test docker compose version + docker run --rm querya-devcontainer:test bash -c 'clang --version && cmake --version && ninja --version && pkg-config --exists gtk+-3.0 libsecret-1' + + - name: Push + if: github.event_name != 'pull_request' + uses: docker/build-push-action@v6 + with: + context: .devcontainer + file: .devcontainer/Dockerfile + push: true + cache-from: type=gha + tags: | + ${{ env.IMAGE }}:latest + ${{ env.IMAGE }}:flutter-${{ steps.flutter.outputs.version }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.description=Querya Desktop dev container (Flutter ${{ steps.flutter.outputs.version }} + Linux desktop toolchain) + org.opencontainers.image.licenses=MIT diff --git a/.github/workflows/headless-image.yml b/.github/workflows/headless-image.yml new file mode 100644 index 00000000..c6073e66 --- /dev/null +++ b/.github/workflows/headless-image.yml @@ -0,0 +1,101 @@ +name: Headless image + +# Publishes ghcr.io/queryahub/querya-headless: (and :latest) after a +# Release succeeds, or on demand for an existing release. +# +# `workflow_run`, not `release`: the release is created with GITHUB_TOKEN, and +# events made by that token do not start other workflows. +on: + workflow_run: + workflows: [Release] + types: [completed] + workflow_dispatch: + inputs: + version: + description: Querya Desktop release to build the image for (e.g. 0.4.19) + required: true + latest: + description: Also tag the image as latest + type: boolean + default: true + +permissions: + contents: read + packages: write + +jobs: + image: + name: Build and push querya-headless + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' + steps: + - uses: actions/checkout@v4 + + - name: Resolve the version + id: v + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ inputs.version }}" + LATEST="${{ inputs.latest }}" + else + # A tag push has the tag as head_branch. + VERSION="${{ github.event.workflow_run.head_branch }}" + LATEST=true + fi + VERSION="${VERSION#v}" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::notice::'$VERSION' is not a release tag, nothing to build" + echo "build=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "build=true" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "latest=$LATEST" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + if: steps.v.outputs.build == 'true' + + - uses: docker/login-action@v3 + if: steps.v.outputs.build == 'true' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build (loaded locally for the smoke test) + if: steps.v.outputs.build == 'true' + uses: docker/build-push-action@v6 + with: + context: docker/headless + file: docker/headless/Dockerfile + build-args: QUERYA_VERSION=${{ steps.v.outputs.version }} + load: true + tags: querya-headless:test + + - name: Smoke test + if: steps.v.outputs.build == 'true' + shell: bash + run: | + set -euo pipefail + docker run --rm querya-headless:test --help | grep -q "Usage: querya-ext-tester" + mkdir -p empty + set +e + docker run --rm -v "$PWD/empty:/ext:ro" querya-headless:test /ext --no-color + code=$? + set -e + # No manifest.json: the run must fail with exit code 1. + test "$code" -eq 1 + + - name: Push + if: steps.v.outputs.build == 'true' + uses: docker/build-push-action@v6 + with: + context: docker/headless + file: docker/headless/Dockerfile + build-args: QUERYA_VERSION=${{ steps.v.outputs.version }} + push: true + tags: | + ghcr.io/queryahub/querya-headless:${{ steps.v.outputs.version }} + ${{ steps.v.outputs.latest == 'true' && 'ghcr.io/queryahub/querya-headless:latest' || '' }} diff --git a/.github/workflows/headless-smoke.yml b/.github/workflows/headless-smoke.yml new file mode 100644 index 00000000..fcc80d8c --- /dev/null +++ b/.github/workflows/headless-smoke.yml @@ -0,0 +1,87 @@ +name: Headless smoke (L2) + +# Builds the Linux bundle and runs the real app under Xvfb in portable mode with +# scripts/dev/headless_app.sh: it has to start, show a window and stay up. The +# screenshot is kept as an artifact. Then the MCP path end to end +# (scripts/dev/headless_mcp_check.sh): the app with its MCP server on, a shared +# fixture connection, and a client reading it through the real querya-mcp. Not part of ci.yml: a Linux build takes +# minutes, so this runs when the script or this file changes, weekly, and on +# demand. +on: + pull_request: + paths: + - scripts/dev/headless_app.sh + - scripts/dev/headless_mcp_check.sh + - scripts/dev/mcp_stdio_check.py + - lib/core/mcp/** + - packages/querya_mcp_bridge/** + - .github/workflows/headless-smoke.yml + schedule: + - cron: "0 4 * * 2" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: headless-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: App under Xvfb + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev \ + libsecret-1-dev xvfb xdotool imagemagick sqlite3 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.1' + channel: 'stable' + cache: true + + - name: Get dependencies + run: flutter pub get + + - name: Build Linux (release) + run: flutter build linux --release + + - name: Run the app headless + run: | + mkdir -p build/headless + scripts/dev/headless_app.sh \ + --bundle build/linux/x64/release/bundle \ + --screenshot build/headless/screenshot.png \ + --log build/headless/app.log \ + --wait 90 --keep 5 + + - name: Build querya-mcp + run: | + cd packages/querya_mcp_bridge + dart pub get + dart compile exe bin/querya_mcp.dart -o "$RUNNER_TEMP/querya-mcp" + + - name: MCP end to end under Xvfb + run: | + mkdir -p build/headless + scripts/dev/headless_mcp_check.sh \ + --mcp "$RUNNER_TEMP/querya-mcp" \ + --bundle build/linux/x64/release/bundle \ + --log build/headless/mcp-app.log + + - name: Keep the screenshot and the log + if: always() + uses: actions/upload-artifact@v4 + with: + name: headless-smoke + path: build/headless/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml new file mode 100644 index 00000000..e183ec86 --- /dev/null +++ b/.github/workflows/macos-build.yml @@ -0,0 +1,39 @@ +name: macOS build + +# The PR checks run on Linux, so Swift in macos/Runner was first compiled by +# the release workflow on a tag. This builds the macOS app on pull requests +# that touch the native side (#1589). +on: + pull_request: + branches: [ main, dev ] + paths: + - 'macos/**' + - 'pubspec.yaml' + - 'pubspec.lock' + - '.github/workflows/macos-build.yml' + workflow_dispatch: + +concurrency: + group: macos-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build macOS (release, unsigned) + runs-on: macos-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.1' + channel: 'stable' + cache: true + + - name: Get dependencies + run: flutter pub get + + - name: Build macOS (release) + run: flutter build macos --release diff --git a/.github/workflows/release-hooks.yml b/.github/workflows/release-hooks.yml new file mode 100644 index 00000000..1bb163e3 --- /dev/null +++ b/.github/workflows/release-hooks.yml @@ -0,0 +1,77 @@ +name: Release hooks + +# After a Release succeeds, tells the repositories that follow Querya's +# releases: +# +# QueryaHub/extensions re-tests every registered extension against the +# new version and refreshes compat.json +# QueryaHub/extension-template opens a pull request that raises the test +# database image tag and adds the version to the +# CI matrix +# +# The querya-testdb-* images and querya-headless are published by their own +# workflows on the same Release. The dev container Feature does not follow +# releases: it downloads the newest tester when it is installed. +# +# `workflow_run`, not `release`: the release is created with GITHUB_TOKEN, and +# events made by that token do not start other workflows. A repository_dispatch +# into another repository needs a token that can write there, so this needs the +# secret QUERYA_DISPATCH_TOKEN (see docs/extension-registry.md); without it the +# job only says so. +on: + workflow_run: + workflows: [Release] + types: [completed] + workflow_dispatch: + inputs: + version: + description: Querya Desktop release to announce (e.g. 0.5.0) + required: true + +permissions: + contents: read + +jobs: + dispatch: + name: Tell the registry and the template + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' + steps: + - name: Resolve the version + id: v + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ inputs.version }}" + else + # A tag push has the tag as head_branch. + VERSION="${{ github.event.workflow_run.head_branch }}" + fi + VERSION="${VERSION#v}" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::notice::'$VERSION' is not a release tag, nothing to announce" + echo "announce=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "announce=true" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Dispatch + if: steps.v.outputs.announce == 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.QUERYA_DISPATCH_TOKEN }} + VERSION: ${{ steps.v.outputs.version }} + run: | + set -euo pipefail + if [ -z "$GH_TOKEN" ]; then + echo "::warning::QUERYA_DISPATCH_TOKEN is not set, so the registry and the template were not told about $VERSION" + exit 0 + fi + for repo in QueryaHub/extensions QueryaHub/extension-template; do + gh api "repos/${repo}/dispatches" -X POST \ + -f event_type=querya-release \ + -f "client_payload[version]=${VERSION}" + echo "told ${repo} about ${VERSION}" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdf190dd..ec926001 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,15 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Release checks (scripts/release/check_release.sh) + if: startsWith(github.ref, 'refs/tags/') + run: | + # The same checks as before tagging (#1498); here they only warn, the + # tag is already pushed. + if ! ./scripts/release/check_release.sh "${{ github.ref_name }}" --no-tag-check; then + echo "::warning::scripts/release/check_release.sh found problems with tag ${{ github.ref_name }} (see the log above)." + fi + - name: Warn when git tag and pubspec semver differ if: startsWith(github.ref, 'refs/tags/') run: | @@ -251,6 +260,19 @@ jobs: - name: Build macOS (release) run: flutter build macos --release + # Shipped as is when there are no signing secrets: it must not be the + # sandboxed app, which cannot reach ~/.querya, ~/.ssh or update itself + # (#1592). + - name: Check the build has no App Sandbox + run: | + set -euo pipefail + APP="build/macos/Build/Products/Release/querya_desktop.app" + ENTITLEMENTS="$(codesign -d --entitlements - --xml "$APP" 2>/dev/null || true)" + if printf '%s' "$ENTITLEMENTS" | grep -q 'com.apple.security.app-sandbox'; then + echo "::error::The release build has the App Sandbox entitlement (see macos/Runner/ReleaseUnsigned.entitlements)." + exit 1 + fi + - name: Sign and notarize macOS app env: MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} @@ -470,9 +492,14 @@ jobs: name: Publish GitHub Release needs: [e2e, build-windows, build-linux, build-macos, build-ext-tester] runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write outputs: version: ${{ needs.build-windows.outputs.version }} release_tag: ${{ steps.rel.outputs.tag }} + prerelease: ${{ steps.kind.outputs.prerelease }} steps: - uses: actions/checkout@v4 with: @@ -512,11 +539,53 @@ jobs: cd dist sha256sum *.* | tee SHA256SUMS.txt + - name: Attest build provenance + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + dist/*.zip + dist/*.dmg + dist/*.deb + dist/*.rpm + dist/*.flatpak + dist/*.AppImage + dist/*-windows-setup.exe + dist/querya-ext-tester-* + dist/querya-mcp-* + + # A version or tag with a SemVer pre-release part (`0.5.0-beta.1`) is a + # Beta release (#1492): published as a GitHub pre-release, never + # `latest`, so Stable users (who read `releases/latest`) are not offered + # it, and not sent to AUR or the APT / DNF repositories. The pubspec + # version decides for a manual run; on a tag either one having a `-` + # is enough, so a mismatch can only make it a pre-release. + - name: Release kind + id: kind + run: | + set -euo pipefail + VERSION="${{ needs.build-windows.outputs.version }}" + TAG="" + if [ "${{ github.ref_type }}" = "tag" ]; then TAG="${{ github.ref_name }}"; fi + if [[ "$VERSION" == *-* || "$TAG" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + echo "Pre-release: version $VERSION, tag ${TAG:-none}" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + - name: Build release notes from CHANGELOG.md run: | set -euo pipefail VERSION="${{ needs.build-windows.outputs.version }}" - ./scripts/extract-changelog-section.sh "$VERSION" > release-notes.md + if [ "${{ steps.kind.outputs.prerelease }}" = "true" ]; then + { + echo "> **Pre-release (Beta channel).** Offered in the app only to users on *Beta / Early Access* (Preferences → Release channel), and not published to AUR or the APT / DNF repositories." + echo "" + } > release-notes.md + else + : > release-notes.md + fi + ./scripts/extract-changelog-section.sh "$VERSION" >> release-notes.md { echo "" echo "---" @@ -546,7 +615,7 @@ jobs: echo "#### MCP (AI clients)" echo "- **querya-mcp** (stdio bridge for Claude Desktop, Cursor, VS Code and other MCP clients; also bundled with the Linux and Windows builds): \`querya-mcp-v${VERSION}-linux-x64.tar.gz\`, \`-macos-arm64.tar.gz\`, \`-macos-x64.tar.gz\`, \`-windows-x64.zip\`" echo "" - echo "Verify checksums: \`SHA256SUMS.txt\`" + echo "Verify checksums: \`SHA256SUMS.txt\`. Build provenance: \`gh attestation verify --repo QueryaHub/Querya-Desktop\`" echo "" echo "### Build info" echo "- **pubspec**: ${{ needs.build-windows.outputs.full_version }}" @@ -568,6 +637,8 @@ jobs: tag_name: ${{ steps.rel.outputs.tag }} name: Querya Desktop ${{ needs.build-windows.outputs.version }} body_path: release-notes.md + prerelease: ${{ steps.kind.outputs.prerelease == 'true' }} + make_latest: ${{ steps.kind.outputs.prerelease == 'true' && 'false' || 'true' }} fail_on_unmatched_files: false files: | dist/Querya-Desktop-*.zip @@ -586,6 +657,8 @@ jobs: publish-aur: name: Publish AUR (querya-desktop) needs: [publish] + # A beta stays off AUR (#1492). + if: needs.publish.outputs.prerelease != 'true' runs-on: ubuntu-latest steps: - name: Check AUR secret @@ -624,6 +697,8 @@ jobs: publish-linux-repo: name: Publish APT + DNF repository needs: [publish] + # A beta stays off the APT / DNF repositories (#1492). + if: needs.publish.outputs.prerelease != 'true' runs-on: ubuntu-latest steps: - name: Check repository secrets diff --git a/.github/workflows/testdb-images.yml b/.github/workflows/testdb-images.yml new file mode 100644 index 00000000..c99fdd1b --- /dev/null +++ b/.github/workflows/testdb-images.yml @@ -0,0 +1,145 @@ +name: Test database images + +# Publishes ghcr.io/queryahub/querya-testdb-: (and :latest): the +# docker/ seeds baked into the official database images, for extension +# authors (no bind mounts). A database is built when docker//Dockerfile.testdb +# exists, so adding one appears here without editing this file. +# +# Pull requests only build and smoke-test. `workflow_run`, not `release`: the +# release is created with GITHUB_TOKEN, and events made by that token do not +# start other workflows. +on: + pull_request: + paths: + - docker/** + - .github/workflows/testdb-images.yml + workflow_run: + workflows: [Release] + types: [completed] + workflow_dispatch: + inputs: + version: + description: Querya Desktop release the images are tagged with (e.g. 0.4.19) + required: true + latest: + description: Also tag the images as latest + type: boolean + default: true + +permissions: + contents: read + packages: write + +jobs: + plan: + name: Plan + runs-on: ubuntu-latest + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + outputs: + databases: ${{ steps.dbs.outputs.databases }} + push: ${{ steps.v.outputs.push }} + version: ${{ steps.v.outputs.version }} + latest: ${{ steps.v.outputs.latest }} + steps: + - uses: actions/checkout@v4 + + - name: Databases with a Dockerfile.testdb + id: dbs + shell: bash + run: | + set -euo pipefail + list=$(for f in docker/*/Dockerfile.testdb; do basename "$(dirname "$f")"; done | jq -R . | jq -sc .) + echo "databases=$list" >> "$GITHUB_OUTPUT" + + - name: Resolve the version + id: v + shell: bash + run: | + set -euo pipefail + case "${{ github.event_name }}" in + workflow_dispatch) VERSION="${{ inputs.version }}"; LATEST="${{ inputs.latest }}" ;; + workflow_run) VERSION="${{ github.event.workflow_run.head_branch }}"; LATEST=true ;; + *) VERSION=""; LATEST=false ;; + esac + VERSION="${VERSION#v}" + if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "push=true" >> "$GITHUB_OUTPUT" + else + [ -z "$VERSION" ] || echo "::notice::'$VERSION' is not a release tag, images are only built" + echo "push=false" >> "$GITHUB_OUTPUT" + VERSION=dev + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "latest=$LATEST" >> "$GITHUB_OUTPUT" + + image: + name: querya-testdb-${{ matrix.db }} + needs: plan + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + db: ${{ fromJson(needs.plan.outputs.databases) }} + env: + IMAGE: ghcr.io/queryahub/querya-testdb-${{ matrix.db }} + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + if: needs.plan.outputs.push == 'true' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build (loaded locally for the smoke test) + uses: docker/build-push-action@v6 + with: + context: docker/${{ matrix.db }} + file: docker/${{ matrix.db }}/Dockerfile.testdb + load: true + tags: querya-testdb:test + cache-from: type=gha,scope=testdb-${{ matrix.db }} + cache-to: type=gha,mode=max,scope=testdb-${{ matrix.db }} + + - name: Smoke test (seeds are in, no bind mounts) + shell: bash + run: | + set -euo pipefail + docker run -d --name testdb querya-testdb:test + for i in $(seq 1 60); do + s=$(docker inspect -f '{{.State.Health.Status}}' testdb) + [ "$s" = healthy ] && break + sleep 3 + done + [ "$s" = healthy ] || { docker logs testdb; echo "::error::not healthy: $s"; exit 1; } + + case "${{ matrix.db }}" in + postgres) out=$(docker exec testdb psql -U querya -d querya -Atc "SELECT count(*) FROM shop.customers") ;; + mysql) out=$(docker exec testdb mysql -uquerya -pquerya querya -Nse "SELECT count(*) FROM customers") ;; + mongo) out=$(docker exec testdb mongosh --quiet -u querya -p querya --authenticationDatabase admin querya --eval "db.users.countDocuments()") ;; + clickhouse) out=$(docker exec testdb clickhouse-client --user querya --password querya --query "SELECT count() FROM querya.customers") ;; + redis) out=$(docker exec testdb redis-cli GET querya:seed:marker) ;; + *) echo "::error::no smoke test for ${{ matrix.db }}"; exit 1 ;; + esac + echo "seed check: $out" + # A seeded table or key is a positive number; an empty database is 0 or empty. + [[ "$out" =~ ^[1-9][0-9]*$ ]] || { docker logs testdb; echo "::error::seed missing"; exit 1; } + + - name: Push + if: needs.plan.outputs.push == 'true' + uses: docker/build-push-action@v6 + with: + context: docker/${{ matrix.db }} + file: docker/${{ matrix.db }}/Dockerfile.testdb + push: true + cache-from: type=gha,scope=testdb-${{ matrix.db }} + tags: | + ${{ env.IMAGE }}:${{ needs.plan.outputs.version }} + ${{ needs.plan.outputs.latest == 'true' && format('{0}:latest', env.IMAGE) || '' }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.description=Querya test database (${{ matrix.db }}) with the Querya seeds baked in + org.opencontainers.image.licenses=MIT diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index ae4d82a8..e1e51fea 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -30,12 +30,28 @@ jobs: ref: main token: ${{ secrets.GITHUB_TOKEN }} - - name: Get current version + # A release branch carrying a beta (`0.5.0-beta.2+7`) keeps its version + # on merge: stripping `-beta.N` and bumping the patch would turn it into + # 0.5.1 (#1498). The bump happens on the next ordinary merge after the + # final version is in pubspec. + - name: Skip pre-release versions if: steps.check.outputs.merged == 'true' + id: pre + run: | + LINE=$(grep "^version:" pubspec.yaml | sed 's/^version:[[:space:]]*//') + if [[ "${LINE%%+*}" == *-* ]]; then + echo "::notice::pubspec has the pre-release version $LINE; not bumping." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Get current version + if: steps.check.outputs.merged == 'true' && steps.pre.outputs.skip != 'true' id: current_version run: | LINE=$(grep "^version:" pubspec.yaml | sed 's/^version:[[:space:]]*//') - # Drop +build, then any -prerelease (e.g. 0.4.11-b → 0.4.11). + # Drop +build (a -prerelease was skipped above). CORE=$(echo "$LINE" | sed 's/+.*//; s/-.*//') if [[ "$LINE" == *'+'* ]]; then BUILD="${LINE##*+}" @@ -50,7 +66,7 @@ jobs: echo "current_build=$BUILD" >> "$GITHUB_OUTPUT" - name: Bump patch version - if: steps.check.outputs.merged == 'true' + if: steps.check.outputs.merged == 'true' && steps.pre.outputs.skip != 'true' id: bump run: | CURRENT_VERSION="${{ steps.current_version.outputs.current_version }}" @@ -73,7 +89,7 @@ jobs: echo "new_build=$NEW_BUILD" >> "$GITHUB_OUTPUT" - name: Update pubspec.yaml - if: steps.check.outputs.merged == 'true' + if: steps.check.outputs.merged == 'true' && steps.pre.outputs.skip != 'true' run: | NEW_VERSION="${{ steps.bump.outputs.new_version }}" NEW_BUILD="${{ steps.bump.outputs.new_build }}" @@ -81,7 +97,7 @@ jobs: grep "^version:" pubspec.yaml - name: Sync AUR PKGBUILD pkgver with pubspec - if: steps.check.outputs.merged == 'true' + if: steps.check.outputs.merged == 'true' && steps.pre.outputs.skip != 'true' run: | NEW_VERSION="${{ steps.bump.outputs.new_version }}" sed -i "s/^pkgver=.*/pkgver=${NEW_VERSION}/" packaging/linux/aur/PKGBUILD @@ -90,7 +106,7 @@ jobs: grep ^pkgver packaging/linux/aur/PKGBUILD - name: Commit version bump - if: steps.check.outputs.merged == 'true' + if: steps.check.outputs.merged == 'true' && steps.pre.outputs.skip != 'true' run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c1f2cf0..69b3faa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0-beta.1] - 2026-10-18 + +### Added + +- **Beta channel** — the first pre-release line. A version like `0.5.0-beta.1` is published as a GitHub pre-release: only users on *Preferences → Release channel → Beta / Early Access* are offered it, Stable users, AUR, the APT / DNF repositories and Flathub never see it, and the final `0.5.0` replaces it for everyone. Beta always offers the **highest** version (it used to take the first release by date, so a hotfix published after a beta hid the beta, #1493); a beta `.deb` / `.rpm` is versioned `0.5.0~beta.1` so the final package upgrades it (#1492, #1493, #1494, #1495, #1496, #1498; see [docs/tags-and-releases.md](docs/tags-and-releases.md)). +- **A copy of your local data before every in-app update** — `querya.db.pre--.bak` next to the database, newest three kept; if the copy fails you are asked before the update goes on. A database written by a newer version is refused and left untouched (the startup screen says so), and on Snap, Flatpak, AUR, `.deb` and `.rpm` installs the Release channel row is disabled with the reason (#1497). +- **macOS: Finder "Open With" and double-click open `.sql` and SQLite files**, at launch and in a running app. Also a macOS build check on pull requests that touch `macos/` (#1589). +- **macOS: the App Sandbox is detected** — in a sandboxed (debug or store-style) run the in-app update refuses to replace the app and points to the release page, and the MCP and Extensions sections of Preferences say why they cannot work (#1593). +- **Readable query errors** — the headline is the driver's message (`no such column: u.name`), the full text sits behind *Show details* without the statement Querya wraps around your SQL, and *Copy error* copies it (#1557). +- **Toasts animate out, the welcome tour slides in the direction you move**, and a button label that does not fit shows in full as a tooltip (#1645, #1689). +- **`MacOsSandbox`, `UpdateBackup`, `check_release.sh` and `prepare_beta.sh`** for the release process (#1498): a tag can be checked before it is pushed, and a release branch runs the check in CI. +- **`querya-ext-tester --strict`** — warnings fail the run (exit 1), for example a recommended method that errors or an OS sandbox that could not be applied; with `--sandbox` in CI a green run now means the plugin really ran isolated. The `test-extension` action has a matching `strict` input. + +- **Extension registry** — `StaticRegistryRepository` reads a static `index.json` published by [QueryaHub/extensions](https://github.com/QueryaHub/extensions) (GitHub Pages): yanked entries, non-GitHub-release URLs and extensions the running version is too old for are hidden, the index is cached, installs keep the SHA-256 / archive / sandbox checks. The Extensions → Marketplace tab now shows this catalog instead of the built-in demo one (which listed fake extensions and wrote placeholder folders into the real extensions directory, #1568); while the registry is empty or unreachable the tab says so, with a *Retry*. Also new for extension authors: the `ghcr.io/queryahub/features/querya-ext-tester` Dev Container Feature, `querya-testdb-*` test database images, and the [extension template](https://github.com/QueryaHub/extension-template). See [docs/extension-registry.md](docs/extension-registry.md). +- **`manifestVersion` in `manifest.json`** — the version of the manifest format itself, apart from the extension's `version` and the host range `engines`. Absent means format 1, so nothing existing changes; `querya-ext-tester` rejects a format the emulated host does not read, and the app does not offer, install or load an extension in a format newer than it reads. See [docs/extension-registry.md](docs/extension-registry.md#versions-what-is-checked-against-what). +- **`querya-ext-tester --manifest-only`** — checks `manifest.json` (including `engines` and `manifestVersion`) against the emulated host and stops; the extension is not started, so no database or executable is needed. +- **`engines.querya_desktop` accepts several comparators** — `>=0.4.0 <3.0.0` (`>=`, `>`, `<=`, `<`, `=`, all must hold), as the ClickHouse driver declares. The app used to read only `>=x.y.z` and `^x.y.z`, so such an extension was shown on every version. +- **Release hooks** — after a Release the registry and the template are told about the new version (`repository_dispatch`); needs the `QUERYA_DISPATCH_TOKEN` secret. + +### Changed + +- **Faster grid filtering, selection copy and CJK text** — a filter parses a cell as a number only when its target is one, and its operator is resolved once; copying a selection as JSON, CSV or TSV skips work on plain text; CJK text in grid cells lays out about 40x faster because the cell font stack now names CJK families (#1553, #1550, #1519). +- **Large JSON / XML cells open without freezing** — from 256 KB the inspector checks, formats and minifies them on a worker (#1688). +- **Smaller wins** — Redis folder counts are counted once instead of on every rebuild, group sums read `1,234.5` without copying the cell, the ERD relation painter reuses its paint, paths and labels, and editor token styles are shared (#1518, #1543). +- **Faster result grids** — sorting a timestamp column of 50,000 rows ~400 ms → ~60 ms (#1533); choosing column types for a 5,000-row result ~3.5 ms → ~0.5 ms (#1548); selection statistics read `1,234.5` without copying the cell (#1520); copying a large result to the clipboard no longer stops the UI for 20–30 ms while it is handed to the background (#1537). + +### Fixed + +- **Security: the SQL statement splitter read PostgreSQL and SQLite literals with MySQL rules** — in `'a\'; SELECT pg_terminate_backend(1); --'` it saw one statement with a long literal, while the server runs the second one. The MCP read-only guard, the destructive-query confirmation and the mutation audit's classifier now follow the connection's dialect (`E'...'` strings keep their escape everywhere). The guard also refuses `QUERY_TO_XML*`, `TABLE_TO_XML*`, `PG_ADVISORY_*`, `PG_SLEEP*`, `LO_*`, `NEXTVAL` and `SETVAL`, and no longer refuses `EXPLAIN SELECT REPLACE(...)` (#1687, #1565). +- **A failed extension upgrade keeps the working extension** — it is extracted next to the old one, checked, and only then swapped in; the Marketplace and the local installer share this path and no longer mix files of two versions. Archive entries under `../` are refused before the common folder is stripped (#1562). +- **Extension `engines` ranges accept `>= 0.4.0 < 3.0.0`** with spaces after the operator (#1635, #1636). +- **MCP server: overlapping start / stop calls** could leave a second socket host listening; they now run one after another. A connection that never sends its token is cut off after 4 KiB and at most 16 wait at once (#1563, #1565). +- **Importing a team profile with a malformed field** failed with a crash; the entry is skipped and counted. More secret names are scrubbed (`pwd`, `private_key`, `access_key`, `credential`, `signature`) (#1564). +- **The status bar no longer keeps "Running query…"** after a plain row-count result (#1556). +- **MongoDB `find` is never unbounded** — `.limit(0)` is capped at 1,000 documents and the summary says so (#1357). +- **Declarative extension modules** read JSONC with `//` inside strings and trailing commas, and format SQL without touching literals (#1567). +- **macOS:** the Copy menu item works, the extension sandbox profile names the real path (`/var` is a link) and escapes it, and an update must be signed by the same Team ID as the installed app (#1590, #1588). +- **Dialogs fit small windows** — the destructive-query dialog follows the window and the UI scale, and a long table name in the DML preview is cut (#1642). +- **Smaller fixes** — a TLS client key file whose `chmod` failed is no longer used, audit-log and preferences read failures are logged, `docs/getting-started.md` no longer suggests `flutter create .` (#1637, #1501). +- **Redis: a command can no longer run in another database** — every explorer of a connection shared one socket and sent `SELECT n` before each command, so switching to db3 while a folder delete in db0 was running sent the remaining deletes to db3, and a `SELECT` typed in the CLI was undone by the next key you opened while the prompt still showed the old database. Each database now has its own socket (selected again after a reconnect), and the CLI has its own. Folder delete uses `UNLINK` (falls back to `DEL` before Redis 4.0), pipelines its chunks and reloads the list if a chunk fails (#1569). +- **macOS in-app update no longer quits into a broken or missing app** — when the app could not be moved aside (no write access to its folder, a disk image, a copy macOS runs from a temporary location) the update copied the new app *into* the old one, and a failed copy left no app at all. The update dialog now says up front when the app cannot be replaced and offers the release page; otherwise the old app is restored if the copy fails, the failure is logged to `~/Library/Logs/Querya/querya-update.log` and the app opens again. The leftover `.old` bundle and the extracted update are removed (#1587). +- **macOS builds without Developer ID signing no longer run in the App Sandbox** — they did, unlike the signed build, so `~/.querya` (extensions, themes), `~/.ssh` keys, saved SQLite files, the MCP endpoint and in-app updates did not work. Unsigned and signed builds now differ only in how Gatekeeper treats them, and the release fails if the sandbox comes back (#1592). +- **Format SQL keeps PostgreSQL and SQLite literals that end in a backslash** — `'C:\'` was read with MySQL rules, so the next literal was taken for code and its keywords upper-cased; backslashes now follow the tab's database (#1566). +- **MongoDB queries with escapes, constructors or many documents** — in mongosh-style input, escapes stayed in the value (`"a\"b"` searched for `a\"b`), a string containing `ISODate(...)` broke the query, and an `insertMany` of 1,000 documents took seconds to parse; it is one pass now (#1513, #1539). +- **MongoDB: nested `ObjectId(...)`, `ISODate(...)` and Extended JSON are real BSON values** — `find({_id: ObjectId("…")})` matched nothing and inserts stored `{ $oid: … }` sub-documents. `ISODate` without a zone is UTC, as in mongosh (#1667). +- **ERD of one table in a large schema** — its columns could be missing when the schema had more than 200,000 catalog rows; the diagram and MCP `describe_table` / `sample_rows` now read only the tables they need (#1572). +- **README install steps** — the macOS Gatekeeper command pointed at `Querya.app`, but the app installs as `Querya Desktop.app`; and Fedora now uses `dnf install` so dependencies are installed (#1500). +- **Extension manifest `id` is checked before it becomes a folder** — a sideloaded `.qext` with `"id": "../some/dir"` made the installer recursively delete and overwrite a folder outside the extensions directory. The id must now be letters, digits, `.`, `_` or `-` (starting with a letter or digit, no `..`), and every install / uninstall refuses a folder that is not inside the extensions directory (#1560). + +- **Format SQL no longer changes what the query means** — it uppercased keywords inside string literals (`'not null'` → `'NOT NULL'`), quoted identifiers (`"order"` → `"ORDER"`, a different column in PostgreSQL), comments and `$$` function bodies. It now touches code only, and the caret stays where it was instead of jumping to the end (#1566) +- **macOS: saved passwords use the login keychain** — the secure storage plugin defaults to the data protection keychain, which needs an entitlement no Querya build has, so saving or reading a connection password failed with "Could not read the saved credentials from the system keyring". macOS may ask once to allow Querya Desktop to use its keychain items, and again after an update of an unsigned build (#1586). +- **Zip limits hold even when an archive lies about its sizes** — the per-entry, total and ratio limits for extension installs, sideloads and updates are checked against the sizes the zip headers declare; the app now requires `archive` 4.5+, which refuses to decode an entry past its declared size, and reports that as an error instead of inflating it (#1561). +- **`querya-ext-tester` sends a `--connect` password with `system.injectCredentials`, like the app** — a driver that reads its password only from the injected secret (the ClickHouse driver) failed authentication under the tester because the password was in the `db.connect` payload only. +- **Demo Marketplace themes were named `${manifest.name}`** — the generated theme.json now carries the real name (the demo catalog is only used with `--dart-define=QUERYA_MARKETPLACE_MOCK=true`). +- **In-app update of a package install (AUR, `.deb`, `.rpm`)** — the updater no longer downloads a zip and quits when the app lives in `/opt/querya-desktop` (owned by root): the update dialog says how to update it (`yay -Syu querya-desktop`, `apt`, `dnf`) with a *Copy update command* button, or offers the release page for any other folder the user cannot write. If an in-place replace fails anyway, the app is started again and the reason is logged to `~/.local/state/querya-update.log`, instead of staying closed. + +- **Preferences no longer flash a wrong state** — the MCP Server pane showed the server switch off, "No SQL connections" and "No calls yet" for a moment on every visit before the real values jumped in; it now draws them only once loaded. Panes you visited stay as they were (no reload, their own scroll position: Shortcuts no longer opens half-scrolled after another pane). +- **Data Grid preferences: removed "Confirm DML staging applications"** — it changed the SQL destructive-query confirmation (DROP / TRUNCATE / DELETE) and controlled nothing else. +- **About shows the real version** instead of a hardcoded 0.4.17. +- **MCP Server pane** — MongoDB connections can be shared (the server already had MongoDB tools), type badges use product names, the `querya-mcp` path is on its own line, copying configs and regenerating the token are separate, and recent calls are rows with a status icon. +- **One monospace font outside the result grid** — PostgreSQL, Redis and MongoDB views, the DML and destructive-query dialogs, the recovery screen, the welcome tour and monospace badges used the system's generic `monospace` family and looked different from the SQL editor; they now use the editor's font stack (#1633). + ### Planned - Planned 0.5.0 — live Marketplace download and install diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d87df081..5800e16d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,4 +120,7 @@ If you fix something **after** pushing a release tag, either move the tag (only the team agrees and the release is not yet consumed) or ship a **new** semver (update `pubspec.yaml` / `CHANGELOG.md`) and push a new tag. See [docs/tags-and-releases.md](docs/tags-and-releases.md) and -[docs/release-checklist.md](docs/release-checklist.md). +[docs/release-checklist.md](docs/release-checklist.md). Betas +(`X.Y.Z-beta.N`, a GitHub pre-release only Beta-channel users see) have their +own procedure and a `scripts/release/check_release.sh` to run before the tag: +[docs/tags-and-releases.md](docs/tags-and-releases.md#beta-канал-pre-release). diff --git a/README.md b/README.md index e768c3fb..88fedbe5 100644 --- a/README.md +++ b/README.md @@ -122,43 +122,64 @@ Whether you are navigating multi-million row datasets, authoring complex analyti ## Installation ### Linux -**Debian / Ubuntu (APT repository, updates with `apt upgrade`):** +Download the latest `.deb`, `.AppImage`, `.rpm`, or `.flatpak` from [Releases](https://github.com/QueryaHub/Querya-Desktop/releases): ```bash -curl -fsSL https://repo.querya.app/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/querya.gpg -echo "deb [signed-by=/etc/apt/keyrings/querya.gpg] https://repo.querya.app/apt stable main" | sudo tee /etc/apt/sources.list.d/querya.list -sudo apt update && sudo apt install querya-desktop -``` - -**Fedora / RHEL (DNF repository):** +# Debian / Ubuntu (.deb) +sudo apt install ./Querya-Desktop-*-linux.deb -```bash -sudo dnf config-manager --add-repo https://repo.querya.app/rpm/querya.repo -sudo dnf install querya-desktop -``` +# Fedora / RHEL (.rpm; dnf also installs its dependencies, such as libsecret) +sudo dnf install ./Querya-Desktop-*-linux.rpm -Or download the latest `.deb`, `.AppImage`, or `.tar.gz` from [Releases](https://github.com/QueryaHub/Querya-Desktop/releases): +# AppImage (standalone executable; Ubuntu 22.04+ requires libfuse2) +# Ubuntu 22.04+: sudo apt install libfuse2 # or libfuse2t64 +chmod +x Querya-Desktop-*-linux.AppImage +./Querya-Desktop-*-linux.AppImage -```bash -# Debian / Ubuntu (.deb) -sudo dpkg -i querya-desktop-linux-amd64.deb -sudo apt-get install -f +# Arch Linux (AUR) +yay -S querya-desktop -# AppImage (Standalone executable) -chmod +x querya-desktop-*.AppImage -./querya-desktop-*.AppImage +# Flatpak (local bundle) +flatpak install --user ./Querya-Desktop-*-linux.flatpak ``` > **Note:** Linux builds utilize `libsecret-1` for OS-level credential protection. Ensure `libsecret-1-0` is installed on your system. +> **APT/DNF repositories:** A hosted repository (`repo.querya.app`) is planned — see [#1059](https://github.com/QueryaHub/Querya-Desktop/issues/1059). Until then, use the GitHub Release assets above. + ### Windows Download the Windows installer (`.exe`) or portable archive (`.zip`) from [Releases](https://github.com/QueryaHub/Querya-Desktop/releases). Run the setup to automatically register `.sql` and `.sqlite` file associations. ### macOS -Download the universal `.dmg` from [Releases](https://github.com/QueryaHub/Querya-Desktop/releases), mount the disk image, and drag **Querya** into your `Applications` folder. +Requires **macOS 10.15 (Catalina) or later**; the app is a universal build (Apple silicon and Intel). + +1. Download `Querya-Desktop--macos.dmg` from [Releases](https://github.com/QueryaHub/Querya-Desktop/releases), open it and drag **Querya Desktop** into `Applications`. +2. Start it from `Applications`, not from the disk image or `Downloads`: there macOS runs a temporary read-only copy, and the in-app update cannot replace it. + +> **Unsigned builds.** A release built without the Developer ID secrets is ad-hoc signed, and Gatekeeper blocks its first launch: +> - **macOS 14 and earlier:** Control-click (right-click) the app → **Open** → **Open**. +> - **macOS 15 and later:** that no longer works. Try to open the app once, then go to **System Settings → Privacy & Security**, find "Querya Desktop was blocked…" and click **Open Anyway**. +> - Or clear the quarantine flag in Terminal: +> ```bash +> xattr -dr com.apple.quarantine "/Applications/Querya Desktop.app" +> ``` +> +> The unsigned build is not sandboxed and behaves like the signed one (connections, SSH keys, extensions, MCP, in-app updates); only this first-launch step differs. See [macOS signing](docs/macos-signing.md). --- +### Verify your download + +Every release ships `SHA256SUMS.txt` and a signed build provenance attestation: + +```bash +# checksums (Linux / macOS) +sha256sum --check --ignore-missing SHA256SUMS.txt + +# provenance: proves the file was built by this repository's release workflow +gh attestation verify Querya-Desktop-*-linux.AppImage --repo QueryaHub/Querya-Desktop +``` + ## Building from Source ### Prerequisites diff --git a/assets/images/querya_logo.png b/assets/images/querya_logo.png new file mode 100644 index 00000000..b0dd4048 Binary files /dev/null and b/assets/images/querya_logo.png differ diff --git a/benchmark/app_perf_bench.dart b/benchmark/app_perf_bench.dart index 1dc5abab..5212e560 100644 --- a/benchmark/app_perf_bench.dart +++ b/benchmark/app_perf_bench.dart @@ -31,6 +31,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/erd/erd_view.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; import 'package:vm_service/vm_service.dart' as vms; @@ -80,13 +81,17 @@ Future main() async { runApp(const QueryaApp()); WidgetsBinding.instance.addPostFrameCallback((_) async { + var failed = false; try { await _runAll(); } catch (e, st) { - stdout.writeln('BENCH error: $e\n$st'); + // `flutter run` reports 0 whatever the app exits with, so the CI step + // looks for this line as well (scripts/ci/app_bench_report.py). + stdout.writeln('BENCH FAILED: $e\n$st'); + failed = true; } await dataDir.delete(recursive: true).catchError((_) => dataDir); - exit(0); + exit(failed ? 1 : 0); }); } @@ -108,9 +113,15 @@ Future _runAll() async { await _wait(1500); await _scenario('idle_workspace', () => _wait(1500)); + await _preferencesScenarios(); + if (only.isNotEmpty && + only.split(',').every((id) => id.trim().startsWith('prefs_'))) { + return; // nothing after this point was asked for + } + await _scenario('sidebar_toggle', () async { for (var i = 0; i < 8; i++) { - (_findState((s) => s.runtimeType.toString() == '_MainContentSplitState') + (_findState((s) => s.runtimeType.toString() == 'MainContentSplitState') as dynamic) .toggleSidebar(); await _wait(450); @@ -250,10 +261,25 @@ Future _runAll() async { SqlEditorCommandBridge.instance.invokeNew(); await _wait(300); await _runInActiveTab(ddl.toString()); + // The script must have run: a failed or half-run script leaves the diagram + // of the demo's own tables, which looks like a slow ERD. + final ddlSession = ws.activeSession; + stdout.writeln('BENCH erd_ddl status=${ddlSession.statusLine} ' + 'error=${ddlSession.error}'); + if (ddlSession.error != null) { + throw StateError('the 200-table script failed: ${ddlSession.error}'); + } await _scenario('erd_open_200', () async { - await _tap(_byKey('open_diagram_tab')); - await _waitFor(() => _find((e) => e.widget.key == const ValueKey('erd_table_erd_t199')) != null, - 'diagram of 200 tables'); + // Not a tap on the toolbar button: with seven query tabs open the tab bar + // is full and the button is pushed out of the 1280x720 window, so the tap + // landed on a tab and the diagram never opened (#1546). + ws.openDiagramTab(); + await _waitFor(() => ws.activeSession.isDiagram as bool, 'the Diagram tab', + onTimeout: () => _dumpErdState(ws)); + await _waitFor( + () => _find((e) => e.widget.key == const ValueKey('erd_table_erd_t199')) != null, + 'diagram of 200 tables', + onTimeout: () => _dumpErdState(ws)); await _wait(800); }); await _scenario('erd_hover', () => _hoverOver(_find((e) => e.widget is ErdView)!, 3000)); @@ -264,10 +290,88 @@ Future _runAll() async { }); } +/// What the screen shows when the diagram did not come up: the active tab and +/// how many table cards exist, so a failed run says which of the two it was. +void _dumpErdState(dynamic ws) { + var cards = 0; + void visit(Element e) { + final k = e.widget.key; + if (k is ValueKey && k.value.startsWith('erd_table_')) cards++; + e.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement!.visitChildren(visit); + final session = ws.activeSession; + stdout.writeln('BENCH erd_state activeTab="${session.title}" ' + 'isDiagram=${session.isDiagram} cards=$cards'); +} + /// The SQL workspace of the open connection. dynamic _workspace() => _findState((s) => s.runtimeType.toString() == 'GenericSqlWorkspaceState')!; +/// Preferences over the workspace: the dialog sits on a full-window frosted +/// backdrop, so these show what one repaint inside it costs. +Future _preferencesScenarios() async { + Element dialog() => _find((e) => + e.widget.runtimeType.toString() == '_PreferencesDialogContent')!; + + await _scenario('prefs_open', () async { + showPreferencesDialog(_find((e) => e.widget is ConnectionsPanel)!); + await _waitFor(() => _findText('General Settings') != null, 'Preferences'); + await _wait(600); + }); + await _scenario('prefs_idle', () => _wait(1500)); + await _scenario('prefs_hover', () => _hoverOver(dialog(), 3000)); + // One scenario per category: switching to it builds its pane from scratch. + for (final label in const [ + 'Appearance', 'SQL & Editor', 'Data Grid', 'Extensions', + 'MCP Server', 'Shortcuts', 'About & Storage', 'General', + ]) { + final id = 'prefs_cat_${label.split(' ').first.toLowerCase()}'; + await _scenario(id, () => _tap(_byText(label))); + } + await _scenario('prefs_reopen', () async { + await _tap(_byText('Close')); + await _wait(500); + showPreferencesDialog(_find((e) => e.widget is ConnectionsPanel)!); + await _waitFor(() => _findText('General Settings') != null, 'Preferences'); + await _wait(600); + }); + await _tap(_byText('Shortcuts')); + await _scenario('prefs_scroll', () => _scrollLoop( + dialog, Axis.vertical, 10, 3000, minExtent: 100)); + await _scenario('prefs_typing', () async { + final search = _findState((s) => + s is EditableTextState && + s.widget.controller.runtimeType == TextEditingController) as EditableTextState; + var text = ''; + for (final ch in 'mcp server token'.split('')) { + text += ch; + search.updateEditingValue(TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + )); + await _wait(90); + } + search.updateEditingValue(TextEditingValue.empty); + await _wait(300); + }); + await _tap(_byText('MCP Server')); + await _scenario('prefs_toggle', () async { + final share = _find((e) => '${e.widget.key}'.contains('mcp_share_')); + if (share == null) { + stdout.writeln('BENCH skip: no shareable connection'); + return; + } + for (var i = 0; i < 6; i++) { + await _tap(share); + } + }); + await _tap(_byText('Close')); + await _wait(600); +} + /// Puts [sql] into the active query tab, runs it and waits for the run to end. Future _runInActiveTab(String sql) async { final ws = _workspace(); diff --git a/benchmark/grid_perf_bench.dart b/benchmark/grid_perf_bench.dart index 0bd3c3c0..a71c8794 100644 --- a/benchmark/grid_perf_bench.dart +++ b/benchmark/grid_perf_bench.dart @@ -16,6 +16,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter/scheduler.dart'; import 'package:flutter/semantics.dart' show SemanticsBinding; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; @@ -57,10 +58,9 @@ void main() { [for (var c = 0; c < colCount; c++) c == 0 ? '${(r * 7919) % rowCount}' : 'v${r}_$c'], ]; final buffer = DataGridStagingBuffer(columns: columns, rows: rows); - final td = QueryaTheme.darkDefault - .toShadcnThemeData() - .copyWith(platform: () => TargetPlatform.linux); - runApp(ShadcnApp( + final td = materialThemeFromQuerya(QueryaTheme.darkDefault.colors) + .copyWith(platform: TargetPlatform.linux); + runApp(material.MaterialApp( theme: td, home: material.Scaffold( body: mode == 'rerun' diff --git a/benchmark/result_consumers_bench.dart b/benchmark/result_consumers_bench.dart new file mode 100644 index 00000000..9873df2f --- /dev/null +++ b/benchmark/result_consumers_bench.dart @@ -0,0 +1,78 @@ +// What the consumers of a big result set cost through the dataset's lazy row +// list versus a `List>` built from it (#1549). Headless: +// flutter test benchmark/result_consumers_bench.dart --dart-define=ROWS=50000 +// Prints `RESULT materialized= lazy=` (median of 5, after a +// warm-up). Memory is not measured here: RSS in the test runner is too noisy. +// The 50k x 20 figures in #1549 (68.6 MiB as strings, 17.5 MiB columnar) came +// from a standalone AOT run. +// +// Decision (see #1549): consumers keep `List>` at their boundary. +// At 50k x 20 the lazy list is as fast for sort and filter on typed columns, +// and 25-40 ms slower for a free-text filter and the CSV export, which run +// once per user action; the app only ever holds the columnar dataset. +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/compact_result_dataset.dart'; +import 'package:querya_desktop/features/workspace/grid/grid_sorting.dart'; +import 'package:querya_desktop/features/workspace/grid_filter_engine.dart'; +import 'package:querya_desktop/shared/services/data_export_service.dart'; + +const _rows = int.fromEnvironment('ROWS', defaultValue: 50000); + +List> _source(int n) => [ + for (var i = 0; i < n; i++) + [ + for (var c = 0; c < 20; c++) + c < 6 + ? i * 7 % 10007 + c + : c < 10 + ? i * 0.37 + c + : 'v${(i * 31) % 5000}_$c', + ], + ]; + +void main() { + test('result set consumers: lazy dataset rows vs materialized strings', () { + final names = [for (var c = 0; c < 20; c++) 'c$c']; + + final dataset = CompactResultDataset.fromRawRows(names, _source(_rows)); + final materialized = [for (var i = 0; i < _rows; i++) dataset.rowStrings(i)]; + final lazy = dataset.asLazyRowList(); + int time(void Function() f) { + final sw = Stopwatch()..start(); + f(); + return sw.elapsedMilliseconds; + } + + void consumer(String name, void Function(List>) run) { + run(materialized); + run(lazy); + final m = [for (var i = 0; i < 5; i++) time(() => run(materialized))] + ..sort(); + final l = [for (var i = 0; i < 5; i++) time(() => run(lazy))]..sort(); + // ignore: avoid_print + print('RESULT $name materialized=${m[2]}ms lazy=${l[2]}ms'); + } + + consumer( + 'sort int column', + (r) => sortResultGridRowsWithIndices( + rows: r, columnIndex: 0, order: ResultGridSortOrder.ascending)); + consumer( + 'sort double column', + (r) => sortResultGridRowsWithIndices( + rows: r, columnIndex: 7, order: ResultGridSortOrder.ascending)); + consumer( + 'sort string column', + (r) => sortResultGridRowsWithIndices( + rows: r, columnIndex: 12, order: ResultGridSortOrder.descending)); + consumer( + 'filter c0 > 5000', + (r) => GridFilterEngine.filterRowIndices( + filterText: 'c0 > 5000', columns: names, rows: r)); + consumer( + 'filter free text', + (r) => GridFilterEngine.filterRowIndices( + filterText: 'v42', columns: names, rows: r)); + consumer('csv export', (r) => DataExportService.formatCsv(names, r)); + }); +} diff --git a/bin/querya_ext_tester.dart b/bin/querya_ext_tester.dart index 4417754f..a71a5f67 100644 --- a/bin/querya_ext_tester.dart +++ b/bin/querya_ext_tester.dart @@ -4,7 +4,8 @@ // // dart run bin/querya_ext_tester.dart [options] // -// Exit codes: 0 all checks passed, 1 a check failed, 2 bad usage. +// Exit codes: 0 all checks passed, 1 a check failed (with --strict, or warned), +// 2 bad usage. import 'dart:convert'; import 'dart:io'; @@ -24,9 +25,16 @@ Options: --table also check db.getTableSchema for this table --database database passed to db.connect --sandbox run the plugin in the OS sandbox when available + --manifest-only check manifest.json against --target-version and + stop: the extension is not started (no database, + no executable needed) + --strict treat warnings as failures (exit 1), for example a + recommended method that errors or a sandbox that + could not be applied --timeout-ms per-request timeout (default 5000) --junit write a JUnit XML report --tap write a TAP report + --trace write every RPC call and a bounded view of the data as JSON --no-color disable ANSI colors -h, --help show this help '''; @@ -45,9 +53,12 @@ Future _run(List args) async { String? table; String? database; var sandbox = false; + var strict = false; + var manifestOnly = false; var timeoutMs = 5000; String? junit; String? tap; + String? trace; var color = stdout.hasTerminal; String? value(int i, String flag) { @@ -66,6 +77,10 @@ Future _run(List args) async { return 0; case '--sandbox': sandbox = true; + case '--strict': + strict = true; + case '--manifest-only': + manifestOnly = true; case '--no-color': color = false; case '--target-version' || @@ -75,7 +90,8 @@ Future _run(List args) async { '--database' || '--timeout-ms' || '--junit' || - '--tap': + '--tap' || + '--trace': final v = value(i, a); if (v == null) return 2; i++; @@ -101,6 +117,8 @@ Future _run(List args) async { junit = v; case '--tap': tap = v; + case '--trace': + trace = v; } default: if (a.startsWith('-') || dir != null) { @@ -155,9 +173,18 @@ Future _run(List args) async { database: database, ), sandbox: sandbox, + manifestOnly: manifestOnly, ); stdout.write(report.toConsole(color: color)); if (junit != null) File(junit).writeAsStringSync(report.toJUnitXml()); if (tap != null) File(tap).writeAsStringSync(report.toTap()); - return report.isSuccess ? 0 : 1; + if (trace != null) { + File(trace).writeAsStringSync(report.toTraceJson()); + stdout.writeln('RPC trace of ${report.rpc.length} calls written to $trace'); + } + if (report.isSuccess && strict && report.warningCount > 0) { + stdout.writeln('--strict: ${report.warningCount} warning(s) count as ' + 'failures'); + } + return report.passes(strict: strict) ? 0 : 1; } diff --git a/devcontainer-features/src/querya-ext-tester/devcontainer-feature.json b/devcontainer-features/src/querya-ext-tester/devcontainer-feature.json new file mode 100644 index 00000000..314fd79c --- /dev/null +++ b/devcontainer-features/src/querya-ext-tester/devcontainer-feature.json @@ -0,0 +1,18 @@ +{ + "id": "querya-ext-tester", + "version": "1.0.0", + "name": "Querya extension tester", + "description": "Installs querya-ext-tester, the headless test runner for Querya database-driver extensions, from a Querya Desktop release (SHA-256 checked).", + "documentationURL": "https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/extension-testing.md", + "options": { + "version": { + "type": "string", + "default": "latest", + "proposals": ["latest", "0.4.19"], + "description": "Querya Desktop release to take the tester from (for example 0.4.19), or latest. Must be 0.4.19 or newer." + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} diff --git a/devcontainer-features/src/querya-ext-tester/install.sh b/devcontainer-features/src/querya-ext-tester/install.sh new file mode 100755 index 00000000..ac07a0f2 --- /dev/null +++ b/devcontainer-features/src/querya-ext-tester/install.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Installs querya-ext-tester from a Querya Desktop release and checks the +# archive against the SHA256SUMS.txt published with that release. +set -euo pipefail + +REPO="QueryaHub/Querya-Desktop" +MIN_VERSION="0.4.19" # earlier testers ignored the exit code of main() +REQUESTED="${VERSION:-latest}" +DEST="${QUERYA_EXT_TESTER_DEST:-/usr/local/bin/querya-ext-tester}" + +fail() { echo "querya-ext-tester feature: $*" >&2; exit 1; } + +ensure_tools() { + local missing=() + for tool in curl tar sha256sum; do + command -v "$tool" >/dev/null 2>&1 || missing+=("$tool") + done + [ ${#missing[@]} -eq 0 ] && return 0 + if command -v apt-get >/dev/null 2>&1; then + apt-get update -y && apt-get install -y --no-install-recommends curl ca-certificates tar coreutils + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache curl ca-certificates tar coreutils + elif command -v dnf >/dev/null 2>&1; then + dnf install -y curl ca-certificates tar coreutils + else + fail "missing ${missing[*]} and no known package manager to install them" + fi +} + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) TARGET="linux-x64" ;; + *) fail "only Linux x86_64 is released ($(uname -s)-$(uname -m))" ;; +esac + +ensure_tools + +if [ "$REQUESTED" = "latest" ]; then + # The redirect avoids the API and its unauthenticated rate limit. + TAG="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest")" + TAG="${TAG##*/}" +else + TAG="${REQUESTED#v}" +fi +VERSION_NUMBER="${TAG#v}" +[[ "$VERSION_NUMBER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "cannot read a version from '${TAG}'" + +if [ "$(printf '%s\n%s\n' "$MIN_VERSION" "$VERSION_NUMBER" | sort -V | head -n1)" != "$MIN_VERSION" ]; then + fail "querya-ext-tester ${VERSION_NUMBER} is older than ${MIN_VERSION}; its exit codes are not reliable" +fi + +ASSET="querya-ext-tester-v${VERSION_NUMBER}-${TARGET}.tar.gz" +BASE="https://github.com/${REPO}/releases/download/${TAG}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +echo "Downloading ${ASSET} from release ${TAG}" +curl -fsSL -o "${WORK}/${ASSET}" "${BASE}/${ASSET}" || fail "cannot download ${BASE}/${ASSET}" +curl -fsSL -o "${WORK}/SHA256SUMS.txt" "${BASE}/SHA256SUMS.txt" || fail "cannot download ${BASE}/SHA256SUMS.txt" + +EXPECTED="$(grep " ${ASSET}\$" "${WORK}/SHA256SUMS.txt" | cut -d' ' -f1 || true)" +[ -n "$EXPECTED" ] || fail "${ASSET} is not listed in SHA256SUMS.txt of ${TAG}" +ACTUAL="$(sha256sum "${WORK}/${ASSET}" | cut -d' ' -f1)" +[ "$EXPECTED" = "$ACTUAL" ] || fail "checksum mismatch for ${ASSET}" + +tar -xzf "${WORK}/${ASSET}" -C "$WORK" +install -m 0755 "${WORK}/querya-ext-tester" "$DEST" + +"$DEST" --help | grep -q "Usage: querya-ext-tester" || fail "installed binary does not run" +echo "Installed querya-ext-tester ${VERSION_NUMBER}" diff --git a/devcontainer-features/test/querya-ext-tester/pinned.sh b/devcontainer-features/test/querya-ext-tester/pinned.sh new file mode 100755 index 00000000..fad349d4 --- /dev/null +++ b/devcontainer-features/test/querya-ext-tester/pinned.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Scenario "pinned": version 0.4.19, the oldest supported tester. +set -e +source dev-container-features-test-lib + +check "tester is on PATH" command -v querya-ext-tester +check "tester prints usage" bash -c 'querya-ext-tester --help | grep -q "Usage: querya-ext-tester"' + +reportResults diff --git a/devcontainer-features/test/querya-ext-tester/scenarios.json b/devcontainer-features/test/querya-ext-tester/scenarios.json new file mode 100644 index 00000000..770aeea7 --- /dev/null +++ b/devcontainer-features/test/querya-ext-tester/scenarios.json @@ -0,0 +1,8 @@ +{ + "pinned": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "querya-ext-tester": { "version": "0.4.19" } + } + } +} diff --git a/devcontainer-features/test/querya-ext-tester/test.sh b/devcontainer-features/test/querya-ext-tester/test.sh new file mode 100755 index 00000000..ce0c28b2 --- /dev/null +++ b/devcontainer-features/test/querya-ext-tester/test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Default scenario: the Feature with its default options (latest release). +set -e +source dev-container-features-test-lib + +check "tester is on PATH" command -v querya-ext-tester +check "tester prints usage" bash -c 'querya-ext-tester --help | grep -q "Usage: querya-ext-tester"' +check "tester fails on an empty directory" bash -c 'd=$(mktemp -d); ! querya-ext-tester "$d" --no-color' + +reportResults diff --git a/docker/.env.example b/docker/.env.example index 8b14d3a2..1a672726 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -3,6 +3,8 @@ POSTGRES_PORT=5432 MYSQL_PORT=3306 +# MySQL 8.0 + mysql_native_password for the live tests (compose profile live-tests) +MYSQL_LIVE_PORT=3307 REDIS_PORT=6379 MONGO_PORT=27017 CLICKHOUSE_HTTP_PORT=8123 diff --git a/docker/clickhouse/Dockerfile.testdb b/docker/clickhouse/Dockerfile.testdb new file mode 100644 index 00000000..d2e55514 --- /dev/null +++ b/docker/clickhouse/Dockerfile.testdb @@ -0,0 +1,9 @@ +# querya-testdb-clickhouse: ClickHouse with the Querya seeds, no bind mounts. +FROM clickhouse/clickhouse-server:24.8 +ENV CLICKHOUSE_DB=querya \ + CLICKHOUSE_USER=querya \ + CLICKHOUSE_PASSWORD=querya \ + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 +COPY init/ /docker-entrypoint-initdb.d/ +HEALTHCHECK --interval=5s --timeout=5s --retries=20 --start-period=40s \ + CMD clickhouse-client --user querya --password querya --query 'SELECT 1' diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 13d1d6f7..c8bdb4c1 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -16,6 +16,8 @@ # auth source: admin collections: users, products, orders # ClickHouse HTTP 8123 / native 9000 db querya user querya password querya # tables: customers, products, orders, order_lines, events +# MySQL 8.0 port 3307 (profile live-tests: docker compose --profile live-tests up -d) +# mysql_native_password, no TLS: for test/core/database/stream_take_drain_test.dart # SQLite local file ./sqlite/data/querya.db # tables: users, products, orders # ───────────────────────────────────────────────────────────────────────── @@ -79,6 +81,44 @@ services: retries: 20 start_period: 30s + # MySQL for the live database tests (profile `live-tests`, not started by a + # plain `docker compose up`): 8.0 with mysql_native_password, because the + # client in those tests connects without TLS and 8.4's default + # caching_sha2_password needs a secure connection. Same server CI starts. + # docker compose --profile live-tests up -d --wait mysql-live + # MYSQL_PORT=3307 flutter test test/core/database/stream_take_drain_test.dart + mysql-live: + profiles: ["live-tests"] + image: mysql:8.0 + container_name: querya-mysql-live + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-querya} + MYSQL_DATABASE: ${MYSQL_DATABASE:-querya} + MYSQL_USER: ${MYSQL_USER:-querya} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-querya} + ports: + - "${MYSQL_LIVE_PORT:-3307}:3306" + command: + - --default-authentication-plugin=mysql_native_password + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + healthcheck: + test: + [ + "CMD", + "mysqladmin", + "ping", + "-h", + "127.0.0.1", + "-u${MYSQL_USER:-querya}", + "-p${MYSQL_PASSWORD:-querya}", + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 30s + redis: image: redis:7-alpine container_name: querya-redis diff --git a/docker/headless/Dockerfile b/docker/headless/Dockerfile new file mode 100644 index 00000000..3aa1f618 --- /dev/null +++ b/docker/headless/Dockerfile @@ -0,0 +1,49 @@ +# querya-headless: the extension tester of a Querya Desktop release in a pinned, +# reproducible environment, to run in CI or locally without a display, Flutter +# or the Querya source tree. +# +# docker build -f docker/headless/Dockerfile \ +# --build-arg QUERYA_VERSION=0.4.19 -t querya-headless:0.4.19 docker/headless +# +# docker run --rm -v "$PWD/dist:/ext:ro" querya-headless:0.4.19 /ext \ +# --connect '{"host":"db","port":5432}' --trace /out/trace.json +# +# The tester is downloaded from the GitHub Release and checked against its +# SHA256SUMS.txt; the build fails when the checksum does not match. +# +# glibc (Debian) on purpose: extension entry points are native executables, and +# most of them are not linked against musl. +FROM debian:bookworm-slim + +ARG QUERYA_VERSION +ARG TARGET=linux-x64 + +RUN test -n "${QUERYA_VERSION}" || (echo "set --build-arg QUERYA_VERSION=x.y.z" >&2; exit 1) + +# bubblewrap is the OS sandbox of `--sandbox`; curl and tar fetch the tester. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl tar bubblewrap \ + && rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + base="https://github.com/QueryaHub/Querya-Desktop/releases/download/${QUERYA_VERSION}"; \ + asset="querya-ext-tester-v${QUERYA_VERSION}-${TARGET}.tar.gz"; \ + cd /tmp; \ + curl -fsSLO "${base}/${asset}"; \ + curl -fsSLO "${base}/SHA256SUMS.txt"; \ + grep " ${asset}\$" SHA256SUMS.txt | sha256sum -c -; \ + tar -xzf "${asset}" -C /usr/local/bin querya-ext-tester; \ + rm -f "${asset}" SHA256SUMS.txt; \ + querya-ext-tester --help > /dev/null + +LABEL org.opencontainers.image.title="querya-headless" \ + org.opencontainers.image.description="Querya Desktop extension tester (headless)" \ + org.opencontainers.image.source="https://github.com/QueryaHub/Querya-Desktop" \ + org.opencontainers.image.version="${QUERYA_VERSION}" + +RUN useradd --create-home --uid 1000 querya +USER querya +WORKDIR /ext + +ENTRYPOINT ["querya-ext-tester"] +CMD ["--help"] diff --git a/docker/mongo/Dockerfile.testdb b/docker/mongo/Dockerfile.testdb new file mode 100644 index 00000000..4abfa9ed --- /dev/null +++ b/docker/mongo/Dockerfile.testdb @@ -0,0 +1,8 @@ +# querya-testdb-mongo: MongoDB with the Querya seeds, no bind mounts. +# Auth source is admin; the init script runs on the first start. +FROM mongo:7 +ENV MONGO_INITDB_ROOT_USERNAME=querya \ + MONGO_INITDB_ROOT_PASSWORD=querya +COPY init/ /docker-entrypoint-initdb.d/ +HEALTHCHECK --interval=5s --timeout=5s --retries=20 --start-period=30s \ + CMD mongosh --quiet -u querya -p querya --authenticationDatabase admin --eval "db.adminCommand('ping').ok" diff --git a/docker/mysql/Dockerfile.testdb b/docker/mysql/Dockerfile.testdb new file mode 100644 index 00000000..44a0f7c3 --- /dev/null +++ b/docker/mysql/Dockerfile.testdb @@ -0,0 +1,11 @@ +# querya-testdb-mysql: MySQL 8.0 with mysql_native_password (what CI runs), so +# a client without TLS can connect. The init scripts run on the first start. +FROM mysql:8.0 +ENV MYSQL_ROOT_PASSWORD=querya \ + MYSQL_DATABASE=querya \ + MYSQL_USER=querya \ + MYSQL_PASSWORD=querya +COPY init/ /docker-entrypoint-initdb.d/ +CMD ["--default-authentication-plugin=mysql_native_password", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"] +HEALTHCHECK --interval=5s --timeout=5s --retries=20 --start-period=30s \ + CMD mysqladmin ping -h 127.0.0.1 -uquerya -pquerya diff --git a/docker/postgres/Dockerfile.testdb b/docker/postgres/Dockerfile.testdb new file mode 100644 index 00000000..91199edc --- /dev/null +++ b/docker/postgres/Dockerfile.testdb @@ -0,0 +1,9 @@ +# querya-testdb-postgres: PostgreSQL with the Querya seeds, no bind mounts. +# The init scripts run on the first start of an empty data directory. +FROM postgres:16-alpine +ENV POSTGRES_USER=querya \ + POSTGRES_PASSWORD=querya \ + POSTGRES_DB=querya +COPY init/ /docker-entrypoint-initdb.d/ +HEALTHCHECK --interval=5s --timeout=5s --retries=12 \ + CMD pg_isready -U querya -d querya diff --git a/docker/redis/Dockerfile.testdb b/docker/redis/Dockerfile.testdb new file mode 100644 index 00000000..8ea2ad49 --- /dev/null +++ b/docker/redis/Dockerfile.testdb @@ -0,0 +1,10 @@ +# querya-testdb-redis: Redis seeded with the Querya keys on every start. +# Redis has no init directory, so the entrypoint starts the server, runs +# seed.sh against it and then keeps serving. +FROM redis:7-alpine +COPY seed.sh /seed.sh +COPY testdb-entrypoint.sh /testdb-entrypoint.sh +RUN chmod +x /testdb-entrypoint.sh +ENTRYPOINT ["/testdb-entrypoint.sh"] +HEALTHCHECK --interval=5s --timeout=3s --retries=12 \ + CMD redis-cli ping | grep -q PONG diff --git a/docker/redis/testdb-entrypoint.sh b/docker/redis/testdb-entrypoint.sh new file mode 100755 index 00000000..c96b6acc --- /dev/null +++ b/docker/redis/testdb-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Starts redis-server, seeds it once it answers, then waits for the server so +# that a stop signal reaches it and the container exits with its status. +set -eu + +redis-server --save "" --appendonly no "$@" & +SERVER=$! +trap 'kill -TERM "$SERVER" 2>/dev/null' TERM INT + +REDIS_HOST=127.0.0.1 REDIS_PORT=6379 sh /seed.sh + +wait "$SERVER" diff --git a/docs/README.md b/docs/README.md index e607ba2c..8d682b36 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,10 +9,13 @@ Index of Querya Desktop documentation, grouped by audience. - [Security / local data](security.md) — where metadata and secrets are stored. - [MCP server](mcp-server.md) — let Claude Desktop, Cursor, VS Code and other AI clients read shared connections (read-only). - [Testing extensions in CI](extension-testing.md) — `querya-ext-tester`, release binaries and the GitHub Action. +- [Extension manifest](extension-manifest.md) — every field of `manifest.json`, the sandbox policy, what checks it, and how the manifest format is versioned (`manifestVersion`). +- [Extension registry](extension-registry.md) — from an author's machine to the Marketplace: the template, test databases, the registry's CI and index, and how it follows Querya releases. ## For contributors - [Contributing](../CONTRIBUTING.md) — workflow, CI, pre-PR checklist. +- [Dev container and local databases](dev-containers.md) — open the dev container, start the database stack, connect Querya, run the tests. - [Architecture](architecture.md) — `lib/` layout and module responsibilities. - [Theme system](theme.md) — runtime theming and VS Code theme tokens. - [UI Kit](ui-kit.md) — shared widgets, design tokens and guard tests. diff --git a/docs/dev-containers.md b/docs/dev-containers.md new file mode 100644 index 00000000..128da26f --- /dev/null +++ b/docs/dev-containers.md @@ -0,0 +1,318 @@ +# Dev container and local test databases + +How to open Querya Desktop in the repository's dev container, start the local +database stack, connect Querya to it and run the tests. For the plain +(non-container) workflow see [CONTRIBUTING.md](../CONTRIBUTING.md); for the +list of seeded data see the header of [`docker/docker-compose.yml`](../docker/docker-compose.yml). + +## What is in the box + +| File | What it does | +|------|--------------| +| [`.devcontainer/devcontainer.json`](../.devcontainer/devcontainer.json) | The dev container definition: build, features, post-create / post-start commands, forwarded ports, VS Code extensions, host requirements (4 CPUs, 8 GB). | +| [`.devcontainer/Dockerfile`](../.devcontainer/Dockerfile) | Ubuntu 24.04 with Flutter pinned to the version CI uses (`ARG FLUTTER_VERSION`, checked against `ci.yml` by a guard test), the Linux desktop toolchain (clang, cmake, ninja, GTK 3, libsecret, SQLite headers, Xvfb), `bubblewrap` for the extension sandbox tests, `socat`, and the Docker CLI with the Compose plugin. `DISPLAY` is `:99`. | +| [`scripts/dev/devcontainer_start.sh`](../scripts/dev/devcontainer_start.sh) | Runs on every start: brings the stack up, waits for the databases to be healthy, joins the stack's network and forwards `localhost:` to the services. | +| [`docker/docker-compose.yml`](../docker/docker-compose.yml) | The database stack (project `querya-dev`): PostgreSQL 16, MySQL 8.4, Redis 7, MongoDB 7, ClickHouse 24.8 and a seeded SQLite file. | + +The image is also published to GitHub Packages as +`ghcr.io/queryahub/querya-devcontainer` (`:latest` and `:flutter-`) by the +[Dev container image](../.github/workflows/devcontainer-image.yml) workflow, rebuilt when the +Dockerfile changes and weekly for security updates. `devcontainer.json` uses it as a layer +cache (`build.cacheFrom`), so the first build pulls the toolchain layers instead of compiling +them; if the package is unreachable the build simply falls back to building from the Dockerfile. + +When the container is created it runs `flutter config --enable-linux-desktop` +and `flutter pub get`; every time it starts it runs +[`scripts/dev/devcontainer_start.sh`](../scripts/dev/devcontainer_start.sh): it brings the +stack up, waits (up to `QUERYA_STACK_TIMEOUT`, 180 s) for PostgreSQL, MySQL, Redis, MongoDB +and ClickHouse to be healthy, prints which are not, and does not fail the container start +when Docker is unreachable. + +## Open the dev container + +### VS Code + +1. Install Docker and the **Dev Containers** extension. +2. Open the repository folder and run **Dev Containers: Reopen in Container**. +3. Wait for the first build (the Flutter image is large) and for + `flutter pub get` to finish. + +### Command line + +```bash +npm i -g @devcontainers/cli # once +devcontainer up --workspace-folder . +devcontainer exec --workspace-folder . bash +``` + +### GitHub Codespaces + +Codespaces reads the same definition; `hostRequirements` ask for a 4-core / 8 GB machine. + +## The database stack + +The container uses **docker-outside-of-docker**: the Docker CLI inside it talks +to the **host's** daemon, so the databases are ordinary containers on your host, +next to the dev container, not inside it. + +```bash +cd docker +cp .env.example .env # optional: change host ports if they are taken +docker compose up -d # start (the dev container does this on start) +docker compose ps # health: wait for "healthy" +docker compose logs -f mysql # follow one service +docker compose down # stop, keep data +docker compose down -v # stop and wipe volumes: init scripts run again +``` + +| Service | Host port | Database / user / password | Seeded with | +|---------|-----------|----------------------------|-------------| +| PostgreSQL | 5432 | `querya` / `querya` / `querya` | schema `shop.*`, database `analytics`, an all-types table | +| MySQL | 3306 | `querya` / `querya` / `querya` | `shop` tables, database `analytics`, an all-types table | +| Redis | 6379 | no auth | keys `querya:*` (seeded by `redis-seed`) | +| MongoDB | 27017 | `querya` / `querya`, auth source `admin` | collections `users`, `products`, `orders`, `types_showcase` | +| ClickHouse | 8123 (HTTP), 9000 (native) | `querya` / `querya` / `querya` | `customers`, `products`, `orders`, `order_lines`, `events`, `all_clickhouse_types` | +| SQLite | file `docker/sqlite/data/querya.db` | none | `users`, `products`, `orders` | + +The init scripts run only on the first start of a volume; use `down -v` to +re-seed. + +## Connect Querya to the stack + +### From the host + +Run Querya on the host (or `flutter run -d linux` there) and use `localhost` and +the ports above. + +### From inside the dev container + +The databases run on the host's daemon next to the dev container, so nothing +listens on the container's own loopback. The start script therefore does two +things for you: + +- joins the container to the stack's network (`querya-dev_default`), so the + service names `postgres`, `mysql`, `redis`, `mongo`, `clickhouse` (and + `mysql-live`) resolve, with the container-side ports 5432, 3306, 6379, 27017, + 8123 / 9000; +- forwards `127.0.0.1:` to those services with `socat`, so `localhost` and + the usual ports work too (`psql -h localhost -U querya querya`, + `redis-cli -h localhost ping`, the connection form with `localhost`). A port + something already listens on is left alone. + +The forwards live as long as the container does; after `docker compose down` / +`up` re-run `bash scripts/dev/devcontainer_start.sh`. If you start the +container with host networking, `localhost` already reaches the published ports +and nothing is forwarded. + +On Docker Desktop `host.docker.internal` with the host ports works as well. + +### Per database + +| Database | In Querya | +|----------|-----------| +| PostgreSQL | Host, port 5432, database `querya`, user and password `querya`. Schemas `shop` and `analytics` show up in the tree. | +| MySQL | The user authenticates with `caching_sha2_password` (MySQL 8.4), which needs a secure connection: tick **Use SSL/TLS** in the form. Without it the connection is refused. | +| Redis | Host, port 6379, no password. | +| MongoDB | Host, port 27017, user and password `querya`, **authentication database `admin`**. A URL works too: `mongodb://querya:querya@HOST:27017/querya?authSource=admin`. | +| SQLite | Pick `docker/sqlite/data/querya.db` with *Browse…* (the file is `chmod 666`). In the container, the path is inside the workspace. | +| ClickHouse | No built-in driver: it is the target for testing **extension drivers** (HTTP 8123 / native 9000). Install a ClickHouse extension from the Driver Manager or a local `.zip` / `.qext`. | + +The **Demo Playground** (a bundled SQLite database) needs none of this. + +## Headless modes + +"Headless" means no display and no window. There are three levels, each one +command and each runnable in a container or on a CI runner +(tracked in [#1487](https://github.com/QueryaHub/Querya-Desktop/issues/1487)). + +| Level | What | Command | +|-------|------|---------| +| L0 | unit, widget, e2e and guard tests | `flutter test --exclude-tags=golden` | +| L1 | an extension against a real database, no GUI | `querya-ext-tester ./ext --connect '{...}' --sandbox` (see *Testing an extension*) | +| L2 | the real app under a virtual display, in portable mode | `scripts/dev/headless_app.sh` | +| L2 + MCP | the same, with the MCP server on and a client reading a fixture through `querya-mcp` | `scripts/dev/headless_mcp_check.sh --mcp PATH` | + +What each level needs from the database stack (start only that, by service name): + +| Level | Containers | +|-------|------------| +| L0 | none; the live MySQL test needs `docker compose --profile live-tests up -d --wait mysql-live` | +| L1 | the database the extension talks to, e.g. `docker compose up -d --wait postgres` (or `clickhouse`, `redis`, `mongo`) | +| L2, L2 + MCP | none: the fixture is a throw-away SQLite file | + +L1 needs a tester built from 0.4.19 or newer: older ones exited 0 whatever the +result. + +### L2: `scripts/dev/headless_app.sh` + +Starts Xvfb on a free display, runs a **copy** of a built bundle with +`QUERYA_PORTABLE=1` (so its `QueryaData` is a throw-away profile and the bundle +stays untouched), waits for the window, keeps watching for a few seconds, and +stops everything. + +```bash +flutter build linux --release # or --debug +scripts/dev/headless_app.sh # picks build/linux/x64/release/bundle +scripts/dev/headless_app.sh --bundle path/to/bundle --screenshot out/shot.png --log out/app.log +``` + +Exit code 0: the app started, showed a window and stayed up; 1: it died or no +window appeared (the last lines of its output are printed); 2: a missing bundle +or tool. It needs `xvfb`, plus `xdotool` or `xwininfo` to see the window and +ImageMagick (`import`) for `--screenshot`; all of them are in the CI job below, +the dev container image has Xvfb. + +The [Headless smoke (L2)](../.github/workflows/headless-smoke.yml) workflow builds +the Linux bundle and runs the script (on changes to it, weekly, and on demand), +keeping the screenshot and the log as an artifact. + +Two more options let a script drive the running app: + +- `--seed-sql FILE` applies SQL to the profile database (`querya.db`). The app + starts once to create and migrate it, is stopped, `sqlite3` applies the file, + and the app starts again. Use the same `app_settings` keys and tables the UI + writes, so a test needs no code in the app. +- `--run COMMAND` runs a command once the window is up and keeps the app alive + meanwhile; its exit code becomes the script's. The command gets + `QUERYA_MCP_ENDPOINT` (this run's own endpoint file, so no other instance can + answer), `QUERYA_HEADLESS_DB` and `QUERYA_HEADLESS_DATA`. + +### L2 + MCP: `scripts/dev/headless_mcp_check.sh` + +```bash +dart compile exe packages/querya_mcp_bridge/bin/querya_mcp.dart -o build/querya-mcp +scripts/dev/headless_mcp_check.sh --mcp build/querya-mcp --bundle build/linux/x64/release/bundle +``` + +It creates a fixture SQLite database with two connections, turns the MCP server +on and shares only one of them (the same settings keys the Preferences page +writes), starts the app under Xvfb and runs `scripts/dev/mcp_stdio_check.py`: a +small MCP client that talks to the real `querya-mcp` and checks `initialize`, +`tools/list`, that only the shared connection is listed, the table, its columns +and data, that a `DELETE` is refused and changes nothing, and that the unshared +connection is refused. It needs `sqlite3` and `python3` besides what L2 needs. +The same step runs in the Headless smoke workflow. + +A window under Xvfb is all the MCP server needs, so there is no separate +"headless Querya" mode: `querya-mcp` still needs a running Desktop, and the +virtual display provides it. + +## Run the app in the container + +The container has no display of its own. Two ways: + +- **Headless smoke:** start a virtual display and run the app, or just run the + tests. + + ```bash + Xvfb :99 -screen 0 1280x800x24 & # DISPLAY=:99 is already set + flutter run -d linux + ``` + +- **See the window on a Linux host** (not part of the repository's + configuration, add it to your own `devcontainer.json` if you want it): + + ```jsonc + "mounts": ["source=/tmp/.X11-unix,target=/tmp/.X11-unix,type=bind"], + "containerEnv": { "DISPLAY": "${localEnv:DISPLAY}" } + ``` + + plus `xhost +local:` on the host. Wayland-only hosts need XWayland. + +For day-to-day UI work running Querya on the host is simpler; the container is +best for builds, tests and the database stack. + +## Run the tests + +CI is the source of truth; the same commands work in the container. + +```bash +flutter pub get +flutter test --exclude-tags=golden # everything CI runs +scripts/ci/test_sections.sh list # core-data, core-extensions, ... +flutter test --exclude-tags=golden \ + $(scripts/ci/test_sections.sh paths features-workspace) # one section +flutter test test/e2e # scenario tests +scripts/ci/test_sections.sh check # every test is in a section +``` + +- **No database server is needed** for the suite: widget and e2e tests start the + app on an isolated data directory, an empty `LocalDb` and in-memory secrets + ([`test/e2e/README.md`](../test/e2e/README.md)). +- **Goldens** are tagged `golden` and excluded in CI; run them only when you + change the UI they cover. +- **Extension sandbox tests** need `bubblewrap` (installed in the image) and + unprivileged user namespaces; without them they skip. Inside a container they + may still skip because nested namespaces are restricted (run it with + `--security-opt seccomp=unconfined` or add `"runArgs": ["--privileged"]` to + your own `devcontainer.json` if you need them locally): CI runs them on a VM. +- **Analysis:** `flutter analyze`; CI runs it on every PR. + +### The live MySQL test + +`test/core/database/stream_take_drain_test.dart` talks to a real MySQL on +`127.0.0.1` and **skips** when there is none or when it cannot log in. It reads +`MYSQL_PORT` (default 3306), `MYSQL_USER`, `MYSQL_PASSWORD` and `MYSQL_DATABASE` +(defaults `querya`) and connects **without TLS**, so the stack's MySQL 8.4 +(`caching_sha2_password`) does not work for it. The `live-tests` compose profile +starts the server CI uses, MySQL 8.0 with `mysql_native_password`, on its own +port: + +```bash +cd docker +docker compose --profile live-tests up -d --wait mysql-live # host port 3307, MYSQL_LIVE_PORT to change +cd .. +MYSQL_PORT=3307 flutter test test/core/database/stream_take_drain_test.dart +``` + +Inside the dev container the start script forwards `localhost:3307` to it when +the service is running (re-run the script after starting the profile). + +### Testing an extension + +Build and run the tester the way CI does it: + +```bash +scripts/ci/build_ext_tester.sh build/ext-tester/querya-ext-tester +build/ext-tester/querya-ext-tester ./my-extension --target-version 0.5.0 +``` + +See [extension-testing.md](extension-testing.md) for the options, reports and +the GitHub Action. The exit code is 0 on success, 1 when a check failed and 2 on +a usage error. + +## For extension authors + +You do not need this repository to write a database-driver extension. Start from +[QueryaHub/extension-template](https://github.com/QueryaHub/extension-template) +(a Rust driver with a dev container, a test database and CI). The pieces it uses: + +- the **Dev Container Feature** `ghcr.io/queryahub/features/querya-ext-tester` + installs `querya-ext-tester` from a release, checked against `SHA256SUMS.txt` + (see [extension-testing.md](extension-testing.md#dev-container-feature)); +- **test database images** `ghcr.io/queryahub/querya-testdb-{postgres,mysql,mongo,clickhouse,redis}:` + are the `docker/` seeds baked into the official images, no bind mounts. + Built from `docker//Dockerfile.testdb`; a new `Dockerfile.testdb` adds a + database to the build by itself. + +The whole path from the template to the Marketplace is in +[extension-registry.md](extension-registry.md). + +## Known gaps + +- **The registry has no extensions yet**, so the Marketplace tab is empty until + authors add records (see [extension-registry.md](extension-registry.md)). + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `Docker is not reachable from the container` on start | Check that the host daemon runs and the `docker-outside-of-docker` feature is installed, then `bash scripts/dev/devcontainer_start.sh`. | +| `not healthy after 180s: ...` on start | A service is still starting or failed: `docker compose -f docker/docker-compose.yml ps` and `logs `; raise `QUERYA_STACK_TIMEOUT` for a slow machine. | +| `port is already allocated` | A local PostgreSQL / MySQL / Redis uses the port. Set another one in `docker/.env` (`POSTGRES_PORT`, `MYSQL_PORT`, ...). | +| Connection refused to `localhost` from inside the container | The forwards are started by the start script: run `bash scripts/dev/devcontainer_start.sh` again (after a `docker compose down` / `up`), or use the service names (`postgres`, `mysql`, ...). | +| MySQL: connection refused or *requires a secure connection* | Tick **Use SSL/TLS** in the form (MySQL 8.4 uses `caching_sha2_password`). | +| MongoDB: authentication failed | The authentication database is `admin`, not `querya`. | +| Seeds missing or outdated | `docker compose down -v && docker compose up -d`. | +| MySQL / MongoDB / ClickHouse show `starting` for a while | They have a start period of 30–40 s; wait for `healthy`. | +| `flutter test` hangs or is slow in the container | Give Docker at least 4 CPUs and 8 GB, and run one section at a time. | diff --git a/docs/extension-manifest.md b/docs/extension-manifest.md new file mode 100644 index 00000000..f613f16a --- /dev/null +++ b/docs/extension-manifest.md @@ -0,0 +1,130 @@ +# The extension manifest (`manifest.json`) + +Every extension is a folder or zip with a `manifest.json` at its root. This is +the reference for that file: what the fields mean, what the tester and the app +check, and how the format itself is versioned. + +Start from a working one: the [extension template](https://github.com/QueryaHub/extension-template) +has a complete manifest of a database driver. How an extension gets from there +to the Marketplace is in [extension-registry.md](extension-registry.md); how to +test it is in [extension-testing.md](extension-testing.md). + +## A database driver + +```json +{ + "manifestVersion": 1, + "id": "acme.redis-driver", + "name": "Redis driver", + "version": "1.0.0", + "publisher": "Acme", + "type": "database_driver", + "main": "bin/redis-driver", + "engines": { "querya_desktop": ">=0.4.19" }, + "capabilities": { "databaseDriver": true }, + "sandbox": { + "engine": "process", + "permissions": { + "network": { "mode": "connection_host_only", "allow_ssl": false }, + "filesystem": { "scratch_mb": 10, "access": "scratch_only" }, + "resources": { "memory_mb": 128, "max_open_files": 32, "timeout_seconds": 60 } + } + }, + "contributions": { + "drivers": [ + { + "driverId": "acme-redis", + "displayName": "Redis (Acme)", + "defaultPort": 6379, + "connectionFormSchema": "assets/connection_form.json" + } + ] + } +} +``` + +## Fields + +| Field | Required | Meaning | +|-------|----------|---------| +| `manifestVersion` | no | The format of this file: a whole number, **1 when absent**. See [Versioning](#versioning-of-the-manifest-format) | +| `id` | yes | `publisher.name` in lowercase, digits and dashes. The install folder, the registry record (`extensions/.json`) and everything else key on it | +| `name` | yes | Shown in the Marketplace and the Extensions list | +| `version` | yes | The extension's own version, `x.y.z`. The registry requires it to equal the record's | +| `publisher` | no | Shown as the author (`author` is read when `publisher` is missing) | +| `type` | yes | `database_driver`, `theme` or `script`. Only `database_driver` can use the OS process sandbox, and only it is run by the tester | +| `main` | drivers | The executable, relative to the root. Must be a file in the archive | +| `engines.querya_desktop` | yes | The Querya Desktop versions it runs on: `^x.y.z`, `*`, or comparators that must all hold, such as `>=0.4.0 <3.0.0` (`>=`, `>`, `<=`, `<`, `=`). The app hides the extension outside the range; the tester warns when the emulated host is outside it, and the registry refuses a range it cannot read | +| `description`, `icon`, `homepage`, `license`, `tags`, `preview` | no | Marketplace listing. `icon` is a path inside the archive | +| `capabilities` | no | `databaseDriver`, `sduiForms` flags | +| `sandbox` | drivers | What the driver is allowed to do; see below | +| `contributions` | drivers | `drivers[]` (`driverId`, `displayName`, `defaultPort`, `connectionFormSchema`, `icon`) and `commands[]` (`id`, `title`, `category`, `aliases`; commands need Querya Desktop 0.4.16 or newer). `contributes` is accepted as an alias | + +Fields the marketplace adds (`downloadUrl`, `sha256`) are not written by the +author: the registry builds them from the release asset. + +## The sandbox block + +A database driver runs as its own process inside an OS sandbox (`bubblewrap` on +Linux, `sandbox-exec` on macOS). It declares what it needs, and the app refuses +anything beyond the policy: + +| Setting | Allowed | Notes | +|---------|---------|-------| +| `engine` | `process` | `wasm` and `quickjs` are reserved for embedded scripts | +| `permissions.network.mode` | `connection_host_only` or `none` | Sockets only to the host the user configured. `allow_ssl` says it uses TLS | +| `permissions.filesystem` | `access: "scratch_only"`, `scratch_mb` up to 100 | The only writable place is a scratch directory | +| `permissions.resources` | `memory_mb` up to 512, `max_open_files` up to 64 | `timeout_seconds` is optional | + +A driver without a sandbox block, or with one the policy rejects, is shown in the +Marketplace as a **Preview** listing and cannot be installed. + +## What checks the manifest + +| Where | What it checks | +|-------|----------------| +| `querya-ext-tester` (the `manifest` step) | Required fields, `type`, `main`, `engines` against `--target-version`, `manifestVersion` against that host, `contributions.drivers[]`. Every problem comes with its JSON path | +| The registry's CI (`registry.py check`) | `manifest.json` at the archive root, `id` and `version` equal the record's, `engines.querya_desktop` present, a driver's `main` is in the archive, `manifestVersion` is a format Querya defines. All platform archives of a record agree on `id`, `version`, `name`, `type`, `engines` and `manifestVersion` | +| The app, when it lists, installs or loads | The sandbox policy, the preview rule, `manifestVersion` not newer than it reads, the driver package (`main` exists) | + +## Versioning of the manifest format + +Three different versions meet in the file: + +| Field | Versions | Who raises it | +|-------|----------|---------------| +| `version` | the extension | the author, for each release | +| `engines.querya_desktop` | the hosts the author tested | the author | +| `manifestVersion` | **the format of `manifest.json`** | Querya, when the format changes | + +`manifestVersion` is **1 when it is absent**, so every manifest written so far is +format 1 and nothing has to be changed. Writing `"manifestVersion": 1` is +optional but makes the format explicit (the template does). + +An older Querya Desktop must not guess what a newer manifest means, so one in a +format it does not read is refused outright: the tester reports an error for the +host it emulates, the Marketplace does not list it, and the installer and the +local loader say to update Querya Desktop. A format that a host reads is read +completely. + +### When the format changes + +Keep a change **additive** when it can be: a new optional field that an older +host ignores safely needs no new `manifestVersion`, only a line in this document +and, if the tester should warn about it, a rule in the validator for the hosts +that know it. Raise the format only for a change an older host would misread: a +field that changes the meaning of an existing one, a removed or renamed field, a +stricter requirement. + +To add format *N*: + +1. `ExtensionManifest.currentManifestVersion` → *N* (`lib/core/extensions/models/extension_manifest.dart`). +2. `ExtensionProtocolProfile._manifestFormats`: add `(N, )`. +3. Teach the reading code (`ExtensionManifest.fromJson` and what uses it) the new shape, keeping format 1 working. +4. Add the rule to `validateExtensionManifest`, gated on `profile.manifestVersion >= N` where it only applies to the new format. +5. `QueryaHub/extensions`: `MAX_MANIFEST_VERSION` in `scripts/registry.py` → *N*, with tests. +6. Update this document, the [extension template](https://github.com/QueryaHub/extension-template) if its manifest should use the new format, and the CHANGELOG. +7. Tests: the validator on a host before and after the release, the support check, the local registry skip, the registry reader. + +Authors then opt in with `"manifestVersion": N` once the hosts they support +(`engines`) all read it. diff --git a/docs/extension-registry.md b/docs/extension-registry.md new file mode 100644 index 00000000..40b6dabc --- /dev/null +++ b/docs/extension-registry.md @@ -0,0 +1,159 @@ +# Extension registry + +How a database-driver extension gets from an author's machine into Querya +Desktop's Marketplace, and what keeps it safe. There is no Marketplace server: +the catalog is a Git repository and one static file. + +``` +author ──► extension-template ──► release zip on GitHub ──► record PR to QueryaHub/extensions + │ CI: download, SHA-256, archive, + │ manifest, querya-ext-tester --sandbox + ▼ +Querya Desktop ◄── index.json (GitHub Pages) ◄── publish (on merge, nightly, on a Querya release) +``` + +| Piece | Where | What it does | +|-------|-------|--------------| +| `extension-template` | [QueryaHub/extension-template](https://github.com/QueryaHub/extension-template) | A working Rust driver with a dev container, a test database and CI. Click *Use this template*. | +| `querya-ext-tester` | Querya Desktop releases, the Dev Container Feature, the `test-extension` action | Runs the extension like the app does and checks its JSON-RPC lifecycle ([extension-testing.md](extension-testing.md)). | +| `querya-testdb-*` | `ghcr.io/queryahub/querya-testdb-{postgres,mysql,mongo,clickhouse,redis}:` | The test databases, seeds inside. Built from `docker//Dockerfile.testdb`. | +| Registry | [QueryaHub/extensions](https://github.com/QueryaHub/extensions) | Records, CI, and `index.json` / `compat.json` on GitHub Pages. | +| `StaticRegistryRepository` | `lib/core/market/static_registry_repository.dart` | The app's reader of `index.json`. | + +## Publishing an extension + +1. Start from the template, or any project that produces a **zip** with + `manifest.json` at its root (and, for a driver, the executable `main` points at). +2. Test it: `scripts/test.sh` in the template's dev container, or + `querya-ext-tester ./dist --connect '{...}' --sandbox` against a + `querya-testdb-*` container. +3. Publish a GitHub release with the zip as an asset. +4. Add `extensions/.json` to the registry in a pull request (the format and + what CI checks are in the registry's README). An extension that ships a native + binary lists one archive per platform under `platforms`. Everything the Marketplace + shows is read from `manifest.json` **inside the archive**; the record only + holds `id`, `version`, `downloadUrl`, `sha256` and an optional `testEnv`. +5. A new version is a change to `version`, `downloadUrl` and `sha256` in the same + file. `"yanked": true` hides an extension without deleting its record. + +The extension's binary runs in the registry's CI, inside the OS sandbox, in a job +with no secrets and a read-only token. Only the Linux x86-64 build is exercised; the other platforms' archives are +checked (hash, archive, manifest) but not run. + +## Versions: what is checked against what + +The whole manifest is described in [extension-manifest.md](extension-manifest.md); this is the part about versions. + +Three different versions meet in `manifest.json`: + +| Field | What it versions | Checked | +|-------|------------------|---------| +| `version` | the extension itself | the registry record and the archive must agree | +| `engines.querya_desktop` | the Querya Desktop versions the author says it runs on | the app hides the extension outside the range (`>=x.y.z` or `^x.y.z`) | +| `manifestVersion` | the **format of `manifest.json`** (apart from the other two) | see below | + +`manifestVersion` is a whole number, **1 when absent** (every manifest written so +far). It exists so the format can change later without guessing from the host +version: a release that starts reading a new format raises +`ExtensionManifest.currentManifestVersion` and lists the first host version that +reads it in `ExtensionProtocolProfile`. Until a format 2 exists there is nothing +to opt into; new manifests may set `"manifestVersion": 1` explicitly (the +template does). + +- **The tester** checks `manifestVersion` against the host it emulates + (`--target-version`): a format newer than that host reads is an error, a value + that is not a whole number >= 1 is an error. +- **The app** does not offer, install or load an extension in a format newer than + it reads, and says to update Querya Desktop. +- **The registry** refuses a record whose archive uses a format Querya does not + define yet, and copies the format into the index entry. + +## What the app trusts + +`StaticRegistryRepository` reads `https://queryahub.github.io/extensions/index.json`: + +```json +{ + "schema": 1, + "generatedAt": "2026-10-16T18:59:53Z", + "extensions": [ + { + "id": "acme.redis-driver", "name": "Redis", "version": "1.0.0", "type": "database_driver", + "engines": { "querya_desktop": ">=0.4.19" }, "main": "bin/driver", + "downloadUrl": "https://github.com/acme/redis-driver/releases/download/v1.0.0/redis-driver.zip", + "sha256": "…", "size": 232353 + } + ] +} +``` + +- An entry can carry one archive for every platform (`downloadUrl`, `sha256`) or a + `platforms` map with one build per target (`linux-x64`, `macos-arm64`, + `macos-x64`, `windows-x64`), each with its own `downloadUrl`, `sha256` and + `main`. The app resolves the map to the build for the platform it runs on and + does not offer the extension where there is none. +- An index with another `schema` is refused, not half read. (`schema` is the + index's own format; `manifestVersion` of an entry is its manifest's.) +- Hidden from the list: `yanked` entries, entries without a checksum, a + `downloadUrl` that is not `https://github.com///releases/download//`, + extensions whose `engines.querya_desktop` range excludes the running + version, and extensions whose `manifestVersion` is newer than the app reads. One malformed entry does not hide the others. +- The index is cached for ten minutes; if a refresh fails the last good copy is + served. +- Installing is the same pipeline as any marketplace install: the archive's + SHA-256 must equal the index's, the archive is unpacked with path-traversal and + zip-bomb limits, and the sandbox policy must accept the manifest. + +The app picks it at startup (`lib/core/market/marketplace_selection.dart`), with +the running version as `hostVersion`, so the Extensions → Marketplace tab lists +what the registry publishes. While the registry is empty the tab says so; when +the registry cannot be reached it shows the reason and a *Retry* button. + +Two build-time switches, for people working on the app or on a registry: + +| `--dart-define` | Effect | +|-----------------|--------| +| `QUERYA_MARKETPLACE_INDEX=` | read another `index.json` (a fork, a test registry); `http://localhost` is accepted in debug builds | +| `QUERYA_MARKETPLACE_MOCK=true` | the built-in demo catalog, for working on the Marketplace page; for debug and profile builds only | + +`compat.json` lists, per extension and per Querya version in the registry's +`hosts.json`, `pass`, `fail` or `untested`. The app does not read it yet. + +## Following Querya's releases + +The [Release hooks](../.github/workflows/release-hooks.yml) workflow runs after a +**Release** succeeds and sends `repository_dispatch` `querya-release` with the +version to: + +- **the registry**, which tests every record against that version too and adds a + column to `compat.json` (raise `hosts.json` in a pull request to keep the + column); +- **the template**, which pushes a branch `querya-` that moves its test + image tag and CI matrix, starts its Test workflow on it, and opens a pull + request (or an issue pointing at the branch when the organization does not + allow Actions to open pull requests). + +The `querya-testdb-*` images and `querya-headless` have their own workflows on +the same Release. The Dev Container Feature does not follow releases: it +downloads the newest tester when it is installed. + +### One-time setup + +A dispatch into another repository needs a token that can write there. Create a +fine-grained personal access token (or a GitHub App token) with **Contents: +Read and write** on `QueryaHub/extensions` and `QueryaHub/extension-template` +and store it as the secret **`QUERYA_DISPATCH_TOKEN`** in this repository. +Without it the Release hooks only log a warning, and nothing breaks. + +To let the template open its pull request itself, enable *Settings → Actions → +General → Allow GitHub Actions to create and approve pull requests* for the +organization. Without that the template opens an issue instead. + +## The nightly check + +The registry rebuilds the index every night from the records. A record whose +asset disappeared, changed its hash, or whose extension fails its tests on every +Querya version is **left out of the index** and gets an issue +`Registry check failed: ` (label `registry-check`) with the reason; the +issue closes itself when the record passes again. The rest of the index is still +published. diff --git a/docs/extension-testing.md b/docs/extension-testing.md index 91f01481..52ada48e 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -8,11 +8,13 @@ server, Flutter or the Querya Desktop source tree. ## What it checks 1. **`manifest.json`** — `id`, `name`, `version`, `type: "database_driver"`, - `main`, `engines.querya_desktop` (against the emulated host version), + `main`, `engines.querya_desktop` and `manifestVersion` (both against the + emulated host version; see [extension-registry.md](extension-registry.md#versions-what-is-checked-against-what)), `contributions.drivers[]` (`driverId`, `displayName`). Every problem is reported with its JSON path. 2. **Launch** — the `main` entry point exists and starts. 3. **RPC lifecycle**, in this order: `system.handshake`, `system.ping`, + `system.injectCredentials` (only when `--connect` has a `password`), `db.connect`, `db.getCapabilities`, `db.getServerStats`, `db.getSchemaTree`, `db.query` (must return `{"columns": [...], "rows": [...]}`), `db.getTableSchema` (only with `--table`), `db.disconnect`, @@ -20,8 +22,8 @@ server, Flutter or the Querya Desktop source tree. `system.handshake`, `system.shutdown` and the `db.connect` / `db.disconnect` / `db.query` / `db.getSchemaTree` calls are **required**: a failure fails the -run. `system.ping`, `db.getCapabilities`, `db.getServerStats` and -`db.getTableSchema` are **recommended**: the host falls back gracefully, so +run. `system.ping`, `system.injectCredentials`, `db.getCapabilities`, +`db.getServerStats` and `db.getTableSchema` are **recommended**: the host falls back gracefully, so the runner only warns. Methods the emulated host version does not send are skipped. @@ -69,16 +71,143 @@ querya-ext-tester ./my-extension \ | `--table ` | also check `db.getTableSchema` | | `--database ` | database passed to `db.connect` | | `--sandbox` | run the plugin in the OS sandbox (`bwrap` on Linux, `sandbox-exec` on macOS) when available | +| `--manifest-only` | check `manifest.json` against `--target-version` and stop: the extension is not started, so no database and no executable are needed. For a quick lint, or a CI step with no database. Not in 0.4.19 or earlier | +| `--strict` | warnings fail the run too (exit `1`): a recommended method that errors, or a sandbox that could not be applied. Not in 0.4.19 or earlier | | `--timeout-ms ` | per-request timeout (default 5000) | | `--junit ` / `--tap ` | write a JUnit XML / TAP report | +| `--trace ` | write every RPC round trip as JSON: method, time, request and response size and a bounded view of the data (see *Following the data*) | | `--no-color` | plain output | -Exit codes: `0` all checks passed (warnings and skips do not fail), `1` a -check failed, `2` bad command line. +Exit codes: `0` all checks passed (warnings and skips do not fail, unless +`--strict`), `1` a check failed, `2` bad command line. + +A `password` in `--connect` is sent the way the app sends it: with +`system.injectCredentials` before `db.connect`, and left out of the `db.connect` +payload. A driver that reads its password only from the injected secret (as the +ClickHouse driver does) therefore works with the tester. Credentials travel over +the plugin's stdio only; they are never put on the command line of the plugin or +into its environment. Use a throwaway database in CI. + +## Following the data: `--trace` + +A green run says the lifecycle works. `--trace trace.json` shows *what* went +over the wire, so you can see whether the data of your driver arrives as the +host expects: + +```json +{ + "extension": "acme.clickhouse-driver", + "targetVersion": "0.5.0", + "success": true, + "totalMs": 41, + "checks": [{ "name": "db.query", "status": "passed", "ms": 3 }], + "rpc": [ + { "method": "db.connect", "ms": 12, "ok": true, "requestBytes": 76, + "responseBytes": 28, "request": { "host": "db", "password": "***" } }, + { "method": "db.query", "ms": 3, "ok": true, "requestBytes": 47, + "responseBytes": 1760, + "response": { "columns": ["id", "name"], "rows": [[0, "user0"], "… (+7 more)"] } } + ] +} +``` + +- Values of keys that look like secrets (`password`, `secret`, `token`, + `passphrase`, ...) are written as `***`. +- Responses are cut: at most 5 items per list, strings of at most 120 + characters. The file shows the shape and a sample, not a copy of your data; + still, use a throwaway database and do not publish traces of real data. +- A failing call is recorded with its `error`, so a CI artifact is enough to + see which call broke and after how long. + +## Docker image + +`ghcr.io/queryahub/querya-headless:` (and `:latest`) is the tester of +that Querya Desktop release in a pinned environment: Debian (glibc), the +tester checked against the release's `SHA256SUMS.txt`, `bubblewrap` for +`--sandbox`, a non-root user, entry point `querya-ext-tester`. Nothing else is +needed on the machine: no Flutter, no display, no Querya install. + +```bash +docker pull ghcr.io/queryahub/querya-headless:latest + +docker run --rm \ + -v "$PWD/dist:/ext:ro" -v "$PWD/out:/out" \ + ghcr.io/queryahub/querya-headless:0.4.19 /ext \ + --connect '{"host":"db","port":5432}' \ + --junit /out/junit.xml --trace /out/trace.json +``` + +The extension's `main` is a native executable and runs **inside** the image, so +build it for Linux x86-64 against glibc. To reach a database from the image, +put both on one Docker network and use the database's service name as the host. + +### In CI, next to a test database + +```yaml +jobs: + rpc: + runs-on: ubuntu-latest + services: + db: + image: postgres:16-alpine + env: { POSTGRES_PASSWORD: querya, POSTGRES_USER: querya, POSTGRES_DB: querya } + options: >- + --health-cmd "pg_isready -U querya" --health-interval 5s + --health-retries 12 + steps: + - uses: actions/checkout@v4 + - run: ./build.sh # writes ./dist with manifest.json + - name: RPC and data check + run: | + mkdir -p out && chmod 777 out + docker run --rm --network "${{ job.services.db.network }}" \ + -v "$PWD/dist:/ext:ro" -v "$PWD/out:/out" \ + ghcr.io/queryahub/querya-headless:0.4.19 /ext \ + --target-version 0.5.0 \ + --connect '{"host":"db","port":5432,"username":"querya","password":"querya","database":"querya"}' \ + --query 'SELECT 1' --junit /out/junit.xml --trace /out/trace.json + - uses: actions/upload-artifact@v4 + if: always() + with: { name: rpc-trace, path: out/ } +``` + +Use the same `docker run` locally with `--network querya-dev_default` against +the stack from [dev-containers.md](dev-containers.md) (`host` is the service +name, for example `postgres`). + +### `--sandbox` in a container + +The OS sandbox is `bubblewrap`, which needs unprivileged user namespaces. Many +container runtimes block them by default; the tester then runs the plugin +without the sandbox and says so in the report. To test the sandboxed run, start +the container with `--security-opt seccomp=unconfined` (and, on hosts that +restrict namespaces with AppArmor, `--security-opt apparmor=unconfined`), or +run the tester directly on the CI runner with the action below. + +The fields of `manifest.json` are in [extension-manifest.md](extension-manifest.md). + +Starting a new extension? The [extension template](https://github.com/QueryaHub/extension-template) +has all of this wired up, and [extension-registry.md](extension-registry.md) +shows how a tested extension gets into the Marketplace. + +## Dev Container Feature + +In a dev container the tester can be installed as a Feature. It downloads the +release archive, checks it against `SHA256SUMS.txt` and puts `querya-ext-tester` +in `PATH` (Linux x86_64, Debian-family images): + +```json +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/queryahub/features/querya-ext-tester:1": { "version": "latest" } + } +} +``` -Credentials only travel inside the `db.connect` payload over the plugin's -stdio; they are never put on the command line of the plugin or into its -environment. Use a throwaway database in CI. +`version` is a Querya Desktop release (`0.4.19`) or `latest`. Releases before +0.4.19 are refused: their exit codes were not reliable. The source is in +`devcontainer-features/src/querya-ext-tester`. ## GitHub Actions @@ -230,8 +359,9 @@ test-extension: the plugin in the same OS sandbox. 5. Capture machine-readable results with `--junit report.xml` or `--tap report.tap`. -There is no `--strict` mode: recommended methods only warn, and exit code `1` -means a required check failed. +Recommended methods only warn, and exit code `1` means a required check failed. +Add `--strict` (with `--sandbox` in CI) so a warning, in particular "OS sandbox +is not available", fails the run instead of passing as green. ## Running from source diff --git a/docs/getting-started.md b/docs/getting-started.md index a89d52fd..5b2dd714 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,17 +11,19 @@ How to build and run Querya Desktop from source. flutter config --enable-linux-desktop # or --enable-windows-desktop / --enable-macos-desktop ``` -- **Linux** also needs the desktop headers used by Flutter and its plugins - (GTK, etc.). For `flutter_secure_storage` you typically need - **`libsecret-1-dev`**: +- **Linux** also needs a C++ toolchain and the desktop headers used by + Flutter and its plugins (GTK, `libsecret` for `flutter_secure_storage`): ```bash - sudo apt install libsecret-1-dev # Debian / Ubuntu - sudo dnf install libsecret-devel # Fedora + # Debian / Ubuntu + sudo apt install clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev + # Fedora + sudo dnf install clang cmake ninja-build pkgconf-pkg-config gtk3-devel libsecret-devel ``` - CI pins a specific stable Flutter version; matching it locally avoids - "works on my machine" drift (see [Contributing](../CONTRIBUTING.md)). +- **Flutter version.** CI builds with Flutter **3.47.1** (stable channel, see + `.github/workflows/ci.yml`); using the same one locally avoids "works on my + machine" drift (see [Contributing](../CONTRIBUTING.md)). ## Install @@ -31,23 +33,28 @@ From the project root: flutter pub get ``` -If the platform folders are missing: +The `linux/`, `windows/` and `macos/` folders are part of the repository and +hold native code of their own (window chrome, refresh-rate handling, runner +changes, icons, entitlements). **Do not run `flutter create .` here:** it would +overwrite them with the stock templates. If a platform folder looks broken, +restore it from Git (`git checkout -- linux/`) instead. + +## Run + +**Linux.** Use the helper script: ```bash -flutter create . --project-name querya_desktop --platforms=linux,windows,macos -flutter pub get +./run_linux.sh # extra arguments go to `flutter run` ``` -## Run +It checks that `libsecret-1` is installed (with install hints for Debian, +Fedora and Arch if it is not) and sets `GDK_BACKEND=x11`, which avoids Gdk +pointer warnings on Wayland. Plain `flutter run -d linux` also works, and runs +natively on Wayland if you prefer that. -```bash -# Linux -flutter run -d linux -# On Wayland, force X11 to avoid Gdk pointer warnings: -GDK_BACKEND=x11 flutter run -d linux -# Or use the helper script: -./run_linux.sh +**Other platforms:** +```bash # Windows flutter run -d windows @@ -86,4 +93,5 @@ cd docker && docker compose up -d Default credentials: user/password **`querya`**, database **`querya`** (MongoDB auth source: **`admin`**; ClickHouse HTTP **`8123`**, native **`9000`**). -Stop with `docker compose down`. +Stop with `docker compose down`. Connecting from inside the dev container, MySQL +TLS and the live-database test are in [dev-containers.md](dev-containers.md). diff --git a/docs/macos-signing.md b/docs/macos-signing.md index 32b713c5..30543e6d 100644 --- a/docs/macos-signing.md +++ b/docs/macos-signing.md @@ -102,12 +102,12 @@ Go to **Settings → Secrets and variables → Actions → New repository secret - Creates a temporary keychain in the runner. - Imports the Developer ID certificate. - Signs nested frameworks, dylibs, and the main `.app` bundle with the **Hardened Runtime**. -- Applies entitlements from `macos/Runner/Release.entitlements`. +- Applies entitlements from `macos/Runner/ReleaseSigned.entitlements`. - Submits the app to Apple **notarytool**, waits for approval. - **Staples** the notarization ticket to the `.app` so it works offline. -- The final zip is then produced from the signed/stapled bundle. The signing step uses `macos/Runner/ReleaseSigned.entitlements` (hardened runtime, no sandbox), while the default `macos/Runner/Release.entitlements` is left untouched for unsigned builds. +- The final zip is then produced from the signed/stapled bundle. -If the secrets are **not** set, the step is skipped and the zip is built exactly as before (unsigned). This keeps the release workflow safe for forks and local testing. +If the secrets are **not** set, the step is skipped and the zip ships the build as Xcode made it: ad-hoc signed with `macos/Runner/ReleaseUnsigned.entitlements`. That build has no App Sandbox either, so it behaves like the signed one and only Gatekeeper treats it differently. A step after the build fails the release if the App Sandbox entitlement ever comes back. --- @@ -130,15 +130,39 @@ If all three commands report success, the app should open with a normal double-c ## Entitlements -Two entitlement files are kept side by side: +Two entitlement files are kept side by side; neither enables the **App Sandbox** (#1592): -- `macos/Runner/Release.entitlements` — the original Flutter default, kept as-is for **unsigned** builds. -- `macos/Runner/ReleaseSigned.entitlements` — used only when the CI signs the app. It disables the **App Sandbox** (the app needs arbitrary network, file picker, and `~/.querya` access) and enables **Hardened Runtime** exceptions for Flutter/Dart (`allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation`). +- `macos/Runner/ReleaseUnsigned.entitlements` — the Xcode Release configuration: local release builds and releases built without the signing secrets (ad-hoc signed). Inside the sandbox `HOME` would be the app's container, so `~/.querya`, `~/.ssh`, saved SQLite paths, `sandbox-exec` for extensions, the MCP endpoint file and the in-app updater would not work. +- `macos/Runner/ReleaseSigned.entitlements` — used only when the CI signs the app with Developer ID. It enables **Hardened Runtime** exceptions for Flutter/Dart (`allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation`). + +Debug and Profile builds still use `DebugProfile.entitlements`, the Flutter default with the sandbox. If you later add plugins that require microphone, camera, or other protected resources, add the corresponding hardened-runtime and/or sandbox entitlements to `ReleaseSigned.entitlements` and re-sign. --- +## Running an unsigned build + +Without the signing secrets the release ships an ad-hoc signed app. It has no App Sandbox (see Entitlements above), so it can reach `~/.querya`, `~/.ssh`, saved SQLite paths, run extensions under `sandbox-exec`, serve MCP and update itself, like the signed build. What differs is Gatekeeper: + +| macOS | First launch | +|---|---| +| 14 and earlier | Control-click the app → **Open** → **Open**. | +| 15 and later | Control-click → Open no longer bypasses Gatekeeper. Open the app once, then **System Settings → Privacy & Security → Open Anyway**. | +| any | `xattr -dr com.apple.quarantine "/Applications/Querya Desktop.app"` | + +Move the app to `/Applications` before the first launch. Started from `~/Downloads` or the disk image, macOS runs it through App Translocation (a read-only copy at a random path): the in-app update refuses to run there and points to the release page instead (#1587). + +Related: secrets are stored in the login keychain, so they work without `keychain-access-groups` and without a provisioning profile (#1586); an update must carry the same Team ID as the installed app when the installed app is signed (#1588). + +## Minimum macOS version + +`MACOSX_DEPLOYMENT_TARGET` is **10.15** in every Xcode configuration, and `LSMinimumSystemVersion` follows it. That is also the highest minimum among the macOS plugins in use (`file_selector_macos` and `url_launcher_macos` need 10.15; `package_info_plus`, `device_info_plus` and `refresh_rate` need 10.14). Raising it needs no plugin change, lowering it is not possible. + +Rendering above 60 Hz on a ProMotion display needs **macOS 14** or later (`refresh_rate`, see [motion and high refresh](motion-and-high-refresh.md)); on older versions the app runs at 60 Hz. + +--- + ## References - Flutter: [Build and release a macOS app](https://docs.flutter.dev/deployment/macos) diff --git a/docs/motion-and-high-refresh.md b/docs/motion-and-high-refresh.md index 6ac8cca4..6382c003 100644 --- a/docs/motion-and-high-refresh.md +++ b/docs/motion-and-high-refresh.md @@ -65,7 +65,7 @@ Root cause (engine): per `flutter/flutter#160952`, the engine "can render at 120 ### Practical implication for Querya (desktop) - **Windows:** usually follows DWM monitor Hz; **measure and verify**. -- **Linux:** **query-only** — `enable` / prefer-max are no-ops in `third_party/refresh_rate` Linux plugin. Overlay/`RefreshRate.info` report what GDK sees; actual frame pacing is compositor-dependent. Do not treat a 120/144 reading as “unlocked by Querya”. +- **Linux:** **query-only** — `enable` / prefer-max are no-ops in the `refresh_rate` Linux plugin. Overlay/`RefreshRate.info` report what GDK sees; actual frame pacing is compositor-dependent. Do not treat a 120/144 reading as “unlocked by Querya”. - **macOS:** unlock via `RefreshRate.enable()` when capped at 60 (ProMotion / high-Hz). - Use `refresh_rate` for **diagnostics** (debug overlay / benchmark) plus the macOS unlock in `main()`. - **Implementation:** `lib/core/motion/display_refresh_service.dart` calls `RefreshRate.enable()` in `main()`. Debug Hz badge: `flutter run --dart-define=QUERYA_REFRESH_OVERLAY=true`. diff --git a/docs/packaging.md b/docs/packaging.md index fe675889..90165b7c 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -68,6 +68,12 @@ payload as the portable zip and AppImage. **Runtime dependencies (deb/rpm):** GTK 3, libsecret, GLib; app indicator recommended for tray. +### Betas on package channels (decision, #1495) + +**Betas are GitHub-only.** A pre-release (`X.Y.Z-beta.N`) is published as a GitHub pre-release with its zip, AppImage, `.deb`, `.rpm`, `.flatpak` and Windows / macOS builds, and it **does not** reach AUR, the APT / DNF repository or Flathub: the Release workflow skips those jobs for it (#1492), so package users keep Stable. Someone who wants a beta on a package install downloads the zip or AppImage from the release page. The final `X.Y.Z` then reaches everyone, and a beta `.deb` / `.rpm` is versioned `X.Y.Z~beta.N` so that it upgrades to the final package (#1494). + +A separate beta line per package system (an APT `beta` component and a DNF `querya-beta` repository from `publish_package_repo.sh`, an AUR `querya-desktop-beta` package that conflicts with `querya-desktop`, a Flatpak `beta` branch) is **not** built. Revisit it when there is demand; `publish_package_repo.sh` would need a parameter for the component that never touches the stable index from a beta tag, and the Flatpak branch waits for the Flathub submission. + ### Updater policy | Install type | In-app updater | diff --git a/docs/release-checklist.md b/docs/release-checklist.md index cd7ef0ce..db161745 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -4,6 +4,22 @@ Use this before tagging **`0.4.19`** or running the **Release** workflow. See [tags-and-releases.md](tags-and-releases.md), [CHANGELOG.md](../CHANGELOG.md). Manual 120 Hz DevTools QA: issue [#739](https://github.com/QueryaHub/Querya-Desktop/issues/739) (does not block this cut). +## Beta release (`X.Y.Z-beta.N`) + +Procedure and traps: [tags-and-releases.md](tags-and-releases.md#beta-канал-pre-release). + +- [ ] On `release/X.Y.Z`: `scripts/release/prepare_beta.sh X.Y.Z N`, then `scripts/release/check_release.sh X.Y.Z-beta.N` passes. +- [ ] Smoke subset on the beta build: start with an existing database, open a connection per engine, run a query, update check says *Beta*. +- [ ] After the tag: the GitHub Release is marked **Pre-release**, is **not** *Latest* (`gh release view --json isPrerelease,isLatest`), `releases/latest` still returns the previous stable; no AUR commit, no APT / DNF update. +- [ ] A Stable install is not offered the beta; a Beta install is. +- [ ] **How long a beta stays out:** at least 3 days and one weekend of use; blockers are a crash on startup, data loss or corruption, a connection / query regression, a broken updater. A blocker means `beta.N+1` from the same branch. + +### Promote to stable + +- [ ] `version: X.Y.Z+B`, `## [X.Y.Z]` with the full notes, `check_release.sh X.Y.Z`, annotated tag `X.Y.Z`. +- [ ] The release is *Latest*; AUR and the APT / DNF repositories are updated; a `.deb` / `.rpm` of the beta upgrades to the final package. +- [ ] Beta users are offered `X.Y.Z` (a stable outranks its own beta). + ## Product smoke (manual) — 0.4.19 - [ ] **Buttons and controls (#1331)** — in the SQL toolbar, results bar, ERD toolbar and Preferences every control of a row has one height; hover, pressed, keyboard focus ring (Tab, not mouse click) and disabled states look the same on primary / secondary / ghost / destructive; icon buttons show a tooltip. @@ -85,10 +101,13 @@ Verify the 0.4.4 motion tokens, smooth animations, and high refresh rate support ## Versioning and release +(The items below are for the 0.4.19 cut; the versions are examples. For a beta or the stable that follows it, see *Beta release* above.) + - [x] `pubspec.yaml` on the 0.4.19 track is **`0.4.19+2`** (the same as `main` after the bump that followed 0.4.18, so the merge has no version conflict). - [ ] After merge to `main`, confirm **Auto Version Bump** yields a **0.4.20+…** placeholder. - [ ] **Tag** `0.4.19` is placed on the **main merge commit that includes `0.4.19+2`** (not the auto-bump commit). - [ ] Run the **Release** workflow via that tag (see [tags-and-releases.md](tags-and-releases.md)). +- [ ] After the Release workflow: the **Release hooks** run told the registry and the template (`QUERYA_DISPATCH_TOKEN` must be set, see [extension-registry.md](extension-registry.md#one-time-setup)), the `querya-testdb-*` and `querya-headless` images are published for the new version, and the template's *Move to Querya …* pull request or issue exists. - [ ] Verify **portable** zips (`*-linux.zip`, `*-windows.zip`, `*-macos.zip`), **installable** artifacts (`*.AppImage`, `*.deb`, `*.rpm`, `*.flatpak`, `*-windows-setup.exe`), and `SHA256SUMS.txt` on the GitHub Release. - [ ] After the release, set the release zip's URL and `sha256` in `packaging/linux/flatpak/com.queryahub.querya_desktop.flathub.yml` (and the `metainfo.xml` release entry is already `0.4.19`); the Flathub submission (flathub/flathub#10606) is separate and has open lint errors (HTTPS on `queryahub.com` or another app id, no `--filesystem=home`, a supported GNOME runtime). diff --git a/docs/security.md b/docs/security.md index ae760465..2ac1e9c1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -42,6 +42,10 @@ Credentials never reach the model: tools return only connection id, name, type, **Audit:** every call is logged (Preferences → MCP Server → Recent calls). +## Local database, updates and downgrades + +`querya.db` carries a schema version. Before an in-app update the file (and its `-wal`) is copied to `querya.db.pre--.bak`, the newest three copies are kept, and a failed copy asks before the update goes on (#1497). On open, a version **newer than the build knows** raises `LocalDbNewerSchemaException`: nothing is migrated, nothing is written, and the file keeps its version number (without this, sqflite would stamp the older number on it and an older build would carry on with tables it does not understand). The startup recovery screen shows the message. Restoring the pre-update copy is the supported way back; see the [user guide](user-guide.md#updates-betas-and-going-back). + ## 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 50a01a8b..395010f1 100644 --- a/docs/tags-and-releases.md +++ b/docs/tags-and-releases.md @@ -15,10 +15,49 @@ Если после merge в `main` сработал **автобамп** версии в `pubspec`, а вы поставили тег со **старым** номером (например тег `0.1.1`, а в коммите уже `0.1.2+…`), сборка **всё равно пройдёт**: имена zip возьмутся из **pubspec** (`0.1.2`), а GitHub Release останется на **вашем теге** (`0.1.1`). В логах будет предупреждение; чтобы номер тега и архивов совпадали, ставьте тег на актуальный semver из `pubspec` (например `0.1.2`). +## Beta-канал (pre-release) + +Beta — это GitHub **pre-release** с версией вида `X.Y.Z-beta.N`. Пользователи **Stable** его не видят: приложение читает `releases/latest`, а бета никогда не становится `latest`. Пользователи, выбравшие **Beta / Early Access** (Настройки → Release channel), получают самую высокую версию из всех релизов; бета заменяется финальным релизом той же версии (`0.5.0-beta.2` < `0.5.0`). Пакетные установки (AUR, `.deb`, `.rpm`, Snap, Flatpak) получают только stable от своего менеджера пакетов. + +### Как выпустить бету + +1. Ветка `release/X.Y.Z` от `dev`. +2. `scripts/release/prepare_beta.sh X.Y.Z N`: ставит `version: X.Y.Z-beta.N+B` в `pubspec.yaml` и секцию `## [X.Y.Z-beta.N] - <дата>` в `CHANGELOG.md` (под `## [Unreleased]`, её содержимое становится заметками беты). Ничего не коммитит. +3. `scripts/release/check_release.sh X.Y.Z-beta.N` — проверка до тега (версия в pubspec = тег, есть секция CHANGELOG, у беты есть build number, тег свободен) и описание того, что сделает workflow. +4. Коммит, **аннотированный** тег `X.Y.Z-beta.N` на этом коммите, `git push origin HEAD X.Y.Z-beta.N`. +5. Workflow **Release** собирает всё и публикует **pre-release** (не `latest`), пропускает AUR и репозитории APT/DNF. В описание релиза добавляется пометка о бете. PR из `release/*` проходит job **Release branch check** (`check_release.sh`). + +Исправления беты: `beta.2`, `beta.3` из той же ветки тем же способом. Заметки CHANGELOG беты — дельта; финальный релиз повторяет всё. + +### Как повысить бету до stable (promote) + +Когда бета пролежала несколько дней без блокеров: в `pubspec.yaml` `version: X.Y.Z+B`, секция `## [X.Y.Z]` с **полными** заметками, `check_release.sh X.Y.Z`, аннотированный тег `X.Y.Z` (теги бет остаются). Это обычный релиз: он `latest`, уходит в AUR и APT/DNF, и пользователи Beta и Stable получают его. Пакеты беты (`X.Y.Z~beta.N`) обновляются до финального пакета. + +### Как откатить плохой релиз + +Пометьте плохой **stable** как pre-release (или удалите): `releases/latest` снова вернёт предыдущий. AUR и APT/DNF откатываются отдельно (удалить пакет из репозитория `QueryaHub/repo` / вернуть предыдущий `pkgver`). + +### Ловушки + +| Симптом | Причина | Что делать | +|---|---|---| +| Job **Publish GitHub Release** падает | нет секции `## [X.Y.Z-beta.N]` в `CHANGELOG.md` | `check_release.sh` называет её; добавьте до тега | +| После merge в `main` версия беты стала `0.5.1+…` | раньше `version-bump.yml` отрезал `-beta.N` | теперь бета-версия не трогается (notice в логе); тег всё равно ставьте вручную на коммит ветки `release/*` | +| Предупреждение `Git tag is … but pubspec semver is …` | тег и pubspec не совпадают | `check_release.sh` до тега; артефакты называются по pubspec | +| Бета предложена Stable-пользователям | release опубликован без `prerelease` | не должно случаться (#1492); проверьте, что в версии/теге есть `-` | +| Пакет беты «новее» финального | версия `0.5.0-beta.1` в dpkg/rpm | пакеты получают `0.5.0~beta.1` (#1494) | + +### Что получает каждый канал + +| | Stable (GitHub `latest`) | Beta / Early Access | AUR, APT/DNF, Flatpak | +|---|---|---|---| +| `X.Y.Z-beta.N` | нет | да | нет (бета только на GitHub, решение #1495) | +| `X.Y.Z` | да | да | да | + ## Ручной запуск (как раньше) - **Actions → Release → Run workflow** на нужном ref (обычно `main`). -- Workflow возьмёт semver из **pubspec** на этом коммите, соберёт zip и создаст **GitHub Release** с тегом **`X.Y.Z`** (как в pubspec до `+`), если тега ещё нет. +- Workflow возьмёт semver из **pubspec** на этом коммите, соберёт zip и создаст **GitHub Release** с тегом **`X.Y.Z`** (как в pubspec до `+`), если тега ещё нет. Если в pubspec версия с `-` (бета), релиз будет pre-release. ## Что внутри релиза diff --git a/docs/ui-kit.md b/docs/ui-kit.md index 8cd1603c..4514a8e9 100644 --- a/docs/ui-kit.md +++ b/docs/ui-kit.md @@ -38,6 +38,21 @@ QueryaSearchField( Anti-pattern: a bare `TextField` with a manual `Timer` for debouncing. +### `QueryaTextField` + +The text input of every form (#1733). A single line has the height of a control size (the nearest `QueryaControlScope`, else `md`), so a field and a `Browse…` button in one row line up; `maxLines` / `minLines` / `expands` make it grow. The border takes the accent while it has the keyboard and the destructive colour with an `errorText`. + +```dart +QueryaTextField( + controller: _host, + placeholder: 'localhost', + leading: const Icon(Icons.dns_outlined), +) +QueryaTextField(controller: _password, obscureText: true) // show / hide button +``` + +`leading` / `trailing` take an icon or a small button; `framed: false` is for a field inside a frame of its own (the code editor). The barrel hides shadcn's `TextField`; `test/guards/shadcn_imports_ratchet_test.dart` rejects it anywhere in `lib/`. + ### `QueryaIconButton` / `QueryaToolbarButton` Compact toolbar buttons with `tooltip`, `isActive`, `isDestructive` and a diff --git a/docs/user-guide.md b/docs/user-guide.md index a0e8d654..fa5252dc 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -34,6 +34,12 @@ Command Palette (`Ctrl+P`) → **Export Team Profile…** writes a `team-profile - **Connection list and settings**: local SQLite (`querya.db` under the app support directory). - **Passwords / connection strings**: OS secure store (see [security.md](security.md)). +### Updates, betas and going back + +- **Release channel** (Preferences → General): *Stable* gets the releases marked **Latest** on GitHub; *Beta / Early Access* also gets pre-releases (`0.5.0-beta.2`) and always the highest version. A beta is replaced by the final release of the same version (`0.5.0-beta.2` → `0.5.0`). The channel only applies to the portable zip / AppImage, the Windows setup and the macOS app; a Snap, Flatpak, AUR, `.deb` or `.rpm` install is updated by its package manager, stable only, and the row is shown as such. +- **Before an in-app update** Querya copies `querya.db` next to itself as `querya.db.pre--.bak` (the newest 3 are kept). If the copy fails you are asked whether to install anyway. +- **Going back to an older version is not supported once a newer one has run.** A beta may change the format of the local data. An older build that finds data written by a newer one **refuses to open it and does not change it**: the startup screen names the newer format. To go back, close Querya, replace `querya.db` with the `.bak` made before the update (and delete any `querya.db-wal` / `-shm` next to it), then start the older version. Changes made since that copy are lost. + ## Preferences Open **Edit → Preferences…** to change options that apply across the app: diff --git a/lib/app/app.dart b/lib/app/app.dart index 6b30d595..09c3b189 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -5,7 +5,7 @@ import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/core/motion/querya_theme_motion.dart'; import 'package:querya_desktop/core/theme/animated_querya_theme.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:flutter/material.dart'; import 'app_lifecycle_cleanup.dart'; import '../features/main_screen/main_screen.dart'; @@ -23,7 +23,6 @@ class QueryaApp extends StatelessWidget { listenable: themeController, builder: (context, __) { final queryaTheme = themeController.activeTheme; - final colorScheme = queryaTheme.colorScheme; return ListenableBuilder( listenable: uiScaleController, @@ -53,23 +52,17 @@ class QueryaApp extends StatelessWidget { disableAnimations: disableAnimations, ); - return ShadcnApp( + return MaterialApp( title: 'Querya', - theme: themeController.lightShadcnTheme, - darkTheme: themeController.darkShadcnTheme, + theme: themeController.lightMaterialTheme, + darkTheme: themeController.darkMaterialTheme, themeMode: themeController.themeMode, - materialTheme: themeController.materialThemeFor(colorScheme), debugShowCheckedModeBanner: false, - menuHandler: const PopoverOverlayHandler( - defaultShowDuration: Duration(milliseconds: 60), - defaultDismissDuration: Duration(milliseconds: 50), - showCurve: Curves.easeOutCubic, - dismissCurve: Curves.easeIn, - ), - enableThemeAnimation: themeAnimEnabled, - themeAnimationDuration: themeDuration, + // The Material theme follows the mode with the same + // duration as the Querya tokens below. + themeAnimationDuration: + themeAnimEnabled ? themeDuration : Duration.zero, themeAnimationCurve: themeCurve, - enableScrollInterception: false, // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. builder: (context, child) { final mq = MediaQuery.maybeOf(context); diff --git a/lib/app/querya_core_commands.dart b/lib/app/querya_core_commands.dart index a110e1b8..58f253ca 100644 --- a/lib/app/querya_core_commands.dart +++ b/lib/app/querya_core_commands.dart @@ -1,7 +1,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' show Icons; -import 'package:shadcn_flutter/shadcn_flutter.dart' show ThemeMode; +import 'package:flutter/material.dart' show ThemeMode; import 'package:querya_desktop/core/actions/data_grid_command_bridge.dart'; import 'package:querya_desktop/core/actions/querya_command.dart'; import 'package:querya_desktop/core/actions/querya_command_host.dart'; diff --git a/lib/core/actions/querya_command.dart b/lib/core/actions/querya_command.dart index 2bc1c074..cbf120a4 100644 --- a/lib/core/actions/querya_command.dart +++ b/lib/core/actions/querya_command.dart @@ -38,20 +38,56 @@ class QueryaCommand { bool enabledIn(BuildContext context) => isEnabled?.call(context) ?? true; - /// Case-insensitive match on title, category, aliases, and the last id segment. - bool matches(String query) { + static final RegExp _whitespace = RegExp(r'\s+'); + + /// Lower-cased search text per command, built on first use. An [Expando] + /// because the class is `const` (no late fields) and commands are immutable. + static final Expando _haystacks = Expando('QueryaCommand'); + + /// [query] trimmed, lower-cased and split on whitespace; empty when the query + /// is blank. Split once per search, then pass to [matchesParts] for each + /// command (#1528). + static List queryParts(String query) { final q = query.trim().toLowerCase(); - if (q.isEmpty) return true; - final idTail = id.split('.').last.toLowerCase(); - final fields = [ - title.toLowerCase(), - id.toLowerCase(), - idTail, - if (category != null) category!.toLowerCase(), - ...aliases.map((a) => a.toLowerCase()), - ]; - return q.split(RegExp(r'\s+')).every( - (part) => fields.any((field) => field.contains(part)), - ); + return q.isEmpty ? const [] : q.split(_whitespace); + } + + String get _haystack => _haystacks[this] ??= _buildHaystack(); + + String _buildHaystack() { + final tail = id.substring(id.lastIndexOf('.') + 1); + final buffer = StringBuffer() + ..write(title.toLowerCase()) + ..write('\n') + ..write(id.toLowerCase()) + ..write('\n') + ..write(tail.toLowerCase()); + final category = this.category; + if (category != null) { + buffer + ..write('\n') + ..write(category.toLowerCase()); + } + for (final alias in aliases) { + buffer + ..write('\n') + ..write(alias.toLowerCase()); + } + return buffer.toString(); + } + + /// Case-insensitive match on title, category, aliases, and the last id segment. + bool matches(String query) => matchesParts(queryParts(query)); + + /// Every part of [parts] (from [queryParts]) occurs in the title, id, id + /// tail, category or an alias. A part has no whitespace, so it can never + /// match across the `\n` that separates those fields in the haystack. + bool matchesParts(List parts) { + if (parts.isEmpty) return true; + final haystack = _haystack; + for (final part in parts) { + if (!haystack.contains(part)) return false; + } + return true; } } diff --git a/lib/core/actions/querya_command_registry.dart b/lib/core/actions/querya_command_registry.dart index 902562d5..8ba9ac65 100644 --- a/lib/core/actions/querya_command_registry.dart +++ b/lib/core/actions/querya_command_registry.dart @@ -90,7 +90,11 @@ class QueryaCommandRegistry extends ChangeNotifier { if (query.trim().isEmpty) { return List.from(pool); } - return [for (final command in pool) if (command.matches(query)) command]; + final parts = QueryaCommand.queryParts(query); + return [ + for (final command in pool) + if (command.matchesParts(parts)) command, + ]; } @visibleForTesting diff --git a/lib/core/actions/querya_schema_object.dart b/lib/core/actions/querya_schema_object.dart index 26158a79..9d5407eb 100644 --- a/lib/core/actions/querya_schema_object.dart +++ b/lib/core/actions/querya_schema_object.dart @@ -124,6 +124,14 @@ class QueryaSchemaObject { String get searchHaystack => '$name $qualifiedName ${kind.name} $kindLabel ${database ?? ''} ${schema ?? ''}'; + static final Expando _lowerHaystacks = + Expando('QueryaSchemaObject'); + + /// [searchHaystack] lower-cased, built once per object: the switcher filters + /// thousands of objects on every keystroke (#1528). + String get searchHaystackLower => + _lowerHaystacks[this] ??= searchHaystack.toLowerCase(); + @override bool operator ==(Object other) => identical(this, other) || other is QueryaSchemaObject && other.id == id; @@ -149,7 +157,7 @@ List filterSchemaObjects( final parts = q.split(RegExp(r'\s+')); final hits = []; for (final object in objects) { - final hay = object.searchHaystack.toLowerCase(); + final hay = object.searchHaystackLower; if (parts.every(hay.contains)) { hits.add(object); if (hits.length >= limit) break; diff --git a/lib/core/actions/sql_script_format.dart b/lib/core/actions/sql_script_format.dart index 2827cd14..ae21c5d4 100644 --- a/lib/core/actions/sql_script_format.dart +++ b/lib/core/actions/sql_script_format.dart @@ -1,3 +1,7 @@ +import 'package:querya_desktop/core/database/sql_lexical_scanner.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart' + show SqlDialect; + const _kSqlKeywords = { 'ADD', 'ALL', @@ -61,10 +65,33 @@ const _kSqlKeywords = { final _kSqlWord = RegExp(r"[A-Za-z_][A-Za-z0-9_]*"); /// Uppercases SQL keywords. Enough for palette "Format SQL" without a parser. -String formatSqlScript(String sql) { - return sql.replaceAllMapped(_kSqlWord, (match) { - final word = match[0]!; - final upper = word.toUpperCase(); - return _kSqlKeywords.contains(upper) ? upper : word; - }); +/// +/// Only code is touched: string literals, quoted identifiers, comments and +/// dollar-quoted bodies are kept as written (#1566). Keywords keep their +/// length, so every offset into [sql] is the same offset into the result. +/// +/// [dialect] decides whether `\'` escapes a quote: it does in MySQL (and when +/// the dialect is unknown), not in PostgreSQL or SQLite, where reading it as +/// an escape would take the code after `'C:\'` for part of the literal and +/// the next literal for code. +String formatSqlScript(String sql, {SqlDialect? dialect}) { + final backslashEscapes = + dialect != SqlDialect.postgres && dialect != SqlDialect.sqlite; + final out = StringBuffer(); + for (final segment in SqlLexicalScanner.segments( + sql, + backslashEscapes: backslashEscapes, + )) { + final text = sql.substring(segment.start, segment.end); + if (segment.kind != SqlSegmentKind.code) { + out.write(text); + continue; + } + out.write(text.replaceAllMapped(_kSqlWord, (match) { + final word = match[0]!; + final upper = word.toUpperCase(); + return _kSqlKeywords.contains(upper) ? upper : word; + })); + } + return out.toString(); } diff --git a/lib/core/csv/result_grid_csv.dart b/lib/core/csv/result_grid_csv.dart index f58e157f..7b4a2f33 100644 --- a/lib/core/csv/result_grid_csv.dart +++ b/lib/core/csv/result_grid_csv.dart @@ -2,7 +2,6 @@ library; import 'dart:io'; -import 'dart:isolate'; String escapeCsvField(String s) { final needsQuotes = s.contains(',') || @@ -27,8 +26,9 @@ String formatCsvDataRow(List row, int columnCount) { /// One line per row; pads short rows with empty cells to [columns.length]. /// -/// Prefer [resultGridAsCsvAsync] for large grids on the UI isolate, and -/// [writeResultGridCsv] when writing to a file. +/// For large grids use [writeResultGridCsv] when writing to a file; +/// `DataExportService.formatAsync` runs this off the UI isolate for the +/// clipboard. String resultGridAsCsv(List columns, List> rows) { final buf = StringBuffer(); buf.write(columns.map(escapeCsvField).join(',')); @@ -39,14 +39,6 @@ String resultGridAsCsv(List columns, List> rows) { return buf.toString(); } -/// Builds CSV off the UI isolate so large grids do not freeze the main thread. -Future resultGridAsCsvAsync( - List columns, - List> rows, -) { - return Isolate.run(() => resultGridAsCsv(columns, rows)); -} - /// Streams CSV to [sink] without assembling the full document in memory. Future writeResultGridCsv( IOSink sink, { diff --git a/lib/core/database/compact_result_dataset.dart b/lib/core/database/compact_result_dataset.dart index 50f8e5d9..fc9bab91 100644 --- a/lib/core/database/compact_result_dataset.dart +++ b/lib/core/database/compact_result_dataset.dart @@ -1,6 +1,7 @@ import 'dart:collection'; import 'dart:typed_data'; +import 'result_columns_builder.dart'; import 'result_row_string_convert.dart'; /// Supported compact column storage kinds. @@ -215,165 +216,25 @@ class CompactResultDataset { /// Constructs a [CompactResultDataset] from raw rows, detecting column types /// and packing numeric columns into [Int64List] / [Float64List]. /// - /// An optional [cellFormatter] callback can be provided to customize display string - /// formatting for driver-specific types (e.g. blobs, timestamps, boolean tinyints). + /// An optional [cellFormatter] callback can be provided to customize display + /// string formatting for driver-specific types (e.g. blobs, timestamps, + /// boolean tinyints). A driver that has the rows one at a time uses a + /// [ResultColumnsBuilder] instead and never holds them all. static CompactResultDataset fromRawRows( List columnNames, List> rawRows, { String Function(Object? value, int colIndex)? cellFormatter, StringInternPool? pool, }) { - final rowCount = rawRows.length; - final colCount = columnNames.length; - final internPool = pool ?? StringInternPool(); - - if (rowCount == 0 || colCount == 0) { - return CompactResultDataset( - columnNames: columnNames, - columns: const [], - rowCount: 0, - pool: internPool, - ); - } - - // 1. Detect column types by inspecting non-null samples - final kinds = List.filled(colCount, CompactColumnKind.generic); - for (var col = 0; col < colCount; col++) { - var allInt = true; - var allFloat = true; - var allString = true; - var sampleCount = 0; - - for (var row = 0; row < rowCount; row++) { - final val = rawRows[row].length > col ? rawRows[row][col] : null; - if (val == null) continue; - sampleCount++; - - if (cellFormatter != null) { - final formatted = cellFormatter(val, col); - if (allInt && (val is! int || formatted != val.toString())) { - allInt = false; - } - if (allFloat && (val is! num || formatted != val.toString())) { - allFloat = false; - } - if (allString && (val is! String || formatted != val)) { - allString = false; - } - } else { - if (val is! int) { - allInt = false; - } - if (val is! num) { - allFloat = false; - } - if (val is! String) { - allString = false; - } - } - - if (sampleCount >= 60 && !allInt && !allFloat && !allString) { - break; - } - } - - if (sampleCount > 0) { - if (allInt) { - kinds[col] = CompactColumnKind.int64; - } else if (allFloat) { - kinds[col] = CompactColumnKind.float64; - } else if (allString) { - kinds[col] = CompactColumnKind.string; - } else { - kinds[col] = CompactColumnKind.generic; - } - } else { - kinds[col] = CompactColumnKind.string; - } - } - - // 2. Allocate packed columnar buffers - final compactColumns = []; - final bitmapBytes = (rowCount + 7) >> 3; - - for (var col = 0; col < colCount; col++) { - final kind = kinds[col]; - switch (kind) { - case CompactColumnKind.int64: - final values = Int64List(rowCount); - final nullBitmap = Uint8List(bitmapBytes); - for (var row = 0; row < rowCount; row++) { - final val = rawRows[row].length > col ? rawRows[row][col] : null; - if (val == null) { - final byte = row >> 3; - final bit = row & 7; - nullBitmap[byte] |= (1 << bit); - } else if (val is int) { - values[row] = val; - } else if (val is num) { - values[row] = val.toInt(); - } - } - compactColumns.add(Int64CompactColumn( - values: values, - nullBitmap: nullBitmap, - length: rowCount, - )); - - case CompactColumnKind.float64: - final values = Float64List(rowCount); - final nullBitmap = Uint8List(bitmapBytes); - for (var row = 0; row < rowCount; row++) { - final val = rawRows[row].length > col ? rawRows[row][col] : null; - if (val == null) { - final byte = row >> 3; - final bit = row & 7; - nullBitmap[byte] |= (1 << bit); - } else if (val is num) { - values[row] = val.toDouble(); - } - } - compactColumns.add(Float64CompactColumn( - values: values, - nullBitmap: nullBitmap, - length: rowCount, - )); - - case CompactColumnKind.string: - final values = List.filled(rowCount, null); - for (var row = 0; row < rowCount; row++) { - final val = rawRows[row].length > col ? rawRows[row][col] : null; - if (val is String) { - values[row] = internPool.intern(val); - } else if (val != null) { - values[row] = internPool.intern(val.toString()); - } - } - compactColumns.add(StringCompactColumn( - values: values, - length: rowCount, - )); - - case CompactColumnKind.generic: - final values = List.filled(rowCount, null); - for (var row = 0; row < rowCount; row++) { - final val = rawRows[row].length > col ? rawRows[row][col] : null; - values[row] = val; - } - compactColumns.add(GenericCompactColumn( - values: values, - length: rowCount, - formatter: cellFormatter != null ? (v) => cellFormatter(v, col) : null, - )); - } - } - - return CompactResultDataset( - columnNames: columnNames, - columns: compactColumns, - rowCount: rowCount, - pool: internPool, + final builder = ResultColumnsBuilder( + columnNames, + cellFormatter: cellFormatter, + pool: pool, ); + for (final row in rawRows) { + builder.addRow(row); + } + return builder.build(); } } diff --git a/lib/core/database/destructive_sql_detector.dart b/lib/core/database/destructive_sql_detector.dart index 8684dcdc..415cb956 100644 --- a/lib/core/database/destructive_sql_detector.dart +++ b/lib/core/database/destructive_sql_detector.dart @@ -1,3 +1,4 @@ +import 'package:querya_desktop/core/database/sql_lexical_scanner.dart'; import 'package:querya_desktop/core/database/sql_statement_splitter.dart'; /// Categorization of destructive SQL / Mongo / Redis operations that can @@ -190,6 +191,9 @@ abstract final class DestructiveSqlDetector { caseSensitive: false, ); + static final _whereRegex = RegExp(r'\bWHERE\b', caseSensitive: false); + static final _setRegex = RegExp(r'\bSET\b', caseSensitive: false); + /// True when [sanitized] has a WHERE outside any parentheses, so a WHERE /// inside a subquery (`SET x = (SELECT ... WHERE ...)`) does not count. static bool _hasTopLevelWhere(String sanitized) { @@ -205,101 +209,184 @@ abstract final class DestructiveSqlDetector { top.write(ch); } } - return RegExp(r'\bWHERE\b', caseSensitive: false).hasMatch(top.toString()); + return _whereRegex.hasMatch(top.toString()); + } + + static const int _chLf = 0x0A; + static const int _chCr = 0x0D; + static const int _chQuote = 0x27; // ' + static const int _chDollar = 0x24; // $ + static const int _chStar = 0x2A; // * + static const int _chSlash = 0x2F; // / + static const int _chDash = 0x2D; // - + static const int _chBackslash = 0x5C; // \ + + static bool _isTagChar(int c) => + (c >= 0x30 && c <= 0x39) || // 0-9 + (c >= 0x41 && c <= 0x5A) || // A-Z + (c >= 0x61 && c <= 0x7A) || // a-z + c == 0x5F; // _ + + /// Offset of the end of the line comment whose body starts at [start]: the + /// line break itself is not consumed. + static int _skipLineComment(String sql, int start, int len) { + var i = start; + while (i < len) { + final c = sql.codeUnitAt(i); + if (c == _chLf || c == _chCr) break; + i++; + } + return i; + } + + /// Offset after the `*/` that closes the block comment whose body starts at + /// [start]; [len] when it is never closed. + static int _skipBlockComment(String sql, int start, int len) { + var i = start; + while (i + 1 < len) { + if (sql.codeUnitAt(i) == _chStar && sql.codeUnitAt(i + 1) == _chSlash) { + return i + 2; + } + i++; + } + return len; + } + + /// Offset after the dollar-quoted string (`$$...$$`, `$tag$...$tag$`) that + /// opens at [start], [len] when it is never closed, or [start] when there is + /// no opening tag at [start] (`$1`, a lone `$`). + static int _skipDollarQuote(String sql, int start, int len) { + var j = start + 1; + while (j < len && _isTagChar(sql.codeUnitAt(j))) { + j++; + } + if (j >= len || sql.codeUnitAt(j) != _chDollar) return start; + final bodyStart = j + 1; + final close = sql.indexOf(sql.substring(start, bodyStart), bodyStart); + return close == -1 ? len : close + (bodyStart - start); + } + + /// Offset after the single-quoted literal whose body starts at [start] + /// (`''` and backslash escapes); [len] when it is never closed. + static int _skipStringLiteral( + String sql, + int start, + int len, { + required bool backslashEscapes, + }) { + var i = start; + while (i < len) { + final c = sql.codeUnitAt(i); + if (c == _chQuote) { + if (i + 1 < len && sql.codeUnitAt(i + 1) == _chQuote) { + i += 2; // escaped quote + } else { + return i + 1; // closing quote + } + } else if (backslashEscapes && c == _chBackslash && i + 1 < len) { + i += 2; // escaped char + } else { + i++; + } + } + return len; } /// Strips comments and string literals to prevent false positives when keywords /// appear inside strings or comments. - static String stripCommentsAndStrings(String sql) { + /// + /// Scans code units with the `_skip*` helpers (no per-character strings, no + /// regex, no copy of the rest of the script at every `$`), and appends the + /// text between tokens in runs (#1511). + /// + /// [backslashEscapes] is whether `\'` escapes a quote (MySQL; the default). + /// With `false` (PostgreSQL, SQLite) a backslash is an ordinary character, + /// except inside an `E'...'` string, as the server reads it. + static String stripCommentsAndStrings( + String sql, { + bool backslashEscapes = true, + }) { final buffer = StringBuffer(); final len = sql.length; var i = 0; + var runStart = 0; - while (i < len) { - // 1. Line comment: -- - if (i + 1 < len && sql[i] == '-' && sql[i + 1] == '-') { - i += 2; - while (i < len && sql[i] != '\n' && sql[i] != '\r') { - i++; - } - buffer.write(' '); - continue; - } + void flushRun(int end) { + if (end > runStart) buffer.write(sql.substring(runStart, end)); + } - // 2. Block comment: /* ... */ - if (i + 1 < len && sql[i] == '/' && sql[i + 1] == '*') { - i += 2; - while (i + 1 < len && !(sql[i] == '*' && sql[i + 1] == '/')) { + while (i < len) { + final c = sql.codeUnitAt(i); + final next = i + 1 < len ? sql.codeUnitAt(i + 1) : -1; + int after; + String? replacement; + + if (c == _chDash && next == _chDash) { + after = _skipLineComment(sql, i + 2, len); + replacement = ' '; + } else if (c == _chSlash && next == _chStar) { + after = _skipBlockComment(sql, i + 2, len); + replacement = ' '; + } else if (c == _chDollar) { + after = _skipDollarQuote(sql, i, len); + if (after == i) { i++; - } - if (i + 1 < len) { - i += 2; // skip */ - } else { - i = len; - } - buffer.write(' '); - continue; - } - - // 3. Dollar quotes in PostgreSQL: $$ or $tag$ - if (sql[i] == '\$') { - final match = - RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); - if (match != null) { - final tag = match.group(0)!; - i += tag.length; - final closeIdx = sql.indexOf(tag, i); - if (closeIdx != -1) { - i = closeIdx + tag.length; - } else { - i = len; - } - buffer.write("''"); continue; } - } - - // 4. Standard string literal: '...' (supporting '' escaping) - if (sql[i] == "'") { + replacement = "''"; + } else if (c == _chQuote) { + after = _skipStringLiteral( + sql, + i + 1, + len, + backslashEscapes: + backslashEscapes || SqlLexicalScanner.isEscapeStringPrefix(sql, i), + ); + replacement = "''"; + } else { i++; - while (i < len) { - if (sql[i] == "'") { - if (i + 1 < len && sql[i + 1] == "'") { - i += 2; // escaped quote - } else { - i++; // closing quote - break; - } - } else if (sql[i] == '\\' && i + 1 < len) { - i += 2; // escaped char - } else { - i++; - } - } - buffer.write("''"); continue; } - buffer.write(sql[i]); - i++; + flushRun(i); + buffer.write(replacement); + i = after; + runStart = i; } + flushRun(len); return buffer.toString(); } /// Splits an SQL query into individual statements on `;`, taking into account /// comments and string literals. - static List splitStatements(String sql) => [ - for (final span in SqlStatementSplitter.spans(sql)) span.textIn(sql), + /// + /// [backslashEscapes]: see [stripCommentsAndStrings]. + static List splitStatements( + String sql, { + bool backslashEscapes = true, + }) => + [ + for (final span + in SqlStatementSplitter.spans(sql, backslashEscapes: backslashEscapes)) + span.textIn(sql), ]; /// Inspects [sql] and returns any detected destructive operations. - static DestructiveSqlInspectionResult inspect(String sql) { - final statements = splitStatements(sql); + /// + /// [backslashEscapes]: see [stripCommentsAndStrings]. + static DestructiveSqlInspectionResult inspect( + String sql, { + bool backslashEscapes = true, + }) { + final statements = splitStatements(sql, backslashEscapes: backslashEscapes); final operations = []; for (final rawStmt in statements) { - final sanitized = stripCommentsAndStrings(rawStmt).trim(); + final sanitized = stripCommentsAndStrings( + rawStmt, + backslashEscapes: backslashEscapes, + ).trim(); if (sanitized.isEmpty) continue; // 1. DROP DATABASE @@ -420,7 +507,7 @@ abstract final class DestructiveSqlDetector { // 8. UPDATE table SET ... without WHERE final updateMatch = _updateRegex.firstMatch(sanitized); if (updateMatch != null && - RegExp(r'\bSET\b', caseSensitive: false).hasMatch(sanitized) && + _setRegex.hasMatch(sanitized) && !_hasTopLevelWhere(sanitized)) { final schema = updateMatch.group(1); final table = updateMatch.group(2) ?? 'table'; diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 2bbc4a4e..b20ca1a9 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -3,15 +3,16 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mongo_dart/mongo_dart.dart'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/ssh_tunnel_host.dart'; import 'package:querya_desktop/core/database/mongodb_uri.dart'; import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; -import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// MongoDB connection configuration and state. -class MongoConnection { +class MongoConnection with SshTunnelHost { MongoConnection({ required this.id, required this.name, @@ -60,11 +61,10 @@ class MongoConnection { final String? replicaSet; String? _connectionString; + @override final SshTunnelConfig? sshConfig; + @override final SshTunnelSecrets? sshSecrets; - SshTunnelHandle? _sshTunnelHandle; - - SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; /// Handshake URI for this live session (includes auth). Not persisted; not /// exposed via [password] / [connectionString] after [scrubCredentials]. @@ -234,17 +234,6 @@ class MongoConnection { try { if (sshConfig != null && sshConfig!.enabled) { - var sec = sshSecrets; - if (sec == null && id > 0) { - final stored = - await ConnectionSecretsStore.readSshSecretsForConnection(id); - sec = SshTunnelSecrets( - password: stored.password, - privateKey: stored.privateKey, - passphrase: stored.passphrase, - jumpPassword: stored.jumpPassword, - ); - } String targetHost = host; int targetPort = port; if (effectiveConnectionString != null && @@ -258,19 +247,15 @@ class MongoConnection { targetPort = seed.port ?? 27017; } } - _sshTunnelHandle = await SshTunnelManager.instance.openTunnel( - config: sshConfig!, - secrets: sec ?? SshTunnelSecrets(), - remoteHost: targetHost, - remotePort: targetPort, - ); + adoptSshTunnel( + await openSshTunnel(id: id, host: targetHost, port: targetPort)); } final uri = await _effectiveMongoUri( pass: effectivePassword, connStr: effectiveConnectionString, - hostOverride: _sshTunnelHandle?.localHost, - portOverride: _sshTunnelHandle?.localPort, + hostOverride: sshTunnelHandle?.localHost, + portOverride: sshTunnelHandle?.localPort, ); _db = await Db.create(uri); await _db!.open(); @@ -285,10 +270,7 @@ class MongoConnection { _isConnected = false; _db = null; await _cleanupTempTlsKey(); - try { - await _sshTunnelHandle?.release(); - } catch (_) {} - _sshTunnelHandle = null; + await releaseSshTunnel(); rethrowMappedDatabaseError(e, st, driver: DatabaseDriver.mongodb); } } @@ -361,12 +343,7 @@ class MongoConnection { } } await _cleanupTempTlsKey(); - try { - await _sshTunnelHandle?.release(); - } catch (e) { - debugPrint('MongoConnection.sshRelease: $e'); - } - _sshTunnelHandle = null; + await releaseSshTunnel(logLabel: 'MongoConnection.sshRelease'); } Future _cleanupTempTlsKey() async { @@ -392,7 +369,9 @@ class MongoConnection { _openedDbs.remove(databaseName); try { await existing.close(); - } catch (_) {} + } catch (e) { + logIgnored('mongodb_connection', e); + } } return _openingDbs.putIfAbsent(databaseName, () async { try { @@ -403,7 +382,9 @@ class MongoConnection { } catch (_) { try { await db.close(); - } catch (_) {} + } catch (e) { + logIgnored('mongodb_connection', e); + } rethrow; } _openedDbs[databaseName] = db; @@ -495,7 +476,9 @@ class MongoConnection { _openedDbs.remove(databaseName); try { await db.close(); - } catch (_) {} + } catch (e) { + logIgnored('mongodb_connection', e); + } if (identical(_db, db)) { _db = null; for (final other in _openedDbs.values) { diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 82ad0e04..e7c0634c 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -30,6 +30,16 @@ void _throwIfMongoWriteFailed( } /// Service for managing MongoDB connections. +/// How many documents `find` returns when the caller gives no limit (or `0`, +/// which MongoDB reads as "no limit"). Decoding is about 64 us per document on +/// the UI isolate: 50,000 documents are a three-second wait and a 40-60 ms +/// stall (#1357). +const int kMongoDefaultFindLimit = 1000; + +/// The limit `find` really applies for a requested [limit]. +int effectiveMongoFindLimit(int? limit) => + limit == null || limit <= 0 ? kMongoDefaultFindLimit : limit; + class MongoService { MongoService._(); @@ -170,9 +180,8 @@ class MongoService { if (skip != null && skip > 0) { selector.skip(skip); } - if (limit != null && limit > 0) { - selector.limit(limit); - } + // Never unbounded: `stream.toList()` below holds every document. + selector.limit(effectiveMongoFindLimit(limit)); final stream = coll.find(selector); return await stream.toList(); diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index e269fa8d..afd23671 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/database/statement_queue.dart'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/ssh_tunnel_host.dart'; import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; @@ -12,6 +13,7 @@ import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// TLS policy from `ssl-mode` / `sslmode` / `ssl` on a MySQL URI. enum MysqlSslMode { @@ -93,7 +95,7 @@ String replaceDatabaseInMysqlConnectionString( } /// Pure-Dart MySQL client wrapper around [MySQLConnection] (`mysql_client`). -class MysqlConnection { +class MysqlConnection with SshTunnelHost { MysqlConnection({ required this.id, required this.name, @@ -137,11 +139,10 @@ class MysqlConnection { final bool useSSL; String? _connectionString; + @override final SshTunnelConfig? sshConfig; + @override final SshTunnelSecrets? sshSecrets; - SshTunnelHandle? _sshTunnelHandle; - - SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; String? get password => _password; String? get connectionString => _connectionString; @@ -212,7 +213,9 @@ class MysqlConnection { if (_conn != null && !_conn!.connected) { try { await _conn?.close(); - } catch (_) {} + } catch (e) { + logIgnored('mysql_connection', e); + } _conn = null; } final generation = _generation; @@ -234,27 +237,9 @@ class MysqlConnection { } try { - if (sshConfig != null && sshConfig!.enabled) { - var sec = sshSecrets; - if (sec == null && id > 0) { - final stored = - await ConnectionSecretsStore.readSshSecretsForConnection(id); - sec = SshTunnelSecrets( - password: stored.password, - privateKey: stored.privateKey, - passphrase: stored.passphrase, - jumpPassword: stored.jumpPassword, - ); - } - openedHandle = await SshTunnelManager.instance.openTunnel( - config: sshConfig!, - secrets: sec ?? SshTunnelSecrets(), - remoteHost: host, - remotePort: port, - ); - if (generation != _generation) throw _ConnectSuperseded(); - _sshTunnelHandle = openedHandle; - } + openedHandle = await openSshTunnel(id: id, host: host, port: port); + if (generation != _generation) throw _ConnectSuperseded(); + adoptSshTunnel(openedHandle); final user = username ?? ''; final pass = effectivePassword ?? ''; @@ -274,8 +259,8 @@ class MysqlConnection { } validateMysqlSslMode(sslMode, sslPaths); final securityContext = buildSecurityContext(sslPaths); - final effectiveHost = _sshTunnelHandle?.localHost ?? parsed.host; - final effectivePort = _sshTunnelHandle?.localPort ?? parsed.port; + final effectiveHost = sshTunnelHandle?.localHost ?? parsed.host; + final effectivePort = sshTunnelHandle?.localPort ?? parsed.port; opened = await MySQLConnection.createConnection( host: effectiveHost, port: effectivePort, @@ -293,8 +278,8 @@ class MysqlConnection { final sslPaths = extractSslCertificatePathsFromString(effectiveConnectionString); final securityContext = buildSecurityContext(sslPaths); - final effectiveHost = _sshTunnelHandle?.localHost ?? host; - final effectivePort = _sshTunnelHandle?.localPort ?? port; + final effectiveHost = sshTunnelHandle?.localHost ?? host; + final effectivePort = sshTunnelHandle?.localPort ?? port; opened = await MySQLConnection.createConnection( host: effectiveHost, port: effectivePort, @@ -323,19 +308,20 @@ class MysqlConnection { if (identical(_conn, opened)) _conn = null; try { if (opened != null && opened.connected) await opened.close(); - } catch (_) {} + } catch (e) { + logIgnored('mysql_connection', e); + } try { await openedHandle?.release(); - } catch (_) {} - if (identical(_sshTunnelHandle, openedHandle)) _sshTunnelHandle = null; + } catch (e) { + logIgnored('mysql_connection', e); + } + forgetSshTunnel(openedHandle); throw StateError('The connection was closed while it was being opened'); } _isConnected = false; _conn = null; - try { - await _sshTunnelHandle?.release(); - } catch (_) {} - _sshTunnelHandle = null; + await releaseSshTunnel(); rethrowMappedDatabaseError(e, st, driver: DatabaseDriver.mysql); } } @@ -432,12 +418,7 @@ class MysqlConnection { } catch (e) { debugPrint('MysqlConnection.disconnect: $e'); } - try { - await _sshTunnelHandle?.release(); - } catch (e) { - debugPrint('MysqlConnection.sshRelease: $e'); - } - _sshTunnelHandle = null; + await releaseSshTunnel(logLabel: 'MysqlConnection.sshRelease'); } /// Best-effort close. The `mysql_client` driver may not allow graceful [close] @@ -459,12 +440,7 @@ class MysqlConnection { } catch (e) { debugPrint('MysqlConnection.forceClose: $e'); } - try { - await _sshTunnelHandle?.release(); - } catch (e) { - debugPrint('MysqlConnection.forceClose ssh: $e'); - } - _sshTunnelHandle = null; + await releaseSshTunnel(logLabel: 'MysqlConnection.forceClose ssh'); } /// Session hint for Table Browser / tree (`MysqlSessionMode.readOnly`). @@ -591,7 +567,9 @@ class MysqlConnection { if (!join) { try { await execute('ROLLBACK'); - } catch (_) {} + } catch (e) { + logIgnored('mysql_connection', e); + } } rethrow; } diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index 875d6ea7..b324ed04 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -5,9 +5,9 @@ import 'package:flutter/foundation.dart'; import 'package:postgres/postgres.dart'; import 'package:querya_desktop/core/database/statement_queue.dart'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/ssh_tunnel_host.dart'; import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; -import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -69,7 +69,7 @@ int? postgresReltuplesEstimate(Object? value) { } /// PostgreSQL connection using the pure-Dart `postgres` package. -class PostgresConnection { +class PostgresConnection with SshTunnelHost { PostgresConnection({ required this.id, required this.name, @@ -135,11 +135,10 @@ class PostgresConnection { final String? sslCert; final String? sslKey; + @override final SshTunnelConfig? sshConfig; + @override final SshTunnelSecrets? sshSecrets; - SshTunnelHandle? _sshTunnelHandle; - - SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; String? get password => _password; String? get connectionString => _connectionString; @@ -262,25 +261,7 @@ class PostgresConnection { } try { - if (sshConfig != null && sshConfig!.enabled) { - var sec = sshSecrets; - if (sec == null && id > 0) { - final stored = - await ConnectionSecretsStore.readSshSecretsForConnection(id); - sec = SshTunnelSecrets( - password: stored.password, - privateKey: stored.privateKey, - passphrase: stored.passphrase, - jumpPassword: stored.jumpPassword, - ); - } - _sshTunnelHandle = await SshTunnelManager.instance.openTunnel( - config: sshConfig!, - secrets: sec ?? SshTunnelSecrets(), - remoteHost: host, - remotePort: port, - ); - } + adoptSshTunnel(await openSshTunnel(id: id, host: host, port: port)); if (_usesConnectionString(effectiveConnectionString)) { // Pool passes target catalog via [database]; URI alone would always open @@ -300,10 +281,10 @@ class PostgresConnection { (sslKey != null && sslKey!.trim().isNotEmpty), hasRootCert: sslRootCert != null && sslRootCert!.trim().isNotEmpty, ); - final endpoint = _sshTunnelHandle != null + final endpoint = sshTunnelHandle != null ? Endpoint( - host: _sshTunnelHandle!.localHost, - port: _sshTunnelHandle!.localPort, + host: sshTunnelHandle!.localHost, + port: sshTunnelHandle!.localPort, database: parsed.endpoints.first.database, username: parsed.endpoints.first.username, password: parsed.endpoints.first.password, @@ -330,8 +311,8 @@ class PostgresConnection { _conn = await Connection.open( _buildEndpoint( pass: effectivePassword, - hostOverride: _sshTunnelHandle?.localHost, - portOverride: _sshTunnelHandle?.localPort, + hostOverride: sshTunnelHandle?.localHost, + portOverride: sshTunnelHandle?.localPort, ), settings: _buildSettings(), ); @@ -342,10 +323,7 @@ class PostgresConnection { } catch (e, st) { _isConnected = false; _conn = null; - try { - await _sshTunnelHandle?.release(); - } catch (_) {} - _sshTunnelHandle = null; + await releaseSshTunnel(); Error.throwWithStackTrace( PostgresConnectionException( 'Failed to connect to PostgreSQL${name.isNotEmpty ? ' ($name)' : ''}: ' @@ -368,12 +346,7 @@ class PostgresConnection { } catch (e) { debugPrint('PostgresConnection.disconnect: $e'); } - try { - await _sshTunnelHandle?.release(); - } catch (e) { - debugPrint('PostgresConnection.sshRelease: $e'); - } - _sshTunnelHandle = null; + await releaseSshTunnel(logLabel: 'PostgresConnection.sshRelease'); } /// Drops the TCP session immediately (kills pending client I/O). Used when @@ -388,12 +361,7 @@ class PostgresConnection { } catch (e) { debugPrint('PostgresConnection.forceClose: $e'); } - try { - await _sshTunnelHandle?.release(); - } catch (e) { - debugPrint('PostgresConnection.forceClose ssh: $e'); - } - _sshTunnelHandle = null; + await releaseSshTunnel(logLabel: 'PostgresConnection.forceClose ssh'); } /// Session-level default for transactions (browse vs SQL editor). diff --git a/lib/core/database/postgres_result_cells.dart b/lib/core/database/postgres_result_cells.dart index 7c1f89d3..bbe0d65c 100644 --- a/lib/core/database/postgres_result_cells.dart +++ b/lib/core/database/postgres_result_cells.dart @@ -51,10 +51,14 @@ String postgresResultCellToDisplayString( } if (value is DateTime) { + final iso = value.toIso8601String(); if (_isDateOnly(typeOid, dataTypeName)) { - return value.toIso8601String().split('T').first; + // The text before `T` (also right for years outside 0..9999, which + // carry a sign and six digits), without a split() list (#1529). + final t = iso.indexOf('T'); + return t < 0 ? iso : iso.substring(0, t); } - return value.toIso8601String(); + return iso; } if (value is bool) return value ? 'true' : 'false'; @@ -77,11 +81,18 @@ String postgresResultCellToDisplayString( /// PostgreSQL bytea text `\xdeadbeef`. String postgresByteaHexDisplay(Uint8List bytes) { - final out = StringBuffer(r'\x'); + // Two lower-case hex digits per byte, written as code units into one buffer: + // no toRadixString / padLeft string per byte (#1529, #1521). + const hexDigits = '0123456789abcdef'; + final out = Uint8List(2 + bytes.length * 2); + out[0] = 0x5C; // \ + out[1] = 0x78; // x + var k = 2; for (final b in bytes) { - out.write(b.toRadixString(16).padLeft(2, '0')); + out[k++] = hexDigits.codeUnitAt(b >> 4); + out[k++] = hexDigits.codeUnitAt(b & 0x0F); } - return out.toString(); + return String.fromCharCodes(out); } String postgresByteaHexDisplayFromCell(Object value) { diff --git a/lib/core/database/query_error_text.dart b/lib/core/database/query_error_text.dart new file mode 100644 index 00000000..1b8b1cac --- /dev/null +++ b/lib/core/database/query_error_text.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; + +import 'package:postgres/postgres.dart' show ServerException; + +/// A failed query as the results pane shows it (#1557): one readable line, +/// and the driver's text without the statement the app wrapped around the +/// user's SQL. +class QueryErrorText { + const QueryErrorText({required this.summary, required this.detail}); + + /// Reads [raw], the `toString()` of whatever the driver threw. + factory QueryErrorText.parse(String raw) { + final detail = _unwrap(raw.trim()); + return QueryErrorText(summary: _summary(detail), detail: detail); + } + + /// The driver's message alone: `no such column: u.name`. + final String summary; + + /// The whole driver text, minus the app's own wrapping. + final String detail; + + /// Whether [detail] says more than [summary]. + bool get hasDetail => detail != summary; + + static const _maxSummary = 300; + + // SQLite (sqlite3): `SqliteException(1): while preparing statement, , + // SQL logic error (code 1)`. + static final _sqlite = RegExp( + r'SqliteException\(\d+\): (?:while [^,\n]+, )?([^\n]+?)' + r'(?:, [^,\n]*\(code \d+\))?(?:\n|$)', + ); + + // PostgreSQL (postgres 3): `Severity.error 42703: column "x" does not exist + // detail: ... hint: ...`. + static final _postgres = RegExp( + r'\b(?:Severity\.)?(?:error|fatal|panic|ERROR|FATAL|PANIC) ' + r'[0-9A-Z]{5}: ([^\n]+?)(?= (?:detail|hint|table|column|constraint)\b|\n|$)', + ); + + // MySQL (mysql_client): `MySQLServerException [1054]: Unknown column ...`. + static final _mysql = RegExp(r'MySQLServerException \[\d+\]: ([^\n]+)'); + + /// The `SELECT * FROM (\n\n)` that keeps SQLite column names unique + /// (#1144), with the row limit the app adds after it. + static final _wrapper = RegExp( + r'SELECT \* FROM \(\n([\s\S]*?)\n\)(?:\s*LIMIT \d+(?:\s+OFFSET \d+)?)?', + ); + + // Offsets count from the start of the wrapper, not of the user's text. + static final _position = RegExp(r' \(at position \d+\)'); + + static String _unwrap(String raw) { + if (!_wrapper.hasMatch(raw)) return raw; + return raw + .replaceAllMapped(_wrapper, (m) => m[1]!) + .replaceAll(_position, ''); + } + + static String _summary(String detail) { + for (final pattern in [_sqlite, _postgres, _mysql]) { + final m = pattern.firstMatch(detail); + final message = m?.group(1)?.trim(); + if (message != null && message.isNotEmpty) return _cap(message); + } + final first = detail + .split('\n') + .map((l) => l.trim()) + .firstWhere((l) => l.isNotEmpty, orElse: () => detail); + return _cap(first.startsWith('Exception: ') ? first.substring(11) : first); + } + + static String _cap(String s) => + s.length <= _maxSummary ? s : '${s.substring(0, _maxSummary)}…'; + + /// `SELECT * FROM (\n` the wrapper puts before the user's SQL. + static const _wrapperPrefix = 'SELECT * FROM (\n'; + + // SQLite: `Causing statement (at position 29): ...`, a byte offset into the + // statement the driver prepared. + static final _sqlitePosition = RegExp(r'\(at position (\d+)\)'); + + /// Where in [sql], the text that was sent, the error is: a 0-based offset in + /// characters, or null when the driver does not say (MySQL) or the place is + /// not in [sql]. + /// + /// PostgreSQL gives a 1-based character position in the statement; SQLite a + /// byte offset in the statement it prepared, which is the user's SQL inside + /// the app's wrapper when [sql] is a query (#1144), so the wrapper's prefix + /// is taken off. The row limit the app appends comes after the text, so it + /// does not move anything. + static int? offsetIn(Object error, String sql) { + if (error is ServerException) return offsetOfPgPosition(error.position, sql); + final text = error.toString(); + final m = _sqlitePosition.firstMatch(text); + if (m == null) return null; + var bytes = int.parse(m[1]!); + if (text.contains(_wrapperPrefix)) bytes -= _wrapperPrefix.length; + if (bytes < 0) return null; + final encoded = utf8.encode(sql); + if (bytes > encoded.length) return null; + return utf8.decode(encoded.sublist(0, bytes), allowMalformed: true).length; + } + + /// The offset in [sql] of a PostgreSQL `position` (1-based, in characters), + /// or null when there is none or it lies outside [sql]. + static int? offsetOfPgPosition(int? position, String sql) { + if (position == null) return null; + final offset = position - 1; + return offset >= 0 && offset <= sql.length ? offset : null; + } +} diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index e199041a..37acffef 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -1,16 +1,19 @@ import 'dart:async'; +import 'dart:math' show min; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/ssh_tunnel_host.dart'; import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; -import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:querya_desktop/core/database/redis_reply.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:redis/redis.dart' as redis; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// First page / load-more size for hash, list, set, and zset editors. const redisCollectionPageSize = 200; @@ -19,7 +22,7 @@ const redisCollectionPageSize = 200; const redisLargeCollectionWarnAt = 10000; /// Redis connection using the Dart redis package (no Java/JRE). -class RedisConnection { +class RedisConnection with SshTunnelHost { RedisConnection({ required this.id, required this.name, @@ -109,11 +112,10 @@ class RedisConnection { final bool useSSL; String? _connectionString; + @override final SshTunnelConfig? sshConfig; + @override final SshTunnelSecrets? sshSecrets; - SshTunnelHandle? _sshTunnelHandle; - - SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; String? get password => _password; String? get connectionString => _connectionString; @@ -125,6 +127,12 @@ class RedisConnection { bool get isConnected => _isConnected && _command != null; + /// The database index this socket works in. `SELECT`ed on every (re)connect, + /// so a socket that drops does not come back in db 0, and moved only by + /// [selectDatabase]. A socket serves one database: views never `SELECT` + /// before a command, which was not atomic on a shared socket (#1569). + int database = 0; + @visibleForTesting void setConnectionForTest({ redis.RedisConnection? conn, @@ -175,7 +183,9 @@ class RedisConnection { if (_socket != null) { try { _socket?.destroy(); - } catch (_) {} + } catch (e) { + logIgnored('redis_connection', e); + } _socket = null; _command = null; _conn = null; @@ -205,28 +215,10 @@ class RedisConnection { final limit = connectTimeout > Duration.zero ? connectTimeout : null; try { - if (sshConfig != null && sshConfig!.enabled) { - var sec = sshSecrets; - if (sec == null && id > 0) { - final stored = - await ConnectionSecretsStore.readSshSecretsForConnection(id); - sec = SshTunnelSecrets( - password: stored.password, - privateKey: stored.privateKey, - passphrase: stored.passphrase, - jumpPassword: stored.jumpPassword, - ); - } - _sshTunnelHandle = await SshTunnelManager.instance.openTunnel( - config: sshConfig!, - secrets: sec ?? SshTunnelSecrets(), - remoteHost: host, - remotePort: port, - ); - } + adoptSshTunnel(await openSshTunnel(id: id, host: host, port: port)); - final effectiveHost = _sshTunnelHandle?.localHost ?? host; - final effectivePort = _sshTunnelHandle?.localPort ?? port; + final effectiveHost = sshTunnelHandle?.localHost ?? host; + final effectivePort = sshTunnelHandle?.localPort ?? port; // The socket is opened here, with a timeout, for plain and TLS alike. socket = secure @@ -267,6 +259,9 @@ class RedisConnection { 'Redis did not answer within ${limit.inSeconds} s', ), )); + if (database != 0) { + await command.send_object(['SELECT', database]); + } _isConnected = true; if (_clientReadOnly) { try { @@ -285,11 +280,10 @@ class RedisConnection { // a silent server): close it, or every failed try leaves one behind. try { socket?.destroy(); - } catch (_) {} - try { - await _sshTunnelHandle?.release(); - } catch (_) {} - _sshTunnelHandle = null; + } catch (e) { + logIgnored('redis_connection', e); + } + await releaseSshTunnel(); rethrow; } } @@ -318,7 +312,9 @@ class RedisConnection { _socket = null; try { sock?.destroy(); - } catch (_) {} + } catch (e) { + logIgnored('redis_connection', e); + } final c = _conn; _conn = null; if (c != null && wasConnected) { @@ -330,10 +326,7 @@ class RedisConnection { } } } - try { - await _sshTunnelHandle?.release(); - } catch (_) {} - _sshTunnelHandle = null; + await releaseSshTunnel(); } Future forceClose() => disconnect(); @@ -378,7 +371,9 @@ class RedisConnection { if (!isConnected) return; try { await sendCommand([readOnly ? 'READONLY' : 'READWRITE']); - } catch (_) {} + } catch (e) { + logIgnored('redis_connection', e); + } } void _assertWritable() { @@ -406,24 +401,38 @@ class RedisConnection { } } - /// SELECT database index. + /// A command with typed arguments, its reply parsed once (#1571). + /// + /// Each argument is a `String`, `int`, `double`, `Uint8List` or + /// [RedisBulkValue] (a key or value that may not be UTF-8); anything else is + /// an [ArgumentError] here and not a malformed command on the wire. + Future command(List args) async => + RedisReply.from(await sendCommand([for (final a in args) _commandArg(a)])); + + static Object _commandArg(Object a) => switch (a) { + RedisBulkValue() => a.commandArg, + Uint8List() => redis.RedisBulk(a), + String() || int() || double() || redis.RedisBulk() => a, + _ => throw ArgumentError.value( + a, 'args', 'not a Redis command argument (${a.runtimeType})'), + }; + + /// SELECT database index; later reconnects come back to it too. Future selectDatabase(int db) async { - await sendCommand(['SELECT', db]); + await command(['SELECT', db]); + database = db; } /// DBSIZE — number of keys in the currently selected database. Future dbSize() async { - final result = await sendCommand(['DBSIZE']); - return redisReplyInt(result); + return (await command(['DBSIZE'])).asInt(); } /// CONFIG GET databases — max number of databases. Future getMaxDatabases() async { try { - final result = await sendCommand(['CONFIG', 'GET', 'databases']); - if (result is List && result.length >= 2) { - return redisReplyInt(result[1], 16); - } + final reply = (await command(['CONFIG', 'GET', 'databases'])).asArray(); + if (reply.length >= 2) return reply[1].asInt(); } catch (e) { debugPrint('RedisConnection.getMaxDatabases: $e'); // Some Redis instances don't allow CONFIG; fall back. @@ -438,31 +447,42 @@ class RedisConnection { String? match, int count = 100, }) async { - final args = ['SCAN', cursor]; + final args = ['SCAN', cursor]; if (match != null && match.isNotEmpty) { args.addAll(['MATCH', match]); } args.addAll(['COUNT', count]); - final result = await sendCommand(args); - if (result is List && result.length == 2) { - final nextCursor = redisReplyInt(result[0]); - final keys = - (result[1] as List?)?.map(RedisBulkValue.fromReply).toList() ?? []; - return (nextCursor, keys); + return _cursorAndBulks(await command(args)); + } + + /// The cursor and the bulk strings of a SCAN / SSCAN reply. + static (int, List) _cursorAndBulks(RedisReply reply) { + final parts = reply.asArray(); + if (parts.length != 2) { + throw RedisProtocolException( + 'a scan reply has 2 parts, got ${parts.length}'); } - return (0, []); + return ( + parts[0].asInt(), + [for (final e in parts[1].asArray()) e.asBulk()], + ); } + /// Field / value pairs of an HGETALL or HSCAN array. + static Map _pairs(List items) => { + for (var i = 0; i + 1 < items.length; i += 2) + items[i].asBulk(): items[i + 1].asBulk(), + }; + /// TYPE key. Future keyType(Object key) async { - final result = await sendCommand(['TYPE', redisCommandArg(key)]); - return RedisBulkValue.fromReply(result).text ?? 'none'; + final reply = await command(['TYPE', key]); + return reply.asText() ?? 'none'; } /// TTL key (returns -1 if no expiry, -2 if missing). Future ttl(Object key) async { - final result = await sendCommand(['TTL', redisCommandArg(key)]); - return redisReplyInt(result, -1); + return (await command(['TTL', key])).asInt(); } /// Pipelined TYPE + TTL for a SCAN batch. @@ -482,15 +502,15 @@ class RedisConnection { try { final typeFutures = >[ for (final key in keys) - sendCommand(['TYPE', key.commandArg]).then( - (v) => RedisBulkValue.fromReply(v).text ?? 'none', + command(['TYPE', key]).then( + (v) => v.asText() ?? 'none', onError: (_) => 'unknown', ), ]; final ttlFutures = >[ for (final key in keys) - sendCommand(['TTL', key.commandArg]).then( - (v) => redisReplyInt(v, -1), + command(['TTL', key]).then( + (v) => v.asInt(), onError: (_) => -1, ), ]; @@ -506,9 +526,7 @@ class RedisConnection { /// GET. Returns null for a missing key. Non-UTF-8 values keep raw bytes. Future get(Object key) async { - final result = await sendCommand(['GET', redisCommandArg(key)]); - if (result == null) return null; - return RedisBulkValue.fromReply(result); + return (await command(['GET', key])).asBulkOrNull(); } /// SET key value [EX seconds]. @@ -561,15 +579,7 @@ class RedisConnection { /// HGETALL key. Future> hgetall(Object key) async { - final result = await sendCommand(['HGETALL', redisCommandArg(key)]); - final map = {}; - if (result is List) { - for (var i = 0; i + 1 < result.length; i += 2) { - map[RedisBulkValue.fromReply(result[i])] = - RedisBulkValue.fromReply(result[i + 1]); - } - } - return map; + return _pairs((await command(['HGETALL', key])).asArray()); } /// HSCAN key cursor [COUNT count]. Returns (nextCursor, fields). @@ -578,27 +588,18 @@ class RedisConnection { int cursor = 0, int count = redisCollectionPageSize, }) async { - final result = await sendCommand( - ['HSCAN', redisCommandArg(key), cursor, 'COUNT', count]); - if (result is List && result.length == 2) { - final nextCursor = redisReplyInt(result[0]); - final map = {}; - final pairs = result[1]; - if (pairs is List) { - for (var i = 0; i + 1 < pairs.length; i += 2) { - map[RedisBulkValue.fromReply(pairs[i])] = - RedisBulkValue.fromReply(pairs[i + 1]); - } - } - return (nextCursor, map); + final parts = + (await command(['HSCAN', key, cursor, 'COUNT', count])).asArray(); + if (parts.length != 2) { + throw RedisProtocolException( + 'a scan reply has 2 parts, got ${parts.length}'); } - return (0, {}); + return (parts[0].asInt(), _pairs(parts[1].asArray())); } /// HLEN key. Future hlen(Object key) async { - final result = await sendCommand(['HLEN', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['HLEN', key])).asInt(); } /// HSET key field value. @@ -615,18 +616,13 @@ class RedisConnection { /// LRANGE key start stop. Future> lrange(Object key, int start, int stop) async { - final result = - await sendCommand(['LRANGE', redisCommandArg(key), start, stop]); - if (result is List) { - return result.map(RedisBulkValue.fromReply).toList(); - } - return []; + final reply = await command(['LRANGE', key, start, stop]); + return [for (final e in reply.asArray()) e.asBulk()]; } /// LLEN key. Future llen(Object key) async { - final result = await sendCommand(['LLEN', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['LLEN', key])).asInt(); } /// RPUSH key value. @@ -637,12 +633,7 @@ class RedisConnection { /// LINDEX key index. Future lindex(Object key, int index) async { - final result = - await sendCommand(['LINDEX', redisCommandArg(key), index]); - if (result == null || _isRedisNil(result)) { - return null; - } - return RedisBulkValue.fromReply(result); + return (await command(['LINDEX', key, index])).asBulkOrNull(); } /// LSET key index value. @@ -654,18 +645,13 @@ class RedisConnection { /// LREM key count element. Future lrem(Object key, int count, Object element) async { _assertWritable(); - final result = await sendCommand( - ['LREM', redisCommandArg(key), count, redisCommandArg(element)]); - return redisReplyInt(result); + return (await command(['LREM', key, count, element])).asInt(); } /// SMEMBERS key. Future> smembers(Object key) async { - final result = await sendCommand(['SMEMBERS', redisCommandArg(key)]); - if (result is List) { - return result.map(RedisBulkValue.fromReply).toList(); - } - return []; + final reply = await command(['SMEMBERS', key]); + return [for (final e in reply.asArray()) e.asBulk()]; } /// SSCAN key cursor [COUNT count]. Returns (nextCursor, members). @@ -674,21 +660,13 @@ class RedisConnection { int cursor = 0, int count = redisCollectionPageSize, }) async { - final result = await sendCommand( - ['SSCAN', redisCommandArg(key), cursor, 'COUNT', count]); - if (result is List && result.length == 2) { - final nextCursor = redisReplyInt(result[0]); - final members = - (result[1] as List?)?.map(RedisBulkValue.fromReply).toList() ?? []; - return (nextCursor, members); - } - return (0, []); + return _cursorAndBulks( + await command(['SSCAN', key, cursor, 'COUNT', count])); } /// SCARD key. Future scard(Object key) async { - final result = await sendCommand(['SCARD', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['SCARD', key])).asInt(); } /// SADD key member. @@ -706,24 +684,17 @@ class RedisConnection { /// ZRANGE key start stop WITHSCORES → list of (member, score). Future> zrangeWithScores( Object key, int start, int stop) async { - final result = await sendCommand( - ['ZRANGE', redisCommandArg(key), start, stop, 'WITHSCORES']); - final list = <(RedisBulkValue, double)>[]; - if (result is List) { - for (var i = 0; i + 1 < result.length; i += 2) { - list.add(( - RedisBulkValue.fromReply(result[i]), - redisReplyDouble(result[i + 1]), - )); - } - } - return list; + final items = + (await command(['ZRANGE', key, start, stop, 'WITHSCORES'])).asArray(); + return [ + for (var i = 0; i + 1 < items.length; i += 2) + (items[i].asBulk(), items[i + 1].asDouble()), + ]; } /// ZCARD key. Future zcard(Object key) async { - final result = await sendCommand(['ZCARD', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['ZCARD', key])).asInt(); } /// ZADD key score member. @@ -741,25 +712,75 @@ class RedisConnection { /// DEL key. Future del(Object key) async { _assertWritable(); - final result = await sendCommand(['DEL', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['DEL', key])).asInt(); } - /// DEL key [key ...]. Returns total number of keys removed. + /// False once the server answered `UNLINK` with an unknown command (< 4.0). + bool? _unlinkSupported; + + /// Removes [keys] in chunks of 200 and returns how many were removed. + /// + /// `UNLINK` frees the values in the background where `DEL` blocks the + /// server on a large one; a server without it gets `DEL`. After the first + /// chunk the rest are pipelined. When a chunk fails the error is rethrown + /// once every chunk has answered, so some keys may be gone: reload (#1569). Future delMany(Iterable keys) async { _assertWritable(); final list = keys.toList(); if (list.isEmpty) return 0; - var total = 0; const chunkSize = 200; - for (var i = 0; i < list.length; i += chunkSize) { - final chunk = list.sublist(i, (i + chunkSize).clamp(0, list.length)); - final result = await sendCommand(['DEL', ...chunk.map(redisCommandArg)]); - total += redisReplyInt(result); + final chunks = [ + for (var i = 0; i < list.length; i += chunkSize) + [ + for (final key in list.sublist(i, min(i + chunkSize, list.length))) + key, + ], + ]; + + // The first chunk on its own tells whether the server knows UNLINK. + var total = 0; + var verb = _unlinkSupported == false ? 'DEL' : 'UNLINK'; + try { + total += (await command([verb, ...chunks.first])).asInt(); + } catch (e) { + if (verb != 'UNLINK' || !_isUnknownCommand(e)) rethrow; + verb = 'DEL'; + total += (await command([verb, ...chunks.first])).asInt(); + } + _unlinkSupported ??= verb == 'UNLINK'; + if (chunks.length == 1) return total; + + Object? firstError; + StackTrace? firstStack; + final cmd = _command; + cmd?.pipe_start(); + try { + final replies = await Future.wait([ + for (final chunk in chunks.skip(1)) + command([verb, ...chunk]).then( + (reply) => reply.asInt(), + onError: (Object e, StackTrace st) { + firstError ??= e; + firstStack ??= st; + return 0; + }, + ), + ]); + for (final removed in replies) { + total += removed; + } + } finally { + cmd?.pipe_end(); + } + if (firstError != null) { + Error.throwWithStackTrace(firstError!, firstStack!); } return total; } + static bool _isUnknownCommand(Object error) => + error.toString().toLowerCase().contains('unknown command'); + /// RENAME old new. Future rename(Object oldKey, String newKey) async { _assertWritable(); @@ -769,15 +790,12 @@ class RedisConnection { /// RENAMENX old new. Returns 1 if key was renamed, 0 if newKey already exists. Future renamenx(Object oldKey, String newKey) async { _assertWritable(); - final result = - await sendCommand(['RENAMENX', redisCommandArg(oldKey), newKey]); - return redisReplyInt(result); + return (await command(['RENAMENX', oldKey, newKey])).asInt(); } /// EXISTS key. Returns 1 if key exists, 0 if missing. Future exists(Object key) async { - final result = await sendCommand(['EXISTS', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['EXISTS', key])).asInt(); } /// EXPIRE key seconds. @@ -812,284 +830,6 @@ class RedisConnection { } } -/// In-memory stub for widget tests (no socket). Handles SELECT, DBSIZE, SCAN, -/// TYPE, TTL used by [RedisKeysView]. -class RedisConnectionTestFake extends RedisConnection { - RedisConnectionTestFake({ - this.firstScanKeys = const ['alpha', 'beta'], - this.secondScanKeys = const [], - this.dbSizeResult = 2, - this.getResult, - List? listItems, - this.llenResult, - this.hashFirstPage = const {}, - this.hashSecondPage = const {}, - this.hlenResult, - this.setFirstPage = const [], - this.setSecondPage = const [], - this.scardResult, - this.zsetItems = const <(String, double)>[], - this.zcardResult, - this.typeResult = 'string', - this.failType = false, - this.getBytesResult, - this.binaryScanKeys = const >[], - }) : listItems = - listItems != null ? List.from(listItems) : [], - super( - id: -1, - name: 'test-fake', - host: 'localhost', - port: 6379, - ); - - final List firstScanKeys; - final List secondScanKeys; - final int dbSizeResult; - final String? getResult; - final List listItems; - final int? llenResult; - final Map hashFirstPage; - final Map hashSecondPage; - final int? hlenResult; - final List setFirstPage; - final List setSecondPage; - final int? scardResult; - final List<(String, double)> zsetItems; - final int? zcardResult; - final String typeResult; - final bool failType; - final List? getBytesResult; - final List> binaryScanKeys; - final List sentCommands = []; - - bool _firstScanDone = false; - bool _firstHscanDone = false; - bool _firstSscanDone = false; - - @override - bool get isConnected => _isConnected; - - @override - Future connect() async { - _isConnected = true; - _conn = null; - _command = null; - } - - @override - Future disconnect() async { - _isConnected = false; - final c = _conn; - _conn = null; - _command = null; - if (c != null) { - try { - await c.close(); - } catch (_) {} - } - } - - @override - Future sendCommand(List args) async { - if (!_isConnected) { - throw StateError('Not connected to Redis'); - } - final op = args.first.toString().toUpperCase(); - sentCommands.add(op); - switch (op) { - case 'SELECT': - return 'OK'; - case 'DBSIZE': - return dbSizeResult; - case 'SCAN': - final cursor = int.tryParse(args[1].toString()) ?? 0; - List keysFor(List named) => - [...named, ...binaryScanKeys]; - if (cursor == 0 && !_firstScanDone) { - _firstScanDone = true; - final next = secondScanKeys.isNotEmpty ? 1 : 0; - return [next, keysFor(firstScanKeys)]; - } - if (cursor == 1 && secondScanKeys.isNotEmpty) { - return [0, keysFor(secondScanKeys)]; - } - return [0, []]; - case 'TYPE': - if (failType) { - throw StateError('TYPE failed'); - } - return typeResult; - case 'TTL': - return -1; - case 'GET': - if (getBytesResult != null) return getBytesResult; - if (typeResult != 'string') { - throw StateError( - 'WRONGTYPE Operation against a key holding the wrong kind of value', - ); - } - return getResult; - case 'SET': - if (typeResult != 'string') { - throw StateError('SET refused: key is $typeResult'); - } - return 'OK'; - case 'HGETALL': - case 'SMEMBERS': - throw StateError('$op is unbounded; use a paged command'); - case 'HLEN': - return hlenResult ?? hashFirstPage.length + hashSecondPage.length; - case 'LLEN': - return llenResult ?? listItems.length; - case 'SCARD': - return scardResult ?? setFirstPage.length + setSecondPage.length; - case 'ZCARD': - return zcardResult ?? zsetItems.length; - case 'LRANGE': - return _sliceList(listItems, args); - case 'ZRANGE': - final stop = int.tryParse(args[3].toString()) ?? -1; - if (stop < 0) { - throw StateError('unbounded ZRANGE'); - } - final start = int.tryParse(args[2].toString()) ?? 0; - if (start >= zsetItems.length || start > stop) return []; - final end = (stop + 1).clamp(0, zsetItems.length); - final out = []; - for (final (member, score) in zsetItems.sublist(start, end)) { - out.add(member); - out.add(score); - } - return out; - case 'HSCAN': - return _pagedPairs( - cursor: int.tryParse(args[2].toString()) ?? 0, - first: hashFirstPage, - second: hashSecondPage, - firstDone: _firstHscanDone, - markFirst: () => _firstHscanDone = true, - ); - case 'SSCAN': - final cursor = int.tryParse(args[2].toString()) ?? 0; - if (cursor == 0 && !_firstSscanDone) { - _firstSscanDone = true; - final next = setSecondPage.isNotEmpty ? 1 : 0; - return [next, setFirstPage]; - } - if (cursor == 1 && setSecondPage.isNotEmpty) { - return [0, setSecondPage]; - } - return [0, []]; - case 'READONLY': - case 'READWRITE': - return 'OK'; - case 'EXISTS': - final target = args[1].toString(); - final allKeys = [...firstScanKeys, ...secondScanKeys]; - return allKeys.contains(target) ? 1 : 0; - case 'RENAME': - return 'OK'; - case 'RENAMENX': - final target = args[2].toString(); - final allKeys = [...firstScanKeys, ...secondScanKeys]; - return allKeys.contains(target) ? 0 : 1; - case 'RPUSH': - listItems.add(args[2].toString()); - return 'OK'; - case 'LINDEX': - final lindexIdx = int.tryParse(args[2].toString()) ?? 0; - if (lindexIdx >= 0 && lindexIdx < listItems.length) { - return listItems[lindexIdx]; - } - return null; - case 'LSET': - final idx = int.tryParse(args[2].toString()) ?? 0; - final val = args[3].toString(); - if (idx >= 0 && idx < listItems.length) { - listItems[idx] = val; - } - return 'OK'; - case 'LREM': - final count = int.tryParse(args[2].toString()) ?? 0; - final val = args[3].toString(); - var removed = 0; - if (count == 0) { - removed = listItems.where((e) => e == val).length; - listItems.removeWhere((e) => e == val); - } else if (count > 0) { - for (var i = 0; i < count; i++) { - final idx = listItems.indexOf(val); - if (idx != -1) { - listItems.removeAt(idx); - removed++; - } - } - } else { - for (var i = 0; i < count.abs(); i++) { - final idx = listItems.lastIndexOf(val); - if (idx != -1) { - listItems.removeAt(idx); - removed++; - } - } - } - return removed; - case 'DEL': - final allKeys = [...firstScanKeys, ...secondScanKeys]; - var count = 0; - for (var i = 1; i < args.length; i++) { - final target = args[i].toString(); - if (allKeys.contains(target)) { - count++; - } - } - return count; - default: - return null; - } - } - - List _sliceList(List items, List args) { - if (items.isEmpty) return const []; - final start = int.tryParse(args[2].toString()) ?? 0; - var stop = int.tryParse(args[3].toString()) ?? -1; - if (stop < 0) { - throw StateError('unbounded LRANGE'); - } - if (start >= items.length || start > stop) return const []; - final end = (stop + 1).clamp(0, items.length); - return items.sublist(start, end); - } - - List _pagedPairs({ - required int cursor, - required Map first, - required Map second, - required bool firstDone, - required void Function() markFirst, - }) { - List flatten(Map map) { - final out = []; - map.forEach((k, v) { - out.add(k); - out.add(v); - }); - return out; - } - - if (cursor == 0 && !firstDone) { - markFirst(); - final next = second.isNotEmpty ? 1 : 0; - return [next, flatten(first)]; - } - if (cursor == 1 && second.isNotEmpty) { - return [0, flatten(second)]; - } - return [0, []]; - } -} - bool _isRedisNil(Object? result) => result == null || result.toString().toLowerCase() == 'null'; diff --git a/lib/core/database/redis_reply.dart b/lib/core/database/redis_reply.dart new file mode 100644 index 00000000..24a70f73 --- /dev/null +++ b/lib/core/database/redis_reply.dart @@ -0,0 +1,157 @@ +import 'dart:typed_data'; + +import 'redis_bulk.dart'; + +/// The reply to a Redis command was not the shape the caller needs. +class RedisProtocolException implements Exception { + const RedisProtocolException(this.message); + + final String message; + + @override + String toString() => 'RedisProtocolException: $message'; +} + +/// A Redis reply, parsed once where it leaves the socket so that no caller +/// has to test `is List` / `is int` on a `dynamic` (#1571). +/// +/// The `redis` package gives integers as `int`, simple strings as `String`, +/// bulk strings as bytes (Querya parses with `RedisParserBulkBinary`), arrays +/// as `List` and nil as `null`. An error reply is thrown by the package and +/// never arrives here as a value; [RedisErrorReply] is for a caller that wants +/// to show one it caught. +sealed class RedisReply { + const RedisReply(); + + factory RedisReply.from(Object? raw) { + if (raw == null) return const RedisNil(); + if (raw is int) return RedisInt(raw); + if (raw is double) return RedisBulk(RedisBulkValue.fromReply(raw)); + if (raw is String) return RedisSimple(raw); + if (raw is Uint8List || raw is List) { + return RedisBulk(RedisBulkValue.fromReply(raw)); + } + if (raw is List) { + // Bulk bytes from a source that does not type them: all ints, or empty. + if (raw.isNotEmpty && raw.every((e) => e is int)) { + return RedisBulk(RedisBulkValue.fromReply(raw)); + } + return RedisArray([for (final e in raw) RedisReply.from(e)]); + } + return RedisBulk(RedisBulkValue.fromReply(raw)); + } + + bool get isNil => this is RedisNil; + + /// The integer: an integer reply, or a string/bulk that holds one. + int asInt(); + + /// The elements of an array reply. + List asArray(); + + /// The value as a bulk string; a nil is an empty one. + RedisBulkValue asBulk(); + + /// The UTF-8 text, or null when the bytes are not UTF-8. + String? asText() => asBulk().text; + + /// A double from a string/bulk or an integer reply. + double asDouble(); + + /// The bulk value, or null for a nil reply (`GET` of a missing key). + RedisBulkValue? asBulkOrNull() => isNil ? null : asBulk(); + + Never _mismatch(String wanted) => + throw RedisProtocolException('expected $wanted, got $runtimeType'); +} + +/// `$-1` / `*-1`: no value. +final class RedisNil extends RedisReply { + const RedisNil(); + + @override + int asInt() => _mismatch('an integer'); + @override + List asArray() => _mismatch('an array'); + @override + RedisBulkValue asBulk() => RedisBulkValue.fromReply(null); + @override + double asDouble() => _mismatch('a number'); +} + +final class RedisInt extends RedisReply { + const RedisInt(this.value); + + final int value; + + @override + int asInt() => value; + @override + List asArray() => _mismatch('an array'); + @override + RedisBulkValue asBulk() => RedisBulkValue.fromReply(value); + @override + double asDouble() => value.toDouble(); +} + +/// `+OK` style status, or a string a source gave as text. +final class RedisSimple extends RedisReply { + const RedisSimple(this.text); + + final String text; + + @override + int asInt() => int.tryParse(text) ?? _mismatch('an integer'); + @override + List asArray() => _mismatch('an array'); + @override + RedisBulkValue asBulk() => RedisBulkValue.utf8(text); + @override + double asDouble() => double.tryParse(text) ?? _mismatch('a number'); +} + +final class RedisBulk extends RedisReply { + const RedisBulk(this.value); + + final RedisBulkValue value; + + @override + int asInt() => int.tryParse(value.text ?? '') ?? _mismatch('an integer'); + @override + List asArray() => _mismatch('an array'); + @override + RedisBulkValue asBulk() => value; + @override + double asDouble() => double.tryParse(value.text ?? '') ?? _mismatch('a number'); +} + +final class RedisArray extends RedisReply { + const RedisArray(this.items); + + final List items; + + @override + int asInt() => _mismatch('an integer'); + @override + List asArray() => items; + @override + RedisBulkValue asBulk() => _mismatch('a bulk string'); + @override + double asDouble() => _mismatch('a number'); +} + +/// An error reply (`-ERR ...`) held as a value. +final class RedisErrorReply extends RedisReply { + const RedisErrorReply(this.message); + + final String message; + + @override + int asInt() => _mismatch('an integer'); + @override + List asArray() => _mismatch('an array'); + @override + RedisBulkValue asBulk() => _mismatch('a bulk string'); + @override + double asDouble() => _mismatch('a number'); +} diff --git a/lib/core/database/redis_service.dart b/lib/core/database/redis_service.dart index 1ad1044d..dad7971b 100644 --- a/lib/core/database/redis_service.dart +++ b/lib/core/database/redis_service.dart @@ -12,8 +12,12 @@ enum RedisSessionRole { /// Overview INFO polling (kept alive under Explorer). stats, - /// Keys/editor SCAN/GET/SET plus `SELECT`. + /// Keys view and key editor of one database: SCAN/GET/SET/DEL. explorer, + + /// The CLI console. Its own socket, so a `SELECT` typed there moves only + /// the console and not the explorer next to it (#1569). + cli, } /// Service for Redis connections (Dart redis package, no Java). @@ -23,22 +27,27 @@ class RedisService { final Map _connections = {}; - String _key(int id, RedisSessionRole role) => '$id::${role.name}'; + /// One socket per connection, role and database. A socket never switches + /// database for a view, so a command cannot land in another view's database + /// between a `SELECT` and the command (#1569). + String _key(int id, RedisSessionRole role, int database) => + '$id::${role.name}::$database'; - /// Returns the existing socket for [role], or creates one without touching - /// the other role. + /// Returns the existing socket for [role] and [database], or creates one + /// without touching the others. RedisConnection acquire( ConnectionRow row, { required RedisSessionRole role, + int database = 0, }) { if (row.type != 'redis') { throw ArgumentError('Connection type must be redis'); } final id = row.id ?? 0; - final k = _key(id, role); + final k = _key(id, role, database); final existing = _connections[k]; if (existing != null) return existing; - final conn = RedisConnection.fromConnectionRow(row); + final conn = RedisConnection.fromConnectionRow(row)..database = database; _connections[k] = conn; return conn; } @@ -54,7 +63,7 @@ class RedisService { } final id = row.id ?? 0; - final k = _key(id, role); + final k = _key(id, role, 0); final existing = _connections.remove(k); if (existing != null) { @@ -69,8 +78,9 @@ class RedisService { RedisConnection? getConnection( int id, { RedisSessionRole role = RedisSessionRole.stats, + int database = 0, }) => - _connections[_key(id, role)]; + _connections[_key(id, role, database)]; Future connect(RedisConnection connection) async { await connection.connect(); @@ -82,10 +92,15 @@ class RedisService { await connection.disconnect(); } - /// Awaits close of every role for [id] (user Disconnect). + /// Awaits close of every role and database for [id] (user Disconnect). Future disconnectByConnectionId(int id) async { - for (final role in RedisSessionRole.values) { - final conn = _connections.remove(_key(id, role)); + final prefix = '$id::'; + final keys = [ + for (final k in _connections.keys) + if (k.startsWith(prefix)) k, + ]; + for (final k in keys) { + final conn = _connections.remove(k); if (conn != null) await conn.disconnect(); } } diff --git a/lib/core/database/result_columns_builder.dart b/lib/core/database/result_columns_builder.dart new file mode 100644 index 00000000..db1219d6 --- /dev/null +++ b/lib/core/database/result_columns_builder.dart @@ -0,0 +1,291 @@ +import 'dart:typed_data'; + +import 'compact_result_dataset.dart'; +import 'result_row_string_convert.dart'; + +/// Builds a [CompactResultDataset] while a driver delivers the rows, without +/// first keeping a `List>` of the whole result (#1552). +/// +/// A column's kind is what [CompactResultDataset.fromRawRows] always decided: +/// all ints is int64, all numbers float64, all strings string, nothing +/// (all NULL) string, anything else generic. Here that is learnt as values +/// arrive, so a column can be demoted late: ints stay in the int64 buffer and +/// doubles go to a second buffer, so a column that ends up generic still holds +/// its ints as ints. +/// +/// [cellFormatter] is the driver's display formatter; the first +/// [formattedSamples] non-null cells of a column go through it to see whether +/// the column prints as its values do (bytea, boolean, ...). +class ResultColumnsBuilder { + ResultColumnsBuilder( + this.columnNames, { + this.cellFormatter, + StringInternPool? pool, + }) : pool = pool ?? StringInternPool(), + _columns = [ + for (var i = 0; i < columnNames.length; i++) _ColumnBuilder(i), + ]; + + /// Non-null cells per column checked against the formatter (#1548). + static const formattedSamples = 60; + + final List columnNames; + final String Function(Object? value, int colIndex)? cellFormatter; + final StringInternPool pool; + final List<_ColumnBuilder> _columns; + + int _rows = 0; + + int get rowCount => _rows; + + /// Adds one row. A short row is padded with NULLs. + void addRow(List row) { + final n = _columns.length; + for (var i = 0; i < n; i++) { + _columns[i].add(i < row.length ? row[i] : null, cellFormatter); + } + _rows++; + } + + /// Adds one row cell by cell: call [addCell] for every column in order, then + /// [endRow]. For drivers whose rows are not lists. + void addCell(int col, Object? value) => + _columns[col].add(value, cellFormatter); + + void endRow() => _rows++; + + CompactResultDataset build() { + if (_rows == 0 || _columns.isEmpty) { + return CompactResultDataset( + columnNames: columnNames, + columns: const [], + rowCount: 0, + pool: pool, + ); + } + return CompactResultDataset( + columnNames: columnNames, + columns: [ + for (final c in _columns) + c.finish(_rows, pool, cellFormatter == null + ? null + : (v) => cellFormatter!(v, c.index)), + ], + rowCount: _rows, + pool: pool, + ); + } +} + +enum _Mode { empty, numeric, string, generic } + +class _ColumnBuilder { + _ColumnBuilder(this.index); + + final int index; + + var _mode = _Mode.empty; + var _allInt = true; + var _allFloat = true; + var _allString = true; + var _nonNull = 0; + var _length = 0; + + // numeric: ints in [_ints], doubles in [_doubles] where [_isDouble] has the + // bit; [_nulls] has a bit per NULL row (also kept while empty). + var _capacity = 0; + var _ints = Int64List(0); + Float64List? _doubles; + Uint8List? _isDouble; + var _nulls = Uint8List(0); + + // string / generic + List _strings = []; + List _objects = []; + + void add(Object? value, String Function(Object?, int)? formatter) { + if (value == null) { + _addNull(); + _length++; + return; + } + _nonNull++; + // The driver formatters print a column's ints, numbers and strings by the + // column's type, not by the value, so the first cells show whether the + // column prints as its values do. Past those, only the type is tested. + if (formatter != null && _nonNull <= ResultColumnsBuilder.formattedSamples) { + final formatted = formatter(value, index); + if (_allInt && (value is! int || formatted != value.toString())) { + _allInt = false; + } + if (_allFloat && (value is! num || formatted != value.toString())) { + _allFloat = false; + } + if (_allString && (value is! String || formatted != value)) { + _allString = false; + } + } else { + if (_allInt && value is! int) _allInt = false; + if (_allFloat && value is! num) _allFloat = false; + if (_allString && value is! String) _allString = false; + } + + final target = !_allInt && !_allFloat && !_allString + ? _Mode.generic + : _allString + ? _Mode.string + : _Mode.numeric; + if (_mode != target) _switchTo(target); + + switch (_mode) { + case _Mode.numeric: + _ensureCapacity(); + if (value is int) { + _ints[_length] = value; + } else { + final number = value as num; + _doubles ??= Float64List(_capacity); + _isDouble ??= Uint8List((_capacity + 7) >> 3); + _doubles![_length] = number.toDouble(); + _isDouble![_length >> 3] |= 1 << (_length & 7); + } + case _Mode.string: + _strings.add(value as String); + case _Mode.generic: + _objects.add(value); + case _Mode.empty: + break; + } + _length++; + } + + void _addNull() { + switch (_mode) { + case _Mode.empty: + case _Mode.numeric: + _ensureCapacity(); + _nulls[_length >> 3] |= 1 << (_length & 7); + case _Mode.string: + _strings.add(null); + case _Mode.generic: + _objects.add(null); + } + } + + void _ensureCapacity() { + if (_length < _capacity) return; + final grown = _capacity == 0 ? 256 : _capacity * 2; + _ints = _grow64(_ints, grown); + if (_doubles != null) _doubles = _growF(_doubles!, grown); + if (_isDouble != null) _isDouble = _growBytes(_isDouble!, (grown + 7) >> 3); + _nulls = _growBytes(_nulls, (grown + 7) >> 3); + _capacity = grown; + } + + static Int64List _grow64(Int64List a, int n) => Int64List(n)..setAll(0, a); + static Float64List _growF(Float64List a, int n) => + Float64List(n)..setAll(0, a); + static Uint8List _growBytes(Uint8List a, int n) => + Uint8List(n)..setAll(0, a); + + bool _isNullRow(int i) => (_nulls[i >> 3] & (1 << (i & 7))) != 0; + + Object? _numericAt(int i) { + if (_isNullRow(i)) return null; + final dbl = _isDouble; + if (dbl != null && (dbl[i >> 3] & (1 << (i & 7))) != 0) { + return _doubles![i]; + } + return _ints[i]; + } + + /// Moves what was stored so far into the storage of [target]. + void _switchTo(_Mode target) { + final from = _mode; + _mode = target; + switch (target) { + case _Mode.numeric: + // Only from empty: the rows so far are NULLs, already in the bitmap. + break; + case _Mode.string: + // Only from empty. + _strings = List.filled(_length, null, growable: true); + case _Mode.generic: + if (from == _Mode.string) { + _objects = List.of(_strings, growable: true); + _strings = []; + } else if (from == _Mode.numeric) { + _objects = [for (var i = 0; i < _length; i++) _numericAt(i)]; + _releaseNumeric(); + } else { + _objects = List.filled(_length, null, growable: true); + } + case _Mode.empty: + break; + } + } + + void _releaseNumeric() { + _capacity = 0; + _ints = Int64List(0); + _doubles = null; + _isDouble = null; + _nulls = Uint8List(0); + } + + CompactColumn finish( + int rows, + StringInternPool pool, + String Function(Object?)? formatter, + ) { + // Rows a column never saw (a short last row) are NULLs. + while (_length < rows) { + _addNull(); + _length++; + } + final bitmapBytes = (rows + 7) >> 3; + if (_nonNull == 0) { + return StringCompactColumn( + values: List.filled(rows, null), + length: rows, + ); + } + if (_mode == _Mode.numeric) { + final nulls = Uint8List.fromList(Uint8List.sublistView(_nulls, 0, bitmapBytes)); + if (_allInt) { + return Int64CompactColumn( + values: Int64List.fromList(Int64List.sublistView(_ints, 0, rows)), + nullBitmap: nulls, + length: rows, + ); + } + // Numbers of both kinds: all float64. + final values = Float64List(rows); + final dbl = _isDouble; + for (var i = 0; i < rows; i++) { + if (_isNullRow(i)) continue; + values[i] = dbl != null && (dbl[i >> 3] & (1 << (i & 7))) != 0 + ? _doubles![i] + : _ints[i].toDouble(); + } + return Float64CompactColumn( + values: values, + nullBitmap: nulls, + length: rows, + ); + } + if (_mode == _Mode.string) { + return StringCompactColumn( + values: [ + for (final s in _strings) s == null ? null : pool.intern(s), + ], + length: rows, + ); + } + return GenericCompactColumn( + values: _objects, + length: rows, + formatter: formatter, + ); + } +} diff --git a/lib/core/database/sql_lexical_scanner.dart b/lib/core/database/sql_lexical_scanner.dart new file mode 100644 index 00000000..7125c0df --- /dev/null +++ b/lib/core/database/sql_lexical_scanner.dart @@ -0,0 +1,185 @@ +/// What a stretch of SQL text is, lexically. +enum SqlSegmentKind { + /// Keywords, identifiers, operators, numbers and blanks. + code, + + /// `'...'`, with `''` (and backslash escapes where the dialect has them). + string, + + /// `"name"` (PostgreSQL, standard SQL) or `` `name` `` (MySQL). + quotedIdentifier, + + /// `-- ...` up to the line break, or `/* ... */`. + comment, + + /// `$$ ... $$` or `$tag$ ... $tag$` (PostgreSQL function bodies). + dollarQuoted, +} + +/// One segment of a script, as offsets into the script. +class SqlSegment { + const SqlSegment(this.kind, this.start, this.end); + + final SqlSegmentKind kind; + + /// Offset of the first character, including the opening quote or `--`. + final int start; + + /// Offset after the last character, including the closing quote. + final int end; + + @override + bool operator ==(Object other) => + other is SqlSegment && + other.kind == kind && + other.start == start && + other.end == end; + + @override + int get hashCode => Object.hash(kind, start, end); + + @override + String toString() => 'SqlSegment(${kind.name}, $start, $end)'; +} + +/// Splits SQL into code and the parts that are not code (literals, quoted +/// identifiers, comments, dollar quotes). An unterminated literal or comment +/// runs to the end. +abstract final class SqlLexicalScanner { + static final RegExp _dollarTag = RegExp(r'\$([a-zA-Z0-9_]*)\$'); + + static const int _chDash = 0x2D; + static const int _chSlash = 0x2F; + static const int _chAsterisk = 0x2A; + static const int _chDollar = 0x24; + static const int _chSingleQuote = 0x27; + static const int _chDoubleQuote = 0x22; + static const int _chBacktick = 0x60; + static const int _chBackslash = 0x5C; + static const int _chLf = 0x0A; + static const int _chCr = 0x0D; + + /// Segments that cover [sql] end to end, in order; neighbouring code is + /// one segment. + /// + /// [backslashEscapes]: whether `\'` inside `'...'` is an escaped quote, as + /// in MySQL. In PostgreSQL and SQLite a backslash is an ordinary character + /// and `'C:\'` is a whole literal; only PostgreSQL's `E'...'` strings + /// escape with it, so those are honoured either way. + static List segments(String sql, {bool backslashEscapes = true}) { + final out = []; + final len = sql.length; + var codeStart = 0; + var i = 0; + + void add(SqlSegmentKind kind, int start, int end) { + if (start > codeStart) { + out.add(SqlSegment(SqlSegmentKind.code, codeStart, start)); + } + out.add(SqlSegment(kind, start, end)); + codeStart = end; + } + + while (i < len) { + final code = sql.codeUnitAt(i); + + if (code == _chDash && i + 1 < len && sql.codeUnitAt(i + 1) == _chDash) { + var j = i + 2; + while (j < len && + sql.codeUnitAt(j) != _chLf && + sql.codeUnitAt(j) != _chCr) { + j++; + } + add(SqlSegmentKind.comment, i, j); + i = j; + continue; + } + + if (code == _chSlash && + i + 1 < len && + sql.codeUnitAt(i + 1) == _chAsterisk) { + final close = sql.indexOf('*/', i + 2); + final end = close == -1 ? len : close + 2; + add(SqlSegmentKind.comment, i, end); + i = end; + continue; + } + + if (code == _chDollar) { + final match = _dollarTag.matchAsPrefix(sql, i); + if (match != null) { + final tag = match.group(0)!; + final close = sql.indexOf(tag, i + tag.length); + final end = close == -1 ? len : close + tag.length; + add(SqlSegmentKind.dollarQuoted, i, end); + i = end; + continue; + } + } + + if (code == _chSingleQuote) { + final escapes = backslashEscapes || isEscapeStringPrefix(sql, i); + var j = i + 1; + while (j < len) { + final curr = sql.codeUnitAt(j); + if (curr == _chSingleQuote) { + if (j + 1 < len && sql.codeUnitAt(j + 1) == _chSingleQuote) { + j += 2; + } else { + j++; + break; + } + } else if (escapes && curr == _chBackslash && j + 1 < len) { + j += 2; + } else { + j++; + } + } + add(SqlSegmentKind.string, i, j); + i = j; + continue; + } + + if (code == _chDoubleQuote || code == _chBacktick) { + var j = i + 1; + while (j < len) { + if (sql.codeUnitAt(j) == code) { + if (j + 1 < len && sql.codeUnitAt(j + 1) == code) { + j += 2; + } else { + j++; + break; + } + } else { + j++; + } + } + add(SqlSegmentKind.quotedIdentifier, i, j); + i = j; + continue; + } + + i++; + } + if (len > codeStart) { + out.add(SqlSegment(SqlSegmentKind.code, codeStart, len)); + } + return out; + } + + /// Whether the quote at [quote] opens a PostgreSQL escape string: `E'` or + /// `e'`, the `E` not being the end of a longer word (`TYPE'...'`). + static bool isEscapeStringPrefix(String sql, int quote) { + if (quote < 1) return false; + final e = sql.codeUnitAt(quote - 1); + if (e != 0x45 && e != 0x65) return false; + if (quote < 2) return true; + final before = sql.codeUnitAt(quote - 2); + final word = (before >= 0x30 && before <= 0x39) || + (before >= 0x41 && before <= 0x5A) || + (before >= 0x61 && before <= 0x7A) || + before == 0x5F || + before == 0x24; + return !word; + } +} diff --git a/lib/core/database/sql_limit.dart b/lib/core/database/sql_limit.dart index 42205440..47d79994 100644 --- a/lib/core/database/sql_limit.dart +++ b/lib/core/database/sql_limit.dart @@ -20,25 +20,67 @@ int _blockCommentEnd(String sql, int start) { return sql.length; } -/// Removes leading whitespace, `--` line comments and `/* */` block comments. -String stripLeadingWhitespaceAndLineComments(String sql) { - var s = sql.trimLeft(); +/// The characters `String.trimLeft` removes: Unicode White_Space and the BOM. +bool _isTrimWhitespace(int c) => + (c >= 0x09 && c <= 0x0D) || + c == 0x20 || + c == 0x85 || + c == 0xA0 || + c == 0x1680 || + (c >= 0x2000 && c <= 0x200A) || + c == 0x2028 || + c == 0x2029 || + c == 0x202F || + c == 0x205F || + c == 0x3000 || + c == 0xFEFF; + +/// Offset of the first character of [sql] that is not whitespace, a `--` line +/// comment or a `/* */` block comment; `sql.length` when nothing else is there. +/// +/// Walks an index, so a header of many comment lines in front of a large script +/// costs one pass and no copies (#1515). +int _skipLeadingCommentsOffset(String sql) { + final length = sql.length; + var i = 0; while (true) { - if (s.isEmpty) return s; - if (s.startsWith('--')) { - final nl = s.indexOf('\n'); - if (nl == -1) return ''; - s = s.substring(nl + 1).trimLeft(); + while (i < length && _isTrimWhitespace(sql.codeUnitAt(i))) { + i++; + } + if (i >= length) return length; + if (sql.startsWith('--', i)) { + final nl = sql.indexOf('\n', i); + if (nl == -1) return length; + i = nl + 1; continue; } - if (s.startsWith('/*')) { - s = s.substring(_blockCommentEnd(s, 0)).trimLeft(); + if (sql.startsWith('/*', i)) { + i = _blockCommentEnd(sql, i); continue; } - return s; + return i; } } +/// Whether [sql] has [upperKeyword] at [at], ignoring ASCII case. Like the +/// `startsWith` it replaces, it does not require a word boundary after it. +bool _hasKeywordAt(String sql, int at, String upperKeyword) { + if (at + upperKeyword.length > sql.length) return false; + for (var k = 0; k < upperKeyword.length; k++) { + var c = sql.codeUnitAt(at + k); + if (c >= 0x61 && c <= 0x7A) c -= 0x20; + if (c != upperKeyword.codeUnitAt(k)) return false; + } + return true; +} + +/// Removes leading whitespace, `--` line comments and `/* */` block comments. +String stripLeadingWhitespaceAndLineComments(String sql) { + final start = _skipLeadingCommentsOffset(sql); + if (start >= sql.length) return ''; + return start == 0 ? sql : sql.substring(start); +} + final _limitAll = RegExp(r'\bLIMIT\s+ALL\b', caseSensitive: false); final _limitCount = RegExp( r'\bLIMIT\s+(\d+)(\s+OFFSET\s+\d+)?', @@ -177,12 +219,12 @@ Match? _outerMatch(RegExp pattern, String sql) { String injectSqlLimit(String sql, int limit) { if (limit <= 0) return sql; - final cleanSql = stripLeadingWhitespaceAndLineComments(sql); - final upper = cleanSql.toUpperCase(); - - final isSelect = upper.startsWith('SELECT') || - upper.startsWith('WITH') || - upper.startsWith('VALUES'); + // The keyword is compared in place: neither the comment-free tail nor an + // upper-cased copy of a large script is made just to read its first word. + final start = _skipLeadingCommentsOffset(sql); + final isSelect = _hasKeywordAt(sql, start, 'SELECT') || + _hasKeywordAt(sql, start, 'WITH') || + _hasKeywordAt(sql, start, 'VALUES'); if (!isSelect) { return sql; diff --git a/lib/core/database/sql_mutation_classifier.dart b/lib/core/database/sql_mutation_classifier.dart index f051893b..b3366ddd 100644 --- a/lib/core/database/sql_mutation_classifier.dart +++ b/lib/core/database/sql_mutation_classifier.dart @@ -12,9 +12,14 @@ final _dataModifyingCte = RegExp(r'\b(INSERT|UPDATE|DELETE|MERGE)\b'); /// /// Comments and string literals are ignored. `WITH ...` statements count when /// a data-modifying statement appears inside them. -bool isMutatingSqlStatement(String statement) { - final sanitized = - DestructiveSqlDetector.stripCommentsAndStrings(statement).trim(); +/// +/// [backslashEscapes]: whether `\'` escapes a quote (MySQL; the default), see +/// [DestructiveSqlDetector.stripCommentsAndStrings]. +bool isMutatingSqlStatement(String statement, {bool backslashEscapes = true}) { + final sanitized = DestructiveSqlDetector.stripCommentsAndStrings( + statement, + backslashEscapes: backslashEscapes, + ).trim(); if (sanitized.isEmpty) return false; final upper = sanitized.toUpperCase(); final first = _firstWord.firstMatch(upper)?.group(1); @@ -25,6 +30,8 @@ bool isMutatingSqlStatement(String statement) { } /// Whether the SQL script [sql] contains at least one mutating statement. -bool containsMutatingSql(String sql) => DestructiveSqlDetector - .splitStatements(sql) - .any(isMutatingSqlStatement); +bool containsMutatingSql(String sql, {bool backslashEscapes = true}) => + DestructiveSqlDetector.splitStatements( + sql, + backslashEscapes: backslashEscapes, + ).any((s) => isMutatingSqlStatement(s, backslashEscapes: backslashEscapes)); diff --git a/lib/core/database/sql_statement_splitter.dart b/lib/core/database/sql_statement_splitter.dart index 5f36951d..b609c343 100644 --- a/lib/core/database/sql_statement_splitter.dart +++ b/lib/core/database/sql_statement_splitter.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/database/sql_lexical_scanner.dart'; /// One statement of a script, as offsets into the script. class SqlStatementSpan { @@ -24,21 +25,8 @@ class SqlStatementSpan { /// literals, quoted identifiers, comments and PostgreSQL dollar quotes do not /// end a statement. abstract final class SqlStatementSplitter { - /// `$tag$` or `$$`, matched at an offset (no `^`: it would only match at the - /// start of the script). One compiled instance for every dollar sign. - static final RegExp _dollarTag = RegExp(r'\$([a-zA-Z0-9_]*)\$'); - - static const int _chDash = 0x2D; - static const int _chSlash = 0x2F; - static const int _chAsterisk = 0x2A; - static const int _chDollar = 0x24; - static const int _chSingleQuote = 0x27; - static const int _chDoubleQuote = 0x22; - static const int _chBacktick = 0x60; static const int _chSemicolon = 0x3B; - static const int _chBackslash = 0x5C; static const int _chLf = 0x0A; - static const int _chCr = 0x0D; /// Default script size threshold (64 KB) above which [spansAsync] offloads /// parsing to a background isolate via [compute]. @@ -50,17 +38,28 @@ abstract final class SqlStatementSplitter { static Future> spansAsync( String sql, { int isolateThreshold = defaultIsolateThreshold, + bool backslashEscapes = true, }) async { if (sql.length <= isolateThreshold) { - return spans(sql); + return spans(sql, backslashEscapes: backslashEscapes); } - return compute(spans, sql); + return compute(_spansInIsolate, (sql, backslashEscapes)); } - static List spans(String sql) { + static List _spansInIsolate((String, bool) args) => + spans(args.$1, backslashEscapes: args.$2); + + /// [backslashEscapes] says whether `\'` escapes a quote inside `'...'`, as + /// it does in MySQL (the default, and what an unknown dialect gets). Pass + /// `dialect.backslashEscapesQuotes` when the dialect is known: reading + /// `'a\'` as an unfinished literal in PostgreSQL or SQLite hides the `;` + /// after it, and the statement the server then runs. + static List spans( + String sql, { + bool backslashEscapes = true, + }) { final out = []; final len = sql.length; - var i = 0; var segmentStart = 0; // Statement starts only move forward, so the line of each one is found by @@ -90,93 +89,19 @@ abstract final class SqlStatementSplitter { } } - while (i < len) { - final code = sql.codeUnitAt(i); - - // Line comment: runs to the end of the line. - if (code == _chDash && i + 1 < len && sql.codeUnitAt(i + 1) == _chDash) { - while (i < len && - sql.codeUnitAt(i) != _chLf && - sql.codeUnitAt(i) != _chCr) { - i++; - } - continue; - } - - // Block comment. - if (code == _chSlash && - i + 1 < len && - sql.codeUnitAt(i + 1) == _chAsterisk) { - i += 2; - while (i + 1 < len && - !(sql.codeUnitAt(i) == _chAsterisk && - sql.codeUnitAt(i + 1) == _chSlash)) { - i++; - } - i = i + 1 < len ? i + 2 : len; - continue; - } - - // Dollar quote: $tag$ ... $tag$. - if (code == _chDollar) { - final match = _dollarTag.matchAsPrefix(sql, i); - if (match != null) { - final tag = match.group(0)!; - final close = sql.indexOf(tag, i + tag.length); - i = close == -1 ? len : close + tag.length; - continue; + // A `;` ends a statement only in code: literals, quoted identifiers, + // comments and dollar quotes are the scanner's other segments. + for (final segment in SqlLexicalScanner.segments( + sql, + backslashEscapes: backslashEscapes, + )) { + if (segment.kind != SqlSegmentKind.code) continue; + for (var i = segment.start; i < segment.end; i++) { + if (sql.codeUnitAt(i) == _chSemicolon) { + close(i); + segmentStart = i + 1; } } - - // String literal, with doubled quotes and backslash escapes. - if (code == _chSingleQuote) { - i++; - while (i < len) { - final curr = sql.codeUnitAt(i); - if (curr == _chSingleQuote) { - if (i + 1 < len && sql.codeUnitAt(i + 1) == _chSingleQuote) { - i += 2; - } else { - i++; - break; - } - } else if (curr == _chBackslash && i + 1 < len) { - i += 2; - } else { - i++; - } - } - continue; - } - - // Quoted identifier: "name" (PostgreSQL) or `name` (MySQL). - if (code == _chDoubleQuote || code == _chBacktick) { - final quoteChar = code; - i++; - while (i < len) { - final curr = sql.codeUnitAt(i); - if (curr == quoteChar) { - if (i + 1 < len && sql.codeUnitAt(i + 1) == quoteChar) { - i += 2; - } else { - i++; - break; - } - } else { - i++; - } - } - continue; - } - - if (code == _chSemicolon) { - close(i); - i++; - segmentStart = i; - continue; - } - - i++; } close(len); return out; diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index 9a857bc6..3b549994 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/sqlite_sql.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// In-memory paths (`:memory:`) skip the missing-file check. bool sqlitePathIsInMemory(String path) => @@ -275,7 +276,9 @@ class SqliteConnection { if (!join) { try { await execute('ROLLBACK'); - } catch (_) {} + } catch (e) { + logIgnored('sqlite_connection', e); + } } rethrow; } @@ -353,7 +356,9 @@ class SqliteConnection { } catch (_) { try { await _db!.execute('DROP VIEW IF EXISTS $viewName'); - } catch (_) {} + } catch (e) { + logIgnored('sqlite_connection', e); + } } } @@ -362,7 +367,9 @@ class SqliteConnection { if (target != null) { try { return await listColumnNames(table: target.tableName); - } catch (_) {} + } catch (e) { + logIgnored('sqlite_connection', e); + } } return const []; diff --git a/lib/core/database/ssh_tunnel_host.dart b/lib/core/database/ssh_tunnel_host.dart new file mode 100644 index 00000000..13f3c1c6 --- /dev/null +++ b/lib/core/database/ssh_tunnel_host.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; + +/// The SSH tunnel of a database connection: the same steps for every driver. +/// +/// The class gives [sshConfig] and [sshSecrets]; the mixin holds the open +/// tunnel, opens it with the stored secrets when none were passed in, and +/// releases it. +mixin SshTunnelHost { + SshTunnelConfig? get sshConfig; + SshTunnelSecrets? get sshSecrets; + + SshTunnelHandle? _sshTunnelHandle; + + SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; + + /// Opens the tunnel to [host]:[port] when the connection has one enabled, + /// else null. The handle is not kept: pass it to [adoptSshTunnel] once the + /// connect attempt is still the current one. + @protected + Future openSshTunnel({ + required int id, + required String host, + required int port, + }) async { + final config = sshConfig; + if (config == null || !config.enabled) return null; + var secrets = sshSecrets; + if (secrets == null && id > 0) { + final stored = + await ConnectionSecretsStore.readSshSecretsForConnection(id); + secrets = SshTunnelSecrets( + password: stored.password, + privateKey: stored.privateKey, + passphrase: stored.passphrase, + jumpPassword: stored.jumpPassword, + ); + } + return SshTunnelManager.instance.openTunnel( + config: config, + secrets: secrets ?? SshTunnelSecrets(), + remoteHost: host, + remotePort: port, + ); + } + + @protected + void adoptSshTunnel(SshTunnelHandle? handle) => _sshTunnelHandle = handle; + + /// Releases the open tunnel, if any. A failing release is logged under + /// [logLabel] when given and ignored otherwise. + @protected + Future releaseSshTunnel({String? logLabel}) async { + final handle = _sshTunnelHandle; + _sshTunnelHandle = null; + try { + await handle?.release(); + } catch (e) { + if (logLabel != null) debugPrint('$logLabel: $e'); + } + } + + /// Forgets [handle] when it is the one kept (a superseded connect attempt). + @protected + void forgetSshTunnel(SshTunnelHandle? handle) { + if (identical(_sshTunnelHandle, handle)) _sshTunnelHandle = null; + } +} diff --git a/lib/core/database/stream_take_drain.dart b/lib/core/database/stream_take_drain.dart index 9353bb28..770660d0 100644 --- a/lib/core/database/stream_take_drain.dart +++ b/lib/core/database/stream_take_drain.dart @@ -10,16 +10,35 @@ Future<({List items, bool truncated})> takeThenDrain( Future Function(int count)? onProgress, }) async { final items = []; + final r = await forEachThenDrain( + stream, + cap, + onItem: items.add, + onProgress: onProgress, + ); + return (items: items, truncated: r.truncated); +} + +/// [takeThenDrain] for a caller that consumes each of the first [cap] events +/// as it arrives (packing it into columns) and has no use for the list. +Future<({int count, bool truncated})> forEachThenDrain( + Stream stream, + int cap, { + required void Function(T item) onItem, + Future Function(int count)? onProgress, +}) async { + var count = 0; var truncated = false; await for (final item in stream) { - if (items.length >= cap) { + if (count >= cap) { truncated = true; continue; } - items.add(item); + onItem(item); + count++; if (onProgress != null) { - await onProgress(items.length); + await onProgress(count); } } - return (items: items, truncated: truncated); + return (count: count, truncated: truncated); } diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index b55778f3..bbeb4c0c 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -7,6 +7,13 @@ enum SqlDialect { sqlite, } +extension SqlDialectLexing on SqlDialect { + /// Whether `\'` inside `'...'` is an escaped quote. It is in MySQL; in + /// PostgreSQL and SQLite a backslash is an ordinary character and `'C:\'` + /// is a whole literal (PostgreSQL's `E'...'` strings are the exception). + bool get backslashEscapesQuotes => this == SqlDialect.mysql; +} + /// Type of DML mutation. enum MutationType { insert, diff --git a/lib/core/editor/querya_code_editor.dart b/lib/core/editor/querya_code_editor.dart index c1988c80..fe71993e 100644 --- a/lib/core/editor/querya_code_editor.dart +++ b/lib/core/editor/querya_code_editor.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'querya_code_language.dart'; import 'querya_highlight_controller.dart'; @@ -42,7 +43,7 @@ class QueryaCodeEditor extends StatefulWidget { /// When null, uses [QueryaEditorTheme.fontSize] from scope. final bool readOnly; final ValueChanged? onChanged; - final Widget? placeholder; + final String? placeholder; final QueryaCodeEditorVariant variant; final bool expands; final int? maxLines; @@ -234,13 +235,11 @@ class _QueryaCodeEditorState extends State { widget.textAlignVertical ?? (widget.expands ? material.TextAlignVertical.top : null); - Widget? _resolvedPlaceholder() { + String? _resolvedPlaceholder() { if (widget.placeholder != null) return widget.placeholder; return switch (widget.language) { - QueryaCodeLanguage.sql => const Text( - '-- Enter SQL here…\nSELECT 1;', - ), - QueryaCodeLanguage.json => const Text('{ }'), + QueryaCodeLanguage.sql => '-- Enter SQL here…\nSELECT 1;', + QueryaCodeLanguage.json => '{ }', QueryaCodeLanguage.plain => null, }; } @@ -253,7 +252,29 @@ class _QueryaCodeEditorState extends State { final controller = _effectiveController; if (widget.variant == QueryaCodeEditorVariant.material) { - return material.TextField( + return material.Padding( + padding: widget.contentPadding ?? const material.EdgeInsets.all(12), + child: QueryaTextField( + framed: false, + controller: controller, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + readOnly: widget.readOnly, + maxLines: widget.expands ? null : widget.maxLines, + expands: widget.expands, + style: style, + textAlignVertical: _verticalAlignment, + placeholder: widget.hintText, + ), + ); + } + + // The editor sits in a card of its own (`SqlEditorChrome`), so the field + // draws no frame; the padding is the material variant's. + return material.Padding( + padding: widget.contentPadding ?? const material.EdgeInsets.all(12), + child: QueryaTextField( + framed: false, controller: controller, focusNode: widget.focusNode, autofocus: widget.autofocus, @@ -262,25 +283,8 @@ class _QueryaCodeEditorState extends State { expands: widget.expands, style: style, textAlignVertical: _verticalAlignment, - decoration: material.InputDecoration( - border: material.InputBorder.none, - hintText: widget.hintText, - contentPadding: - widget.contentPadding ?? const material.EdgeInsets.all(12), - ), - ); - } - - return TextField( - controller: controller, - focusNode: widget.focusNode, - autofocus: widget.autofocus, - readOnly: widget.readOnly, - maxLines: widget.expands ? null : widget.maxLines, - expands: widget.expands, - style: style, - textAlignVertical: _verticalAlignment, - placeholder: placeholder, + placeholder: placeholder, + ), ); } } diff --git a/lib/core/editor/querya_highlight_controller.dart b/lib/core/editor/querya_highlight_controller.dart index 04cba12e..5ea03f5b 100644 --- a/lib/core/editor/querya_highlight_controller.dart +++ b/lib/core/editor/querya_highlight_controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:syntax_highlight/syntax_highlight.dart'; @@ -63,7 +64,7 @@ class QueryaHighlightController extends TextEditingController { TextStyle? style, required bool withComposing, }) { - final brightness = Theme.of(context).brightness; + final brightness = context.colors.brightness; final themeConfig = brightness == Brightness.light ? lightThemeConfig : darkThemeConfig; diff --git a/lib/core/editor/syntax_highlight_isolate.dart b/lib/core/editor/syntax_highlight_isolate.dart index 7250b005..b9dab902 100644 --- a/lib/core/editor/syntax_highlight_isolate.dart +++ b/lib/core/editor/syntax_highlight_isolate.dart @@ -212,17 +212,38 @@ FontWeight? _fontWeightFromValue(int? value) { return null; } +/// Token styles built for [_cachedBase] (#1543). A theme has a handful of +/// distinct token looks, but a document has hundreds of tokens, and the spans +/// are rebuilt on every keystroke: the styles are made once per base style and +/// shared by every segment that looks the same. +const Object _noBase = Object(); +Object? _cachedBase = _noBase; +final Map _tokenStyles = {}; +const int _maxTokenStyles = 256; + TextStyle? _styleFromSegment(HighlightSegment s, TextStyle? base) { - if (s.colorArgb == null && - s.fontWeightValue == null && - s.fontStyleIndex == null) { + final argb = s.colorArgb; + final weight = s.fontWeightValue; + final fontStyle = s.fontStyleIndex; + if (argb == null && weight == null && fontStyle == null) { return null; } - return (base ?? const TextStyle()).copyWith( - color: s.colorArgb != null ? Color(s.colorArgb!) : null, - fontWeight: _fontWeightFromValue(s.fontWeightValue), - fontStyle: - s.fontStyleIndex != null ? FontStyle.values[s.fontStyleIndex!] : null, + if (!identical(_cachedBase, base)) { + _cachedBase = base; + _tokenStyles.clear(); + } else if (_tokenStyles.length >= _maxTokenStyles) { + _tokenStyles.clear(); + } + // Colour 32 bits, +1 bit "has a colour", weight 100..900 as 0..9, style as + // 0 (none) or index + 1. + final key = (argb ?? 0) | + ((argb != null ? 1 : 0) << 32) | + (((weight ?? 0) ~/ 100) << 33) | + ((fontStyle != null ? fontStyle + 1 : 0) << 37); + return _tokenStyles[key] ??= (base ?? const TextStyle()).copyWith( + color: argb != null ? Color(argb) : null, + fontWeight: _fontWeightFromValue(weight), + fontStyle: fontStyle != null ? FontStyle.values[fontStyle] : null, ); } diff --git a/lib/core/erd/erd_catalog.dart b/lib/core/erd/erd_catalog.dart index 6cdd320b..d3ccb815 100644 --- a/lib/core/erd/erd_catalog.dart +++ b/lib/core/erd/erd_catalog.dart @@ -26,7 +26,17 @@ class ErdCatalog { /// Columns of [schema] (PostgreSQL) or of the connected database. [schema] /// `null` means the current schema. - static String columnsSql(SqlDialect d, {String? schema}) => switch (d) { + /// + /// [tables] limits it to those bare table names (of [schema]): a diagram + /// around one table or an MCP lookup of one table then reads those tables + /// and not the whole schema, which also kept the row limit from cutting a + /// large schema before the tables that were wanted (#1572). + static String columnsSql( + SqlDialect d, { + String? schema, + Iterable? tables, + }) => + switch (d) { // pg_catalog is readable by every role, unlike information_schema, // which hides keys of tables the current role has only SELECT on. SqlDialect.postgres => ''' @@ -61,7 +71,7 @@ LEFT JOIN pg_catalog.pg_constraint pk LEFT JOIN pg_catalog.pg_attrdef df ON df.adrelid = c.oid AND df.adnum = a.attnum WHERE ns.nspname = ${schema == null ? 'current_schema()' : "'${_lit(schema)}'"} - AND c.relkind IN ('r', 'p') + AND c.relkind IN ('r', 'p')${_nameFilter('c.relname', tables)} AND a.attnum > 0 AND NOT a.attisdropped ORDER BY table_name, a.attnum''', SqlDialect.mysql => ''' @@ -77,7 +87,7 @@ FROM information_schema.columns c JOIN information_schema.tables t ON t.table_schema = c.table_schema AND t.table_name = c.table_name AND t.table_type = 'BASE TABLE' -WHERE c.table_schema = DATABASE() +WHERE c.table_schema = DATABASE()${_nameFilter('c.table_name', tables)} ORDER BY c.table_name, c.ordinal_position''', SqlDialect.sqlite => ''' SELECT m.name AS table_name, p.name AS column_name, p.type AS data_type, @@ -96,14 +106,16 @@ SELECT m.name AS table_name, p.name AS column_name, p.type AS data_type, NULL AS column_comment, NULL AS table_comment FROM sqlite_master m JOIN pragma_table_info(m.name) p -WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' +WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'${_nameFilter('m.name', tables)} ORDER BY m.name, p.cid''', }; /// Columns of the views (and PostgreSQL materialized views) of the current /// schema or database, in the shape of [columnsSql]: no primary key, every - /// column nullable. MCP lists them next to the tables. - static String viewColumnsSql(SqlDialect d) => switch (d) { + /// column nullable. MCP lists them next to the tables. [tables] limits it + /// to those names, as in [columnsSql]. + static String viewColumnsSql(SqlDialect d, {Iterable? tables}) => + switch (d) { SqlDialect.postgres => ''' SELECT ${_pgQualified('c', 'ns')} AS table_name, a.attname AS column_name, format_type(a.atttypid, a.atttypmod) AS data_type, @@ -112,7 +124,7 @@ FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid = a.attrelid JOIN pg_catalog.pg_namespace ns ON ns.oid = c.relnamespace WHERE ns.nspname = current_schema() - AND c.relkind IN ('v', 'm') + AND c.relkind IN ('v', 'm')${_nameFilter('c.relname', tables)} AND a.attnum > 0 AND NOT a.attisdropped ORDER BY table_name, a.attnum''', SqlDialect.mysql => ''' @@ -121,13 +133,13 @@ FROM information_schema.columns c JOIN information_schema.tables t ON t.table_schema = c.table_schema AND t.table_name = c.table_name AND t.table_type = 'VIEW' -WHERE c.table_schema = DATABASE() +WHERE c.table_schema = DATABASE()${_nameFilter('c.table_name', tables)} ORDER BY c.table_name, c.ordinal_position''', SqlDialect.sqlite => ''' SELECT m.name AS table_name, p.name AS column_name, p.type AS data_type, 0 AS is_pk, 1 AS is_nullable FROM sqlite_master m JOIN pragma_table_info(m.name) p -WHERE m.type = 'view' AND m.name NOT LIKE 'sqlite_%' +WHERE m.type = 'view' AND m.name NOT LIKE 'sqlite_%'${_nameFilter('m.name', tables)} ORDER BY m.name, p.cid''', }; @@ -165,8 +177,37 @@ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' ''', }; + /// Names of the tables and views of the current schema or database, and + /// nothing else: what a lookup by name needs, without reading columns. + static String tableNamesSql(SqlDialect d) => switch (d) { + SqlDialect.postgres => ''' +SELECT c.relname AS object_name +FROM pg_catalog.pg_class c +JOIN pg_catalog.pg_namespace ns ON ns.oid = c.relnamespace +WHERE ns.nspname = current_schema() AND c.relkind IN ('r', 'p', 'v', 'm') +ORDER BY 1''', + SqlDialect.mysql => ''' +SELECT table_name AS object_name +FROM information_schema.tables +WHERE table_schema = DATABASE() AND table_type IN ('BASE TABLE', 'VIEW') +ORDER BY 1''', + SqlDialect.sqlite => ''' +SELECT name AS object_name +FROM sqlite_master +WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' +ORDER BY 1''', + }; + static String _lit(String s) => s.replaceAll("'", "''"); + /// `AND [column] IN ('a', 'b')` for [names], or nothing when [names] is + /// null; an empty list matches no row. + static String _nameFilter(String column, Iterable? names) { + if (names == null) return ''; + if (names.isEmpty) return '\n AND 1 = 0'; + return "\n AND $column IN (${names.map((n) => "'${_lit(n)}'").join(', ')})"; + } + /// Schema part of a diagram name: `sales` for `sales.orders`, `null` for a /// table of the current schema. static String? schemaOf(String name) { @@ -217,11 +258,18 @@ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' } var names = neighbourhood(fks, focus, depth); + // Only the columns of these tables, grouped by PostgreSQL schema (#1572). + // `schema.table` asks its schema for `table`, which also brings the bare + // name the fallback below looks for when that schema is the current one. + final wanted = >{}; + for (final n in names) { + final s = dialect == SqlDialect.postgres ? schemaOf(n) : null; + (wanted[s] ??= {}).add(s == null ? n : n.substring(s.length + 1)); + } final fetched = >[]; - final schemas = {for (final n in names) schemaOf(n)}; - for (final s in schemas) { + for (final MapEntry(key: s, value: tables) in wanted.entries) { final rows = await delegate.executeQuery( - columnsSql(dialect, schema: dialect == SqlDialect.postgres ? s : null), + columnsSql(dialect, schema: s, tables: tables), limit: catalogRowLimit, ); truncated = truncated || rows.isTruncated; diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index a0378953..b2946c1c 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -19,6 +19,7 @@ import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// Owns [PluginRpcBridge] sessions for extension-backed connections. class ExtensionDriverSession { @@ -111,7 +112,9 @@ class ExtensionDriverSession { } catch (e) { try { await bridge.shutdown(); - } catch (_) {} + } catch (e) { + logIgnored('extension_driver_session', e); + } rethrow; } @@ -142,12 +145,16 @@ class ExtensionDriverSession { } try { await bridge.sendRequest('db.disconnect', {'connectionId': tempId}); - } catch (_) {} + } catch (e) { + logIgnored('extension_driver_session', e); + } return version; } finally { try { await bridge.shutdown(); - } catch (_) {} + } catch (e) { + logIgnored('extension_driver_session', e); + } } } diff --git a/lib/core/extensions/extension_paths.dart b/lib/core/extensions/extension_paths.dart index a06ba1c4..b863620b 100644 --- a/lib/core/extensions/extension_paths.dart +++ b/lib/core/extensions/extension_paths.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/storage/app_data_root.dart'; /// Centralizes extension file locations. @@ -30,6 +31,17 @@ abstract final class ExtensionPaths { return Directory(p.join(home, '.querya', _extensionsSegment)); } + /// `/`. Throws [ArgumentError] when [id] is not a single valid + /// folder name inside [root]: callers delete this folder recursively, so a + /// manifest id like `../x` must never reach them (#1560). + static Directory extensionDirectory(Directory root, String id) { + final dir = Directory(p.join(root.path, id)); + if (!ExtensionManifest.isValidId(id) || !p.isWithin(root.path, dir.path)) { + throw ArgumentError.value(id, 'id', 'not a valid extension id'); + } + return dir; + } + /// Creates the extensions directory if it doesn't exist. static Future ensureExtensionsDirectory() async { final dir = await extensionsDirectory(); diff --git a/lib/core/extensions/extension_support.dart b/lib/core/extensions/extension_support.dart index cd8336c3..7d5db540 100644 --- a/lib/core/extensions/extension_support.dart +++ b/lib/core/extensions/extension_support.dart @@ -25,6 +25,18 @@ class ExtensionSupport { static const databaseDriverMissingEntryMessage = 'Driver package is missing its main entry file. Installation aborted.'; + /// Why this build cannot read [manifest], or null when it can: the manifest + /// is in a newer format than [ExtensionManifest.currentManifestVersion]. + static String? manifestFormatProblem(ExtensionManifest manifest) { + if (manifest.manifestVersion <= ExtensionManifest.currentManifestVersion) { + return null; + } + return 'Extension "${manifest.id}" uses manifest format ' + '${manifest.manifestVersion}; this Querya Desktop reads format ' + '${ExtensionManifest.currentManifestVersion} or older. ' + 'Update Querya Desktop to use it.'; + } + /// Type-level preview heuristic (drivers default to preview). /// Prefer [isPreviewOnlyManifest] when a full manifest is available. static bool isPreviewOnly(ExtensionType type) => diff --git a/lib/core/extensions/harness/extension_harness.dart b/lib/core/extensions/harness/extension_harness.dart index a6b0e4ea..03bafca4 100644 --- a/lib/core/extensions/harness/extension_harness.dart +++ b/lib/core/extensions/harness/extension_harness.dart @@ -9,6 +9,7 @@ import 'package:querya_desktop/core/extensions/harness/extension_protocol_profil import 'package:querya_desktop/core/extensions/rpc/json_rpc_stdio_client.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// How to start the plugin process. final class HarnessLaunchRequest { @@ -133,7 +134,9 @@ final class _OsPluginProcess implements HarnessPluginProcess { _process.kill(ProcessSignal.sigkill); try { await _process.exitCode.timeout(const Duration(seconds: 2)); - } catch (_) {} + } catch (e) { + logIgnored('extension_harness', e); + } } @override @@ -153,7 +156,8 @@ final class HarnessConnectionOptions { this.database, }); - /// Extra `db.connect` fields (`host`, `port`, `username`, `password`, ...). + /// Extra `db.connect` fields (`host`, `port`, `username`, ...). A `password` + /// is sent with `system.injectCredentials` instead, as the app does. final Map params; /// Statement for the `db.query` step. @@ -168,6 +172,7 @@ final class HarnessConnectionOptions { /// Querya Desktop does, without a GUI. /// /// Sequence: manifest check, launch, `system.handshake`, `system.ping`, +/// `system.injectCredentials` (only when the connection has a password), /// `db.connect`, `db.getCapabilities`, `db.getServerStats`, /// `db.getSchemaTree`, `db.query`, `db.getTableSchema` (when a table is /// given), `db.disconnect`, `system.shutdown`. Methods the emulated host @@ -185,15 +190,19 @@ class QueryaExtensionHarness { static const _connectionId = 1; + /// With [manifestOnly] the run ends after the `manifest` check: the + /// extension is not launched, so no database and no executable are needed. Future run({ required String extensionRoot, required String targetVersion, HarnessConnectionOptions connection = const HarnessConnectionOptions(), bool sandbox = false, + bool manifestOnly = false, }) async { final total = Stopwatch()..start(); final profile = ExtensionProtocolProfile.forTarget(targetVersion); final checks = []; + final rpc = []; var extensionId = p.basename(extensionRoot); var abort = false; @@ -283,6 +292,17 @@ class QueryaExtensionHarness { } } + // Only the manifest was asked for: nothing is started or connected. + if (manifestOnly) { + total.stop(); + return HarnessReport( + extensionId: extensionId, + targetVersion: profile.targetVersion, + checks: checks, + totalDuration: total.elapsed, + ); + } + // 2. Launch. HarnessPluginProcess? process; JsonRpcStdioClient? client; @@ -328,15 +348,35 @@ class QueryaExtensionHarness { Future call(String method, [Map? params]) async { final c = client; if (c == null) throw StateError('plugin is not running'); + final started = Stopwatch()..start(); + final requestBytes = params == null ? 0 : utf8.encode(jsonEncode(params)).length; + void record({Object? result, String? error}) { + rpc.add(HarnessRpcCall( + method: method, + duration: started.elapsed, + requestBytes: requestBytes, + ok: error == null, + responseBytes: + error == null ? utf8.encode(jsonEncode(result)).length : null, + error: error, + request: params == null ? null : redactRpcValue(params), + response: error == null ? boundRpcValue(result) : null, + )); + } + try { - return await c.sendRequest(method, params); + final result = await c.sendRequest(method, params); + record(result: result); + return result; } on JsonRpcException catch (e) { + record(error: '${e.message}${e.code == null ? '' : ' (code ${e.code})'}'); if (e.code == -32601) { throw StateError('method "$method" is not implemented (-32601)'); } throw StateError('"$method" returned an error: ${e.message}' '${e.code == null ? '' : ' (code ${e.code})'}'); } on TimeoutException { + record(error: 'no answer within $requestTimeout'); throw StateError('"$method" did not answer within $requestTimeout' '${_crashHint(exitCode, stderrTail)}'); } on StateError catch (e) { @@ -360,10 +400,15 @@ class QueryaExtensionHarness { await step(name, body, level: level); } + // Like the app: the password goes to the plugin in memory with + // system.injectCredentials and is not part of the db.connect payload. + final password = connection.params['password']; + final injectPassword = password is String && password.isNotEmpty; final connectParams = { 'connectionId': _connectionId, if (connection.database != null) 'database': connection.database, - ...connection.params, + for (final e in connection.params.entries) + if (e.key != 'password') e.key: e.value, }; final idOnly = {'connectionId': _connectionId}; @@ -373,6 +418,14 @@ class QueryaExtensionHarness { await method('system.ping', () async { await call('system.ping'); }); + if (injectPassword) { + await method('system.injectCredentials', () async { + await call('system.injectCredentials', { + 'connectionId': _connectionId, + 'password': password, + }); + }); + } await method('db.connect', () async { await call('db.connect', connectParams); }); @@ -451,6 +504,7 @@ class QueryaExtensionHarness { checks: checks, totalDuration: total.elapsed, peakRssKb: peakRss > 0 ? peakRss : null, + rpc: rpc, ); } } @@ -486,6 +540,8 @@ Future _rssKb(int pid) async { final r = await Process.run('ps', ['-o', 'rss=', '-p', '$pid']); return r.exitCode == 0 ? int.tryParse('${r.stdout}'.trim()) : null; } - } catch (_) {} + } catch (e) { + logIgnored('extension_harness', e); + } return null; } diff --git a/lib/core/extensions/harness/extension_harness_report.dart b/lib/core/extensions/harness/extension_harness_report.dart index 5005b2ee..224d18d7 100644 --- a/lib/core/extensions/harness/extension_harness_report.dart +++ b/lib/core/extensions/harness/extension_harness_report.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:querya_desktop/core/extensions/harness/extension_protocol_profile.dart'; enum HarnessCheckStatus { passed, warning, failed, skipped } @@ -19,6 +21,103 @@ final class HarnessCheck { final String? message; } +/// One JSON-RPC round trip as the harness saw it: what was sent, how long the +/// plugin took, and a bounded, redacted view of the answer (the trace of +/// `--trace`). +final class HarnessRpcCall { + const HarnessRpcCall({ + required this.method, + required this.duration, + required this.requestBytes, + required this.ok, + this.responseBytes, + this.error, + this.request, + this.response, + }); + + final String method; + final Duration duration; + final int requestBytes; + final bool ok; + final int? responseBytes; + final String? error; + + /// The request params with secrets replaced by `***`. + final Object? request; + + /// The result, cut to a few rows and short strings. + final Object? response; + + Map toJson() => { + 'method': method, + 'ms': duration.inMilliseconds, + 'ok': ok, + 'requestBytes': requestBytes, + if (responseBytes != null) 'responseBytes': responseBytes, + if (error != null) 'error': error, + if (request != null) 'request': request, + if (response != null) 'response': response, + }; +} + +/// Replaces the values of keys that look like secrets with `***`. +Object? redactRpcValue(Object? value) { + if (value is Map) { + return { + for (final e in value.entries) + '${e.key}': _isSecretKey('${e.key}') ? '***' : redactRpcValue(e.value), + }; + } + if (value is List) return [for (final v in value) redactRpcValue(v)]; + return value; +} + +bool _isSecretKey(String key) { + final k = key.toLowerCase(); + return k.contains('password') || + k.contains('passwd') || + k.contains('secret') || + k.contains('token') || + k.contains('passphrase') || + k == 'pwd' || + k == 'apikey' || + k == 'api_key' || + k == 'privatekey' || + k == 'private_key'; +} + +/// Cuts [value] for a trace: at most [maxItems] list items, strings of at most +/// [maxString] characters, nesting of at most 5 levels. +Object? boundRpcValue( + Object? value, { + int maxItems = 5, + int maxString = 120, + int depth = 0, +}) { + if (value is String) { + return value.length <= maxString + ? value + : '${value.substring(0, maxString)}… (${value.length} chars)'; + } + if (depth >= 5) return value is Map || value is List ? '…' : value; + if (value is List) { + return [ + for (final v in value.take(maxItems)) + boundRpcValue(v, maxItems: maxItems, maxString: maxString, depth: depth + 1), + if (value.length > maxItems) '… (+${value.length - maxItems} more)', + ]; + } + if (value is Map) { + return { + for (final e in value.entries) + '${e.key}': boundRpcValue(e.value, + maxItems: maxItems, maxString: maxString, depth: depth + 1), + }; + } + return value; +} + /// Results of one harness run, renderable as console text, JUnit XML or TAP. final class HarnessReport { HarnessReport({ @@ -27,8 +126,12 @@ final class HarnessReport { required this.checks, required this.totalDuration, this.peakRssKb, + this.rpc = const [], }); + /// Every RPC round trip of the run, in order. + final List rpc; + final String extensionId; final HostVersion targetVersion; final List checks; @@ -48,6 +151,11 @@ final class HarnessReport { /// `true` when nothing failed (warnings and skips do not fail a run). bool get isSuccess => failedCount == 0; + /// Like [isSuccess], and with [strict] a warning fails the run as well + /// (a recommended method that errors, a sandbox that could not be applied). + bool passes({bool strict = false}) => + isSuccess && (!strict || warningCount == 0); + /// Human-readable summary, one line per check plus indented details. String toConsole({bool color = false}) { String paint(String code, String text) => @@ -77,6 +185,28 @@ final class HarnessReport { return b.toString(); } + /// The checks and every RPC round trip as JSON (the `--trace` file): what + /// was asked, how long it took and a bounded view of the data that came + /// back. Secrets in the requests are redacted. + String toTraceJson() => const JsonEncoder.withIndent(' ').convert({ + 'extension': extensionId, + 'targetVersion': '$targetVersion', + 'success': isSuccess, + 'totalMs': totalDuration.inMilliseconds, + if (peakRssKb != null) 'pluginPeakRssKb': peakRssKb, + 'checks': [ + for (final c in checks) + { + 'name': c.name, + 'status': c.status.name, + 'ms': c.duration.inMilliseconds, + if (c.message != null && c.message!.isNotEmpty) + 'message': c.message, + }, + ], + 'rpc': [for (final r in rpc) r.toJson()], + }); + /// JUnit XML for GitHub Actions / GitLab CI test reporters. String toJUnitXml() { final b = StringBuffer() diff --git a/lib/core/extensions/harness/extension_manifest_validator.dart b/lib/core/extensions/harness/extension_manifest_validator.dart index 1d8378f8..541475d4 100644 --- a/lib/core/extensions/harness/extension_manifest_validator.dart +++ b/lib/core/extensions/harness/extension_manifest_validator.dart @@ -51,6 +51,20 @@ List validateExtensionManifest( requireString('id'); requireString('name'); + final format = manifest['manifestVersion']; + if (format != null) { + if (format is! int || format < 1) { + error('manifestVersion', 'must be a whole number, 1 or more, got "$format"'); + } else if (format > profile.manifestVersion) { + error( + 'manifestVersion', + 'format $format is newer than Querya Desktop ${profile.targetVersion} ' + 'reads (formats up to ${profile.manifestVersion}); an older host ' + 'would not understand this manifest', + ); + } + } + final version = manifest['version']; if (version == null) { warning('version', 'missing; the host falls back to 0.0.0'); @@ -82,10 +96,11 @@ List validateExtensionManifest( if (range is! String) { warning('engines.querya_desktop', 'no Querya Desktop constraint declared'); } else { - final ok = _satisfies(profile.targetVersion, range); + final ok = engineRangeAllows(profile.targetVersion, range); if (ok == null) { warning('engines.querya_desktop', - 'unsupported range "$range"; use ^x.y.z or >=x.y.z'); + 'unsupported range "$range"; use ^x.y.z, or comparators such as ' + '">=x.y.z =, >, <=, <, =, all must hold)'); } else if (!ok) { warning( 'engines.querya_desktop', @@ -145,16 +160,38 @@ List validateExtensionManifest( final _semver = RegExp(r'^\d+\.\d+\.\d+(?:[+-][0-9A-Za-z.+-]+)?$'); -/// `true` / `false` when [range] (`^x.y.z` or `>=x.y.z`) can be evaluated, -/// `null` when its syntax is not understood. -bool? _satisfies(HostVersion host, String range) { - final r = range.trim(); +/// `true` / `false` when [range] can be evaluated, `null` when its syntax is +/// not understood (the caller then shows the extension and warns, it does not +/// guess). +/// +/// A range is one or more space-separated comparators that must all hold, such +/// as `>=0.4.0 <3.0.0`: `>=x.y.z`, `>x.y.z`, `<=x.y.z`, `= 0.4.0` reads the same as `>=0.4.0`: glue an operator to its version. + final parts = range + .trim() + .replaceAllMapped(RegExp(r'(>=|<=|>|<|=|\^)\s+'), (m) => m[1]!) + .split(RegExp(r'\s+')); + if (parts.length == 1 && parts.single == '*') return true; + var allowed = true; + for (final part in parts) { + final ok = _comparatorAllows(host, part); + if (ok == null) return null; + if (!ok) allowed = false; + } + return allowed; +} + +bool? _comparatorAllows(HostVersion host, String c) { try { - if (r.startsWith('>=')) { - return host >= HostVersion.parse(r.substring(2)); - } - if (r.startsWith('^')) { - final base = HostVersion.parse(r.substring(1)); + if (c.startsWith('>=')) return host >= HostVersion.parse(c.substring(2)); + if (c.startsWith('<=')) return host <= HostVersion.parse(c.substring(2)); + if (c.startsWith('>')) return host > HostVersion.parse(c.substring(1)); + if (c.startsWith('<')) return host < HostVersion.parse(c.substring(1)); + if (c.startsWith('=')) return host == HostVersion.parse(c.substring(1)); + if (c.startsWith('^')) { + final base = HostVersion.parse(c.substring(1)); if (host < base) return false; // Caret: same major, or same minor while major is 0. return base.major == 0 diff --git a/lib/core/extensions/harness/extension_protocol_profile.dart b/lib/core/extensions/harness/extension_protocol_profile.dart index b4290fa1..b03010ee 100644 --- a/lib/core/extensions/harness/extension_protocol_profile.dart +++ b/lib/core/extensions/harness/extension_protocol_profile.dart @@ -29,8 +29,10 @@ final class HostVersion implements Comparable { return patch.compareTo(other.patch); } + bool operator >(HostVersion other) => compareTo(other) > 0; bool operator >=(HostVersion other) => compareTo(other) >= 0; bool operator <(HostVersion other) => compareTo(other) < 0; + bool operator <=(HostVersion other) => compareTo(other) <= 0; @override bool operator ==(Object other) => @@ -68,7 +70,11 @@ final class HarnessProtocolMethod { /// `since` values come from the first release tag whose sources contain the /// method. The protocol did not exist before 0.4.11. final class ExtensionProtocolProfile { - ExtensionProtocolProfile._(this.targetVersion, this.methods); + ExtensionProtocolProfile._( + this.targetVersion, + this.methods, + this.manifestVersion, + ); /// Oldest host version with the extension RPC protocol. static const minSupported = HostVersion(0, 4, 11); @@ -80,6 +86,8 @@ final class ExtensionProtocolProfile { 'system.shutdown', minSupported, HarnessMethodLevel.required), HarnessProtocolMethod( 'system.ping', minSupported, HarnessMethodLevel.recommended), + HarnessProtocolMethod( + 'system.injectCredentials', minSupported, HarnessMethodLevel.recommended), HarnessProtocolMethod( 'db.connect', minSupported, HarnessMethodLevel.required), HarnessProtocolMethod( @@ -98,9 +106,20 @@ final class ExtensionProtocolProfile { 'commands.execute', HostVersion(0, 4, 16), HarnessMethodLevel.recommended), ]; + /// `manifest.json` formats and the first Querya Desktop release that reads + /// each. Format 1 is the original; a manifest without `manifestVersion` is + /// format 1. Add an entry here when a release starts reading a new format, and + /// raise `ExtensionManifest.currentManifestVersion` in the same change. + static const _manifestFormats = <(int, HostVersion)>[ + (1, minSupported), + ]; + /// Host version this profile emulates. final HostVersion targetVersion; + /// Newest `manifestVersion` that host reads. + final int manifestVersion; + /// Methods that host version may send. final List methods; @@ -115,10 +134,17 @@ final class ExtensionProtocolProfile { 'Extensions are not supported before Querya Desktop $minSupported', ); } - return ExtensionProtocolProfile._(version, [ - for (final m in _all) - if (version >= m.since) m, - ]); + return ExtensionProtocolProfile._( + version, + [ + for (final m in _all) + if (version >= m.since) m, + ], + [ + for (final (format, since) in _manifestFormats) + if (version >= since) format, + ].fold(1, (a, b) => b > a ? b : a), + ); } bool supports(String method) => methods.any((m) => m.name == method); diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index e8b0f3f4..2a4df220 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; @@ -13,6 +14,7 @@ import 'package:querya_desktop/core/market/marketplace_repository.dart'; import 'package:querya_desktop/core/security/archive_path_guard.dart'; import 'package:querya_desktop/core/security/safe_zip_extractor.dart'; import 'package:querya_desktop/core/updater/sha256_checksums.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// Installs an extension package from a local `.zip` / `.qext` archive (issue #316). /// @@ -105,6 +107,9 @@ class LocalExtensionInstaller { throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); } + final formatProblem = ExtensionSupport.manifestFormatProblem(manifest); + if (formatProblem != null) throw MarketplaceException(formatProblem); + final sandboxViolations = SandboxPolicy.validate(manifest); if (sandboxViolations.isNotEmpty) { throw MarketplaceException( @@ -115,58 +120,110 @@ class LocalExtensionInstaller { onProgress?.call(0.4); + final extDir = await installArchive( + archive: archive, + manifest: manifest, + onProgress: onProgress, + ); + return ExtensionManifest.fromJson( + { + ...manifest.toJson(), + // installPath is not part of toJson; reload will set it. + }, + installPath: extDir.path, + ); + } + + /// Extracts [archive] into a hidden sibling of `extensions//`, validates + /// it there and only then swaps it in, so a failed install or upgrade leaves + /// the previous version untouched (#1562). Shared by local and marketplace + /// installs; [manifest] is what gets written as `manifest.json`. + Future installArchive({ + required Archive archive, + required ExtensionManifest manifest, + void Function(double progress)? onProgress, + }) async { // A running driver process for this extension holds its executable/DLL - // open; on Windows that mandatory lock makes the overwrite below fail - // with Access is denied (#891). Stop every live session first. + // open; on Windows that mandatory lock makes the swap below fail with + // Access is denied (#891). Stop every live session first. await _stopSessionsForExtension(manifest.id); final root = await _extensionsDirectory(); - final extDir = Directory(p.join(root.path, manifest.id)); - if (await extDir.exists()) { - await extDir.delete(recursive: true); - } - await extDir.create(recursive: true); + final extDir = ExtensionPaths.extensionDirectory(root, manifest.id); + // Dot-prefixed: the registry skips them and an extension id cannot start + // with a dot, so they never collide with or shadow a real install. + final staging = Directory(p.join(root.path, '.${manifest.id}.installing')); + final backup = Directory(p.join(root.path, '.${manifest.id}.backup')); + var swapped = false; try { + if (await staging.exists()) await staging.delete(recursive: true); + // A backup left by a crashed swap is the last good install: put it back + // rather than letting this attempt delete it. + if (await backup.exists()) { + if (await extDir.exists()) { + await backup.delete(recursive: true); + } else { + await backup.rename(extDir.path); + } + } + await staging.create(recursive: true); + await _extractArchive( archive: archive, - destDir: extDir, - stripPrefix: stripPrefix, + destDir: staging, + stripPrefix: _commonRootPrefix(archive), ); onProgress?.call(0.85); ExtensionSupport.validateDriverPackage( manifest: manifest, - installDir: extDir, + installDir: staging, ); await ExtensionSupport.ensureDriverExecutables( manifest: manifest, - installDir: extDir, + installDir: staging, ); // Ensure canonical manifest on disk (pretty-printed, with install metadata). - final manifestFile = File(p.join(extDir.path, 'manifest.json')); + final manifestFile = File(p.join(staging.path, 'manifest.json')); const encoder = JsonEncoder.withIndent(' '); await manifestFile.writeAsString( encoder.convert(manifest.toJson()), ); + final hadPrevious = await extDir.exists(); + if (hadPrevious) await extDir.rename(backup.path); + try { + await staging.rename(extDir.path); + swapped = true; + } catch (_) { + if (hadPrevious && !await extDir.exists()) { + await backup.rename(extDir.path); + } + rethrow; + } + if (hadPrevious) { + // The new version is in place; a leftover backup is only clutter. + try { + await backup.delete(recursive: true); + } catch (e) { + logIgnored('local_extension_installer', e); + } + } + await _reloadRegistry(); onProgress?.call(1.0); - return ExtensionManifest.fromJson( - { - ...manifest.toJson(), - // installPath is not part of toJson; reload will set it. - }, - installPath: extDir.path, - ); + return extDir; } catch (e) { - // Best-effort rollback so half-installed packages do not linger. + // The previous install was never touched; drop only the half-built one. try { - if (await extDir.exists()) { - await extDir.delete(recursive: true); + if (!swapped && await staging.exists()) { + await staging.delete(recursive: true); } - } catch (_) {} + } catch (e) { + logIgnored('local_extension_installer', e); + } if (e is MarketplaceException) rethrow; throw MarketplaceException('Failed to install extension: $e'); } @@ -235,6 +292,14 @@ class LocalExtensionInstaller { for (final file in archive) { var filename = file.name.replaceAll('\\', '/'); if (filename.startsWith('__MACOSX/')) continue; + // Judge the name as the archive wrote it: a shared `../` root would + // otherwise be stripped away together with the prefix. + if (!isArchiveEntryNameSafe(filename)) { + throw MarketplaceException( + 'Security violation: Path traversal detected in archive entry ' + '"${file.name}"', + ); + } if (stripPrefix.isNotEmpty && filename.startsWith(stripPrefix)) { filename = filename.substring(stripPrefix.length); } @@ -257,7 +322,13 @@ class LocalExtensionInstaller { if (file.isFile) { final outFile = File(targetPath); await outFile.parent.create(recursive: true); - await outFile.writeAsBytes(file.content); + final Uint8List bytes; + try { + bytes = SafeZipExtractor.readEntry(file); + } on SafeZipException catch (error) { + throw MarketplaceException(error.message); + } + await outFile.writeAsBytes(bytes); file.clear(); } else { await Directory(targetPath).create(recursive: true); diff --git a/lib/core/extensions/local_extension_registry.dart b/lib/core/extensions/local_extension_registry.dart index 2bbf375a..5c73a84c 100644 --- a/lib/core/extensions/local_extension_registry.dart +++ b/lib/core/extensions/local_extension_registry.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import 'extension_command_sync.dart'; import 'extension_paths.dart'; +import 'extension_support.dart'; import 'models/extension_manifest.dart'; import 'sandbox/sandbox_policy.dart'; @@ -49,7 +50,7 @@ class LocalExtensionRegistry { // Use list() rather than listSync() to prevent blocking the UI final entities = await dir.list().toList(); for (final entity in entities) { - if (entity is Directory) { + if (entity is Directory && !p.basename(entity.path).startsWith('.')) { final manifestFile = File(p.join(entity.path, 'manifest.json')); if (await manifestFile.exists()) { try { @@ -59,6 +60,12 @@ class LocalExtensionRegistry { json, installPath: entity.path, ); + final formatProblem = + ExtensionSupport.manifestFormatProblem(manifest); + if (formatProblem != null) { + debugPrint('LocalExtensionRegistry: skipped — $formatProblem'); + continue; + } final violations = SandboxPolicy.validate(manifest); if (violations.isNotEmpty) { debugPrint( diff --git a/lib/core/extensions/models/extension_manifest.dart b/lib/core/extensions/models/extension_manifest.dart index 6e0ab098..853c0b73 100644 --- a/lib/core/extensions/models/extension_manifest.dart +++ b/lib/core/extensions/models/extension_manifest.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import '../../security/archive_path_guard.dart'; import '../../theme/theme_definition.dart'; import 'extension_contributions.dart'; import 'extension_type.dart'; @@ -10,6 +11,26 @@ import 'sandbox_capabilities.dart'; class ExtensionManifest { static const typeTheme = ExtensionType.theme; + /// The newest `manifest.json` format this build reads. A manifest without + /// `manifestVersion` is format 1. Raise it only together with + /// `ExtensionProtocolProfile` (which host version first reads the new format) + /// and docs/extension-registry.md. + static const currentManifestVersion = 1; + + static final _idPattern = RegExp(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$'); + + /// Whether [id] can name the extension's folder: letters, digits, `.`, `_` + /// and `-`, starting with a letter or digit, no `..` and no device names. + /// Installs and uninstalls delete that folder recursively (#1560). + static bool isValidId(String id) => + _idPattern.hasMatch(id) && + !id.contains('..') && + isArchiveEntryNameSafe(id); + + /// `manifestVersion` of `manifest.json`: the format of the file itself, apart + /// from the extension's own [version] and the host range in [engines]. + final int manifestVersion; + final String id; final String name; final String version; @@ -39,6 +60,7 @@ class ExtensionManifest { final ExtensionContributions? contributions; const ExtensionManifest({ + this.manifestVersion = 1, required this.id, required this.name, required this.version, @@ -110,8 +132,18 @@ class ExtensionManifest { factory ExtensionManifest.fromJson(Map json, {String? installPath}) { + final rawFormat = json['manifestVersion']; + final id = json['id']; + if (id is! String || !isValidId(id)) { + throw FormatException( + '"id" must be letters, digits, ".", "_" or "-" ' + '(starting with a letter or digit): $id', + ); + } return ExtensionManifest( - id: json['id'] as String, + // Not a whole number >= 1: the validator reports it; here it reads as 1. + manifestVersion: rawFormat is int && rawFormat >= 1 ? rawFormat : 1, + id: id, name: json['name'] as String, version: json['version'] as String? ?? '0.0.0', publisher: json['publisher'] as String? ?? @@ -180,6 +212,7 @@ class ExtensionManifest { Map toJson() { return { + if (manifestVersion != 1) 'manifestVersion': manifestVersion, 'id': id, 'name': name, 'version': version, diff --git a/lib/core/extensions/rpc/json_rpc_stdio_client.dart b/lib/core/extensions/rpc/json_rpc_stdio_client.dart index df66eaab..7710b987 100644 --- a/lib/core/extensions/rpc/json_rpc_stdio_client.dart +++ b/lib/core/extensions/rpc/json_rpc_stdio_client.dart @@ -92,8 +92,9 @@ class JsonRpcStdioClient { if (params != null) 'params': params, }; - _stdin.writeln(jsonEncode(payload)); try { + // A closed sink throws here, before the flush below. + _stdin.writeln(jsonEncode(payload)); await _stdin.flush().timeout(const Duration(seconds: 3)); } on TimeoutException { _pending.remove(id); diff --git a/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart b/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart index 11e4a074..fb5d185a 100644 --- a/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart +++ b/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart @@ -1,7 +1,16 @@ import 'dart:convert'; +import 'package:querya_desktop/core/actions/sql_script_format.dart'; +import 'package:querya_desktop/core/database/sql_lexical_scanner.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart' + show stripLeadingWhitespaceAndLineComments; import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart'; +import 'package:querya_desktop/core/theme/parser/jsonc_preprocessor.dart'; + +final _blanks = RegExp(r'[ \t]+'); +final _semicolonWithSpace = RegExp(r'\s*;\s*'); +final _firstWord = RegExp(r'\S+'); /// Pure-Dart Level-1 engine for JSON / JSONC declarative modules. /// @@ -126,41 +135,26 @@ class DeclarativeEmbeddedEngine implements EmbeddedSandboxEngine { return EmbeddedInvokeResult.failure('sql.format requires args.sql string.'); } - var out = sql.replaceAll(RegExp(r'[ \t]+'), ' ').trim(); - out = out.replaceAll(RegExp(r'\s*;\s*'), ';\n'); - final upperKeywords = module['uppercaseKeywords'] != false; - if (upperKeywords) { - const keywords = [ - 'select', - 'from', - 'where', - 'and', - 'or', - 'join', - 'left', - 'right', - 'inner', - 'outer', - 'on', - 'group', - 'by', - 'order', - 'limit', - 'insert', - 'into', - 'values', - 'update', - 'set', - 'delete', - ]; - for (final kw in keywords) { - out = out.replaceAllMapped( - RegExp('\\b$kw\\b', caseSensitive: false), - (m) => kw.toUpperCase(), - ); - } + // Blanks and `;` are laid out in code only: a literal, quoted identifier + // or comment keeps every character it was written with (#1567). + final out = StringBuffer(); + final source = sql.trim(); + for (final segment in SqlLexicalScanner.segments(source)) { + final text = source.substring(segment.start, segment.end); + out.write( + segment.kind == SqlSegmentKind.code + ? text + .replaceAll(_blanks, ' ') + .replaceAll(_semicolonWithSpace, ';\n') + : text, + ); } - return EmbeddedInvokeResult.success(out); + final laidOut = out.toString(); + return EmbeddedInvokeResult.success( + module['uppercaseKeywords'] != false + ? formatSqlScript(laidOut) + : laidOut, + ); } EmbeddedInvokeResult _hintsGenerate( @@ -216,7 +210,11 @@ class DeclarativeEmbeddedEngine implements EmbeddedSandboxEngine { return EmbeddedInvokeResult.failure('sql.parse requires args.sql string.'); } final trimmed = sql.trim(); - final first = trimmed.split(RegExp(r'\s+')).first.toUpperCase(); + final first = _firstWord + .firstMatch(stripLeadingWhitespaceAndLineComments(trimmed)) + ?.group(0) + ?.toUpperCase() ?? + ''; final dialect = module['dialect'] as String? ?? 'generic'; return EmbeddedInvokeResult.success({ 'dialect': dialect, @@ -226,11 +224,10 @@ class DeclarativeEmbeddedEngine implements EmbeddedSandboxEngine { }); } - /// Strips `//` and `/* */` comments then `jsonDecode`s an object. + /// Strips comments and trailing commas (string-aware, see [stripJsonc]), + /// then `jsonDecode`s an object. static Map _parseJsoncObject(String source) { - final withoutBlock = source.replaceAll(RegExp(r'/\*[\s\S]*?\*/'), ''); - final withoutLine = withoutBlock.replaceAll(RegExp(r'//[^\n]*'), ''); - final decoded = jsonDecode(withoutLine); + final decoded = jsonDecode(stripJsonc(source)); if (decoded is! Map) { throw const FormatException('Module root must be a JSON object.'); } diff --git a/lib/core/extensions/sandbox/sandbox_launch_command.dart b/lib/core/extensions/sandbox/sandbox_launch_command.dart index 97482cf5..a191ec89 100644 --- a/lib/core/extensions/sandbox/sandbox_launch_command.dart +++ b/lib/core/extensions/sandbox/sandbox_launch_command.dart @@ -140,8 +140,8 @@ class SandboxLaunchCommand { String? extensionRoot, }) { final profile = buildMacOsSeatbeltProfile( - scratchPath: scratchPath, - extensionRoot: extensionRoot, + scratchPath: realPathOrSelf(scratchPath), + extensionRoot: extensionRoot == null ? null : realPathOrSelf(extensionRoot), ); return SandboxLaunchCommand( executable: 'sandbox-exec', @@ -156,6 +156,26 @@ class SandboxLaunchCommand { } } +/// [path] with its symbolic links resolved, or [path] itself when it does not +/// exist or cannot be resolved. +/// +/// Seatbelt matches `subpath` against the real path. The macOS temporary +/// folder is under `/var`, a link to `/private/var`, so a profile that names +/// `/var/folders/...` never matched and the extension could not write its +/// scratch directory (#1590). +String realPathOrSelf(String path) { + try { + return Directory(path).resolveSymbolicLinksSync(); + } on FileSystemException { + return path; + } +} + +/// [value] as a Seatbelt (SBPL) string literal: a quote or a backslash in a +/// path must not end the string and change the rule (#1590). +String seatbeltString(String value) => + '"${value.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}"'; + /// Seatbelt (sandbox-exec) profile allowing network + scratch RW only, /// explicitly denying access to host user credentials and home directories (/Users). String buildMacOsSeatbeltProfile({ @@ -171,10 +191,12 @@ String buildMacOsSeatbeltProfile({ ..writeln('(allow network*)') ..writeln('(allow file-read*)') ..writeln('(deny file-read* (subpath "/Users"))') - ..writeln('(allow file-write* (subpath "$scratchPath"))') - ..writeln('(allow file-read* (subpath "$scratchPath"))'); + ..writeln('(allow file-write* (subpath ${seatbeltString(scratchPath)}))') + ..writeln('(allow file-read* (subpath ${seatbeltString(scratchPath)}))'); if (extensionRoot != null && extensionRoot.isNotEmpty) { - buffer.writeln('(allow file-read* (subpath "$extensionRoot"))'); + buffer.writeln( + '(allow file-read* (subpath ${seatbeltString(extensionRoot)}))', + ); } return buffer.toString().trimRight(); } diff --git a/lib/core/extensions/sandbox/sandbox_process_runner.dart b/lib/core/extensions/sandbox/sandbox_process_runner.dart index e793eac1..07a2f405 100644 --- a/lib/core/extensions/sandbox/sandbox_process_runner.dart +++ b/lib/core/extensions/sandbox/sandbox_process_runner.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.da import 'package:querya_desktop/core/extensions/sandbox/sandbox_os_isolation.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_secret_guard.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// Live handle for a sandboxed OS process (Block E Level 2). class SandboxProcessHandle { @@ -57,8 +58,12 @@ class SandboxProcessHandle { process.kill(ProcessSignal.sigkill); try { await process.exitCode.timeout(const Duration(seconds: 1)); - } catch (_) {} - } catch (_) {} + } catch (e) { + logIgnored('sandbox_process_runner', e); + } + } catch (e) { + logIgnored('sandbox_process_runner', e); + } } catch (e) { debugPrint('SandboxProcessHandle.dispose($pluginId) kill: $e'); } @@ -125,24 +130,31 @@ class SandboxProcessRunner { baseDirectory: scratchBaseDirectory, ); - final detected = bwrapAvailable ?? await detectBwrapAvailability(); - final usesBwrap = detected; - if (bwrapAvailable == null && !usesBwrap) { - debugPrint( - 'SandboxProcessRunner: bubblewrap unavailable or cannot set up user ' - 'namespaces on this system; $pluginId requires consent to launch ' - 'without OS sandbox.', + final SandboxLaunchCommand command; + try { + final detected = bwrapAvailable ?? await detectBwrapAvailability(); + final usesBwrap = detected; + if (bwrapAvailable == null && !usesBwrap) { + debugPrint( + 'SandboxProcessRunner: bubblewrap unavailable or cannot set up user ' + 'namespaces on this system; $pluginId requires consent to launch ' + 'without OS sandbox.', + ); + } + command = SandboxLaunchCommand.build( + pluginExecutable: pluginExecutable, + pluginArguments: pluginArguments, + scratchPath: scratch.path, + extensionRoot: extensionRoot, + capabilities: capabilities, + platformOverride: platformOverride, + bwrapAvailable: usesBwrap, ); + } catch (_) { + // The scratch directory exists before the command is built. + await scratch.delete(); + rethrow; } - final command = SandboxLaunchCommand.build( - pluginExecutable: pluginExecutable, - pluginArguments: pluginArguments, - scratchPath: scratch.path, - extensionRoot: extensionRoot, - capabilities: capabilities, - platformOverride: platformOverride, - bwrapAvailable: usesBwrap, - ); final isolationIssue = SandboxOsIsolation.exceptionForLaunchCommand(command); diff --git a/lib/core/layout/querya_split_handle.dart b/lib/core/layout/querya_split_handle.dart index 0e7be5c0..59ec2c38 100644 --- a/lib/core/layout/querya_split_handle.dart +++ b/lib/core/layout/querya_split_handle.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; /// Accessible split-pane handle that supports mouse dragging and arrow keys. /// @@ -77,7 +77,7 @@ class _QueryaSplitHandleState extends material.State { @override material.Widget build(material.BuildContext context) { - final colors = Theme.of(context).colorScheme; + final colors = context.colors; final horizontal = widget.axis == material.Axis.horizontal; final duration = context.motionDuration(QueryaMotion.fast); final curve = context.motionCurve(QueryaMotion.enter); diff --git a/lib/core/layout/vertical_split_pane.dart b/lib/core/layout/vertical_split_pane.dart index fc969790..5b5f6cdc 100644 --- a/lib/core/layout/vertical_split_pane.dart +++ b/lib/core/layout/vertical_split_pane.dart @@ -1,8 +1,8 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/querya_drag_settle.dart'; import 'package:querya_desktop/core/layout/querya_split_handle.dart'; import 'package:querya_desktop/core/motion/querya_spring.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; /// Counts full rebuilds of [VerticalSplitPane]'s body (cache misses) — should /// only fire when the pane's height or its top/bottom/fraction/etc. inputs diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 7ae19fb5..b5074e21 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -10,10 +10,10 @@ import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; +import 'package:querya_desktop/core/extensions/local_extension_installer.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; -import 'package:querya_desktop/core/security/archive_path_guard.dart'; import 'package:querya_desktop/core/security/safe_zip_extractor.dart'; import 'package:querya_desktop/core/updater/sha256_checksums.dart'; import 'marketplace_download_policy.dart'; @@ -146,6 +146,9 @@ class HttpMarketplaceRepository implements MarketplaceRepository { throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); } + final formatProblem = ExtensionSupport.manifestFormatProblem(manifest); + if (formatProblem != null) throw MarketplaceException(formatProblem); + final sandboxViolations = SandboxPolicy.validate(manifest); if (sandboxViolations.isNotEmpty) { throw MarketplaceException( @@ -199,56 +202,14 @@ class HttpMarketplaceRepository implements MarketplaceRepository { throw MarketplaceException(error.message); } - final dir = await ExtensionPaths.extensionsDirectory(); - final extDir = Directory(p.join(dir.path, manifest.id)); - if (!await extDir.exists()) { - await extDir.create(recursive: true); - } - - final extDirPath = p.normalize(extDir.path); - - for (final file in archive) { - final filename = file.name; - // Check for Path Traversal attempts - if (!isArchiveEntryNameSafe(filename)) { - throw MarketplaceException('Security violation: Path traversal detected in archive entry "$filename"'); - } - - final targetPath = p.normalize(p.join(extDirPath, filename)); - if (!isArchiveExtractPathWithinRoot(extDirPath, targetPath)) { - throw MarketplaceException('Security violation: Extraction path out of bounds "$filename"'); - } - - if (file.isFile) { - final outFile = File(targetPath); - await outFile.create(recursive: true); - final bytes = file.content; - await outFile.writeAsBytes(bytes); - file.clear(); - } else { - await Directory(targetPath).create(recursive: true); - } - } - - onProgress?.call(0.95); - - ExtensionSupport.validateDriverPackage( + // Steps 4-5: extract aside, validate, swap in, write manifest.json and + // reload the registry. Same path as local installs, so a failed upgrade + // keeps the working version and removed files do not linger (#1562). + await LocalExtensionInstaller().installArchive( + archive: archive, manifest: manifest, - installDir: extDir, + onProgress: (value) => onProgress?.call(0.85 + value * 0.15), ); - await ExtensionSupport.ensureDriverExecutables( - manifest: manifest, - installDir: extDir, - ); - - // Step 4: Write/Update manifest.json in the extension directory - final manifestFile = File(p.join(extDir.path, 'manifest.json')); - const encoder = JsonEncoder.withIndent(' '); - await manifestFile.writeAsString(encoder.convert(manifest.toJson())); - - // Step 5: Reload local extension registry - await LocalExtensionRegistry.instance.reload(); - onProgress?.call(1.0); } finally { if (await archiveFile.exists()) { await archiveFile.delete(); @@ -274,7 +235,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository { } } else { final dir = await ExtensionPaths.extensionsDirectory(); - final extDir = Directory(p.join(dir.path, extensionId)); + final extDir = ExtensionPaths.extensionDirectory(dir, extensionId); if (await extDir.exists()) { await extDir.delete(recursive: true); } diff --git a/lib/core/market/marketplace_repository.dart b/lib/core/market/marketplace_repository.dart index fc4de3e8..41b9287d 100644 --- a/lib/core/market/marketplace_repository.dart +++ b/lib/core/market/marketplace_repository.dart @@ -190,6 +190,9 @@ class MockMarketplaceRepository implements MarketplaceRepository { throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); } + final formatProblem = ExtensionSupport.manifestFormatProblem(manifest); + if (formatProblem != null) throw MarketplaceException(formatProblem); + final sandboxViolations = SandboxPolicy.validate(manifest); if (sandboxViolations.isNotEmpty) { throw MarketplaceException( @@ -206,7 +209,7 @@ class MockMarketplaceRepository implements MarketplaceRepository { // Persist to local extensions directory so LocalExtensionRegistry discovers it final dir = await ExtensionPaths.extensionsDirectory(); - final extDir = Directory(p.join(dir.path, manifest.id)); + final extDir = ExtensionPaths.extensionDirectory(dir, manifest.id); if (!await extDir.exists()) { await extDir.create(recursive: true); } @@ -265,7 +268,7 @@ class MockMarketplaceRepository implements MarketplaceRepository { }'''; } return '''{ - "name": "\${manifest.name}", + "name": ${jsonEncode(manifest.name)}, "type": "dark", "colors": { "activityBar.background": "#050508", @@ -323,7 +326,7 @@ class MockMarketplaceRepository implements MarketplaceRepository { } else { // Fallback: check standard extensions directory by ID final dir = await ExtensionPaths.extensionsDirectory(); - final extDir = Directory(p.join(dir.path, extensionId)); + final extDir = ExtensionPaths.extensionDirectory(dir, extensionId); if (await extDir.exists()) { await extDir.delete(recursive: true); } diff --git a/lib/core/market/marketplace_selection.dart b/lib/core/market/marketplace_selection.dart new file mode 100644 index 00000000..1271108e --- /dev/null +++ b/lib/core/market/marketplace_selection.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'marketplace_repository.dart'; +import 'static_registry_repository.dart'; + +/// `--dart-define=QUERYA_MARKETPLACE_INDEX=`: read another registry index +/// (a fork, a test registry; `http://localhost` is accepted in debug builds). +const _indexUrl = String.fromEnvironment( + 'QUERYA_MARKETPLACE_INDEX', + defaultValue: StaticRegistryRepository.defaultIndexUrl, +); + +/// `--dart-define=QUERYA_MARKETPLACE_MOCK=true`: the built-in demo catalog, for +/// working on the Marketplace page without a registry. Never in a release. +const _useMock = bool.fromEnvironment('QUERYA_MARKETPLACE_MOCK'); + +/// Chooses the Marketplace catalog once, at startup. The app lists and installs +/// what the extension registry publishes; nothing else reaches users. +Future installMarketplaceRepository() async { + if (_useMock) { + assert(kDebugMode || kProfileMode, 'the demo catalog is not for releases'); + return; // MarketplaceRepository.instance stays the demo catalog. + } + var version = '0.0.0'; + try { + version = (await PackageInfo.fromPlatform()).version; + } catch (e) { + // Nothing is compatible with 0.0.0, so an unreadable version shows an empty + // catalog rather than extensions that might not run. + debugPrint('Marketplace: could not read the app version: $e'); + } + MarketplaceRepository.instance = StaticRegistryRepository( + indexUrl: _indexUrl, + hostVersion: version, + ); +} diff --git a/lib/core/market/static_registry_repository.dart b/lib/core/market/static_registry_repository.dart new file mode 100644 index 00000000..75c1f55f --- /dev/null +++ b/lib/core/market/static_registry_repository.dart @@ -0,0 +1,206 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:querya_desktop/core/extensions/extension_support.dart'; +import 'package:querya_desktop/core/extensions/harness/extension_manifest_validator.dart'; +import 'package:querya_desktop/core/extensions/harness/extension_protocol_profile.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; + +import 'marketplace_repository.dart'; + +/// The marketplace as a static file: `index.json` published by the extension +/// registry (`QueryaHub/extensions`, on GitHub Pages) with no server of ours. +/// +/// The index is built by the registry's CI from the extensions' own release +/// archives, so every entry already carries the archive's SHA-256. Installing +/// is the safe pipeline of [HttpMarketplaceRepository] (checksum, archive +/// limits, sandbox policy); this class only adds where the list comes from and +/// that an archive may only come from a GitHub release. +class StaticRegistryRepository extends HttpMarketplaceRepository { + StaticRegistryRepository({ + this.indexUrl = defaultIndexUrl, + required this.hostVersion, + String? platform, + super.client, + super.allowLocalhostInDebug, + this.cacheTtl = const Duration(minutes: 10), + DateTime Function()? now, + }) : platform = platform ?? currentPlatformKey(), + _http = client ?? http.Client(), + _now = now ?? DateTime.now, + super( + baseUrl: indexUrl, + extraTrustedDownloadHosts: _releaseHosts, + ); + + static const defaultIndexUrl = + 'https://queryahub.github.io/extensions/index.json'; + + /// The index format this client reads. A newer registry is refused rather + /// than half understood. + static const supportedSchema = 1; + + /// Where a release asset is served from: the redirect of + /// `github.com///releases/download/...` ends on one of these. + static const _releaseHosts = [ + 'github.com', + 'objects.githubusercontent.com', + 'release-assets.githubusercontent.com', + ]; + + /// Querya Desktop version, to hide extensions whose `engines.querya_desktop` + /// range does not include it. + final String hostVersion; + + /// Build target of this app (`linux-x64`, `macos-arm64`, `macos-x64`, + /// `windows-x64`): an extension that ships one archive per platform is offered + /// only when it has a build for it. + final String platform; + final String indexUrl; + final Duration cacheTtl; + final http.Client _http; + final DateTime Function() _now; + + List? _cache; + DateTime? _cachedAt; + + /// The registry's name for the platform this process runs on, from the Dart + /// VM's own description (`... on "linux_x64"`). Empty when it cannot be read, + /// which matches no build. + static String currentPlatformKey() { + final m = RegExp(r'on "([a-z]+)_([a-z0-9]+)"').firstMatch(Platform.version); + return m == null ? '' : '${m.group(1)}-${m.group(2)}'; + } + + static final _releasePath = RegExp(r'^/[^/]+/[^/]+/releases/download/[^/]+/[^/]+$'); + + /// `https://github.com///releases/download//` only. + static bool isReleaseAssetUrl(Uri uri) => + uri.scheme == 'https' && + uri.host.toLowerCase() == 'github.com' && + _releasePath.hasMatch(uri.path); + + /// The installable, compatible extensions of the index (cached for + /// [cacheTtl]; a failed refresh keeps serving the last good copy). + Future> _entries() async { + final cached = _cache; + final at = _cachedAt; + if (cached != null && at != null && _now().difference(at) < cacheTtl) { + return cached; + } + try { + final response = await _http + .get(Uri.parse(indexUrl)) + .timeout(const Duration(seconds: 15)); + if (response.statusCode != 200) { + throw MarketplaceException( + 'Could not load the registry (HTTP ${response.statusCode})'); + } + final body = response.body; + final decoded = await Isolate.run(() => jsonDecode(body)); + _cache = parseIndex(decoded, + hostVersion: hostVersion, platform: platform); + _cachedAt = _now(); + return _cache!; + } catch (e) { + if (cached != null) { + debugPrint('Registry refresh failed, using the last copy: $e'); + return cached; + } + if (e is MarketplaceException) rethrow; + throw MarketplaceException('Could not load the registry: $e'); + } + } + + /// Entries of a decoded `index.json` that can be shown: not yanked, with a + /// release-asset URL and a checksum, compatible with [hostVersion], and + /// installable on [platform]. An entry with a `platforms` map is resolved to + /// the build for [platform] (its `downloadUrl`, `sha256` and `main`) and + /// dropped when there is none. + @visibleForTesting + static List parseIndex( + Object? decoded, { + required String hostVersion, + required String platform, + }) { + if (decoded is! Map) { + throw MarketplaceException('The registry index is not a JSON object'); + } + final schema = decoded['schema']; + if (schema != supportedSchema) { + throw MarketplaceException( + 'The registry uses index format $schema; this Querya reads ' + '$supportedSchema. Update Querya Desktop.'); + } + final host = HostVersion.parse(hostVersion); + final out = []; + for (final raw in (decoded['extensions'] as List? ?? const [])) { + if (raw is! Map) continue; + final json = Map.from(raw); + if (json['yanked'] == true) continue; + final builds = json['platforms']; + if (builds != null) { + final build = builds is Map ? builds[platform] : null; + if (build is! Map) continue; + json + ..remove('platforms') + ..['downloadUrl'] = build['downloadUrl'] + ..['sha256'] = build['sha256'] + ..['main'] = build['main'] ?? json['main']; + } + try { + final manifest = ExtensionManifest.fromJson(json); + final url = Uri.tryParse(manifest.downloadUrl ?? ''); + if (url == null || !isReleaseAssetUrl(url)) continue; + if ((manifest.sha256Checksum ?? '').trim().isEmpty) continue; + if (ExtensionSupport.manifestFormatProblem(manifest) != null) continue; + final range = manifest.engines['querya_desktop']; + if (range != null && engineRangeAllows(host, range) == false) continue; + out.add(manifest); + } catch (_) { + // One malformed entry must not hide the rest of the registry. + } + } + return out; + } + + @override + Future> getTrending({ExtensionType? type}) async { + final all = await _entries(); + return [ + for (final m in all) + if (type == null || m.type == type) m, + ]; + } + + @override + Future> search(String query, + {ExtensionType? type}) async { + final q = query.trim().toLowerCase(); + final all = await getTrending(type: type); + if (q.isEmpty) return all; + return [ + for (final m in all) + if (m.name.toLowerCase().contains(q) || + m.id.toLowerCase().contains(q) || + m.publisher.toLowerCase().contains(q) || + (m.description?.toLowerCase().contains(q) ?? false) || + m.tags.any((t) => t.toLowerCase().contains(q))) + m, + ]; + } + + @override + Future download(String url, {void Function(double)? onProgress}) { + final uri = Uri.tryParse(url); + if (uri == null || !isReleaseAssetUrl(uri)) { + throw MarketplaceException( + 'Only a GitHub release asset may be downloaded from the registry: $url'); + } + return super.download(url, onProgress: onProgress); + } +} diff --git a/lib/core/mcp/mcp_access_store.dart b/lib/core/mcp/mcp_access_store.dart index 217b2c38..b737080d 100644 --- a/lib/core/mcp/mcp_access_store.dart +++ b/lib/core/mcp/mcp_access_store.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// Which connections MCP clients may read. Opt-in per connection: a /// connection that was never enabled is invisible to every MCP tool. @@ -29,7 +30,9 @@ class McpAccessStore implements McpAccessSettings { try { final decoded = jsonDecode(raw); if (decoded is List) return {for (final v in decoded) if (v is int) v}; - } catch (_) {} + } catch (e) { + logIgnored('mcp_access_store', e); + } return {}; } diff --git a/lib/core/mcp/mcp_mongo_service.dart b/lib/core/mcp/mcp_mongo_service.dart index 212189bd..b8256d8c 100644 --- a/lib/core/mcp/mcp_mongo_service.dart +++ b/lib/core/mcp/mcp_mongo_service.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/mcp/mcp_access_store.dart'; import 'package:querya_desktop/core/mcp/mcp_query_service.dart'; import 'package:querya_desktop/core/mcp/mcp_redaction.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// Why [McpMongoGuard] refused a filter or sort: a stable rule id /// for the audit log and the message the model reads. @@ -287,7 +288,9 @@ class McpMongoService { } finally { try { await session?.close(); - } catch (_) {} + } catch (e) { + logIgnored('mcp_mongo_service', e); + } } } diff --git a/lib/core/mcp/mcp_query_service.dart b/lib/core/mcp/mcp_query_service.dart index a4d964fd..c78d83a5 100644 --- a/lib/core/mcp/mcp_query_service.dart +++ b/lib/core/mcp/mcp_query_service.dart @@ -107,9 +107,10 @@ class McpQueryService { String table, ) { return _withDelegate(connectionId, (delegate, dialect) async { - final cat = await _loadCatalog(delegate, dialect); + final name = await _resolveTable(delegate, dialect, table); + final cat = await _loadCatalog(delegate, dialect, tables: [name]); final schema = cat.schema; - final t = _findTable(schema, table); + final t = _findTable(schema, name); if (cat.views.contains(t.name)) return _describe(schema, t, const []); final indexes = await _run( () => delegate.executeQuery( @@ -162,11 +163,10 @@ class McpQueryService { }) { final n = rows.clamp(1, maxSampleRows); return _withDelegate(connectionId, (delegate, dialect) async { - final t = - _findTable((await _loadCatalog(delegate, dialect)).schema, table); + final name = await _resolveTable(delegate, dialect, table); final result = await _run( () => delegate.executeQuery( - 'SELECT * FROM ${quoteIdentifier(t.name, dialect)}', + 'SELECT * FROM ${quoteIdentifier(name, dialect)}', limit: n, timeout: timeout, ), @@ -242,14 +242,19 @@ class McpQueryService { /// Tables and keys, then the views: a name that is already a table stays a /// table. Views have no keys and no indexes. + /// The catalog of the current schema or database; with [tables], of those + /// tables only (#1572). Future<({ErdSchema schema, Set views})> _loadCatalog( SqlQueryRunner delegate, - SqlDialect dialect, - ) async { - final base = await _loadSchema(delegate, dialect); + SqlDialect dialect, { + Iterable? tables, + }) async { + final base = await _loadSchema(delegate, dialect, tables: tables); final viewRows = await _run( - () => delegate.executeQuery(ErdCatalog.viewColumnsSql(dialect), - limit: _catalogRows, timeout: timeout), + () => delegate.executeQuery( + ErdCatalog.viewColumnsSql(dialect, tables: tables), + limit: _catalogRows, + timeout: timeout), dialect, ); final known = {for (final t in base.tables) t.name}; @@ -271,21 +276,59 @@ class McpQueryService { Future _loadSchema( SqlQueryRunner delegate, - SqlDialect dialect, - ) async { - final cols = await _run( - () => delegate.executeQuery(ErdCatalog.columnsSql(dialect), - limit: _catalogRows, timeout: timeout), - dialect, - ); + SqlDialect dialect, { + Iterable? tables, + }) async { final fks = await _run( () => delegate.executeQuery(ErdCatalog.foreignKeysSql(dialect), limit: _catalogRows, timeout: timeout), dialect, ); + // With [tables], also the tables their keys point to: ErdSchema drops a + // relation to a table it has no columns for, and a description lists + // each column's reference. + final wanted = tables == null + ? null + : { + ...tables, + for (final r in fks.rows) + if (r.length >= 4 && tables.contains(r[0])) r[2], + }; + final cols = await _run( + () => delegate.executeQuery( + ErdCatalog.columnsSql(dialect, tables: wanted), + limit: _catalogRows, + timeout: timeout), + dialect, + ); return ErdSchema.fromCatalog(columnRows: cols.rows, fkRows: fks.rows); } + /// The catalog's name for [name]: the exact one, else the only one that + /// matches ignoring case. Reads the names only, not the whole catalog: one + /// table's description or sample no longer loads every column (#1572). + Future _resolveTable( + SqlQueryRunner delegate, + SqlDialect dialect, + String name, + ) async { + final result = await _run( + () => delegate.executeQuery(ErdCatalog.tableNamesSql(dialect), + limit: _catalogRows, timeout: timeout), + dialect, + ); + final names = [ + for (final r in result.rows) + if (r.isNotEmpty) r.first, + ]; + if (names.contains(name)) return name; + final lower = name.toLowerCase(); + final loose = names.where((n) => n.toLowerCase() == lower).toList(); + if (loose.length == 1) return loose.single; + throw McpToolException( + 'Table "$name" not found. Call list_tables to see the available tables.'); + } + /// Exact name first, then a unique case-insensitive match. The table must /// exist in the catalog, so model input never reaches SQL unchecked. ErdTable _findTable(ErdSchema schema, String name) { diff --git a/lib/core/mcp/mcp_server_controller.dart b/lib/core/mcp/mcp_server_controller.dart index ba95bc83..1f9b0c08 100644 --- a/lib/core/mcp/mcp_server_controller.dart +++ b/lib/core/mcp/mcp_server_controller.dart @@ -11,6 +11,7 @@ import 'package:querya_desktop/core/mcp/mcp_query_service.dart'; import 'package:querya_desktop/core/mcp/mcp_socket_host.dart'; import 'package:querya_desktop/core/mcp/querya_mcp_server.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// Owns the in-app MCP server: starts it when enabled in settings, stops it /// on shutdown, and reports status and tool calls to the UI. @@ -99,7 +100,21 @@ class McpServerController { } } - Future start() async { + /// Runs [op] after every earlier start / stop has finished. `start` awaits + /// between its `_host != null` check and the assignment, so two overlapping + /// calls (a toggle clicked twice, `startIfEnabled` racing the settings page) + /// each built a host and the first one was leaked, still listening (#1563). + Future _lifecycle = Future.value(); + + Future _serialized(Future Function() op) { + final run = _lifecycle.then((_) => op()); + _lifecycle = run.then((_) {}, onError: (Object _) {}); + return run; + } + + Future start() => _serialized(_start); + + Future _start() async { if (_host != null) return; final version = await _version(); final host = McpSocketHost( @@ -125,7 +140,9 @@ class McpServerController { } /// Disconnects clients and removes the endpoint file. - Future stop() async { + Future stop() => _serialized(_stop); + + Future _stop() async { final host = _host; _host = null; for (final s in _servers.toList()) { @@ -147,7 +164,9 @@ class McpServerController { try { name = (await LocalDb.instance.getConnectionById(r.connectionId!)) ?.name; - } catch (_) {} + } catch (e) { + logIgnored('mcp_server_controller', e); + } } final sql = r.sql; await LocalDb.instance.recordMcpActivity(McpActivityEntry( @@ -166,11 +185,11 @@ class McpServerController { /// Stops and starts again with a new token; connected clients must /// reconnect (their `querya-mcp` reads the new endpoint file). - Future regenerateToken() async { - if (_host == null) return; - await stop(); - await start(); - } + Future regenerateToken() => _serialized(() async { + if (_host == null) return; + await _stop(); + await _start(); + }); void _publish(McpSocketHost host) { if (_host != host && _host != null) return; diff --git a/lib/core/mcp/mcp_socket_host.dart b/lib/core/mcp/mcp_socket_host.dart index baa2da84..e25cc3ef 100644 --- a/lib/core/mcp/mcp_socket_host.dart +++ b/lib/core/mcp/mcp_socket_host.dart @@ -16,6 +16,8 @@ class McpSocketHost { required this.version, required this.onSession, this.handshakeTimeout = const Duration(seconds: 5), + this.maxUnauthenticated = 16, + this.maxHandshakeBytes = 4096, }); final File endpointFile; @@ -23,9 +25,16 @@ class McpSocketHost { final Future Function(StreamChannel channel) onSession; final Duration handshakeTimeout; + /// Sockets that may wait for their token at once; more are turned away. + final int maxUnauthenticated; + + /// Bytes accepted before the token line is complete (a token is ~44). + final int maxHandshakeBytes; + ServerSocket? _server; String? _token; final _sockets = {}; + var _unauthenticated = 0; bool get isRunning => _server != null; int? get port => _server?.port; @@ -77,19 +86,46 @@ class McpSocketHost { } void _accept(Socket socket) { + if (_unauthenticated >= maxUnauthenticated) { + socket.destroy(); + return; + } _sockets.add(socket); + _unauthenticated++; // A client that disappears mid-write must not surface as an uncaught error. unawaited(socket.done.then((_) {}, onError: (Object _) {})); final incoming = StreamController(); final outgoing = StreamController(); var authed = false; + var waiting = true; + var handshakeBytes = 0; + void stopWaiting() { + if (!waiting) return; + waiting = false; + _unauthenticated--; + } + final timer = Timer(handshakeTimeout, () { - if (!authed) _reject(socket, 'Handshake timed out.'); + if (!authed) { + stopWaiting(); + _reject(socket, 'Handshake timed out.'); + } }); late final StreamSubscription sub; sub = socket .cast>() + .map((chunk) { + // Without a newline the line splitter would buffer without bound. + if (!authed && (handshakeBytes += chunk.length) > maxHandshakeBytes) { + timer.cancel(); + sub.cancel(); + stopWaiting(); + _reject(socket, 'Handshake too long.'); + return const []; + } + return chunk; + }) .transform(utf8.decoder) .transform(const LineSplitter()) .listen( @@ -102,10 +138,12 @@ class McpSocketHost { if (token == null || !McpEndpoint.tokensEqual(line.trim(), token)) { timer.cancel(); sub.cancel(); + stopWaiting(); _reject(socket, 'Invalid Querya MCP token. Restart the MCP client.'); return; } authed = true; + stopWaiting(); timer.cancel(); _sessions++; _changes.add(null); @@ -116,8 +154,14 @@ class McpSocketHost { unawaited(onSession(StreamChannel.withCloseGuarantee( incoming.stream, outgoing.sink))); }, - onDone: () => _closed(socket, incoming, outgoing, authed), - onError: (_) => _closed(socket, incoming, outgoing, authed), + onDone: () { + stopWaiting(); + _closed(socket, incoming, outgoing, authed); + }, + onError: (_) { + stopWaiting(); + _closed(socket, incoming, outgoing, authed); + }, cancelOnError: true, ); } diff --git a/lib/core/mcp/mcp_sql_guard.dart b/lib/core/mcp/mcp_sql_guard.dart index 2ff38d7c..caa0da5f 100644 --- a/lib/core/mcp/mcp_sql_guard.dart +++ b/lib/core/mcp/mcp_sql_guard.dart @@ -38,14 +38,16 @@ abstract final class McpSqlGuard { /// `EXPLAIN ANALYZE` executes the statement; the rest would explain a write. static final _explainForbidden = RegExp( - r'\b(ANALYZE|ANALYSE|INSERT|UPDATE|DELETE|MERGE|REPLACE|CREATE|DROP|ALTER|TRUNCATE|GRANT|REVOKE|CALL|DO|COPY)\b', + r'\b(ANALYZE|ANALYSE|INSERT|UPDATE|DELETE|MERGE|REPLACE\s+INTO|CREATE|DROP|ALTER|TRUNCATE|GRANT|REVOKE|CALL|DO|COPY)\b', ); /// Functions that act on the server even inside a read-only transaction. static final _forbiddenFunctions = RegExp( r'\b(PG_TERMINATE_BACKEND|PG_CANCEL_BACKEND|PG_RELOAD_CONF|PG_ROTATE_LOGFILE|' - r'PG_READ_FILE|PG_READ_BINARY_FILE|PG_LS_DIR|PG_STAT_FILE|LO_IMPORT|LO_EXPORT|' - r'DBLINK\w*|SET_CONFIG|LOAD_FILE|LOAD_EXTENSION|WRITEFILE|READFILE|FSDIR)\s*\(', + r'PG_READ_FILE|PG_READ_BINARY_FILE|PG_LS_DIR|PG_STAT_FILE|LO_\w*|' + r'QUERY_TO_XML\w*|CURSOR_TO_XML|TABLE_TO_XML\w*|PG_(?:TRY_)?ADVISORY_\w*|PG_SLEEP\w*|' + r'NEXTVAL|SETVAL|DBLINK\w*|SET_CONFIG|LOAD_FILE|LOAD_EXTENSION|WRITEFILE|' + r'READFILE|FSDIR)\s*\(', ); /// SQLite pragmas that only read the schema. @@ -72,10 +74,17 @@ abstract final class McpSqlGuard { /// Returns `null` when [sql] may run, or why it may not: the rule that /// refused it (stable, for the audit log) and the message for the model. static McpSqlRefusal? refusal(String sql, SqlDialect dialect) { - final statements = DestructiveSqlDetector.splitStatements(sql) - .where((s) => DestructiveSqlDetector.stripCommentsAndStrings(s) - .trim() - .isNotEmpty) + // Read the text the way the server does: in PostgreSQL and SQLite `'a\'` + // is a whole literal, so a `;` after it ends the statement. + final escapes = dialect.backslashEscapesQuotes; + final statements = DestructiveSqlDetector.splitStatements( + sql, + backslashEscapes: escapes, + ) + .where((s) => DestructiveSqlDetector.stripCommentsAndStrings( + s, + backslashEscapes: escapes, + ).trim().isNotEmpty) .toList(); if (statements.isEmpty) return const McpSqlRefusal('empty_query', 'The query is empty.'); if (statements.length > 1) { @@ -88,7 +97,10 @@ abstract final class McpSqlGuard { RegExp(r'/\*M?!').hasMatch(statement)) { return const McpSqlRefusal('mysql_executable_comment', 'MySQL executable comments (/*! ... */) are not allowed.'); } - final upper = DestructiveSqlDetector.stripCommentsAndStrings(statement) + final upper = DestructiveSqlDetector.stripCommentsAndStrings( + statement, + backslashEscapes: escapes, + ) .trim() .replaceAll(RegExp(r';\s*$'), '') .toUpperCase(); @@ -111,7 +123,7 @@ abstract final class McpSqlGuard { } return null; } - if (isMutatingSqlStatement(statement)) { + if (isMutatingSqlStatement(statement, backslashEscapes: escapes)) { return const McpSqlRefusal('data_modifying', 'Data-modifying statements are not allowed over MCP.'); } if (_selectSideEffects.hasMatch(upper)) { diff --git a/lib/core/motion/display_refresh_service.dart b/lib/core/motion/display_refresh_service.dart index 870d9d2f..64d69f3a 100644 --- a/lib/core/motion/display_refresh_service.dart +++ b/lib/core/motion/display_refresh_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:refresh_rate/refresh_rate.dart'; @@ -7,7 +9,8 @@ import 'package:refresh_rate/refresh_rate.dart'; /// - **macOS 14+:** ProMotion / high-Hz unlock /// - **Windows:** typically follows DWM (enable is effectively a no-op unlock) /// - **Linux:** **query-only** — enable is a no-op; [RefreshRate.info] / overlay -/// report GDK monitor refresh (compositor decides actual pacing) +/// report GDK monitor refresh (compositor decides actual pacing). An unknown +/// rate reads 0 Hz (it no longer falls back to 60) /// /// Debug overlay: `flutter run --dart-define=QUERYA_REFRESH_OVERLAY=true`. abstract final class DisplayRefreshService { @@ -17,7 +20,8 @@ abstract final class DisplayRefreshService { /// Unlock peak refresh where the platform supports it; log Hz in debug builds. static void initialize() { - RefreshRate.enable(); + // 2.x returns the result of the request; nothing here acts on it. + unawaited(RefreshRate.enable()); if (!kDebugMode) return; @@ -31,6 +35,7 @@ abstract final class DisplayRefreshService { } static void _logRefreshInfo() { + // The first reading can still be 0 Hz: 2.x fills `info` asynchronously. final info = RefreshRate.info; debugPrint( 'Display refresh: ${info.currentRate} Hz ' diff --git a/lib/core/motion/polling_controller.dart b/lib/core/motion/polling_controller.dart new file mode 100644 index 00000000..0e60d8f7 --- /dev/null +++ b/lib/core/motion/polling_controller.dart @@ -0,0 +1,179 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +/// What the last polls did: when one last succeeded and how many failed since. +@immutable +class PollStatus { + const PollStatus({this.lastSuccess, this.lastError, this.failures = 0}); + + final DateTime? lastSuccess; + final Object? lastError; + + /// Consecutive failed ticks; zero after a success. + final int failures; + + /// The latest tick failed, so what the view shows is older than it looks. + bool get isStale => failures > 0; +} + +/// Polls with [tick] every [interval], one request at a time. +/// +/// The next tick is scheduled when the previous one has finished, so a slow +/// server is never asked again while it is still answering, and results land +/// in the order they were asked for. A failed tick backs the interval off +/// exponentially (up to [maxBackoff]) and is reported through [status]; the +/// first success returns to [interval]. +/// +/// Start and stop with [setActive], from `didChangeDependencies` (reading +/// `TickerMode`) and from the places that change whether the view has data, +/// not from `build`. +class PollingController { + PollingController({ + required Duration interval, + required this.tick, + this.maxBackoff = const Duration(minutes: 1), + }) : _interval = interval; + + /// One poll. Throw to report a failure. + final Future Function() tick; + final Duration maxBackoff; + + final ValueNotifier status = + ValueNotifier(const PollStatus()); + + Duration _interval; + Timer? _timer; + bool _active = false; + bool _inFlight = false; + bool _disposed = false; + + /// Bumped on every stop, so a tick that was running when polling stopped + /// does not schedule the next one or report into the new session. + int _epoch = 0; + + Duration get interval => _interval; + bool get isActive => _active; + + /// The wait before the next tick: [interval], doubled for each failure. + Duration get nextDelay { + final failures = status.value.failures; + if (failures == 0 || _interval >= maxBackoff) return _interval; + final micros = _interval.inMicroseconds * (1 << math.min(failures, 16)); + return Duration(microseconds: math.min(micros, maxBackoff.inMicroseconds)); + } + + void setActive(bool active) { + if (_disposed || active == _active) return; + _active = active; + _epoch++; + if (active) { + _schedule(); + } else { + _timer?.cancel(); + _timer = null; + } + } + + void setInterval(Duration interval) { + if (interval == _interval) return; + _interval = interval; + if (_active && !_inFlight) _schedule(); + } + + /// Forgets what the previous session reported (a reconnect, a retry). + void reset() { + if (_disposed) return; + status.value = const PollStatus(); + } + + void _schedule() { + _timer?.cancel(); + _timer = Timer(nextDelay, _run); + } + + Future _run() async { + _timer = null; + if (_disposed || !_active) return; + if (_inFlight) { + // A tick from before a stop/start is still answering. + _schedule(); + return; + } + final epoch = _epoch; + _inFlight = true; + try { + await tick(); + if (_disposed || epoch != _epoch) return; + status.value = PollStatus(lastSuccess: DateTime.now()); + } catch (e) { + if (_disposed || epoch != _epoch) return; + final previous = status.value; + status.value = PollStatus( + lastSuccess: previous.lastSuccess, + lastError: e, + failures: previous.failures + 1, + ); + } finally { + _inFlight = false; + } + if (!_disposed && _active && epoch == _epoch) _schedule(); + } + + void dispose() { + _disposed = true; + _timer?.cancel(); + _timer = null; + status.dispose(); + } +} + +/// Wires a [PollingController] to a [State]: polls while the view is under an +/// enabled `TickerMode` (not an offscreen keep-alive layer), has data to +/// refresh ([pollShouldRun]) and has an interval ([pollInterval], null = off). +/// +/// Call [syncPolling] whenever the answer to [pollShouldRun] or [pollInterval] +/// may have changed (a load finished or failed, the interval was picked) and +/// [stopPolling] when the data is dropped (a reload, another connection). +mixin PollingState on State { + Duration? get pollInterval; + bool get pollShouldRun; + + /// One poll; throw to report a failure to [PollingController.status]. + Future pollTick(); + + late final PollingController polling = PollingController( + interval: pollInterval ?? const Duration(seconds: 5), + tick: pollTick, + ); + + bool _tickerEnabled = true; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _tickerEnabled = TickerMode.valuesOf(context).enabled; + syncPolling(); + } + + void syncPolling() { + final interval = pollInterval; + if (interval != null) polling.setInterval(interval); + polling.setActive( + mounted && _tickerEnabled && interval != null && pollShouldRun, + ); + } + + void stopPolling() { + polling + ..setActive(false) + ..reset(); + } + + @override + void dispose() { + polling.dispose(); + super.dispose(); + } +} diff --git a/lib/core/motion/querya_hover_surface.dart b/lib/core/motion/querya_hover_surface.dart index be95a5b1..3fd8b1db 100644 --- a/lib/core/motion/querya_hover_surface.dart +++ b/lib/core/motion/querya_hover_surface.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; + import 'querya_motion.dart'; import 'querya_motion_context.dart'; @@ -39,9 +41,9 @@ class _QueryaHoverSurfaceState extends State { @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; + final colors = context.colors; final hovered = widget.hoveredColor ?? - scheme.onSurface.withValues(alpha: 0.06); + colors.foreground.withValues(alpha: 0.06); final duration = context.motionDuration(QueryaMotion.fast); final curve = context.motionCurve(QueryaMotion.enter); diff --git a/lib/core/motion/ticker_gated_polling.dart b/lib/core/motion/ticker_gated_polling.dart deleted file mode 100644 index 0838fe55..00000000 --- a/lib/core/motion/ticker_gated_polling.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; - -/// Starts/stops a [Timer.periodic] based on [TickerMode] (keep-alive morph). -/// -/// Call from [State.build] or [State.didChangeDependencies]. When the ancestor -/// [TickerMode] is disabled (inactive [QueryaSwitchingBody] / -/// [QueryaCrossFadeStack] layer), the timer is cancelled so network polls do -/// not run offscreen. Re-enabling restarts the timer when [shouldRun] is true. -Timer? syncTickerGatedPeriodicTimer({ - required BuildContext context, - required Timer? timer, - required bool shouldRun, - required Duration interval, - required void Function() onTick, -}) { - final active = TickerMode.valuesOf(context).enabled && shouldRun; - if (!active) { - timer?.cancel(); - return null; - } - return timer ?? Timer.periodic(interval, (_) => onTick()); -} diff --git a/lib/core/platform/file_launch_service.dart b/lib/core/platform/file_launch_service.dart index 43117eea..4bdc98d7 100644 --- a/lib/core/platform/file_launch_service.dart +++ b/lib/core/platform/file_launch_service.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart' show ValueNotifier; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -54,9 +55,6 @@ class FileLaunchService { FileLaunchTarget? get pendingTarget => _pendingTargets.isNotEmpty ? _pendingTargets.first : null; - /// Whether there is at least one pending launch target. - bool get hasPendingTarget => _pendingTargets.isNotEmpty; - /// Sets or clears the pending launch target (useful for testing or single-target flows). void setPendingTarget(FileLaunchTarget? target) { _pendingTargets.clear(); @@ -124,9 +122,14 @@ class FileLaunchService { } } + /// Bumped by [addPendingTarget], so a running window can open files that + /// arrive after it started (Finder "Open With" on macOS, #1589). + final ValueNotifier pendingRevision = ValueNotifier(0); + /// Adds a target to the pending launch list (e.g. for testing or runtime open events). void addPendingTarget(FileLaunchTarget target) { _pendingTargets.add(target); + pendingRevision.value++; } /// Consumes and returns the first pending launch target, or `null` if none. @@ -153,7 +156,7 @@ class FileLaunchService { for (final conn in connections) { if (conn.type == 'sqlite' && conn.host != null) { final existingNormalized = p.normalize(p.absolute(conn.host!)); - if (existingNormalized == target.path) { + if (_samePath(existingNormalized, target.path)) { return conn; } } @@ -174,6 +177,13 @@ class FileLaunchService { return newRow.copyWith(id: id); } + /// Windows and macOS volumes are case-insensitive by default, so + /// `C:\Data\A.db` and `c:\data\a.db` are one database there. + static bool _samePath(String a, String b) => + Platform.isWindows || Platform.isMacOS + ? a.toLowerCase() == b.toLowerCase() + : a == b; + /// Reads the full text of an SQL script for [target]. Future readSqlContent(FileLaunchTarget target) async { final file = File(target.path); diff --git a/lib/core/platform/macos_file_open_channel.dart b/lib/core/platform/macos_file_open_channel.dart new file mode 100644 index 00000000..9f3b5cb4 --- /dev/null +++ b/lib/core/platform/macos_file_open_channel.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/platform/file_launch_service.dart'; + +/// Files Finder hands to the app: "Open With" and a double-click (#1589). +/// +/// macOS delivers them as an Open Documents Apple Event, not in `argv`, so +/// [FileLaunchService.processLaunchArguments] never saw them. `AppDelegate` +/// keeps the ones that arrive before Dart asks ([takeLaunchFiles]) and then +/// sends each later batch over this channel. +abstract final class MacOsFileOpenChannel { + static const channel = MethodChannel('querya/file_open'); + + /// Starts listening and takes the files that arrived during launch. A + /// no-op off macOS. + static Future install() async { + if (!Platform.isMacOS) return; + channel.setMethodCallHandler((call) async { + if (call.method == 'openFiles') queuePaths(call.arguments); + return null; + }); + try { + queuePaths(await channel.invokeListMethod('takePendingFiles')); + } on MissingPluginException { + // A runner without the channel (older build): only argv works. + } on PlatformException catch (e) { + debugPrint('MacOsFileOpenChannel: $e'); + } + } + + /// Adds the paths in [arguments] (a list of strings) that the app can open + /// as pending launch targets; returns how many were added. + @visibleForTesting + static int queuePaths(Object? arguments) { + if (arguments is! List) return 0; + var added = 0; + for (final path in arguments) { + if (path is! String) continue; + final target = FileLaunchService.parseArgument(path); + if (target == null) continue; + FileLaunchService.instance.addPendingTarget(target); + added++; + } + return added; + } +} diff --git a/lib/core/platform/macos_sandbox.dart b/lib/core/platform/macos_sandbox.dart new file mode 100644 index 00000000..cbdcdeb2 --- /dev/null +++ b/lib/core/platform/macos_sandbox.dart @@ -0,0 +1,38 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart' show visibleForTesting; + +/// Whether Querya Desktop runs inside the macOS App Sandbox (#1593). +/// +/// The release builds are not sandboxed (#1592), but a debug build +/// (`DebugProfile.entitlements`) and any store-style build are. In the +/// sandbox the app cannot replace its own bundle, start extension processes, +/// let an MCP client outside the sandbox read its endpoint file, or read SSH +/// keys and database files it was not handed: those features then fail +/// without saying why, so they check this and say it. +abstract final class MacOsSandbox { + static bool? _override; + + /// Forces [isActive] in tests; `null` goes back to detection. + @visibleForTesting + static set debugOverride(bool? value) => _override = value; + + /// Whether this process is sandboxed (always false off macOS). + static bool get isActive => + _override ?? (Platform.isMacOS && detect(Platform.environment)); + + /// The sandbox sets `APP_SANDBOX_CONTAINER_ID`, and moves `HOME` into the + /// app's container (`~/Library/Containers//Data`). + static bool detect(Map environment) { + final container = environment['APP_SANDBOX_CONTAINER_ID']; + if (container != null && container.isNotEmpty) return true; + final home = environment['HOME'] ?? ''; + return home.contains('/Library/Containers/'); + } + + /// One line for a feature that does not work in the sandbox. + static String unavailable(String feature) => + '$feature is not available while Querya Desktop runs in the macOS App ' + 'Sandbox (a debug or store-style build). The release build from the ' + 'download page is not sandboxed.'; +} diff --git a/lib/core/security/connection_environment.dart b/lib/core/security/connection_environment.dart index 7e703d8e..bcc75867 100644 --- a/lib/core/security/connection_environment.dart +++ b/lib/core/security/connection_environment.dart @@ -1,3 +1,4 @@ +import 'package:querya_desktop/core/util/log_ignored.dart'; import 'dart:convert'; /// Which kind of server a connection points at. Drives the color coding in the @@ -40,7 +41,9 @@ enum ConnectionEnvironment { try { final decoded = jsonDecode(driverOptions); if (decoded is Map) return fromStorageValue(decoded[optionsKey]); - } catch (_) {} + } catch (e) { + logIgnored('connection_environment', e); + } return null; } @@ -56,7 +59,9 @@ enum ConnectionEnvironment { try { final decoded = jsonDecode(driverOptions); if (decoded is Map) options = Map.from(decoded); - } catch (_) {} + } catch (e) { + logIgnored('connection_environment', e); + } } if (environment == null) { options.remove(optionsKey); diff --git a/lib/core/security/pii_masker.dart b/lib/core/security/pii_masker.dart index 6daa724e..e74718ae 100644 --- a/lib/core/security/pii_masker.dart +++ b/lib/core/security/pii_masker.dart @@ -15,39 +15,65 @@ const _phoneTokens = { }; const _cardTokens = {'card', 'cc', 'pan', 'iban'}; +bool _isLower(int c) => c >= 0x61 && c <= 0x7A; +bool _isUpper(int c) => c >= 0x41 && c <= 0x5A; +bool _isDigit(int c) => c >= 0x30 && c <= 0x39; + /// Splits a column name into lower-case words: on non-alphanumerics and on /// camelCase boundaries (`userEmail` -> `user`, `email`). +/// +/// One pass over the code units (no regex, no split / join). A boundary is a +/// lower-case letter or digit followed by an upper-case letter. List _tokens(String name) { - final spaced = name - .replaceAllMapped( - RegExp(r'([a-z0-9])([A-Z])'), - (m) => '${m[1]} ${m[2]}', - ) - .replaceAll(RegExp(r'[^A-Za-z0-9]+'), ' '); - return spaced - .toLowerCase() - .split(' ') - .where((t) => t.isNotEmpty) - .toList(); + final tokens = []; + var start = -1; + for (var i = 0; i < name.length; i++) { + final c = name.codeUnitAt(i); + final alnum = _isLower(c) || _isUpper(c) || _isDigit(c); + if (!alnum) { + if (start >= 0) { + tokens.add(name.substring(start, i).toLowerCase()); + start = -1; + } + continue; + } + if (start >= 0 && _isUpper(c)) { + final prev = name.codeUnitAt(i - 1); + if (_isLower(prev) || _isDigit(prev)) { + tokens.add(name.substring(start, i).toLowerCase()); + start = i; + continue; + } + } + if (start < 0) start = i; + } + if (start >= 0) tokens.add(name.substring(start).toLowerCase()); + return tokens; } -/// What [columnName] probably holds, or null when it does not look sensitive. -/// -/// Matching is per word, so `user_email` and `userEmail` match but `hotel` and -/// `email_verified_at` do not trip the phone / email rules by accident. -PiiKind? detectPiiColumn(String columnName) { +const _keyPrefixTokens = {'api', 'private', 'access', 'secret', 'auth'}; + +/// Timestamps / flags *about* a sensitive field are not the value itself. +const _metaTokens = {'at', 'verified', 'confirmed', 'sent', 'count', 'id'}; + +/// Most distinct column names remembered by [detectPiiColumn]. +const int _detectCacheLimit = 1024; + +/// Column names repeat (`id`, `name`, `created_at`...) from query to query, so +/// the answer per name is kept. Per isolate; cleared when it gets large. +final Map _detectCache = {}; + +PiiKind? _detectUncached(String columnName) { final tokens = _tokens(columnName); if (tokens.isEmpty) return null; // `api_key`, `private_key`, `access_key` are split into two words. - final joined = tokens.join('_'); - if (RegExp(r'(^|_)(api|private|access|secret|auth)_key($|_)') - .hasMatch(joined)) { - return PiiKind.secret; + for (var i = 0; i + 1 < tokens.length; i++) { + if (tokens[i + 1] == 'key' && _keyPrefixTokens.contains(tokens[i])) { + return PiiKind.secret; + } } - // Timestamps / flags *about* a sensitive field are not the value itself. - const metaTokens = {'at', 'verified', 'confirmed', 'sent', 'count', 'id'}; final lastToken = tokens.last; - if (metaTokens.contains(lastToken) && tokens.length > 1) return null; + if (_metaTokens.contains(lastToken) && tokens.length > 1) return null; for (final t in tokens) { if (_secretTokens.contains(t)) return PiiKind.secret; if (_cardTokens.contains(t)) return PiiKind.card; @@ -57,6 +83,18 @@ PiiKind? detectPiiColumn(String columnName) { return null; } +/// What [columnName] probably holds, or null when it does not look sensitive. +/// +/// Matching is per word, so `user_email` and `userEmail` match but `hotel` and +/// `email_verified_at` do not trip the phone / email rules by accident. +PiiKind? detectPiiColumn(String columnName) { + if (_detectCache.containsKey(columnName)) return _detectCache[columnName]; + final kind = _detectUncached(columnName); + if (_detectCache.length >= _detectCacheLimit) _detectCache.clear(); + _detectCache[columnName] = kind; + return kind; +} + /// [detectPiiColumn] for every column. List detectPiiColumns(List columns) => [for (final c in columns) detectPiiColumn(c)]; @@ -76,12 +114,34 @@ String maskPiiValue(PiiKind kind, String value) { final visible = local.length <= 2 ? 1 : 2; return '${local.substring(0, visible)}***@$domain'; case PiiKind.phone: - final digits = value.replaceAll(RegExp(r'\D'), ''); - if (digits.length < 8) return kPiiMaskDots; - return '•••••••${digits.substring(digits.length - 2)}'; + if (_countDigits(value, 8) < 8) return kPiiMaskDots; + return '•••••••${_lastDigits(value, 2)}'; case PiiKind.card: - final digits = value.replaceAll(RegExp(r'\D'), ''); - if (digits.length < 12) return kPiiMaskDots; - return '•••• •••• •••• ${digits.substring(digits.length - 4)}'; + if (_countDigits(value, 12) < 12) return kPiiMaskDots; + return '•••• •••• •••• ${_lastDigits(value, 4)}'; + } +} + +/// Number of ASCII digits in [s], counting no further than [limit]. +int _countDigits(String s, int limit) { + var n = 0; + for (var i = 0; i < s.length; i++) { + if (_isDigit(s.codeUnitAt(i))) { + n++; + if (n >= limit) return n; + } + } + return n; +} + +/// The last [take] ASCII digits of [s], in order. [s] must hold at least +/// [take] digits. +String _lastDigits(String s, int take) { + final out = List.filled(take, 0x30); + var k = take; + for (var i = s.length - 1; i >= 0 && k > 0; i--) { + final c = s.codeUnitAt(i); + if (_isDigit(c)) out[--k] = c; } + return String.fromCharCodes(out); } diff --git a/lib/core/security/safe_zip_extractor.dart b/lib/core/security/safe_zip_extractor.dart index 17d05759..8f1deeca 100644 --- a/lib/core/security/safe_zip_extractor.dart +++ b/lib/core/security/safe_zip_extractor.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:archive/archive.dart'; @@ -122,6 +123,24 @@ abstract final class SafeZipExtractor { } } + /// The decoded bytes of [entry], from an archive returned by [decodeBytes] + /// or [readAndDecodeFile]. + /// + /// The limits are checked against the sizes the zip headers declare. That + /// holds because the archive package (4.5.0+) refuses to decode an entry + /// past its declared size, so a header that understates it cannot inflate + /// to more than was checked (#1561). That refusal is a [SafeZipException]. + static Uint8List readEntry(ArchiveFile entry) { + try { + return entry.content; + } on ArchiveException catch (error) { + throw SafeZipException( + 'Archive entry "${entry.name}" is larger than its header declares ' + '(${error.message}).', + ); + } + } + static void _validateArchive( Archive archive, { required int compressedBytes, diff --git a/lib/core/security/ssh_tunnel_manager.dart b/lib/core/security/ssh_tunnel_manager.dart index eadd657c..85a754d0 100644 --- a/lib/core/security/ssh_tunnel_manager.dart +++ b/lib/core/security/ssh_tunnel_manager.dart @@ -6,6 +6,7 @@ import 'package:crypto/crypto.dart'; import 'package:dartssh2/dartssh2.dart'; import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// Result of an SSH connection diagnostic test. class SshTestResult { @@ -79,13 +80,19 @@ class _PooledTunnelSession { keepAliveTimer = null; try { await serverSocket.close(); - } catch (_) {} + } catch (e) { + logIgnored('ssh_tunnel_manager', e); + } try { unawaited(client.close()); - } catch (_) {} + } catch (e) { + logIgnored('ssh_tunnel_manager', e); + } try { unawaited(jumpClient?.close()); - } catch (_) {} + } catch (e) { + logIgnored('ssh_tunnel_manager', e); + } } } @@ -495,19 +502,29 @@ class SshTunnelManager { keepAliveTimer?.cancel(); try { await serverSocket?.close(); - } catch (_) {} + } catch (e) { + logIgnored('ssh_tunnel_manager', e); + } try { unawaited(client?.close()); - } catch (_) {} + } catch (e) { + logIgnored('ssh_tunnel_manager', e); + } try { bastionSocket?.destroy(); - } catch (_) {} + } catch (e) { + logIgnored('ssh_tunnel_manager', e); + } try { unawaited(jumpClient?.close()); - } catch (_) {} + } catch (e) { + logIgnored('ssh_tunnel_manager', e); + } try { jumpSocket?.destroy(); - } catch (_) {} + } catch (e) { + logIgnored('ssh_tunnel_manager', e); + } rethrow; } } diff --git a/lib/core/security/ssl_certificate_support.dart b/lib/core/security/ssl_certificate_support.dart index ae6e3852..b51022f4 100644 --- a/lib/core/security/ssl_certificate_support.dart +++ b/lib/core/security/ssl_certificate_support.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:file_selector/file_selector.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// Querya-standard SSL certificate query parameters (aligned with PostgreSQL). const kSslRootCertParam = 'sslrootcert'; @@ -55,7 +56,8 @@ SslCertificatePaths extractSslCertificatePathsFromString(String? raw) { clientCert: params[kSslCertParam], clientKey: params[kSslKeyParam], ); - } catch (_) { + } catch (e) { + logIgnored('ssl_certificate_support.paths', e); return const SslCertificatePaths(); } } @@ -169,14 +171,28 @@ Future resolveMongoTlsCertificateKeyFile({ if (!Platform.isWindows) { try { await Process.run('chmod', ['700', dir.path]); - } catch (_) {} + } catch (e) { + logIgnored('ssl_certificate_support', e); + } } final merged = File('${dir.path}/client.pem'); await merged.writeAsString('$certBytes\n$keyBytes\n', flush: true); if (!Platform.isWindows) { + // `Process.run` does not throw on a non-zero exit, so check it: a key + // file that stayed at the default mode must not be handed out silently. try { - await Process.run('chmod', ['600', merged.path]); - } catch (_) {} + final result = await Process.run('chmod', ['600', merged.path]); + if (result.exitCode != 0) { + throw FileSystemException( + 'Could not restrict permissions of the TLS client key file: ' + '${result.stderr}'.trim(), + merged.path, + ); + } + } catch (_) { + await cleanupMongoTlsTempFile(merged.path); + rethrow; + } } return merged.path; } @@ -199,7 +215,9 @@ Future cleanupMongoTlsTempFile(String? filePath) async { if (await parent.exists()) { await parent.delete(recursive: true); } - } catch (_) {} + } catch (e) { + logIgnored('ssl_certificate_support', e); + } } /// Cleans up any stale temporary MongoDB TLS directories left from previous sessions. @@ -214,11 +232,15 @@ Future cleanupStaleMongoTlsTempFiles() async { if (name.startsWith(kMongoTlsTempPrefix)) { try { await entity.delete(recursive: true); - } catch (_) {} + } catch (e) { + logIgnored('ssl_certificate_support', e); + } } } } - } catch (_) {} + } catch (e) { + logIgnored('ssl_certificate_support', e); + } } String buildRedisConnectionUri({ diff --git a/lib/core/storage/activity_log_repository.dart b/lib/core/storage/activity_log_repository.dart new file mode 100644 index 00000000..5f12c766 --- /dev/null +++ b/lib/core/storage/activity_log_repository.dart @@ -0,0 +1,105 @@ +import 'package:flutter/foundation.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/storage/local_db_models.dart'; + +/// The local audit trails: executed data / schema changes and MCP tool calls. +/// Part of [LocalDb], which keeps the same methods and delegates here. +class ActivityLogRepository { + ActivityLogRepository(this._open); + + final Future Function() _open; + + var _mutationAuditInsertCount = 0; + + /// Appends one executed mutation to the audit trail and trims the table to + /// [kMutationAuditCap] rows. Never throws: auditing must not break a query. + Future recordMutationAudit({ + int? connectionId, + required String connectionName, + String? environment, + String? databaseName, + required String sqlText, + int? rowsAffected, + required MutationAuditSource source, + }) async { + final sql = sqlText.trim(); + if (sql.isEmpty) return; + try { + final db = await _open(); + await db.insert('mutation_audit', { + 'connection_id': connectionId, + 'connection_name': connectionName, + 'environment': environment, + 'database_name': normalizeHistoryDatabaseName(databaseName), + 'sql_text': sql, + 'rows_affected': rowsAffected, + 'source': source.storageValue, + 'recorded_at': DateTime.now().toUtc().toIso8601String(), + }); + _mutationAuditInsertCount++; + if (_mutationAuditInsertCount >= 50) { + _mutationAuditInsertCount = 0; + await db.rawDelete( + 'DELETE FROM mutation_audit WHERE id <= (' + 'SELECT id FROM mutation_audit ORDER BY id DESC LIMIT 1 OFFSET ?)', + [kMutationAuditCap], + ); + } + } catch (e) { + // Best effort, but a missing audit trail should not be invisible. + debugPrint('LocalDb: could not record a mutation audit entry: $e'); + } + } + + /// Newest-first audit entries, optionally for one connection. + Future> listMutationAudit({ + int? connectionId, + int limit = 200, + }) async { + final db = await _open(); + final rows = await db.query( + 'mutation_audit', + where: connectionId == null ? null : 'connection_id = ?', + whereArgs: connectionId == null ? null : [connectionId], + orderBy: 'id DESC', + limit: limit, + ); + return rows.map(MutationAuditEntry.fromMap).toList(); + } + + Future clearMutationAudit() async { + final db = await _open(); + await db.delete('mutation_audit'); + _mutationAuditInsertCount = 0; + } + + /// Appends one MCP tool call and keeps the newest [kMcpActivityCap]. + /// Never throws: logging must not break a tool call. + Future recordMcpActivity(McpActivityEntry entry) async { + try { + final db = await _open(); + final map = entry.toMap()..remove('id'); + await db.insert('mcp_activity', map); + await db.rawDelete( + 'DELETE FROM mcp_activity WHERE id <= (' + 'SELECT id FROM mcp_activity ORDER BY id DESC LIMIT 1 OFFSET ?)', + [kMcpActivityCap], + ); + } catch (e) { + debugPrint('LocalDb: could not record MCP activity: $e'); + } + } + + /// Newest-first MCP tool calls. + Future> listMcpActivity({int limit = 200}) async { + final db = await _open(); + final rows = + await db.query('mcp_activity', orderBy: 'id DESC', limit: limit); + return rows.map(McpActivityEntry.fromMap).toList(); + } + + Future clearMcpActivity() async { + final db = await _open(); + await db.delete('mcp_activity'); + } +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 83e38f2a..c18d6e36 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -1,7 +1,8 @@ import 'dart:convert'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show ThemeMode; import '../motion/querya_motion_scope.dart'; import '../theme/querya_theme_preset.dart'; diff --git a/lib/core/storage/app_settings_repository.dart b/lib/core/storage/app_settings_repository.dart new file mode 100644 index 00000000..f248f4ab --- /dev/null +++ b/lib/core/storage/app_settings_repository.dart @@ -0,0 +1,60 @@ +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Key-value app settings and the saved ER diagram layouts. +/// Part of [LocalDb], which keeps the same methods and delegates here. +class AppSettingsRepository { + AppSettingsRepository(this._open); + + final Future Function() _open; + + Future getAppSetting(String key) async { + final db = await _open(); + final rows = await db.query( + 'app_settings', + columns: ['value'], + where: 'key = ?', + whereArgs: [key], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first['value'] as String?; + } + + Future setAppSetting(String key, String value) async { + final db = await _open(); + await db.rawInsert( + 'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', + [key, value], + ); + } + + Future deleteAppSetting(String key) async { + final db = await _open(); + await db.delete('app_settings', where: 'key = ?', whereArgs: [key]); + } + + Future readErdLayout(int connectionId, String scope) async { + final db = await _open(); + final rows = await db.query( + 'erd_layouts', + columns: ['payload'], + where: 'connection_id = ? AND scope = ?', + whereArgs: [connectionId, scope], + limit: 1, + ); + return rows.isEmpty ? null : rows.first['payload'] as String?; + } + + Future writeErdLayout( + int connectionId, + String scope, + String payload, + ) async { + final db = await _open(); + await db.rawInsert( + 'INSERT OR REPLACE INTO erd_layouts ' + '(connection_id, scope, payload, updated_at) VALUES (?, ?, ?, ?)', + [connectionId, scope, payload, DateTime.now().toUtc().toIso8601String()], + ); + } +} diff --git a/lib/core/storage/connection_repository.dart b/lib/core/storage/connection_repository.dart new file mode 100644 index 00000000..6d49ead5 --- /dev/null +++ b/lib/core/storage/connection_repository.dart @@ -0,0 +1,196 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/storage/local_db_models.dart'; + +/// Saved connections: rows, secrets and SSH configuration kept in step. +/// Part of [LocalDb], which keeps the same methods and delegates here. +class ConnectionRepository { + ConnectionRepository(this._open, {this.onRemoved}); + + final Future Function() _open; + + /// Told the id of each connection removed, for state kept elsewhere. + final void Function(int id)? onRemoved; + + /// Returns all saved connections. + /// + /// By default, passwords and connection strings are NOT eagerly hydrated from + /// the platform secure store to avoid IPC bottlenecks, Keychain lockups, and + /// D-Bus timeouts during sidebar/startup population. Secrets are resolved + /// on-demand when a connection is initiated. + Future> getConnections( + {bool hydrateSecrets = false}) async { + final db = await _open(); + final rows = + await db.query('connections', orderBy: 'sort_order ASC, name ASC'); + if (!hydrateSecrets) { + return rows.map((m) => ConnectionRow.fromMap(m)).toList(); + } + final futures = + rows.map((m) => _hydrateConnection(ConnectionRow.fromMap(m))); + return Future.wait(futures); + } + + static Future _hydrateConnection(ConnectionRow row) async { + if (row.id == null) return row; + try { + final secrets = await ConnectionSecretsStore.readForConnection(row.id!); + return row.copyWith( + password: secrets.password ?? row.password, + connectionString: secrets.connectionString ?? row.connectionString, + ); + } catch (e) { + debugPrint( + 'LocalDb._hydrateConnection failed for connection ${row.id}: $e'); + return row; + } + } + + /// Hydrates secrets for a single [ConnectionRow] on demand. + Future hydrateConnection(ConnectionRow row) => + _hydrateConnection(row); + + /// Retrieves a single connection by [id], optionally hydrating secrets. + Future getConnectionById(int id, + {bool hydrateSecrets = false}) async { + final db = await _open(); + final rows = + await db.query('connections', where: 'id = ?', whereArgs: [id]); + if (rows.isEmpty) return null; + final row = ConnectionRow.fromMap(rows.first); + if (!hydrateSecrets) return row; + return _hydrateConnection(row); + } + + /// Inserts a row and returns the SQLite row id. + /// Password and connection string are stored in the OS secure store, not in SQLite. + /// + /// If writing secrets fails, the SQLite row is rolled back and the error is + /// rethrown so callers can surface a Keychain / libsecret failure. + Future addConnection(ConnectionRow row) async { + final db = await _open(); + final id = await db.insert('connections', row.toPersistenceMap()); + try { + await ConnectionSecretsStore.writeForConnection( + id, + password: row.password, + connectionString: row.connectionString, + ); + if (row.sshSecrets != null) { + await ConnectionSecretsStore.writeSshSecretsForConnection( + id, + password: row.sshSecrets!.password, + privateKey: row.sshSecrets!.privateKey, + passphrase: row.sshSecrets!.passphrase, + jumpPassword: row.sshSecrets!.jumpPassword, + ); + } + } catch (e) { + try { + await ConnectionSecretsStore.deleteForConnection(id); + } catch (_) { + // Best-effort cleanup of any partial secret writes. + } + await db.delete('connections', where: 'id = ?', whereArgs: [id]); + rethrow; + } + return id; + } + + /// Updates an existing connection row in SQLite and its secrets in the secure store. + /// + /// If writing secrets fails, the previous SQLite row and previous secrets are + /// restored (best effort) and the error is rethrown. + Future updateConnection(ConnectionRow row) async { + if (row.id == null) { + throw ArgumentError( + 'ConnectionRow.id cannot be null when calling updateConnection'); + } + final db = await _open(); + final previousMaps = await db.query( + 'connections', + where: 'id = ?', + whereArgs: [row.id], + ); + if (previousMaps.isEmpty) { + throw ArgumentError('No connection found with id ${row.id}'); + } + final previousRow = ConnectionRow.fromMap(previousMaps.first); + final previousSecrets = + await ConnectionSecretsStore.readForConnection(row.id!); + final previousSsh = + await ConnectionSecretsStore.readSshSecretsForConnection(row.id!); + + await db.transaction((txn) async { + final count = await txn.update( + 'connections', + row.toPersistenceMap(), + where: 'id = ?', + whereArgs: [row.id], + ); + if (count == 0) { + throw ArgumentError('No connection found with id ${row.id}'); + } + }); + + try { + await ConnectionSecretsStore.writeForConnection( + row.id!, + password: row.password, + connectionString: row.connectionString, + ); + if (row.sshSecrets != null) { + await ConnectionSecretsStore.writeSshSecretsForConnection( + row.id!, + password: row.sshSecrets!.password, + privateKey: row.sshSecrets!.privateKey, + passphrase: row.sshSecrets!.passphrase, + jumpPassword: row.sshSecrets!.jumpPassword, + ); + } else if (row.sshTunnelConfig?.enabled != true) { + // The tunnel was turned off: its password and key do not stay in the + // keyring (#1311). + await ConnectionSecretsStore.writeSshSecretsForConnection(row.id!); + } + } catch (e) { + await db.update( + 'connections', + previousRow.toPersistenceMap(), + where: 'id = ?', + whereArgs: [row.id], + ); + try { + await ConnectionSecretsStore.writeForConnection( + row.id!, + password: previousSecrets.password, + connectionString: previousSecrets.connectionString, + ); + // The SSH secrets may be half written too (four keys, one by one). + await ConnectionSecretsStore.writeSshSecretsForConnection( + row.id!, + password: previousSsh.password, + privateKey: previousSsh.privateKey, + passphrase: previousSsh.passphrase, + jumpPassword: previousSsh.jumpPassword, + ); + } catch (_) { + // Best-effort restore of previous secrets; surface the original error. + } + rethrow; + } + } + + /// Deletes a connection. SQLite deletion always proceeds even if the secure + /// store delete fails (e.g. missing key or unavailable libsecret daemon). + Future removeConnection(int id) async { + onRemoved?.call(id); + try { + await ConnectionSecretsStore.deleteForConnection(id); + } catch (_) { + // Do not block removing the connection metadata when the OS store fails. + } + final db = await _open(); + await db.delete('connections', where: 'id = ?', whereArgs: [id]); + } +} diff --git a/lib/core/storage/connection_secrets_store.dart b/lib/core/storage/connection_secrets_store.dart index 363eb254..bb49b1bd 100644 --- a/lib/core/storage/connection_secrets_store.dart +++ b/lib/core/storage/connection_secrets_store.dart @@ -1,4 +1,6 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// Backend for reading/writing per-connection secrets (password, connection string). /// In unit/widget tests, set [ConnectionSecretsStore.backend] to a memory implementation. @@ -9,8 +11,9 @@ abstract class SecretsStorageBackend { } class _FlutterSecureStorageBackend implements SecretsStorageBackend { - /// Desktop targets use the default platform options from the plugin. - final FlutterSecureStorage _storage = const FlutterSecureStorage(); + final FlutterSecureStorage _storage = const FlutterSecureStorage( + mOptions: ConnectionSecretsStore.macOsOptions, + ); @override Future read(String key) => _storage.read(key: key); @@ -60,6 +63,15 @@ class ConnectionSecretsStore { /// Production uses OS-backed storage; tests override [backend] (see `test/flutter_test_config.dart`). static SecretsStorageBackend backend = _FlutterSecureStorageBackend(); + /// macOS: the file-based login keychain. The plugin's default, the data + /// protection keychain, needs the `keychain-access-groups` entitlement, + /// which none of the builds carry (a restricted entitlement: a Developer ID + /// build with it needs a provisioning profile or does not launch), so every + /// read and write failed with errSecMissingEntitlement (#1586). + @visibleForTesting + static const MacOsOptions macOsOptions = + MacOsOptions(usesDataProtectionKeychain: false); + /// Identifies the current profile's local database (installed / portable / /// migrated-legacy — see `AppDataRoot`), so that two independent profile /// databases never collide in the shared OS keyring even when they each @@ -215,12 +227,16 @@ class ConnectionSecretsStore { if (legacyPassword != null) { try { await backend.delete(_legacyPasswordKey(connectionId)); - } catch (_) {} + } catch (e) { + logIgnored('connection_secrets_store', e); + } } if (legacyConnectionString != null) { try { await backend.delete(_legacyConnectionStringKey(connectionId)); - } catch (_) {} + } catch (e) { + logIgnored('connection_secrets_store', e); + } } password = effectivePassword; connectionString = effectiveConnectionString; @@ -242,7 +258,9 @@ class ConnectionSecretsStore { try { await backend.delete(_legacyPasswordKey(connectionId)); await backend.delete(_legacyConnectionStringKey(connectionId)); - } catch (_) {} + } catch (e) { + logIgnored('connection_secrets_store', e); + } } /// One-time migration (issue #986): before keyring keys were namespaced by diff --git a/lib/core/storage/folder_repository.dart b/lib/core/storage/folder_repository.dart new file mode 100644 index 00000000..96afaad0 --- /dev/null +++ b/lib/core/storage/folder_repository.dart @@ -0,0 +1,65 @@ +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/storage/local_db_models.dart'; + +/// Folders: names, order, and which connection sits in which. +/// Part of [LocalDb], which keeps the same methods and delegates here. +class FolderRepository { + FolderRepository(this._open); + + final Future Function() _open; + + Future> getFolders() async { + final db = await _open(); + final rows = await db.query('folders', orderBy: 'sort_order ASC, name ASC'); + return rows.map((r) => r['name'] as String).toList(); + } + + Future addFolder(String name) async { + final n = name.trim(); + if (n.isEmpty) return; + final db = await _open(); + await db.insert('folders', {'name': n}); + } + + /// Removes the folder; its connections stay, without a folder. + /// + /// `connections.folder_id` is `ON DELETE CASCADE`, which would delete them + /// with it. They are detached first, so the cascade finds nothing: no schema + /// migration of the connections table is needed. + Future removeFolder(String name) async { + final db = await _open(); + await db.transaction((txn) async { + await txn.rawUpdate( + 'UPDATE connections SET folder_id = NULL WHERE folder_id IN ' + '(SELECT id FROM folders WHERE name = ?)', + [name], + ); + await txn.delete('folders', where: 'name = ?', whereArgs: [name]); + }); + } + + /// Moves a connection into the folder [folderId], or out of any folder when + /// it is null. Only the folder changes: secrets and settings are untouched. + Future setConnectionFolder(int connectionId, int? folderId) async { + final db = await _open(); + await db.update( + 'connections', + {'folder_id': folderId}, + where: 'id = ?', + whereArgs: [connectionId], + ); + } + + Future clearFolders() async { + final db = await _open(); + await db.delete('folders'); + } + + Future getFolderIdByName(String name) async { + final db = await _open(); + final rows = await db.query('folders', + columns: ['id'], where: 'name = ?', whereArgs: [name]); + if (rows.isEmpty) return null; + return sqliteInt(rows.first['id']); + } +} diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index ffae4447..323b477e 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -1,43 +1,51 @@ -import 'dart:convert'; import 'dart:io'; -import 'dart:math'; -import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; -import 'package:querya_desktop/core/security/connection_environment.dart'; -import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/storage/app_data_root.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:querya_desktop/core/storage/folder_repository.dart'; +import 'package:querya_desktop/core/storage/sql_query_history_repository.dart'; +import 'package:querya_desktop/core/storage/activity_log_repository.dart'; +import 'package:querya_desktop/core/storage/app_settings_repository.dart'; +import 'package:querya_desktop/core/storage/connection_repository.dart'; +import 'package:querya_desktop/core/storage/local_db_models.dart'; +import 'package:querya_desktop/core/storage/local_db_schema.dart'; + +export 'package:querya_desktop/core/storage/local_db_models.dart'; +import 'package:querya_desktop/core/storage/update_backup.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; const _dbName = 'querya.db'; -const _dbVersion = 13; -/// `app_settings` key under which each profile database's random id is -/// stored (see [LocalDb._ensureProfileId] and issue #986). -const _profileIdSettingKey = 'profile_id'; +/// The local database was written by a newer Querya Desktop: its schema +/// version is above the one this build knows (#1497). +/// +/// Opening it would have let sqflite write the older version number into it +/// and carry on with tables it does not understand. The open is refused +/// instead, inside the open transaction, so nothing in the file changes. +class LocalDbNewerSchemaException implements Exception { + const LocalDbNewerSchemaException({ + required this.found, + required this.supported, + required this.path, + }); -String _generateProfileId() { - final rand = Random.secure(); - final bytes = List.generate(16, (_) => rand.nextInt(256)); - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); -} + /// Schema version stored in the file. + final int found; -/// Fallback when [recordSqlQueryHistory] is called without `maxEntries`. -/// Keep in sync with [kDefaultSqlHistoryMaxEntries] in `app_settings.dart`. -const int kDefaultSqlHistoryCap = 100; + /// Highest schema version this build reads. + final int supported; -int? _sqliteInt(Object? v) { - if (v == null) return null; - if (v is int) return v; - if (v is num) return v.toInt(); - return int.tryParse(v.toString()); -} + final String path; -String? _normalizeHistoryDatabaseName(String? databaseName) { - final t = databaseName?.trim(); - if (t == null || t.isEmpty) return null; - return t; + @override + String toString() => + 'The local data in $path was written by a newer version of Querya ' + 'Desktop (data format $found; this version reads up to $supported). ' + 'It has not been changed. Install the newer version again, or restore ' + 'a copy made before the update (querya.db.pre--.bak in the ' + 'same folder).'; } /// Local SQLite database for folders and connections. @@ -81,15 +89,18 @@ class LocalDb { final db = await databaseFactoryFfi.openDatabase( _cachedDbPath!, options: OpenDatabaseOptions( - version: _dbVersion, - onCreate: _onCreate, - onUpgrade: _onUpgrade, - onOpen: (db) async { - await db.execute('PRAGMA foreign_keys = ON'); - await db.execute('PRAGMA journal_mode = WAL'); - await db.execute('PRAGMA busy_timeout = 5000'); - await db.execute('PRAGMA synchronous = NORMAL'); - }, + version: LocalDbSchema.version, + onCreate: LocalDbSchema.onCreate, + onUpgrade: LocalDbSchema.onUpgrade, + // A newer schema: refuse, so sqflite does not stamp the old version + // on it (the open transaction rolls back). + onDowngrade: (db, found, supported) => + throw LocalDbNewerSchemaException( + found: found, + supported: supported, + path: _cachedDbPath!, + ), + onOpen: LocalDbSchema.onOpen, ), ); _db = db; @@ -97,327 +108,35 @@ class LocalDb { // Safety net for the common case (db already at the current version, so // neither onCreate nor onUpgrade ran this open): _onCreate/_onUpgrade // already set this when they do run, ahead of any secret read/write. - ConnectionSecretsStore.profileId ??= await _ensureProfileId(db); + ConnectionSecretsStore.profileId ??= + await LocalDbSchema.ensureProfileId(db); return db; } - /// Returns this profile database's random id (see issue #986), generating - /// and persisting one on first use. Namespaces [ConnectionSecretsStore] - /// keys so two profile databases never collide in the shared OS keyring. - Future _ensureProfileId(Database db) async { - final rows = await db.query( - 'app_settings', - columns: ['value'], - where: 'key = ?', - whereArgs: [_profileIdSettingKey], - limit: 1, - ); - final existing = rows.isNotEmpty ? rows.first['value'] as String? : null; - if (existing != null && existing.isNotEmpty) return existing; - final id = _generateProfileId(); - await db.rawInsert( - 'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', - [_profileIdSettingKey, id], - ); - return id; - } + // One repository per area; the methods below delegate to them. + late final FolderRepository folders = FolderRepository(_open); + late final SqlQueryHistoryRepository sqlHistory = + SqlQueryHistoryRepository(_open); + late final ActivityLogRepository activity = ActivityLogRepository(_open); + late final AppSettingsRepository settings = AppSettingsRepository(_open); + late final ConnectionRepository connections = ConnectionRepository( + _open, + onRemoved: sqlHistory.forgetConnection, + ); - /// Queries an active PRAGMA setting from the database for verification and diagnostic purposes. - Future getPragma(String pragmaName) async { - final db = await _open(); - final res = await db.rawQuery('PRAGMA $pragmaName'); - if (res.isNotEmpty && res.first.values.isNotEmpty) { - return res.first.values.first.toString(); - } - return ''; - } + Future> getFolders() => folders.getFolders(); - Future _onCreate(Database db, int version) async { - await db.execute(''' - CREATE TABLE folders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - sort_order INTEGER NOT NULL DEFAULT 0 - ) - '''); - await db.execute(''' - CREATE TABLE connections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, - name TEXT NOT NULL, - host TEXT, - port INTEGER, - username TEXT, - password TEXT, - database_name TEXT, - auth_source TEXT, - use_ssl INTEGER NOT NULL DEFAULT 0, - connection_string TEXT, - extension_id TEXT, - driver_options TEXT, - folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL - ) - '''); - await db.execute(''' - CREATE TABLE app_settings ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT NOT NULL - ) - '''); - await db.execute(''' - CREATE TABLE sql_query_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, - database_name TEXT, - sql_text TEXT NOT NULL, - recorded_at TEXT NOT NULL - ) - '''); - await db.execute(''' - CREATE INDEX idx_sql_query_history_lookup - ON sql_query_history (connection_id, database_name, recorded_at DESC, id DESC) - '''); - await _createMutationAuditTable(db); - await _createMcpActivityTable(db); - await _createErdLayoutsTable(db); - ConnectionSecretsStore.profileId = await _ensureProfileId(db); - } + Future addFolder(String name) => folders.addFolder(name); - /// Local audit trail of executed data / schema changes (#1062). Not linked - /// to `connections` so entries outlive a deleted connection; the connection - /// name is stored as a snapshot. - Future _createMutationAuditTable(Database db) async { - await db.execute(''' - CREATE TABLE IF NOT EXISTS mutation_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - connection_id INTEGER, - connection_name TEXT NOT NULL, - environment TEXT, - database_name TEXT, - sql_text TEXT NOT NULL, - rows_affected INTEGER, - source TEXT NOT NULL, - recorded_at TEXT NOT NULL - ) - '''); - await db.execute(''' - CREATE INDEX IF NOT EXISTS idx_mutation_audit_recorded - ON mutation_audit (id DESC) - '''); - } + Future removeFolder(String name) => folders.removeFolder(name); - /// Calls made by MCP clients (#1136), newest kept, capped at - /// [kMcpActivityCap]. Connection name is a snapshot, like the audit. - Future _createMcpActivityTable(Database db) async { - await db.execute(''' - CREATE TABLE IF NOT EXISTS mcp_activity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at TEXT NOT NULL, - client TEXT NOT NULL, - tool TEXT NOT NULL, - connection_id INTEGER, - connection_name TEXT, - sql_text TEXT, - row_count INTEGER, - duration_ms INTEGER NOT NULL, - error TEXT, - refusal_rule TEXT - ) - '''); - } + Future setConnectionFolder(int connectionId, int? folderId) => + folders.setConnectionFolder(connectionId, folderId); - Future _onUpgrade(Database db, int oldVersion, int newVersion) async { - // Every upgrade path needs app_settings before it can read/write the - // profile id; the oldVersion < 4 block below also creates this table for - // installs that predate it, but IF NOT EXISTS keeps this idempotent. - await db.execute(''' - CREATE TABLE IF NOT EXISTS app_settings ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT NOT NULL - ) - '''); - ConnectionSecretsStore.profileId = await _ensureProfileId(db); + Future clearFolders() => folders.clearFolders(); - if (oldVersion < 2) { - await db.execute('ALTER TABLE connections ADD COLUMN password TEXT'); - await db.execute('ALTER TABLE connections ADD COLUMN database_name TEXT'); - await db.execute('ALTER TABLE connections ADD COLUMN auth_source TEXT'); - await db.execute( - 'ALTER TABLE connections ADD COLUMN use_ssl INTEGER NOT NULL DEFAULT 0'); - await db - .execute('ALTER TABLE connections ADD COLUMN connection_string TEXT'); - } - if (oldVersion < 3) { - await db.execute('PRAGMA foreign_keys=ON'); - await db.execute(''' - CREATE TABLE connections_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, - name TEXT NOT NULL, - host TEXT, - port INTEGER, - username TEXT, - password TEXT, - database_name TEXT, - auth_source TEXT, - use_ssl INTEGER NOT NULL DEFAULT 0, - connection_string TEXT, - folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL - ) - '''); - await db.execute(''' - INSERT INTO connections_new - SELECT id, type, name, host, port, username, password, database_name, - auth_source, use_ssl, connection_string, folder_id, sort_order, created_at - FROM connections - '''); - await db.execute('DROP TABLE connections'); - await db.execute('ALTER TABLE connections_new RENAME TO connections'); - } - // (oldVersion < 4 used to CREATE TABLE app_settings here; the - // CREATE TABLE IF NOT EXISTS above now covers that install path too.) - 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', - ); - } - if (oldVersion < 6) { - await db.execute(''' - CREATE TABLE sql_query_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, - database_name TEXT, - sql_text TEXT NOT NULL, - recorded_at TEXT NOT NULL - ) - '''); - await db.execute(''' - CREATE INDEX idx_sql_query_history_lookup - ON sql_query_history (connection_id, recorded_at DESC) - '''); - } - if (oldVersion < 7) { - await db.execute('ALTER TABLE connections ADD COLUMN extension_id TEXT'); - await db - .execute('ALTER TABLE connections ADD COLUMN driver_options TEXT'); - } - if (oldVersion < 8) { - await db.execute('DROP INDEX IF EXISTS idx_sql_query_history_lookup'); - await db.execute(''' - CREATE INDEX idx_sql_query_history_lookup - ON sql_query_history (connection_id, database_name, recorded_at DESC, id DESC) - '''); - } - if (oldVersion < 9) { - // Adopt this profile's own pre-#986 unnamespaced keyring secrets (if - // any) under the namespaced key generated above, so this database's - // connections keep working after another profile with an overlapping - // connection id also upgrades. - final rows = await db.query('connections', columns: ['id']); - for (final m in rows) { - final id = _sqliteInt(m['id']); - if (id == null) continue; - await ConnectionSecretsStore.adoptLegacyKeysForConnection(id); - } - } - if (oldVersion < 10) { - await _createMutationAuditTable(db); - } - if (oldVersion < 11) { - await _createMcpActivityTable(db); - } - if (oldVersion >= 11 && oldVersion < 12) { - // The rule that refused a call, next to its error text. - await db.execute('ALTER TABLE mcp_activity ADD COLUMN refusal_rule TEXT'); - } - if (oldVersion < 13) { - await _createErdLayoutsTable(db); - } - } - - /// What the user arranged on an ER diagram (#1275), one row per connection - /// and scope (database / schema), removed with its connection. - Future _createErdLayoutsTable(Database db) async { - await db.execute(''' - CREATE TABLE IF NOT EXISTS erd_layouts ( - connection_id INTEGER NOT NULL - REFERENCES connections(id) ON DELETE CASCADE, - scope TEXT NOT NULL, - payload TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (connection_id, scope) - ) - '''); - } - - Future readErdLayout(int connectionId, String scope) async { - final db = await _open(); - final rows = await db.query( - 'erd_layouts', - columns: ['payload'], - where: 'connection_id = ? AND scope = ?', - whereArgs: [connectionId, scope], - limit: 1, - ); - return rows.isEmpty ? null : rows.first['payload'] as String?; - } - - Future writeErdLayout( - int connectionId, - String scope, - String payload, - ) async { - final db = await _open(); - await db.rawInsert( - 'INSERT OR REPLACE INTO erd_layouts ' - '(connection_id, scope, payload, updated_at) VALUES (?, ?, ?, ?)', - [connectionId, scope, payload, DateTime.now().toUtc().toIso8601String()], - ); - } - - Future getAppSetting(String key) async { - final db = await _open(); - final rows = await db.query( - 'app_settings', - columns: ['value'], - where: 'key = ?', - whereArgs: [key], - limit: 1, - ); - if (rows.isEmpty) return null; - return rows.first['value'] as String?; - } - - Future setAppSetting(String key, String value) async { - final db = await _open(); - await db.rawInsert( - 'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', - [key, value], - ); - } - - Future deleteAppSetting(String key) async { - final db = await _open(); - await db.delete('app_settings', where: 'key = ?', whereArgs: [key]); - } - - final Map _historyInsertCounts = {}; + Future getFolderIdByName(String name) => + folders.getFolderIdByName(name); Future recordSqlQueryHistory({ required int connectionId, @@ -425,132 +144,32 @@ class LocalDb { required String sqlText, int maxEntries = kDefaultSqlHistoryCap, bool forcePrune = false, - }) async { - final sql = sqlText.trim(); - if (sql.isEmpty) return; - if (maxEntries < 1) return; - final db = await _open(); - final dbKey = _normalizeHistoryDatabaseName(databaseName); - final now = DateTime.now().toUtc().toIso8601String(); - await db.insert('sql_query_history', { - 'connection_id': connectionId, - 'database_name': dbKey, - 'sql_text': sql, - 'recorded_at': now, - }); - - final bucketKey = '$connectionId::${dbKey ?? ''}'; - final insertCount = (_historyInsertCounts[bucketKey] ?? 0) + 1; - _historyInsertCounts[bucketKey] = insertCount; - - final batchThreshold = maxEntries <= 10 ? 1 : 10; - if (forcePrune || insertCount >= batchThreshold) { - _historyInsertCounts[bucketKey] = 0; - await _pruneSqlQueryHistoryBucket( - db, - connectionId: connectionId, - databaseName: dbKey, - maxEntries: maxEntries, - ); - } - } - - /// Keeps the newest [maxEntries] rows in a (connection, database) bucket. - /// - /// Selects overflow ids (oldest first), then deletes by primary key — avoids - /// nested `DELETE … SELECT … OFFSET` plans as history grows. - Future _pruneSqlQueryHistoryBucket( - Database db, { - required int connectionId, - required String? databaseName, - required int maxEntries, - }) async { - final countRows = await db.rawQuery( - ''' - SELECT COUNT(*) AS c FROM sql_query_history - WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? - ''', - [connectionId, databaseName], - ); - final count = _sqliteInt(countRows.first['c']) ?? 0; - final excess = count - maxEntries; - if (excess <= 0) return; - - final overflow = await db.rawQuery( - ''' - SELECT id FROM sql_query_history - WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? - ORDER BY id ASC - LIMIT ? - ''', - [connectionId, databaseName, excess], - ); - if (overflow.isEmpty) return; - - final ids = []; - for (final row in overflow) { - final id = row['id']; - if (id != null) ids.add(id); - } - if (ids.isEmpty) return; - - final placeholders = List.filled(ids.length, '?').join(','); - await db.rawDelete( - 'DELETE FROM sql_query_history WHERE id IN ($placeholders)', - ids, - ); - } + }) => + sqlHistory.recordSqlQueryHistory( + connectionId: connectionId, + databaseName: databaseName, + sqlText: sqlText, + maxEntries: maxEntries, + forcePrune: forcePrune); Future> listSqlQueryHistory({ required int connectionId, String? databaseName, int limit = 50, - }) async { - final db = await _open(); - final dbKey = _normalizeHistoryDatabaseName(databaseName); - final rows = await db.rawQuery( - ''' - SELECT id, connection_id, database_name, sql_text, recorded_at - FROM sql_query_history - WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? - ORDER BY id DESC - LIMIT ? - ''', - [connectionId, dbKey, limit], - ); - return rows.map(SqlQueryHistoryEntry.fromMap).toList(); - } + }) => + sqlHistory.listSqlQueryHistory( + connectionId: connectionId, databaseName: databaseName, limit: limit); - /// Removes all history rows for [connectionId] (every database bucket). - Future clearSqlQueryHistoryForConnection(int connectionId) async { - _historyInsertCounts.removeWhere((k, _) => k.startsWith('$connectionId::')); - final db = await _open(); - await db.delete( - 'sql_query_history', - where: 'connection_id = ?', - whereArgs: [connectionId], - ); - } + Future clearSqlQueryHistoryForConnection(int connectionId) => + sqlHistory.clearSqlQueryHistoryForConnection(connectionId); - /// Removes history for [connectionId] and [databaseName] only (same bucket as [listSqlQueryHistory]). Future clearSqlQueryHistoryBucket({ required int connectionId, String? databaseName, - }) async { - final dbKey = _normalizeHistoryDatabaseName(databaseName); - _historyInsertCounts.remove('$connectionId::${dbKey ?? ''}'); - final db = await _open(); - await db.rawDelete( - ''' - DELETE FROM sql_query_history - WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? - ''', - [connectionId, dbKey], - ); - } + }) => + sqlHistory.clearSqlQueryHistoryBucket( + connectionId: connectionId, databaseName: databaseName); - /// Appends one executed mutation to the audit trail and trims the table to - /// [kMutationAuditCap] rows. Never throws: auditing must not break a query. Future recordMutationAudit({ int? connectionId, required String connectionName, @@ -559,323 +178,75 @@ class LocalDb { required String sqlText, int? rowsAffected, required MutationAuditSource source, - }) async { - final sql = sqlText.trim(); - if (sql.isEmpty) return; - try { - final db = await _open(); - await db.insert('mutation_audit', { - 'connection_id': connectionId, - 'connection_name': connectionName, - 'environment': environment, - 'database_name': _normalizeHistoryDatabaseName(databaseName), - 'sql_text': sql, - 'rows_affected': rowsAffected, - 'source': source.storageValue, - 'recorded_at': DateTime.now().toUtc().toIso8601String(), - }); - _mutationAuditInsertCount++; - if (_mutationAuditInsertCount >= 50) { - _mutationAuditInsertCount = 0; - await db.rawDelete( - 'DELETE FROM mutation_audit WHERE id <= (' - 'SELECT id FROM mutation_audit ORDER BY id DESC LIMIT 1 OFFSET ?)', - [kMutationAuditCap], - ); - } - } catch (_) { - // Best effort. - } - } - - var _mutationAuditInsertCount = 0; + }) => + activity.recordMutationAudit( + connectionId: connectionId, + connectionName: connectionName, + environment: environment, + databaseName: databaseName, + sqlText: sqlText, + rowsAffected: rowsAffected, + source: source); - /// Newest-first audit entries, optionally for one connection. Future> listMutationAudit({ int? connectionId, int limit = 200, - }) async { - final db = await _open(); - final rows = await db.query( - 'mutation_audit', - where: connectionId == null ? null : 'connection_id = ?', - whereArgs: connectionId == null ? null : [connectionId], - orderBy: 'id DESC', - limit: limit, - ); - return rows.map(MutationAuditEntry.fromMap).toList(); - } + }) => + activity.listMutationAudit(connectionId: connectionId, limit: limit); - Future clearMutationAudit() async { - final db = await _open(); - await db.delete('mutation_audit'); - _mutationAuditInsertCount = 0; - } + Future clearMutationAudit() => activity.clearMutationAudit(); - /// Appends one MCP tool call and keeps the newest [kMcpActivityCap]. - /// Never throws: logging must not break a tool call. - Future recordMcpActivity(McpActivityEntry entry) async { - try { - final db = await _open(); - final map = entry.toMap()..remove('id'); - await db.insert('mcp_activity', map); - await db.rawDelete( - 'DELETE FROM mcp_activity WHERE id <= (' - 'SELECT id FROM mcp_activity ORDER BY id DESC LIMIT 1 OFFSET ?)', - [kMcpActivityCap], - ); - } catch (_) { - // Best effort. - } - } + Future recordMcpActivity(McpActivityEntry entry) => + activity.recordMcpActivity(entry); - /// Newest-first MCP tool calls. - Future> listMcpActivity({int limit = 200}) async { - final db = await _open(); - final rows = - await db.query('mcp_activity', orderBy: 'id DESC', limit: limit); - return rows.map(McpActivityEntry.fromMap).toList(); - } - - Future clearMcpActivity() async { - final db = await _open(); - await db.delete('mcp_activity'); - } - - Future> getFolders() async { - final db = await _open(); - final rows = await db.query('folders', orderBy: 'sort_order ASC, name ASC'); - return rows.map((r) => r['name'] as String).toList(); - } + Future> listMcpActivity({int limit = 200}) => + activity.listMcpActivity(limit: limit); - Future addFolder(String name) async { - final n = name.trim(); - if (n.isEmpty) return; - final db = await _open(); - await db.insert('folders', {'name': n}); - } + Future clearMcpActivity() => activity.clearMcpActivity(); - /// Removes the folder; its connections stay, without a folder. - /// - /// `connections.folder_id` is `ON DELETE CASCADE`, which would delete them - /// with it. They are detached first, so the cascade finds nothing: no schema - /// migration of the connections table is needed. - Future removeFolder(String name) async { - final db = await _open(); - await db.transaction((txn) async { - await txn.rawUpdate( - 'UPDATE connections SET folder_id = NULL WHERE folder_id IN ' - '(SELECT id FROM folders WHERE name = ?)', - [name], - ); - await txn.delete('folders', where: 'name = ?', whereArgs: [name]); - }); - } + Future getAppSetting(String key) => settings.getAppSetting(key); - /// Moves a connection into the folder [folderId], or out of any folder when - /// it is null. Only the folder changes: secrets and settings are untouched. - Future setConnectionFolder(int connectionId, int? folderId) async { - final db = await _open(); - await db.update( - 'connections', - {'folder_id': folderId}, - where: 'id = ?', - whereArgs: [connectionId], - ); - } + Future setAppSetting(String key, String value) => + settings.setAppSetting(key, value); - Future clearFolders() async { - final db = await _open(); - await db.delete('folders'); - } + Future deleteAppSetting(String key) => settings.deleteAppSetting(key); - Future getFolderIdByName(String name) async { - final db = await _open(); - final rows = await db.query('folders', - columns: ['id'], where: 'name = ?', whereArgs: [name]); - if (rows.isEmpty) return null; - return _sqliteInt(rows.first['id']); - } + Future readErdLayout(int connectionId, String scope) => + settings.readErdLayout(connectionId, scope); - /// Returns all saved connections. - /// - /// By default, passwords and connection strings are NOT eagerly hydrated from - /// the platform secure store to avoid IPC bottlenecks, Keychain lockups, and - /// D-Bus timeouts during sidebar/startup population. Secrets are resolved - /// on-demand when a connection is initiated. - Future> getConnections( - {bool hydrateSecrets = false}) async { - final db = await _open(); - final rows = - await db.query('connections', orderBy: 'sort_order ASC, name ASC'); - if (!hydrateSecrets) { - return rows.map((m) => ConnectionRow.fromMap(m)).toList(); - } - final futures = - rows.map((m) => _hydrateConnection(ConnectionRow.fromMap(m))); - return Future.wait(futures); - } + Future writeErdLayout( + int connectionId, + String scope, + String payload, + ) => + settings.writeErdLayout(connectionId, scope, payload); - static Future _hydrateConnection(ConnectionRow row) async { - if (row.id == null) return row; - try { - final secrets = await ConnectionSecretsStore.readForConnection(row.id!); - return row.copyWith( - password: secrets.password ?? row.password, - connectionString: secrets.connectionString ?? row.connectionString, - ); - } catch (e) { - debugPrint( - 'LocalDb._hydrateConnection failed for connection ${row.id}: $e'); - return row; - } - } + Future> getConnections({bool hydrateSecrets = false}) => + connections.getConnections(hydrateSecrets: hydrateSecrets); - /// Hydrates secrets for a single [ConnectionRow] on demand. Future hydrateConnection(ConnectionRow row) => - _hydrateConnection(row); + connections.hydrateConnection(row); - /// Retrieves a single connection by [id], optionally hydrating secrets. Future getConnectionById(int id, - {bool hydrateSecrets = false}) async { - final db = await _open(); - final rows = - await db.query('connections', where: 'id = ?', whereArgs: [id]); - if (rows.isEmpty) return null; - final row = ConnectionRow.fromMap(rows.first); - if (!hydrateSecrets) return row; - return _hydrateConnection(row); - } + {bool hydrateSecrets = false}) => + connections.getConnectionById(id, hydrateSecrets: hydrateSecrets); - /// Inserts a row and returns the SQLite row id. - /// Password and connection string are stored in the OS secure store, not in SQLite. - /// - /// If writing secrets fails, the SQLite row is rolled back and the error is - /// rethrown so callers can surface a Keychain / libsecret failure. - Future addConnection(ConnectionRow row) async { - final db = await _open(); - final id = await db.insert('connections', row.toPersistenceMap()); - try { - await ConnectionSecretsStore.writeForConnection( - id, - password: row.password, - connectionString: row.connectionString, - ); - if (row.sshSecrets != null) { - await ConnectionSecretsStore.writeSshSecretsForConnection( - id, - password: row.sshSecrets!.password, - privateKey: row.sshSecrets!.privateKey, - passphrase: row.sshSecrets!.passphrase, - jumpPassword: row.sshSecrets!.jumpPassword, - ); - } - } catch (e) { - try { - await ConnectionSecretsStore.deleteForConnection(id); - } catch (_) { - // Best-effort cleanup of any partial secret writes. - } - await db.delete('connections', where: 'id = ?', whereArgs: [id]); - rethrow; - } - return id; - } + Future addConnection(ConnectionRow row) => + connections.addConnection(row); - /// Updates an existing connection row in SQLite and its secrets in the secure store. - /// - /// If writing secrets fails, the previous SQLite row and previous secrets are - /// restored (best effort) and the error is rethrown. - Future updateConnection(ConnectionRow row) async { - if (row.id == null) { - throw ArgumentError( - 'ConnectionRow.id cannot be null when calling updateConnection'); - } - final db = await _open(); - final previousMaps = await db.query( - 'connections', - where: 'id = ?', - whereArgs: [row.id], - ); - if (previousMaps.isEmpty) { - throw ArgumentError('No connection found with id ${row.id}'); - } - final previousRow = ConnectionRow.fromMap(previousMaps.first); - final previousSecrets = - await ConnectionSecretsStore.readForConnection(row.id!); - final previousSsh = - await ConnectionSecretsStore.readSshSecretsForConnection(row.id!); + Future updateConnection(ConnectionRow row) => + connections.updateConnection(row); - await db.transaction((txn) async { - final count = await txn.update( - 'connections', - row.toPersistenceMap(), - where: 'id = ?', - whereArgs: [row.id], - ); - if (count == 0) { - throw ArgumentError('No connection found with id ${row.id}'); - } - }); + Future removeConnection(int id) => connections.removeConnection(id); - try { - await ConnectionSecretsStore.writeForConnection( - row.id!, - password: row.password, - connectionString: row.connectionString, - ); - if (row.sshSecrets != null) { - await ConnectionSecretsStore.writeSshSecretsForConnection( - row.id!, - password: row.sshSecrets!.password, - privateKey: row.sshSecrets!.privateKey, - passphrase: row.sshSecrets!.passphrase, - jumpPassword: row.sshSecrets!.jumpPassword, - ); - } else if (row.sshTunnelConfig?.enabled != true) { - // The tunnel was turned off: its password and key do not stay in the - // keyring (#1311). - await ConnectionSecretsStore.writeSshSecretsForConnection(row.id!); - } - } catch (e) { - await db.update( - 'connections', - previousRow.toPersistenceMap(), - where: 'id = ?', - whereArgs: [row.id], - ); - try { - await ConnectionSecretsStore.writeForConnection( - row.id!, - password: previousSecrets.password, - connectionString: previousSecrets.connectionString, - ); - // The SSH secrets may be half written too (four keys, one by one). - await ConnectionSecretsStore.writeSshSecretsForConnection( - row.id!, - password: previousSsh.password, - privateKey: previousSsh.privateKey, - passphrase: previousSsh.passphrase, - jumpPassword: previousSsh.jumpPassword, - ); - } catch (_) { - // Best-effort restore of previous secrets; surface the original error. - } - rethrow; - } - } - - /// Deletes a connection. SQLite deletion always proceeds even if the secure - /// store delete fails (e.g. missing key or unavailable libsecret daemon). - Future removeConnection(int id) async { - _historyInsertCounts.removeWhere((k, _) => k.startsWith('$id::')); - try { - await ConnectionSecretsStore.deleteForConnection(id); - } catch (_) { - // Do not block removing the connection metadata when the OS store fails. - } + /// Queries an active PRAGMA setting from the database for verification and diagnostic purposes. + Future getPragma(String pragmaName) async { final db = await _open(); - await db.delete('connections', where: 'id = ?', whereArgs: [id]); + final res = await db.rawQuery('PRAGMA $pragmaName'); + if (res.isNotEmpty && res.first.values.isNotEmpty) { + return res.first.values.first.toString(); + } + return ''; } /// Resolves the absolute path to `querya.db`. @@ -901,20 +272,41 @@ class LocalDb { if (await walFile.exists()) { try { await walFile.delete(); - } catch (_) {} + } catch (e) { + logIgnored('local_db', e); + } } if (await shmFile.exists()) { try { await shmFile.delete(); - } catch (_) {} + } catch (e) { + logIgnored('local_db', e); + } } if (await lockFile.exists()) { try { await lockFile.delete(); - } catch (_) {} + } catch (e) { + logIgnored('local_db', e); + } } } + /// Copies `querya.db` to `querya.db.pre--.bak` before an in-app + /// update and keeps the newest [UpdateBackup.keep] copies (#1497). Closes + /// the database first; the next query opens it again. + Future backupBeforeUpdate({ + required String fromVersion, + required String toVersion, + }) async { + await close(); + return UpdateBackup.create( + dbPath: await databasePath(), + fromVersion: fromVersion, + toVersion: toVersion, + ); + } + /// Creates a timestamped backup copy of `querya.db` (and any sidecar files) /// in the same directory and returns the backup file path. Future backupDatabaseFile() async { @@ -932,7 +324,9 @@ class LocalDb { if (await walFile.exists()) { try { await walFile.copy('$backupPath-wal'); - } catch (_) {} + } catch (e) { + logIgnored('local_db', e); + } } return backupPath; } @@ -946,10 +340,14 @@ class LocalDb { if (await dbFile.exists()) { try { await backupDatabaseFile(); - } catch (_) {} + } catch (e) { + logIgnored('local_db', e); + } try { await dbFile.delete(); - } catch (_) {} + } catch (e) { + logIgnored('local_db', e); + } } await removeWalAndLockFiles(); } @@ -961,382 +359,3 @@ class LocalDb { _cachedDbPath = null; } } - -/// Maximum rows kept in the mutation audit trail. -const int kMutationAuditCap = 10000; - -/// Where an audited mutation was started. -enum MutationAuditSource { - sqlEditor('sql_editor'), - tableEditor('table_editor'); - - const MutationAuditSource(this.storageValue); - - final String storageValue; - - static MutationAuditSource fromStorageValue(String? value) => - values.firstWhere( - (s) => s.storageValue == value, - orElse: () => MutationAuditSource.sqlEditor, - ); -} - -/// One row of the local mutation audit trail. -/// Rows kept in `mcp_activity`. -const kMcpActivityCap = 200; - -/// One tool call made by an MCP client. -class McpActivityEntry { - const McpActivityEntry({ - this.id, - required this.recordedAt, - required this.client, - required this.tool, - this.connectionId, - this.connectionName, - this.sqlText, - this.rowCount, - required this.durationMs, - this.error, - this.refusalRule, - }); - - final int? id; - - /// UTC ISO-8601. - final String recordedAt; - final String client; - final String tool; - final int? connectionId; - final String? connectionName; - final String? sqlText; - final int? rowCount; - final int durationMs; - final String? error; - - /// The guard rule that refused the call (`read_only_only`, ...), when one did. - final String? refusalRule; - - Map toMap() => { - 'id': id, - 'recorded_at': recordedAt, - 'client': client, - 'tool': tool, - 'connection_id': connectionId, - 'connection_name': connectionName, - 'sql_text': sqlText, - 'row_count': rowCount, - 'duration_ms': durationMs, - 'error': error, - 'refusal_rule': refusalRule, - }; - - static McpActivityEntry fromMap(Map m) => McpActivityEntry( - id: _sqliteInt(m['id']), - recordedAt: m['recorded_at'] as String, - client: m['client'] as String, - tool: m['tool'] as String, - connectionId: _sqliteInt(m['connection_id']), - connectionName: m['connection_name'] as String?, - sqlText: m['sql_text'] as String?, - rowCount: _sqliteInt(m['row_count']), - durationMs: _sqliteInt(m['duration_ms']) ?? 0, - error: m['error'] as String?, - refusalRule: m['refusal_rule'] as String?, - ); -} - -class MutationAuditEntry { - const MutationAuditEntry({ - required this.id, - this.connectionId, - required this.connectionName, - this.environment, - this.databaseName, - required this.sqlText, - this.rowsAffected, - required this.source, - required this.recordedAt, - }); - - final int id; - final int? connectionId; - final String connectionName; - - /// `development` / `staging` / `production` when the connection was tagged. - final String? environment; - final String? databaseName; - final String sqlText; - final int? rowsAffected; - final MutationAuditSource source; - final String recordedAt; - - static MutationAuditEntry fromMap(Map m) => - MutationAuditEntry( - id: _sqliteInt(m['id'])!, - connectionId: _sqliteInt(m['connection_id']), - connectionName: m['connection_name'] as String, - environment: m['environment'] as String?, - databaseName: m['database_name'] as String?, - sqlText: m['sql_text'] as String, - rowsAffected: _sqliteInt(m['rows_affected']), - source: MutationAuditSource.fromStorageValue(m['source'] as String?), - recordedAt: m['recorded_at'] as String, - ); -} - -/// One row from [LocalDb.sql_query_history] (recent SQL, no secrets). -class SqlQueryHistoryEntry { - const SqlQueryHistoryEntry({ - required this.id, - required this.connectionId, - this.databaseName, - required this.sqlText, - required this.recordedAt, - }); - - final int id; - final int connectionId; - final String? databaseName; - final String sqlText; - final String recordedAt; - - static SqlQueryHistoryEntry fromMap(Map m) => - SqlQueryHistoryEntry( - id: _sqliteInt(m['id'])!, - connectionId: _sqliteInt(m['connection_id'])!, - databaseName: m['database_name'] as String?, - sqlText: m['sql_text'] as String, - recordedAt: m['recorded_at'] as String, - ); -} - -class ConnectionRow { - const ConnectionRow({ - this.id, - required this.type, - required this.name, - this.host, - this.port, - this.username, - this.password, - this.databaseName, - this.authSource, - this.useSSL = false, - this.connectionString, - this.extensionId, - this.driverOptions, - this.folderId, - this.sortOrder = 0, - required this.createdAt, - this.sshSecrets, - this.removeSavedPassword = false, - }); - - /// Set by an edit form (never stored): the saved password is to be removed, - /// not kept because the password field was left blank (#1311). - final bool removeSavedPassword; - - final int? id; - final String type; - final String name; - final String? host; - final int? port; - final String? username; - final String? password; - final String? databaseName; - final String? authSource; - final bool useSSL; - final String? connectionString; - final SshTunnelSecrets? sshSecrets; - - /// Package id of an installed extension driver (null for built-ins). - final String? extensionId; - - /// Non-secret driver-specific form values as JSON text. - final String? driverOptions; - - final int? folderId; - final int sortOrder; - final String createdAt; - - /// True when this row is backed by an installed extension driver. - bool get isExtensionDriver => - extensionId != null && extensionId!.trim().isNotEmpty; - - /// Returns the SSH tunnel config stored in [driverOptions], if any. - SshTunnelConfig? get sshTunnelConfig { - if (driverOptions == null || driverOptions!.trim().isEmpty) return null; - try { - final decoded = jsonDecode(driverOptions!); - if (decoded is Map && decoded.containsKey('ssh_tunnel')) { - final sshMap = decoded['ssh_tunnel']; - if (sshMap is Map) { - return SshTunnelConfig.fromMap(sshMap); - } - } - } catch (_) {} - return null; - } - - /// Environment tag stored in [driverOptions] (null when untagged). - ConnectionEnvironment? get environment => - ConnectionEnvironment.fromDriverOptions(driverOptions); - - /// Returns a copy of this row tagged with [environment] (null removes the tag). - ConnectionRow withEnvironment(ConnectionEnvironment? environment) { - final options = - ConnectionEnvironment.applyToDriverOptions(driverOptions, environment); - return copyWith( - driverOptions: options, - clearDriverOptions: options == null, - ); - } - - /// Returns a copy of this row with the given [sshTunnelConfig] encoded in [driverOptions]. - ConnectionRow withSshTunnelConfig(SshTunnelConfig? config) { - Map opts = {}; - if (driverOptions != null && driverOptions!.trim().isNotEmpty) { - try { - final decoded = jsonDecode(driverOptions!); - if (decoded is Map) { - opts = Map.from(decoded); - } - } catch (_) {} - } - if (config == null || !config.enabled) { - opts.remove('ssh_tunnel'); - } else { - opts['ssh_tunnel'] = config.toMap(); - } - return copyWith( - driverOptions: opts.isEmpty ? null : jsonEncode(opts), - clearDriverOptions: opts.isEmpty, - ); - } - - Map toMap() => { - 'type': type, - 'name': name, - 'host': host, - 'port': port, - 'username': username, - 'password': password, - 'database_name': databaseName, - 'auth_source': authSource, - 'use_ssl': useSSL ? 1 : 0, - 'connection_string': connectionString, - 'extension_id': extensionId, - 'driver_options': driverOptions, - 'folder_id': folderId, - 'sort_order': sortOrder, - '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, - 'extension_id': extensionId, - 'driver_options': driverOptions, - 'folder_id': folderId, - 'sort_order': sortOrder, - 'created_at': createdAt, - }; - - static ConnectionRow fromMap(Map m) => ConnectionRow( - id: _sqliteInt(m['id']), - type: m['type'] as String, - name: m['name'] as String, - host: m['host'] as String?, - port: _sqliteInt(m['port']), - username: m['username'] as String?, - password: m['password'] as String?, - databaseName: m['database_name'] as String?, - authSource: m['auth_source'] as String?, - useSSL: _sqliteInt(m['use_ssl']) == 1, - connectionString: m['connection_string'] as String?, - extensionId: m['extension_id'] as String?, - driverOptions: m['driver_options'] as String?, - folderId: _sqliteInt(m['folder_id']), - sortOrder: _sqliteInt(m['sort_order']) ?? 0, - createdAt: m['created_at'] as String, - ); - - ConnectionRow copyWith({ - int? id, - String? type, - String? name, - String? host, - int? port, - String? username, - String? password, - String? databaseName, - String? authSource, - bool? useSSL, - String? connectionString, - String? extensionId, - String? driverOptions, - int? folderId, - int? sortOrder, - String? createdAt, - SshTunnelSecrets? sshSecrets, - bool? removeSavedPassword, - bool clearPassword = false, - bool clearConnectionString = false, - bool clearSshSecrets = false, - bool clearDriverOptions = false, - }) { - return ConnectionRow( - id: id ?? this.id, - type: type ?? this.type, - name: name ?? this.name, - host: host ?? this.host, - port: port ?? this.port, - username: username ?? this.username, - password: clearPassword ? null : (password ?? this.password), - databaseName: databaseName ?? this.databaseName, - authSource: authSource ?? this.authSource, - useSSL: useSSL ?? this.useSSL, - connectionString: clearConnectionString - ? null - : (connectionString ?? this.connectionString), - extensionId: extensionId ?? this.extensionId, - driverOptions: clearDriverOptions - ? null - : (driverOptions ?? this.driverOptions), - folderId: folderId ?? this.folderId, - sortOrder: sortOrder ?? this.sortOrder, - createdAt: createdAt ?? this.createdAt, - sshSecrets: clearSshSecrets ? null : (sshSecrets ?? this.sshSecrets), - removeSavedPassword: removeSavedPassword ?? this.removeSavedPassword, - ); - } - - /// Whether in-memory password credentials are set. - bool get hasPassword => password != null && password!.isNotEmpty; - - /// Whether in-memory connection URI credentials are set. - bool get hasConnectionString => - connectionString != null && connectionString!.isNotEmpty; - - /// Whether any in-memory secret credentials are held. - bool get hasSecrets => - hasPassword || hasConnectionString || (sshSecrets != null && sshSecrets!.hasAny); - - /// Returns a clean copy of this [ConnectionRow] with all secret credentials - /// ([password], [connectionString], and [sshSecrets]) scrubbed to null. - ConnectionRow withoutSecrets() => copyWith( - clearPassword: true, - clearConnectionString: true, - clearSshSecrets: true, - ); -} diff --git a/lib/core/storage/local_db_models.dart b/lib/core/storage/local_db_models.dart new file mode 100644 index 00000000..b7cc3e26 --- /dev/null +++ b/lib/core/storage/local_db_models.dart @@ -0,0 +1,405 @@ +import 'dart:convert'; + +import 'package:querya_desktop/core/security/connection_environment.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; + +/// Fallback when [recordSqlQueryHistory] is called without `maxEntries`. +/// Keep in sync with [kDefaultSqlHistoryMaxEntries] in `app_settings.dart`. +const int kDefaultSqlHistoryCap = 100; + +String? normalizeHistoryDatabaseName(String? databaseName) { + final t = databaseName?.trim(); + if (t == null || t.isEmpty) return null; + return t; +} + +/// Maximum rows kept in the mutation audit trail. +const int kMutationAuditCap = 10000; + +int? sqliteInt(Object? v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + return int.tryParse(v.toString()); +} + +/// Where an audited mutation was started. +enum MutationAuditSource { + sqlEditor('sql_editor'), + tableEditor('table_editor'); + + const MutationAuditSource(this.storageValue); + + final String storageValue; + + static MutationAuditSource fromStorageValue(String? value) => + values.firstWhere( + (s) => s.storageValue == value, + orElse: () => MutationAuditSource.sqlEditor, + ); +} + +/// One row of the local mutation audit trail. +/// Rows kept in `mcp_activity`. +const kMcpActivityCap = 200; + +/// One tool call made by an MCP client. +class McpActivityEntry { + const McpActivityEntry({ + this.id, + required this.recordedAt, + required this.client, + required this.tool, + this.connectionId, + this.connectionName, + this.sqlText, + this.rowCount, + required this.durationMs, + this.error, + this.refusalRule, + }); + + final int? id; + + /// UTC ISO-8601. + final String recordedAt; + final String client; + final String tool; + final int? connectionId; + final String? connectionName; + final String? sqlText; + final int? rowCount; + final int durationMs; + final String? error; + + /// The guard rule that refused the call (`read_only_only`, ...), when one did. + final String? refusalRule; + + Map toMap() => { + 'id': id, + 'recorded_at': recordedAt, + 'client': client, + 'tool': tool, + 'connection_id': connectionId, + 'connection_name': connectionName, + 'sql_text': sqlText, + 'row_count': rowCount, + 'duration_ms': durationMs, + 'error': error, + 'refusal_rule': refusalRule, + }; + + static McpActivityEntry fromMap(Map m) => McpActivityEntry( + id: sqliteInt(m['id']), + recordedAt: m['recorded_at'] as String, + client: m['client'] as String, + tool: m['tool'] as String, + connectionId: sqliteInt(m['connection_id']), + connectionName: m['connection_name'] as String?, + sqlText: m['sql_text'] as String?, + rowCount: sqliteInt(m['row_count']), + durationMs: sqliteInt(m['duration_ms']) ?? 0, + error: m['error'] as String?, + refusalRule: m['refusal_rule'] as String?, + ); +} + +class MutationAuditEntry { + const MutationAuditEntry({ + required this.id, + this.connectionId, + required this.connectionName, + this.environment, + this.databaseName, + required this.sqlText, + this.rowsAffected, + required this.source, + required this.recordedAt, + }); + + final int id; + final int? connectionId; + final String connectionName; + + /// `development` / `staging` / `production` when the connection was tagged. + final String? environment; + final String? databaseName; + final String sqlText; + final int? rowsAffected; + final MutationAuditSource source; + final String recordedAt; + + static MutationAuditEntry fromMap(Map m) => + MutationAuditEntry( + id: sqliteInt(m['id'])!, + connectionId: sqliteInt(m['connection_id']), + connectionName: m['connection_name'] as String, + environment: m['environment'] as String?, + databaseName: m['database_name'] as String?, + sqlText: m['sql_text'] as String, + rowsAffected: sqliteInt(m['rows_affected']), + source: MutationAuditSource.fromStorageValue(m['source'] as String?), + recordedAt: m['recorded_at'] as String, + ); +} + +/// One row from [LocalDb.sql_query_history] (recent SQL, no secrets). +class SqlQueryHistoryEntry { + const SqlQueryHistoryEntry({ + required this.id, + required this.connectionId, + this.databaseName, + required this.sqlText, + required this.recordedAt, + }); + + final int id; + final int connectionId; + final String? databaseName; + final String sqlText; + final String recordedAt; + + static SqlQueryHistoryEntry fromMap(Map m) => + SqlQueryHistoryEntry( + id: sqliteInt(m['id'])!, + connectionId: sqliteInt(m['connection_id'])!, + databaseName: m['database_name'] as String?, + sqlText: m['sql_text'] as String, + recordedAt: m['recorded_at'] as String, + ); +} + +class ConnectionRow { + const ConnectionRow({ + this.id, + required this.type, + required this.name, + this.host, + this.port, + this.username, + this.password, + this.databaseName, + this.authSource, + this.useSSL = false, + this.connectionString, + this.extensionId, + this.driverOptions, + this.folderId, + this.sortOrder = 0, + required this.createdAt, + this.sshSecrets, + this.removeSavedPassword = false, + }); + + /// Set by an edit form (never stored): the saved password is to be removed, + /// not kept because the password field was left blank (#1311). + final bool removeSavedPassword; + + final int? id; + final String type; + final String name; + final String? host; + final int? port; + final String? username; + final String? password; + final String? databaseName; + final String? authSource; + final bool useSSL; + final String? connectionString; + final SshTunnelSecrets? sshSecrets; + + /// Package id of an installed extension driver (null for built-ins). + final String? extensionId; + + /// Non-secret driver-specific form values as JSON text. + final String? driverOptions; + + final int? folderId; + final int sortOrder; + final String createdAt; + + /// True when this row is backed by an installed extension driver. + bool get isExtensionDriver => + extensionId != null && extensionId!.trim().isNotEmpty; + + /// Returns the SSH tunnel config stored in [driverOptions], if any. + SshTunnelConfig? get sshTunnelConfig { + if (driverOptions == null || driverOptions!.trim().isEmpty) return null; + try { + final decoded = jsonDecode(driverOptions!); + if (decoded is Map && decoded.containsKey('ssh_tunnel')) { + final sshMap = decoded['ssh_tunnel']; + if (sshMap is Map) { + return SshTunnelConfig.fromMap(sshMap); + } + } + } catch (e) { + logIgnored('local_db', e); + } + return null; + } + + /// Environment tag stored in [driverOptions] (null when untagged). + ConnectionEnvironment? get environment => + ConnectionEnvironment.fromDriverOptions(driverOptions); + + /// Returns a copy of this row tagged with [environment] (null removes the tag). + ConnectionRow withEnvironment(ConnectionEnvironment? environment) { + final options = + ConnectionEnvironment.applyToDriverOptions(driverOptions, environment); + return copyWith( + driverOptions: options, + clearDriverOptions: options == null, + ); + } + + /// Returns a copy of this row with the given [sshTunnelConfig] encoded in [driverOptions]. + ConnectionRow withSshTunnelConfig(SshTunnelConfig? config) { + Map opts = {}; + if (driverOptions != null && driverOptions!.trim().isNotEmpty) { + try { + final decoded = jsonDecode(driverOptions!); + if (decoded is Map) { + opts = Map.from(decoded); + } + } catch (e) { + logIgnored('local_db', e); + } + } + if (config == null || !config.enabled) { + opts.remove('ssh_tunnel'); + } else { + opts['ssh_tunnel'] = config.toMap(); + } + return copyWith( + driverOptions: opts.isEmpty ? null : jsonEncode(opts), + clearDriverOptions: opts.isEmpty, + ); + } + + Map toMap() => { + 'type': type, + 'name': name, + 'host': host, + 'port': port, + 'username': username, + 'password': password, + 'database_name': databaseName, + 'auth_source': authSource, + 'use_ssl': useSSL ? 1 : 0, + 'connection_string': connectionString, + 'extension_id': extensionId, + 'driver_options': driverOptions, + 'folder_id': folderId, + 'sort_order': sortOrder, + '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, + 'extension_id': extensionId, + 'driver_options': driverOptions, + 'folder_id': folderId, + 'sort_order': sortOrder, + 'created_at': createdAt, + }; + + static ConnectionRow fromMap(Map m) => ConnectionRow( + id: sqliteInt(m['id']), + type: m['type'] as String, + name: m['name'] as String, + host: m['host'] as String?, + port: sqliteInt(m['port']), + username: m['username'] as String?, + password: m['password'] as String?, + databaseName: m['database_name'] as String?, + authSource: m['auth_source'] as String?, + useSSL: sqliteInt(m['use_ssl']) == 1, + connectionString: m['connection_string'] as String?, + extensionId: m['extension_id'] as String?, + driverOptions: m['driver_options'] as String?, + folderId: sqliteInt(m['folder_id']), + sortOrder: sqliteInt(m['sort_order']) ?? 0, + createdAt: m['created_at'] as String, + ); + + ConnectionRow copyWith({ + int? id, + String? type, + String? name, + String? host, + int? port, + String? username, + String? password, + String? databaseName, + String? authSource, + bool? useSSL, + String? connectionString, + String? extensionId, + String? driverOptions, + int? folderId, + int? sortOrder, + String? createdAt, + SshTunnelSecrets? sshSecrets, + bool? removeSavedPassword, + bool clearPassword = false, + bool clearConnectionString = false, + bool clearSshSecrets = false, + bool clearDriverOptions = false, + }) { + return ConnectionRow( + id: id ?? this.id, + type: type ?? this.type, + name: name ?? this.name, + host: host ?? this.host, + port: port ?? this.port, + username: username ?? this.username, + password: clearPassword ? null : (password ?? this.password), + databaseName: databaseName ?? this.databaseName, + authSource: authSource ?? this.authSource, + useSSL: useSSL ?? this.useSSL, + connectionString: clearConnectionString + ? null + : (connectionString ?? this.connectionString), + extensionId: extensionId ?? this.extensionId, + driverOptions: clearDriverOptions + ? null + : (driverOptions ?? this.driverOptions), + folderId: folderId ?? this.folderId, + sortOrder: sortOrder ?? this.sortOrder, + createdAt: createdAt ?? this.createdAt, + sshSecrets: clearSshSecrets ? null : (sshSecrets ?? this.sshSecrets), + removeSavedPassword: removeSavedPassword ?? this.removeSavedPassword, + ); + } + + /// Whether in-memory password credentials are set. + bool get hasPassword => password != null && password!.isNotEmpty; + + /// Whether in-memory connection URI credentials are set. + bool get hasConnectionString => + connectionString != null && connectionString!.isNotEmpty; + + /// Whether any in-memory secret credentials are held. + bool get hasSecrets => + hasPassword || hasConnectionString || (sshSecrets != null && sshSecrets!.hasAny); + + /// Returns a clean copy of this [ConnectionRow] with all secret credentials + /// ([password], [connectionString], and [sshSecrets]) scrubbed to null. + ConnectionRow withoutSecrets() => copyWith( + clearPassword: true, + clearConnectionString: true, + clearSshSecrets: true, + ); +} diff --git a/lib/core/storage/local_db_schema.dart b/lib/core/storage/local_db_schema.dart new file mode 100644 index 00000000..dcd222aa --- /dev/null +++ b/lib/core/storage/local_db_schema.dart @@ -0,0 +1,295 @@ +import 'dart:math'; + +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// `app_settings` key under which each profile database's random id is +/// stored (see [LocalDbSchema.ensureProfileId] and issue #986). +const profileIdSettingKey = 'profile_id'; + +String _generateProfileId() { + final rand = Random.secure(); + final bytes = List.generate(16, (_) => rand.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} + +int? _sqliteInt(Object? v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + return int.tryParse(v.toString()); +} + +/// The schema of the local database: its creation, the upgrade from every +/// older version, and the pragmas set on each open. +/// +/// [version] is bumped with each schema change; add the step to [onUpgrade] +/// under `oldVersion < N` and the table to [onCreate]. +abstract final class LocalDbSchema { + static const version = 13; + + /// Run on every open, after create or upgrade. + static Future onOpen(Database db) async { + await db.execute('PRAGMA foreign_keys = ON'); + await db.execute('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA busy_timeout = 5000'); + await db.execute('PRAGMA synchronous = NORMAL'); + } + + static Future onCreate(Database db, int version) async { + await db.execute(''' + CREATE TABLE folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + '''); + await db.execute(''' + CREATE TABLE connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + name TEXT NOT NULL, + host TEXT, + port INTEGER, + username TEXT, + password TEXT, + database_name TEXT, + auth_source TEXT, + use_ssl INTEGER NOT NULL DEFAULT 0, + connection_string TEXT, + extension_id TEXT, + driver_options TEXT, + folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE TABLE app_settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE TABLE sql_query_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, + database_name TEXT, + sql_text TEXT NOT NULL, + recorded_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE INDEX idx_sql_query_history_lookup + ON sql_query_history (connection_id, database_name, recorded_at DESC, id DESC) + '''); + await createMutationAuditTable(db); + await createMcpActivityTable(db); + await createErdLayoutsTable(db); + ConnectionSecretsStore.profileId = await ensureProfileId(db); + } + + /// Local audit trail of executed data / schema changes (#1062). Not linked + /// to `connections` so entries outlive a deleted connection; the connection + /// name is stored as a snapshot. + static Future createMutationAuditTable(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS mutation_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id INTEGER, + connection_name TEXT NOT NULL, + environment TEXT, + database_name TEXT, + sql_text TEXT NOT NULL, + rows_affected INTEGER, + source TEXT NOT NULL, + recorded_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE INDEX IF NOT EXISTS idx_mutation_audit_recorded + ON mutation_audit (id DESC) + '''); + } + + /// Calls made by MCP clients (#1136), newest kept, capped at + /// [kMcpActivityCap]. Connection name is a snapshot, like the audit. + static Future createMcpActivityTable(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS mcp_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at TEXT NOT NULL, + client TEXT NOT NULL, + tool TEXT NOT NULL, + connection_id INTEGER, + connection_name TEXT, + sql_text TEXT, + row_count INTEGER, + duration_ms INTEGER NOT NULL, + error TEXT, + refusal_rule TEXT + ) + '''); + } + + static Future onUpgrade( + Database db, int oldVersion, int newVersion) async { + // Every upgrade path needs app_settings before it can read/write the + // profile id; the oldVersion < 4 block below also creates this table for + // installs that predate it, but IF NOT EXISTS keeps this idempotent. + await db.execute(''' + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ) + '''); + ConnectionSecretsStore.profileId = await ensureProfileId(db); + + if (oldVersion < 2) { + await db.execute('ALTER TABLE connections ADD COLUMN password TEXT'); + await db.execute('ALTER TABLE connections ADD COLUMN database_name TEXT'); + await db.execute('ALTER TABLE connections ADD COLUMN auth_source TEXT'); + await db.execute( + 'ALTER TABLE connections ADD COLUMN use_ssl INTEGER NOT NULL DEFAULT 0'); + await db + .execute('ALTER TABLE connections ADD COLUMN connection_string TEXT'); + } + if (oldVersion < 3) { + await db.execute('PRAGMA foreign_keys=ON'); + await db.execute(''' + CREATE TABLE connections_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + name TEXT NOT NULL, + host TEXT, + port INTEGER, + username TEXT, + password TEXT, + database_name TEXT, + auth_source TEXT, + use_ssl INTEGER NOT NULL DEFAULT 0, + connection_string TEXT, + folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + '''); + await db.execute(''' + INSERT INTO connections_new + SELECT id, type, name, host, port, username, password, database_name, + auth_source, use_ssl, connection_string, folder_id, sort_order, created_at + FROM connections + '''); + await db.execute('DROP TABLE connections'); + await db.execute('ALTER TABLE connections_new RENAME TO connections'); + } + // (oldVersion < 4 used to CREATE TABLE app_settings here; the + // CREATE TABLE IF NOT EXISTS above now covers that install path too.) + 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', + ); + } + if (oldVersion < 6) { + await db.execute(''' + CREATE TABLE sql_query_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, + database_name TEXT, + sql_text TEXT NOT NULL, + recorded_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE INDEX idx_sql_query_history_lookup + ON sql_query_history (connection_id, recorded_at DESC) + '''); + } + if (oldVersion < 7) { + await db.execute('ALTER TABLE connections ADD COLUMN extension_id TEXT'); + await db + .execute('ALTER TABLE connections ADD COLUMN driver_options TEXT'); + } + if (oldVersion < 8) { + await db.execute('DROP INDEX IF EXISTS idx_sql_query_history_lookup'); + await db.execute(''' + CREATE INDEX idx_sql_query_history_lookup + ON sql_query_history (connection_id, database_name, recorded_at DESC, id DESC) + '''); + } + if (oldVersion < 9) { + // Adopt this profile's own pre-#986 unnamespaced keyring secrets (if + // any) under the namespaced key generated above, so this database's + // connections keep working after another profile with an overlapping + // connection id also upgrades. + final rows = await db.query('connections', columns: ['id']); + for (final m in rows) { + final id = _sqliteInt(m['id']); + if (id == null) continue; + await ConnectionSecretsStore.adoptLegacyKeysForConnection(id); + } + } + if (oldVersion < 10) { + await createMutationAuditTable(db); + } + if (oldVersion < 11) { + await createMcpActivityTable(db); + } + if (oldVersion >= 11 && oldVersion < 12) { + // The rule that refused a call, next to its error text. + await db.execute('ALTER TABLE mcp_activity ADD COLUMN refusal_rule TEXT'); + } + if (oldVersion < 13) { + await createErdLayoutsTable(db); + } + } + + /// What the user arranged on an ER diagram (#1275), one row per connection + /// and scope (database / schema), removed with its connection. + static Future createErdLayoutsTable(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS erd_layouts ( + connection_id INTEGER NOT NULL + REFERENCES connections(id) ON DELETE CASCADE, + scope TEXT NOT NULL, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (connection_id, scope) + ) + '''); + } + + + /// Returns this profile database's random id (see issue #986), generating + /// and persisting one on first use. Namespaces [ConnectionSecretsStore] + /// keys so two profile databases never collide in the shared OS keyring. + static Future ensureProfileId(Database db) async { + final rows = await db.query( + 'app_settings', + columns: ['value'], + where: 'key = ?', + whereArgs: [profileIdSettingKey], + limit: 1, + ); + final existing = rows.isNotEmpty ? rows.first['value'] as String? : null; + if (existing != null && existing.isNotEmpty) return existing; + final id = _generateProfileId(); + await db.rawInsert( + 'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', + [profileIdSettingKey, id], + ); + return id; + } +} diff --git a/lib/core/storage/sql_query_history_repository.dart b/lib/core/storage/sql_query_history_repository.dart new file mode 100644 index 00000000..9566eac4 --- /dev/null +++ b/lib/core/storage/sql_query_history_repository.dart @@ -0,0 +1,148 @@ +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/storage/local_db_models.dart'; + +/// The SQL history of each connection and database, capped per bucket. +/// Part of [LocalDb], which keeps the same methods and delegates here. +class SqlQueryHistoryRepository { + SqlQueryHistoryRepository(this._open); + + final Future Function() _open; + + /// Inserts per history bucket since the last prune, so a prune runs every few + /// inserts and not on each one. + final Map _historyInsertCounts = {}; + + /// Forgets the insert counts of a removed connection. + void forgetConnection(int connectionId) => _historyInsertCounts + .removeWhere((k, _) => k.startsWith('$connectionId::')); + + Future recordSqlQueryHistory({ + required int connectionId, + String? databaseName, + required String sqlText, + int maxEntries = kDefaultSqlHistoryCap, + bool forcePrune = false, + }) async { + final sql = sqlText.trim(); + if (sql.isEmpty) return; + if (maxEntries < 1) return; + final db = await _open(); + final dbKey = normalizeHistoryDatabaseName(databaseName); + final now = DateTime.now().toUtc().toIso8601String(); + await db.insert('sql_query_history', { + 'connection_id': connectionId, + 'database_name': dbKey, + 'sql_text': sql, + 'recorded_at': now, + }); + + final bucketKey = '$connectionId::${dbKey ?? ''}'; + final insertCount = (_historyInsertCounts[bucketKey] ?? 0) + 1; + _historyInsertCounts[bucketKey] = insertCount; + + final batchThreshold = maxEntries <= 10 ? 1 : 10; + if (forcePrune || insertCount >= batchThreshold) { + _historyInsertCounts[bucketKey] = 0; + await _pruneSqlQueryHistoryBucket( + db, + connectionId: connectionId, + databaseName: dbKey, + maxEntries: maxEntries, + ); + } + } + + /// Keeps the newest [maxEntries] rows in a (connection, database) bucket. + /// + /// Selects overflow ids (oldest first), then deletes by primary key — avoids + /// nested `DELETE … SELECT … OFFSET` plans as history grows. + Future _pruneSqlQueryHistoryBucket( + Database db, { + required int connectionId, + required String? databaseName, + required int maxEntries, + }) async { + final countRows = await db.rawQuery( + ''' + SELECT COUNT(*) AS c FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ''', + [connectionId, databaseName], + ); + final count = sqliteInt(countRows.first['c']) ?? 0; + final excess = count - maxEntries; + if (excess <= 0) return; + + final overflow = await db.rawQuery( + ''' + SELECT id FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ORDER BY id ASC + LIMIT ? + ''', + [connectionId, databaseName, excess], + ); + if (overflow.isEmpty) return; + + final ids = []; + for (final row in overflow) { + final id = row['id']; + if (id != null) ids.add(id); + } + if (ids.isEmpty) return; + + final placeholders = List.filled(ids.length, '?').join(','); + await db.rawDelete( + 'DELETE FROM sql_query_history WHERE id IN ($placeholders)', + ids, + ); + } + + Future> listSqlQueryHistory({ + required int connectionId, + String? databaseName, + int limit = 50, + }) async { + final db = await _open(); + final dbKey = normalizeHistoryDatabaseName(databaseName); + final rows = await db.rawQuery( + ''' + SELECT id, connection_id, database_name, sql_text, recorded_at + FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ORDER BY id DESC + LIMIT ? + ''', + [connectionId, dbKey, limit], + ); + return rows.map(SqlQueryHistoryEntry.fromMap).toList(); + } + + /// Removes all history rows for [connectionId] (every database bucket). + Future clearSqlQueryHistoryForConnection(int connectionId) async { + _historyInsertCounts.removeWhere((k, _) => k.startsWith('$connectionId::')); + final db = await _open(); + await db.delete( + 'sql_query_history', + where: 'connection_id = ?', + whereArgs: [connectionId], + ); + } + + /// Removes history for [connectionId] and [databaseName] only (same bucket as [listSqlQueryHistory]). + Future clearSqlQueryHistoryBucket({ + required int connectionId, + String? databaseName, + }) async { + final dbKey = normalizeHistoryDatabaseName(databaseName); + _historyInsertCounts.remove('$connectionId::${dbKey ?? ''}'); + final db = await _open(); + await db.rawDelete( + ''' + DELETE FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ''', + [connectionId, dbKey], + ); + } +} diff --git a/lib/core/storage/update_backup.dart b/lib/core/storage/update_backup.dart new file mode 100644 index 00000000..00100a7b --- /dev/null +++ b/lib/core/storage/update_backup.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Copies of the local database made before an in-app update (#1497). +/// +/// An update can bring a newer database schema, and a later downgrade (a +/// beta promoted to an older stable, or an older build installed by hand) +/// cannot read it. The copy next to the database is the way back. +abstract final class UpdateBackup { + /// How many pre-update copies are kept; older ones are deleted. + static const keep = 3; + + /// `querya.db.pre--.bak` next to [dbPath]. + static String pathFor(String dbPath, String fromVersion, String toVersion) { + String safe(String v) => v.replaceAll(RegExp(r'[^0-9A-Za-z.+-]'), '_'); + return '$dbPath.pre-${safe(fromVersion)}-${safe(toVersion)}.bak'; + } + + /// Copies [dbPath] (and its `-wal`, if any) to [pathFor] and deletes all but + /// the newest [keep] copies. The database must be closed. Returns the copy. + static Future create({ + required String dbPath, + required String fromVersion, + required String toVersion, + int keep = UpdateBackup.keep, + }) async { + final db = File(dbPath); + if (!await db.exists()) { + throw FileSystemException('Database file does not exist', dbPath); + } + final backup = pathFor(dbPath, fromVersion, toVersion); + await db.copy(backup); + final wal = File('$dbPath-wal'); + final backupWal = File('$backup-wal'); + if (await wal.exists()) { + await wal.copy(backupWal.path); + } else if (await backupWal.exists()) { + await backupWal.delete(); // from an earlier copy with the same name + } + // A copy made just now is the newest even when the clock is coarse. + await File(backup).setLastModified(DateTime.now()); + await prune(dbPath, keep: keep); + return backup; + } + + /// The pre-update copies of [dbPath], newest first. + static Future> list(String dbPath) async { + final dir = Directory(p.dirname(dbPath)); + final prefix = '${p.basename(dbPath)}.pre-'; + final copies = []; + await for (final e in dir.list(followLinks: false)) { + final name = p.basename(e.path); + if (e is File && name.startsWith(prefix) && name.endsWith('.bak')) { + copies.add(e); + } + } + final stamps = {for (final f in copies) f.path: await f.lastModified()}; + copies.sort((a, b) => stamps[b.path]!.compareTo(stamps[a.path]!)); + return copies; + } + + /// Deletes all but the newest [keep] copies, with their `-wal` files. + static Future prune(String dbPath, {int keep = UpdateBackup.keep}) async { + final copies = await list(dbPath); + for (final old in copies.skip(keep)) { + await old.delete(); + final wal = File('${old.path}-wal'); + if (await wal.exists()) await wal.delete(); + } + } +} diff --git a/lib/core/team/team_profile.dart b/lib/core/team/team_profile.dart index b4e25809..f80e08cf 100644 --- a/lib/core/team/team_profile.dart +++ b/lib/core/team/team_profile.dart @@ -22,8 +22,14 @@ class TeamProfileCodec { static const format = 'querya-team-profile'; static const version = 1; - static final _secretKey = - RegExp(r'pass(word|phrase)?|secret|token|api_?key', caseSensitive: false); + /// Option and query-parameter names whose value is a secret (#1564). The + /// match is a substring of the key, case-insensitive: `privateKey`, + /// `private_key`, `accessKey`, `db_pwd`, `credentials`, `sig` are all caught. + static final _secretKey = RegExp( + r'pass(word|phrase)?|pwd|secret|token|api_?key|private_?key|access_?key|' + r'credential|signature', + caseSensitive: false, + ); /// Serializes [connections] to profile JSON without any secret. static String encode(List connections) { @@ -95,7 +101,16 @@ class TeamProfileCodec { } /// Parses profile JSON into connection rows (no ids, no secrets). - static List decode(String source, {DateTime? now}) { + static List decode(String source, {DateTime? now}) => + read(source, now: now).rows; + + /// Like [decode], and says how many entries were left out because they were + /// not objects, had no type or name, or had a field of the wrong type (a + /// hand-edited or newer file), so the import can report it. + static ({List rows, int skipped}) read( + String source, { + DateTime? now, + }) { final Object? doc; try { doc = jsonDecode(source); @@ -116,30 +131,59 @@ class TeamProfileCodec { } final stamp = (now ?? DateTime.now()).toIso8601String(); final rows = []; + var skipped = 0; for (final item in list) { - if (item is! Map) continue; + if (item is! Map) { + skipped++; + continue; + } final type = item['type']; final name = item['name']; - if (type is! String || name is! String || name.trim().isEmpty) continue; - final options = item['driverOptions']; - final url = item['connectionString']; + if (type is! String || name is! String || name.trim().isEmpty) { + skipped++; + continue; + } + // A field that is present with the wrong type makes the whole entry + // unreadable: dropping just that field would import a connection that + // points somewhere else. + var unreadable = false; + T? field(String key) { + final v = item[key]; + if (v == null) return null; + if (v is T) return v; + unreadable = true; + return null; + } + + final host = field('host'); + final port = field('port'); + final username = field('username'); + final databaseName = field('databaseName'); + final authSource = field('authSource'); + final extensionId = field('extensionId'); + final url = field('connectionString'); + final options = field('driverOptions'); + if (unreadable) { + skipped++; + continue; + } rows.add(ConnectionRow( type: type, name: name, - host: item['host'] as String?, - port: item['port'] as int?, - username: item['username'] as String?, - databaseName: item['databaseName'] as String?, - authSource: item['authSource'] as String?, + host: host, + port: port, + username: username, + databaseName: databaseName, + authSource: authSource, useSSL: item['useSSL'] == true, // Re-check on import: a hand-edited file must not smuggle secrets in. - connectionString: url is String ? _stripUrlCredentials(url) : null, - extensionId: item['extensionId'] as String?, - driverOptions: options is String ? _sanitizeOptions(options) : null, + connectionString: url == null ? null : _stripUrlCredentials(url), + extensionId: extensionId, + driverOptions: options == null ? null : _sanitizeOptions(options), createdAt: stamp, )); } - return rows; + return (rows: rows, skipped: skipped); } /// Imports [rows] into [db], skipping ones that already exist diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart deleted file mode 100644 index 3d8f3758..00000000 --- a/lib/core/theme/app_theme.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; - -import 'querya_theme.dart'; - -/// App theme presets built from [QueryaTheme]. -abstract class AppTheme { - static ThemeData get dark => QueryaTheme.darkDefault.toShadcnThemeData(); - - static ThemeData get light => QueryaTheme.lightDefault.toShadcnThemeData(); -} diff --git a/lib/core/theme/parser/color_parser.dart b/lib/core/theme/parser/color_parser.dart index 8627bc39..2caead4a 100644 --- a/lib/core/theme/parser/color_parser.dart +++ b/lib/core/theme/parser/color_parser.dart @@ -1,34 +1,49 @@ import 'dart:ui'; /// Parses VS Code color strings into Flutter [Color]. +/// +/// Hex digits are read straight from the code units (no substring / `int.parse` +/// per channel), so loading a theme with thousands of rules allocates nothing +/// per color but the result. Color parseVsCodeColor(String input) { - var s = input.trim(); + final s = input.trim(); if (s.isEmpty) { throw const FormatException('Empty color string'); } - if (s.startsWith('#')) { - s = s.substring(1); - } - if (s.length == 3) { - final r = s[0]; - final g = s[1]; - final b = s[2]; - s = '$r$r$g$g$b$b'; - return Color(int.parse('FF$s', radix: 16)); - } - if (s.length == 4) { - final r = s[0]; - final g = s[1]; - final b = s[2]; - final a = s[3]; - s = '$r$r$g$g$b$b$a$a'; - return _fromRgbaHex(s); - } - if (s.length == 6) { - return Color(int.parse('FF$s', radix: 16)); - } - if (s.length == 8) { - return _fromRgbaHex(s); + final from = s.codeUnitAt(0) == 0x23 ? 1 : 0; // '#' + try { + switch (s.length - from) { + case 3: // RGB + return Color.fromARGB( + 0xFF, + _nibble(s, from) * 17, + _nibble(s, from + 1) * 17, + _nibble(s, from + 2) * 17, + ); + case 4: // RGBA + return Color.fromARGB( + _nibble(s, from + 3) * 17, + _nibble(s, from) * 17, + _nibble(s, from + 1) * 17, + _nibble(s, from + 2) * 17, + ); + case 6: // RRGGBB + return Color.fromARGB( + 0xFF, + _byte(s, from), + _byte(s, from + 2), + _byte(s, from + 4), + ); + case 8: // RRGGBBAA + return Color.fromARGB( + _byte(s, from + 6), + _byte(s, from), + _byte(s, from + 2), + _byte(s, from + 4), + ); + } + } on FormatException { + throw FormatException('Unsupported color format: $input'); } throw FormatException('Unsupported color format: $input'); } @@ -51,11 +66,12 @@ Color parseQueryaThemeColor(String raw) { if (s.length == 8) { try { - final aa = int.parse(s.substring(0, 2), radix: 16); - final rr = int.parse(s.substring(2, 4), radix: 16); - final gg = int.parse(s.substring(4, 6), radix: 16); - final bb = int.parse(s.substring(6, 8), radix: 16); - return Color.fromARGB(aa, rr, gg, bb); + return Color.fromARGB( + _byte(s, 0), + _byte(s, 2), + _byte(s, 4), + _byte(s, 6), + ); } on FormatException { throw FormatException('Invalid Querya theme color: $raw'); } @@ -70,30 +86,33 @@ Color parseQueryaThemeColor(String raw) { /// Encodes a [Color] as a VS Code hex string (`#RRGGBB` or `#RRGGBBAA`). String formatVsCodeColor(Color color) { - String channel(double component) => (component * 255.0) - .round() - .clamp(0, 255) - .toRadixString(16) - .padLeft(2, '0'); - final rr = channel(color.r); - final gg = channel(color.g); - final bb = channel(color.b); - if (color.a < 1.0) { - final aa = channel(color.a); - return '#$rr$gg$bb$aa'; - } - return '#$rr$gg$bb'; + final withAlpha = color.a < 1.0; + final units = List.filled(withAlpha ? 9 : 7, 0x23); // '#' + _writeHexByte(units, 1, _channel(color.r)); + _writeHexByte(units, 3, _channel(color.g)); + _writeHexByte(units, 5, _channel(color.b)); + if (withAlpha) _writeHexByte(units, 7, _channel(color.a)); + return String.fromCharCodes(units); +} + +int _channel(double component) => + (component * 255.0).round().clamp(0, 255); + +const String _hexChars = '0123456789abcdef'; + +void _writeHexByte(List out, int at, int value) { + out[at] = _hexChars.codeUnitAt(value >> 4); + out[at + 1] = _hexChars.codeUnitAt(value & 0xF); } -Color _fromRgbaHex(String eight) { - final rr = eight.substring(0, 2); - final gg = eight.substring(2, 4); - final bb = eight.substring(4, 6); - final aa = eight.substring(6, 8); - return Color.fromARGB( - int.parse(aa, radix: 16), - int.parse(rr, radix: 16), - int.parse(gg, radix: 16), - int.parse(bb, radix: 16), - ); +/// The hex digit at [at] in [s], or a [FormatException]. +int _nibble(String s, int at) { + final c = s.codeUnitAt(at); + if (c >= 0x30 && c <= 0x39) return c - 0x30; // 0-9 + if (c >= 0x61 && c <= 0x66) return c - 0x61 + 10; // a-f + if (c >= 0x41 && c <= 0x46) return c - 0x41 + 10; // A-F + throw FormatException('Invalid hex digit', s, at); } + +/// Two hex digits at [at] as one byte. +int _byte(String s, int at) => (_nibble(s, at) << 4) | _nibble(s, at + 1); diff --git a/lib/core/theme/parser/querya_theme_color_scheme.dart b/lib/core/theme/parser/querya_theme_color_scheme.dart index fd962e31..4381ec13 100644 --- a/lib/core/theme/parser/querya_theme_color_scheme.dart +++ b/lib/core/theme/parser/querya_theme_color_scheme.dart @@ -1,9 +1,11 @@ // ignore_for_file: deprecated_member_use import 'package:flutter/foundation.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'dart:ui'; + import '../querya_theme.dart'; +import '../querya_ui_colors.dart'; import 'color_parser.dart'; const _knownShadcnColorKeys = { @@ -33,15 +35,16 @@ const _knownShadcnColorKeys = { 'chart5', }; -/// Builds a shadcn [ColorScheme] from Querya custom `shadcn_colors`. +/// Builds [QueryaUiColors] from Querya custom `shadcn_colors` (the key names of +/// `querya.theme.v1`). /// -/// Missing keys and invalid optional colors fall back to [fallback.colorScheme]. -/// [ColorScheme.brightness] always comes from [fallback], not from [colors]. -ColorScheme colorSchemeFromQueryaThemeColors({ +/// Missing keys and invalid optional colors fall back to [fallback.colors]. +/// [QueryaUiColors.brightness] always comes from [fallback], not from [colors]. +QueryaUiColors uiColorsFromQueryaThemeColors({ required Map colors, required QueryaTheme fallback, }) { - final base = fallback.colorScheme; + final base = fallback.colors; if (kDebugMode) { for (final key in colors.keys) { @@ -65,11 +68,11 @@ ColorScheme colorSchemeFromQueryaThemeColors({ } final destructive = pick('destructive', base.destructive); - // querya.theme.v1 still maps this key; shadcn marks the ColorScheme field legacy. + // querya.theme.v1 still maps this key; the key is kept. final destructiveForeground = pick('destructiveForeground', base.destructiveForeground); - return ColorScheme( + return QueryaUiColors( brightness: base.brightness, background: pick('background', base.background), foreground: pick('foreground', base.foreground), diff --git a/lib/core/theme/parser/querya_theme_from_manifest.dart b/lib/core/theme/parser/querya_theme_from_manifest.dart index dce9dce5..4bb935fd 100644 --- a/lib/core/theme/parser/querya_theme_from_manifest.dart +++ b/lib/core/theme/parser/querya_theme_from_manifest.dart @@ -32,7 +32,7 @@ QueryaTheme queryaThemeFromManifest(QueryaThemeManifest manifest) { editor = applyTokenColorsToEditor(editor, tokenColors); } - final colorScheme = colorSchemeFromQueryaThemeColors( + final colors = uiColorsFromQueryaThemeColors( colors: manifest.shadcnColors, fallback: fallback, ); @@ -41,7 +41,7 @@ QueryaTheme queryaThemeFromManifest(QueryaThemeManifest manifest) { workbench: workbench, editor: editor, brightness: brightness, - colorScheme: colorScheme, + colors: colors, tokenColors: tokenColors, ); } diff --git a/lib/core/theme/parser/querya_theme_from_vscode.dart b/lib/core/theme/parser/querya_theme_from_vscode.dart index 914a1b8c..6c70116a 100644 --- a/lib/core/theme/parser/querya_theme_from_vscode.dart +++ b/lib/core/theme/parser/querya_theme_from_vscode.dart @@ -87,7 +87,7 @@ QueryaTheme buildQueryaThemeFromVsCodeManifest( editor = editor.copyWith(background: workbench.editorBackground); } - var colorScheme = QueryaTheme.colorSchemeFromWorkbench( + var colors = QueryaTheme.colorsFromWorkbench( workbench, brightness: brightness, ); @@ -95,37 +95,37 @@ QueryaTheme buildQueryaThemeFromVsCodeManifest( final editorForegroundChanged = editor.foreground != base.editor.foreground; if (schemeForeground != null || editorForegroundChanged) { final fg = schemeForeground ?? editor.foreground; - colorScheme = colorScheme.copyWith( - foreground: () => fg, - cardForeground: () => fg, - popoverForeground: () => fg, + colors = colors.copyWith( + foreground: fg, + cardForeground: fg, + popoverForeground: fg, ); } if (schemeBackground != null) { - colorScheme = colorScheme.copyWith(background: () => schemeBackground!); + colors = colors.copyWith(background: schemeBackground); } if (schemeCard != null) { - colorScheme = colorScheme.copyWith( - card: () => schemeCard!, - popover: () => schemeCard!, + colors = colors.copyWith( + card: schemeCard, + popover: schemeCard, ); } if (schemeBorder != null) { - colorScheme = colorScheme.copyWith(border: () => schemeBorder!); + colors = colors.copyWith(border: schemeBorder); } if (schemeInput != null) { - colorScheme = colorScheme.copyWith(input: () => schemeInput!); + colors = colors.copyWith(input: schemeInput); } if (schemeRing != null) { - colorScheme = colorScheme.copyWith(ring: () => schemeRing!); + colors = colors.copyWith(ring: schemeRing); } if (schemeMutedForeground != null) { - colorScheme = colorScheme.copyWith( - mutedForeground: () => schemeMutedForeground!, + colors = colors.copyWith( + mutedForeground: schemeMutedForeground, ); } if (schemeAccent != null) { - colorScheme = colorScheme.copyWith(accent: () => schemeAccent!); + colors = colors.copyWith(accent: schemeAccent); } if (manifest.tokenColors.isNotEmpty) { @@ -136,7 +136,7 @@ QueryaTheme buildQueryaThemeFromVsCodeManifest( workbench: workbench, editor: editor, brightness: brightness, - colorScheme: colorScheme, + colors: colors, tokenColors: manifest.tokenColors, ); } diff --git a/lib/core/theme/parser/token_style_resolver.dart b/lib/core/theme/parser/token_style_resolver.dart index acb48322..f2a8b8a4 100644 --- a/lib/core/theme/parser/token_style_resolver.dart +++ b/lib/core/theme/parser/token_style_resolver.dart @@ -22,21 +22,19 @@ class TokenStyleResolver { _cache.putIfAbsent(scope, () => _resolveUncached(scope)); TextStyle _resolveUncached(String scope) { - for (final prefix in _scopePrefixes(scope)) { + // `a.b.c` -> `a.b` -> `a`: cut at the last dot instead of splitting into a + // list and joining sublists (#1530). + var prefix = scope; + while (true) { for (final rule in _rules) { if (rule.scopes.contains(prefix)) { return _styleFromRule(rule); } } + final dot = prefix.lastIndexOf('.'); + if (dot < 0) return _defaultStyle; + prefix = prefix.substring(0, dot); } - return _defaultStyle; - } - - List _scopePrefixes(String scope) { - final parts = scope.split('.'); - return [ - for (var i = parts.length; i >= 1; i--) parts.sublist(0, i).join('.'), - ]; } TextStyle _styleFromRule(TokenColorRule rule) { diff --git a/lib/core/theme/querya_color_scheme.dart b/lib/core/theme/querya_color_scheme.dart deleted file mode 100644 index 177efc15..00000000 --- a/lib/core/theme/querya_color_scheme.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; - -import 'querya_theme.dart'; - -/// Shadcn [ColorScheme] presets — derived from [QueryaTheme] defaults. -abstract class QueryaColorScheme { - static ColorScheme get dark => QueryaTheme.darkDefault.colorScheme; - static ColorScheme get light => QueryaTheme.lightDefault.colorScheme; -} diff --git a/lib/core/theme/querya_colors.dart b/lib/core/theme/querya_colors.dart index f691b7eb..afd0462d 100644 --- a/lib/core/theme/querya_colors.dart +++ b/lib/core/theme/querya_colors.dart @@ -2,7 +2,7 @@ import 'dart:ui'; /// Design tokens aligned with [design-front] mockups (near-black canvas, cyan accent). /// -/// Canonical semantic mapping lives in [QueryaColorScheme]; these are named aliases +/// Canonical semantic mapping lives in [QueryaTheme.colors]; these are named aliases /// for documentation and for a few widgets that need the raw accent (e.g. glows). abstract class QueryaColors { QueryaColors._(); diff --git a/lib/core/theme/querya_material_theme.dart b/lib/core/theme/querya_material_theme.dart index 6ab54c63..1e3749dc 100644 --- a/lib/core/theme/querya_material_theme.dart +++ b/lib/core/theme/querya_material_theme.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart' as material; -import 'package:shadcn_flutter/shadcn_flutter.dart' show ColorScheme; +import 'querya_ui_colors.dart'; -/// Material [ThemeData] aligned with Querya [ColorScheme] for dialogs, dropdowns, etc. -material.ThemeData materialThemeFromQuerya(ColorScheme scheme) { +/// Material [ThemeData] aligned with Querya [QueryaUiColors] for dialogs, dropdowns, etc. +material.ThemeData materialThemeFromQuerya(QueryaUiColors scheme) { final materialScheme = material.ColorScheme( brightness: scheme.brightness, primary: scheme.primary, @@ -21,6 +21,9 @@ material.ThemeData materialThemeFromQuerya(ColorScheme scheme) { return material.ThemeData( useMaterial3: true, + // The app text face: shadcn_flutter carries it until the fonts move (#1736). + fontFamily: 'GeistSans', + package: 'shadcn_flutter', // Safety net: stray Material popups must not ripple. splashFactory: material.NoSplash.splashFactory, colorScheme: materialScheme, diff --git a/lib/core/theme/querya_semantic_palette.dart b/lib/core/theme/querya_semantic_palette.dart index 92e0ccdc..5acefed9 100644 --- a/lib/core/theme/querya_semantic_palette.dart +++ b/lib/core/theme/querya_semantic_palette.dart @@ -21,18 +21,18 @@ class QueryaSemanticPalette { }); factory QueryaSemanticPalette.fromTheme(QueryaTheme theme) { - final scheme = theme.colorScheme; + final colors = theme.colors; final workbench = theme.workbench; return QueryaSemanticPalette( action: workbench.accent, success: workbench.success, destructive: workbench.destructive, muted: workbench.mutedForeground, - type1: scheme.chart1, - type2: scheme.chart2, - type3: scheme.chart3, - type4: scheme.chart4, - type5: scheme.chart5, + type1: colors.chart1, + type2: colors.chart2, + type3: colors.chart3, + type4: colors.chart4, + type5: colors.chart5, ); } diff --git a/lib/core/theme/querya_theme.dart b/lib/core/theme/querya_theme.dart index 3b3cc333..522938e8 100644 --- a/lib/core/theme/querya_theme.dart +++ b/lib/core/theme/querya_theme.dart @@ -1,29 +1,26 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'dart:ui'; import 'color_contrast.dart'; import 'parser/vscode_theme_manifest.dart'; import 'querya_colors.dart'; +import 'querya_ui_colors.dart'; import 'querya_editor_theme.dart'; import 'querya_workbench_theme.dart'; -/// The type of [QueryaTheme.colorScheme], nameable by widgets that must not -/// import the UI library behind it. -typedef QueryaThemeColorScheme = ColorScheme; - -/// Full Querya theme: workbench chrome + editor tokens + shadcn [ColorScheme]. +/// Full Querya theme: workbench chrome + editor tokens + the widgets' [colors]. class QueryaTheme { const QueryaTheme({ required this.workbench, required this.editor, required this.brightness, - required this.colorScheme, + required this.colors, this.tokenColors = const [], }); final QueryaWorkbenchTheme workbench; final QueryaEditorTheme editor; final Brightness brightness; - final ColorScheme colorScheme; + final QueryaUiColors colors; /// VS Code `tokenColors` for syntax highlighting (imported themes). final List tokenColors; @@ -43,18 +40,18 @@ class QueryaTheme { workbench: QueryaWorkbenchTheme.darkDefault, editor: QueryaEditorTheme.darkDefault, brightness: Brightness.dark, - colorScheme: _darkColorScheme, + colors: _darkColors, ); static const QueryaTheme lightDefault = QueryaTheme( workbench: QueryaWorkbenchTheme.lightDefault, editor: QueryaEditorTheme.lightDefault, brightness: Brightness.light, - colorScheme: _lightColorScheme, + colors: _lightColors, ); - /// Matches [QueryaWorkbenchTheme.darkDefault] / legacy [QueryaColorScheme]. - static const ColorScheme _darkColorScheme = ColorScheme( + /// Matches [QueryaWorkbenchTheme.darkDefault]. + static const QueryaUiColors _darkColors = QueryaUiColors( brightness: Brightness.dark, background: QueryaColors.canvas, foreground: Color(0xFFF8FAFC), @@ -83,7 +80,7 @@ class QueryaTheme { ); /// Matches [QueryaWorkbenchTheme.lightDefault]. - static const ColorScheme _lightColorScheme = ColorScheme( + static const QueryaUiColors _lightColors = QueryaUiColors( brightness: Brightness.light, background: Color(0xFFFAFAFA), foreground: Color(0xFF0F172A), @@ -111,14 +108,14 @@ class QueryaTheme { chart5: Color(0xFFE23670), ); - /// Builds [ColorScheme] from [workbench] (for imported / overridden themes). - static ColorScheme colorSchemeFromWorkbench( + /// Builds [QueryaUiColors] from [workbench] (for imported / overridden themes). + static QueryaUiColors colorsFromWorkbench( QueryaWorkbenchTheme w, { required Brightness brightness, }) { final isDark = brightness == Brightness.dark; final fg = isDark ? const Color(0xFFF8FAFC) : const Color(0xFF0F172A); - return ColorScheme( + return QueryaUiColors( brightness: brightness, background: w.canvas, foreground: fg, @@ -155,14 +152,14 @@ class QueryaTheme { QueryaWorkbenchTheme? workbench, QueryaEditorTheme? editor, Brightness? brightness, - ColorScheme? colorScheme, + QueryaUiColors? colors, List? tokenColors, }) { return QueryaTheme( workbench: workbench ?? this.workbench, editor: editor ?? this.editor, brightness: brightness ?? this.brightness, - colorScheme: colorScheme ?? this.colorScheme, + colors: colors ?? this.colors, tokenColors: tokenColors ?? this.tokenColors, ); } @@ -175,27 +172,11 @@ class QueryaTheme { workbench: w, editor: e, brightness: brightness, - colorScheme: ColorScheme.lerp(a.colorScheme, b.colorScheme, t), + colors: QueryaUiColors.lerp(a.colors, b.colors, t), tokenColors: t < 0.5 ? a.tokenColors : b.tokenColors, ); } - ThemeData toShadcnThemeData({ - double radius = 0.58, - double scaling = 1, - Typography? typography, - }) { - final typo = typography ?? const Typography.geist(); - final base = brightness == Brightness.dark - ? ThemeData.dark(colorScheme: colorScheme) - : ThemeData(colorScheme: colorScheme); - return base.copyWith( - radius: () => radius, - scaling: () => scaling, - typography: () => typo, - ); - } - @override bool operator ==(Object other) => identical(this, other) || @@ -203,12 +184,12 @@ class QueryaTheme { workbench == other.workbench && editor == other.editor && brightness == other.brightness && - colorScheme == other.colorScheme && + colors == other.colors && _listEquals(tokenColors, other.tokenColors); @override int get hashCode => - Object.hash(workbench, editor, brightness, colorScheme, tokenColors); + Object.hash(workbench, editor, brightness, colors, tokenColors); static bool _listEquals(List a, List b) { if (a.length != b.length) return false; diff --git a/lib/core/theme/querya_theme_scope.dart b/lib/core/theme/querya_theme_scope.dart index a834d601..28cdc3e1 100644 --- a/lib/core/theme/querya_theme_scope.dart +++ b/lib/core/theme/querya_theme_scope.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'querya_editor_theme.dart'; import 'querya_semantic_palette.dart'; import 'querya_theme.dart'; +import 'querya_ui_colors.dart'; import 'querya_workbench_theme.dart'; -/// Provides [QueryaTheme] (workbench + editor tokens) below [ShadcnApp]. +/// Provides [QueryaTheme] (workbench, editor and colour tokens) below the app. class QueryaThemeScope extends InheritedWidget { const QueryaThemeScope({ super.key, @@ -54,12 +53,23 @@ extension QueryaThemeContext on BuildContext { /// Shortcut for Material [material.Theme.of]. material.ThemeData get theme => material.Theme.of(this); - /// Shortcut for [shadcn.ColorScheme] from [shadcn.Theme.of]. - shadcn.ColorScheme get colors => shadcn.Theme.of(this).colorScheme; + /// The colours widgets read (#1730): the nearest [QueryaThemeScope]'s, which + /// follows a theme animation frame by frame. Without a scope (a bare app in a + /// test) the default theme of the ambient brightness. + QueryaUiColors get colors => + (QueryaThemeScope.maybeOf(this) ?? _defaultTheme).colors; + + QueryaTheme get _defaultTheme => _ambientBrightness == Brightness.light + ? QueryaTheme.lightDefault + : QueryaTheme.darkDefault; QueryaTheme get queryaTheme => QueryaThemeScope.of(this); - QueryaWorkbenchTheme get workbench => queryaTheme.workbench; + /// The brightness the defaults below follow when there is no + /// [QueryaThemeScope]: Material's (a widget under a bare app, as in tests). + Brightness get _ambientBrightness => material.Theme.of(this).brightness; + + QueryaWorkbenchTheme get workbench => workbenchOrDefault; /// The workbench tokens of the nearest [QueryaThemeScope], or the defaults /// for the ambient brightness when there is none (a widget under a bare app, @@ -67,12 +77,21 @@ extension QueryaThemeContext on BuildContext { /// scope. QueryaWorkbenchTheme get workbenchOrDefault => QueryaThemeScope.maybeOf(this)?.workbench ?? - (colors.brightness == Brightness.light + (_ambientBrightness == Brightness.light ? QueryaWorkbenchTheme.lightDefault : QueryaWorkbenchTheme.darkDefault); - QueryaEditorTheme get editorTheme => queryaTheme.editor; + QueryaEditorTheme get editorTheme => + QueryaThemeScope.maybeOf(this)?.editor ?? + (_ambientBrightness == Brightness.light + ? QueryaEditorTheme.lightDefault + : QueryaEditorTheme.darkDefault); QueryaSemanticPalette get semanticPalette => - QueryaSemanticPalette.fromTheme(queryaTheme); + QueryaSemanticPalette.fromTheme( + QueryaThemeScope.maybeOf(this) ?? + (_ambientBrightness == Brightness.light + ? QueryaTheme.lightDefault + : QueryaTheme.darkDefault), + ); } diff --git a/lib/core/theme/querya_typography.dart b/lib/core/theme/querya_typography.dart index a0fb4567..0ced8252 100644 --- a/lib/core/theme/querya_typography.dart +++ b/lib/core/theme/querya_typography.dart @@ -13,6 +13,9 @@ abstract class QueryaTypography { /// - macOS: Menlo, SF Mono, Monaco /// - Linux / BSD: Fira Code, Ubuntu Mono, DejaVu Sans Mono /// - Generic system fallback: monospace + /// + /// Ends with [cjkFontFamilyFallback], so CJK text in the editor, inspectors + /// and badges does not go through the engine's per-paragraph search. static const List monoFontFamilyFallback = [ 'Cascadia Code', 'Consolas', @@ -24,5 +27,33 @@ abstract class QueryaTypography { 'DejaVu Sans Mono', 'Courier New', 'monospace', + ...cjkFontFamilyFallback, + ]; + + /// Families that have CJK glyphs, to put after the primary family (#1553). + /// + /// When the primary family has none, the engine asks fontconfig (or the OS) + /// for a fallback for every paragraph and does not reuse the answer: about + /// 400 us per 18-character string instead of 10-30 us measured with an + /// explicit family. A family that is not installed costs 2-5 us, so the + /// list can be long. Use it alone where the primary family must stay the + /// generic `monospace` (result grid cells). + static const List cjkFontFamilyFallback = [ + // Linux + 'Noto Sans Mono CJK SC', + 'Noto Sans Mono CJK JP', + 'Noto Sans Mono CJK KR', + 'Noto Sans CJK SC', + 'Noto Sans CJK JP', + 'Noto Sans CJK KR', + 'WenQuanYi Micro Hei', + // macOS + 'PingFang SC', + 'Hiragino Sans', + 'Apple SD Gothic Neo', + // Windows + 'Microsoft YaHei', + 'Yu Gothic', + 'Malgun Gothic', ]; } diff --git a/lib/core/theme/querya_ui_colors.dart b/lib/core/theme/querya_ui_colors.dart new file mode 100644 index 00000000..b2c27de2 --- /dev/null +++ b/lib/core/theme/querya_ui_colors.dart @@ -0,0 +1,193 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +/// The colours the app's widgets read: surfaces, text, accent, borders and +/// the chart series (#1730, epic #1465). +/// +/// The theme's colour scheme (the names are shadcn's, which the app grew up +/// on): [QueryaTheme.colors], read by widgets as `context.colors` (#1730, +/// #1735). +@immutable +class QueryaUiColors { + const QueryaUiColors({ + required this.brightness, + required this.background, + required this.foreground, + required this.card, + required this.cardForeground, + required this.popover, + required this.popoverForeground, + required this.primary, + required this.primaryForeground, + required this.secondary, + required this.secondaryForeground, + required this.muted, + required this.mutedForeground, + required this.accent, + required this.accentForeground, + required this.destructive, + required this.destructiveForeground, + required this.border, + required this.input, + required this.ring, + required this.chart1, + required this.chart2, + required this.chart3, + required this.chart4, + required this.chart5, + }); + + final Brightness brightness; + + /// Page background. + final Color background; + + /// Main text and icons. + final Color foreground; + + /// Raised surfaces: cards, panels, and their text. + final Color card; + final Color cardForeground; + + /// Menus, dropdowns and other floating surfaces, and their text. + final Color popover; + final Color popoverForeground; + + /// The accent: filled buttons, selection, links; and what sits on it. + final Color primary; + final Color primaryForeground; + + /// Quiet filled surfaces (secondary buttons) and their text. + final Color secondary; + final Color secondaryForeground; + + /// Subdued backgrounds (hover, chips) and secondary text. + final Color muted; + final Color mutedForeground; + + /// Hover and highlight background, and its text. + final Color accent; + final Color accentForeground; + + /// Errors and destructive actions, and what sits on them. + final Color destructive; + final Color destructiveForeground; + + /// Hairlines, control outlines and the outline of an input; the focus ring. + final Color border; + final Color input; + final Color ring; + + /// Chart series. + final Color chart1; + final Color chart2; + final Color chart3; + final Color chart4; + final Color chart5; + + QueryaUiColors copyWith({ + Brightness? brightness, + Color? background, + Color? foreground, + Color? card, + Color? cardForeground, + Color? popover, + Color? popoverForeground, + Color? primary, + Color? primaryForeground, + Color? secondary, + Color? secondaryForeground, + Color? muted, + Color? mutedForeground, + Color? accent, + Color? accentForeground, + Color? destructive, + Color? destructiveForeground, + Color? border, + Color? input, + Color? ring, + Color? chart1, + Color? chart2, + Color? chart3, + Color? chart4, + Color? chart5, + }) => + QueryaUiColors( + brightness: brightness ?? this.brightness, + background: background ?? this.background, + foreground: foreground ?? this.foreground, + card: card ?? this.card, + cardForeground: cardForeground ?? this.cardForeground, + popover: popover ?? this.popover, + popoverForeground: popoverForeground ?? this.popoverForeground, + primary: primary ?? this.primary, + primaryForeground: primaryForeground ?? this.primaryForeground, + secondary: secondary ?? this.secondary, + secondaryForeground: secondaryForeground ?? this.secondaryForeground, + muted: muted ?? this.muted, + mutedForeground: mutedForeground ?? this.mutedForeground, + accent: accent ?? this.accent, + accentForeground: accentForeground ?? this.accentForeground, + destructive: destructive ?? this.destructive, + destructiveForeground: + destructiveForeground ?? this.destructiveForeground, + border: border ?? this.border, + input: input ?? this.input, + ring: ring ?? this.ring, + chart1: chart1 ?? this.chart1, + chart2: chart2 ?? this.chart2, + chart3: chart3 ?? this.chart3, + chart4: chart4 ?? this.chart4, + chart5: chart5 ?? this.chart5, + ); + + static QueryaUiColors lerp(QueryaUiColors a, QueryaUiColors b, double t) { + Color l(Color x, Color y) => Color.lerp(x, y, t)!; + return QueryaUiColors( + brightness: t < 0.5 ? a.brightness : b.brightness, + background: l(a.background, b.background), + foreground: l(a.foreground, b.foreground), + card: l(a.card, b.card), + cardForeground: l(a.cardForeground, b.cardForeground), + popover: l(a.popover, b.popover), + popoverForeground: l(a.popoverForeground, b.popoverForeground), + primary: l(a.primary, b.primary), + primaryForeground: l(a.primaryForeground, b.primaryForeground), + secondary: l(a.secondary, b.secondary), + secondaryForeground: l(a.secondaryForeground, b.secondaryForeground), + muted: l(a.muted, b.muted), + mutedForeground: l(a.mutedForeground, b.mutedForeground), + accent: l(a.accent, b.accent), + accentForeground: l(a.accentForeground, b.accentForeground), + destructive: l(a.destructive, b.destructive), + destructiveForeground: + l(a.destructiveForeground, b.destructiveForeground), + border: l(a.border, b.border), + input: l(a.input, b.input), + ring: l(a.ring, b.ring), + chart1: l(a.chart1, b.chart1), + chart2: l(a.chart2, b.chart2), + chart3: l(a.chart3, b.chart3), + chart4: l(a.chart4, b.chart4), + chart5: l(a.chart5, b.chart5), + ); + } + + List get _props => [ + brightness, background, foreground, card, cardForeground, popover, popoverForeground, + primary, primaryForeground, secondary, secondaryForeground, muted, + mutedForeground, accent, accentForeground, destructive, destructiveForeground, border, + input, ring, chart1, chart2, chart3, chart4, chart5, + ]; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! QueryaUiColors) return false; + return listEquals(_props, other._props); + } + + @override + int get hashCode => Object.hashAll(_props); +} diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 5f11fcac..9e2a0e29 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/material.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/theme/querya_material_theme.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import 'parser/apply_token_colors_to_editor.dart'; import 'parser/color_parser.dart'; @@ -85,10 +85,8 @@ class ThemeController extends ChangeNotifier with WidgetsBindingObserver { QueryaTheme? _cachedLightTheme; QueryaTheme? _cachedDarkTheme; QueryaTheme? _cachedActiveTheme; - ThemeData? _cachedLightShadcnTheme; - ThemeData? _cachedDarkShadcnTheme; - material.ThemeData? _cachedMaterialTheme; - ColorScheme? _cachedMaterialThemeScheme; + material.ThemeData? _cachedLightMaterialTheme; + material.ThemeData? _cachedDarkMaterialTheme; ThemeMode get themeMode => _themeMode; @@ -166,20 +164,13 @@ class ThemeController extends ChangeNotifier with WidgetsBindingObserver { QueryaTheme get activeTheme => _resolvedThemeForBrightness(_effectiveBrightness()); - ThemeData get lightShadcnTheme => _cachedLightShadcnTheme ??= - _resolvedThemeForBrightness(Brightness.light).toShadcnThemeData(); + /// The Material theme of the light and the dark resolved theme: what the + /// app shell shows for [themeMode] (the Material app picks, system included). + material.ThemeData get lightMaterialTheme => _cachedLightMaterialTheme ??= + materialThemeFromQuerya(_resolvedThemeForBrightness(Brightness.light).colors); - ThemeData get darkShadcnTheme => _cachedDarkShadcnTheme ??= - _resolvedThemeForBrightness(Brightness.dark).toShadcnThemeData(); - - /// Cached Material theme for dialogs/dropdowns (avoids rebuild churn). - material.ThemeData materialThemeFor(ColorScheme scheme) { - if (_cachedMaterialTheme != null && _cachedMaterialThemeScheme == scheme) { - return _cachedMaterialTheme!; - } - _cachedMaterialThemeScheme = scheme; - return _cachedMaterialTheme = materialThemeFromQuerya(scheme); - } + material.ThemeData get darkMaterialTheme => _cachedDarkMaterialTheme ??= + materialThemeFromQuerya(_resolvedThemeForBrightness(Brightness.dark).colors); @visibleForTesting void setRegistryServiceForTest(ThemeRegistryService service) { @@ -266,10 +257,8 @@ class ThemeController extends ChangeNotifier with WidgetsBindingObserver { _cachedLightTheme = null; _cachedDarkTheme = null; _cachedActiveTheme = null; - _cachedLightShadcnTheme = null; - _cachedDarkShadcnTheme = null; - _cachedMaterialTheme = null; - _cachedMaterialThemeScheme = null; + _cachedLightMaterialTheme = null; + _cachedDarkMaterialTheme = null; } void _updateBrightnessObserver() { @@ -471,8 +460,13 @@ class ThemeController extends ChangeNotifier with WidgetsBindingObserver { Future setThemeMode(ThemeMode mode) async { _themeMode = mode; + final effectiveBrightness = _effectiveBrightness(); + if (_registryTheme != null && + _registryTheme!.brightness != effectiveBrightness) { + await _clearRegistrySelection(); + } if (_registryTheme == null && _preset != QueryaThemePreset.imported) { - _preset = mode == ThemeMode.light + _preset = effectiveBrightness == Brightness.light ? QueryaThemePreset.queryaLight : QueryaThemePreset.queryaDark; await AppSettings.instance.setThemePreset(_preset); diff --git a/lib/core/theme/theme_editor_draft.dart b/lib/core/theme/theme_editor_draft.dart index 63442848..9cb7abf6 100644 --- a/lib/core/theme/theme_editor_draft.dart +++ b/lib/core/theme/theme_editor_draft.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:ui'; -import 'package:shadcn_flutter/shadcn_flutter.dart' show ColorScheme; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/theme/parser/color_parser.dart'; import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; import 'package:querya_desktop/core/theme/parser/vscode_theme_manifest.dart'; @@ -144,7 +144,7 @@ class ThemeEditorDraft { ThemeMetadata? metadata, bool readOnlySource = false, }) { - final scheme = theme.colorScheme; + final colors = theme.colors; final editor = theme.editor; final workbench = theme.workbench; @@ -155,7 +155,7 @@ class ThemeEditorDraft { shadcnColors: { for (final field in themeEditorMvpColorFields) if (field.section == 'shadcn_colors') - field.key: _colorForShadcnField(field.key, scheme), + field.key: _colorForShadcnField(field.key, colors), }, editorColors: { 'background': formatVsCodeColor(editor.background), @@ -176,14 +176,14 @@ class ThemeEditorDraft { ); } - static String _colorForShadcnField(String key, ColorScheme scheme) { + static String _colorForShadcnField(String key, QueryaUiColors colors) { final color = switch (key) { - 'primary' => scheme.primary, - 'background' => scheme.background, - 'foreground' => scheme.foreground, - 'card' => scheme.card, - 'border' => scheme.border, - _ => scheme.primary, + 'primary' => colors.primary, + 'background' => colors.background, + 'foreground' => colors.foreground, + 'card' => colors.card, + 'border' => colors.border, + _ => colors.primary, }; return formatVsCodeColor(color); } diff --git a/lib/core/ui/querya_shell_status.dart b/lib/core/ui/querya_shell_status.dart index 0afdccad..4256cd63 100644 --- a/lib/core/ui/querya_shell_status.dart +++ b/lib/core/ui/querya_shell_status.dart @@ -64,7 +64,10 @@ class QueryaShellStatus extends ChangeNotifier { if (duration != null) _lastQueryDuration = duration; if (rowCount != null) _rowCount = rowCount; if (columnCount != null) _columnCount = columnCount; - if (message != null) _statusMessage = message; + // The message describes this result. A plain row count passes none (the + // counts are shown beside it), and then neither "Running query…" nor the + // text of an earlier run may stay (#1556). + _statusMessage = message; _messageIsBusy = false; notifyListeners(); } diff --git a/lib/core/ui/querya_tree_indent_guide.dart b/lib/core/ui/querya_tree_indent_guide.dart index 60636c6f..68ecc9a0 100644 --- a/lib/core/ui/querya_tree_indent_guide.dart +++ b/lib/core/ui/querya_tree_indent_guide.dart @@ -40,8 +40,7 @@ class QueryaTreeIndentGuide extends StatelessWidget { if (depth <= 0) return content; - final base = QueryaThemeScope.maybeOf(context)?.workbench.borderSubtle ?? - Theme.of(context).colorScheme.outline; + final base = context.workbench.borderSubtle; final color = base.withValues(alpha: QueryaTreeTokens.guideAlpha); return CustomPaint( diff --git a/lib/core/updater/app_updater_service.dart b/lib/core/updater/app_updater_service.dart index b5fe0c42..1e327d97 100644 --- a/lib/core/updater/app_updater_service.dart +++ b/lib/core/updater/app_updater_service.dart @@ -48,7 +48,11 @@ class AppUpdaterService { throw AppUpdaterException('Invalid current app version: $currentRaw'); } - final channel = await _settings.getUpdateChannel(); + // A package install only ever gets stable releases from its package + // manager, so it is not told about betas it cannot install (#1497). + final channel = UpdateInstallContext.currentIsPackageManagerInstall + ? UpdateChannel.stable + : await _settings.getUpdateChannel(); final manifest = await _releasesClient.fetchLatest(channel: channel); final candidateVersion = UpdateVersion.tryParse(manifest.version); if (candidateVersion == null) { @@ -202,6 +206,10 @@ class AppUpdaterService { return UpdateInstallContext.current().packageManagerUpdateCommand; } + /// Why in-app install is blocked and what to run instead. + String get packageManagerUpdateHint => + UpdateInstallContext.current().managedUpdateHint; + /// Picks the best Release asset for the current packaging context. UpdateAsset? platformAssetFor( UpdateManifest manifest, { diff --git a/lib/core/updater/github_releases_client.dart b/lib/core/updater/github_releases_client.dart index d262040e..e1f61a36 100644 --- a/lib/core/updater/github_releases_client.dart +++ b/lib/core/updater/github_releases_client.dart @@ -49,13 +49,7 @@ class GitHubReleasesClient { throw const GitHubReleasesException('Unexpected GitHub Releases list payload'); } - for (final entry in decoded) { - if (entry is! Map) continue; - if (entry['draft'] == true) continue; - return parseGitHubRelease(entry); - } - - throw const GitHubReleasesException('No published releases found'); + return pickNewestRelease(decoded); } Future downloadText(String url) async { @@ -71,6 +65,34 @@ class GitHubReleasesClient { void close() => _httpClient.close(); } +/// The release with the highest version in a `/releases` list, for the Beta +/// channel (#1493). +/// +/// The API lists releases by creation date, not by version: with `0.5.0-beta.2` +/// followed by a hotfix `0.4.20`, the first entry is the hotfix and the newer +/// beta would never be offered. Drafts and tags that are not versions are +/// skipped; a stable release outranks a pre-release of the same version. Only +/// the first page of the API is read. +UpdateManifest pickNewestRelease(List releases) { + Map? best; + UpdateVersion? bestVersion; + for (final entry in releases) { + if (entry is! Map) continue; + if (entry['draft'] == true) continue; + final tag = entry['tag_name']?.toString(); + final version = tag == null ? null : UpdateVersion.tryParse(tag); + if (version == null) continue; + if (bestVersion == null || version.compareTo(bestVersion) > 0) { + best = entry; + bestVersion = version; + } + } + if (best == null) { + throw const GitHubReleasesException('No published releases found'); + } + return parseGitHubRelease(best); +} + /// Parses a single GitHub release object into [UpdateManifest]. UpdateManifest parseGitHubRelease(Map json) { final tagName = json['tag_name']?.toString(); diff --git a/lib/core/updater/installers/linux_appimage_installer.dart b/lib/core/updater/installers/linux_appimage_installer.dart index 2f6bc2c1..6fb9ef1a 100644 --- a/lib/core/updater/installers/linux_appimage_installer.dart +++ b/lib/core/updater/installers/linux_appimage_installer.dart @@ -55,6 +55,14 @@ class LinuxAppImageInstaller { 'Could not determine the Linux install directory for in-place update', ); } + // Checked before the app quits: a replace that cannot write leaves the + // user with no running app. + if (context.isManagedPackage) { + throw PackageManagerUpdateRequiredException( + manager: context.linuxSystemPackageManager ?? 'system', + hint: context.managedUpdateHint, + ); + } final executable = context.resolvedExecutable; final sourceDir = resolveLinuxFlutterBundleRoot( diff --git a/lib/core/updater/installers/macos_sparkle_installer.dart b/lib/core/updater/installers/macos_sparkle_installer.dart index 8028eb8b..1b1db156 100644 --- a/lib/core/updater/installers/macos_sparkle_installer.dart +++ b/lib/core/updater/installers/macos_sparkle_installer.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; import '../app_updater_service.dart'; import 'update_install_context.dart'; @@ -12,9 +13,14 @@ import 'update_install_utils.dart'; /// Sparkle integration can wrap this path later; for now we verify `codesign` /// on the downloaded bundle before swapping it in place. class MacosSparkleInstaller { - MacosSparkleInstaller({required this.context}); + MacosSparkleInstaller({ + required this.context, + Future Function(String executable, List arguments)? + runProcess, + }) : _runProcess = runProcess ?? Process.run; final UpdateInstallContext context; + final Future Function(String, List) _runProcess; Future install(File package) async { final lower = package.path.toLowerCase(); @@ -31,21 +37,41 @@ class MacosSparkleInstaller { ); } + // Checked again here, right before the app quits for good (#1587). + final problem = context.macAppBundleReplaceProblemFor(targetApp); + if (problem != null) { + throw PackageManagerUpdateRequiredException( + manager: 'system', + hint: problem, + ); + } + final tempRoot = await getTemporaryDirectory(); final extractDir = Directory( p.join(tempRoot.path, 'querya-update-${DateTime.now().millisecondsSinceEpoch}'), ); - await extractZipSecurely(zipFile: package, destinationDir: extractDir); - - final newApp = await findMacAppBundleInDirectory(extractDir); - if (newApp == null) { - throw const AppUpdaterException( - 'Downloaded macOS update zip does not contain a .app bundle', - ); + final Directory newApp; + try { + await extractZipSecurely(zipFile: package, destinationDir: extractDir); + final found = await findMacAppBundleInDirectory(extractDir); + if (found == null) { + throw const AppUpdaterException( + 'Downloaded macOS update zip does not contain a .app bundle', + ); + } + await _verifyCodesign(found); + await _verifySameSigner(installed: targetApp, update: found.path); + newApp = found; + } catch (_) { + // The app keeps running: do not leave the extracted update behind. + try { + await extractDir.delete(recursive: true); + } catch (e) { + logIgnored('macos_sparkle_installer', e); + } + rethrow; } - await _verifyCodesign(newApp); - final scriptFile = File(p.join(tempRoot.path, 'querya-macos-update-$pid.sh')); await scriptFile.writeAsString( buildMacAppReplaceScript( @@ -53,14 +79,39 @@ class MacosSparkleInstaller { newAppBundle: newApp.path, targetAppBundle: targetApp, executable: context.resolvedExecutable, + workDir: extractDir.path, ), ); await launchDetachedScript(scriptFile.path, const []); exit(0); } + /// `codesign --verify` only proves the seal is intact: any validly signed + /// bundle, even ad-hoc, passed and replaced the app. The update must also + /// carry the Team ID of the installed app (#1588). + Future _verifySameSigner({ + required String installed, + required String update, + }) async { + final problem = signerMismatchProblem( + installedTeam: await _teamId(installed), + updateTeam: await _teamId(update), + ); + if (problem != null) throw AppUpdaterException(problem); + } + + /// The bundle's Team ID, or null when it has none (ad-hoc or unsigned: + /// `codesign -dv` fails or prints `TeamIdentifier=not set`). + Future _teamId(String bundle) async { + final result = + await _runProcess('codesign', ['-dv', '--verbose=4', bundle]); + if (result.exitCode != 0) return null; + // codesign writes the details to stderr. + return parseCodesignTeamId('${result.stderr}\n${result.stdout}'); + } + Future _verifyCodesign(Directory appBundle) async { - final result = await Process.run( + final result = await _runProcess( 'codesign', ['--verify', '--deep', '--strict', appBundle.path], ); @@ -74,3 +125,30 @@ class MacosSparkleInstaller { } } } + +/// The `TeamIdentifier=` of `codesign -dv` output, or null for none / `not set`. +String? parseCodesignTeamId(String codesignOutput) { + final match = RegExp(r'^TeamIdentifier=(.*)$', multiLine: true) + .firstMatch(codesignOutput); + final id = match?.group(1)?.trim(); + if (id == null || id.isEmpty || id == 'not set') return null; + return id; +} + +/// Why an update signed by [updateTeam] may not replace an app signed by +/// [installedTeam], or null when it may. +/// +/// An installed app with no Team ID (the unsigned or ad-hoc build) has +/// nothing to compare with: there the SHA-256 from the release's +/// `SHA256SUMS.txt`, checked before this, is the check. +String? signerMismatchProblem({ + required String? installedTeam, + required String? updateTeam, +}) { + if (installedTeam == null || updateTeam == installedTeam) return null; + return updateTeam == null + ? 'The downloaded update is not signed by the developer of the installed ' + 'app (Team ID $installedTeam). Installation aborted.' + : 'The downloaded update is signed by a different developer (Team ID ' + '$updateTeam, expected $installedTeam). Installation aborted.'; +} diff --git a/lib/core/updater/installers/update_install_context.dart b/lib/core/updater/installers/update_install_context.dart index 54d51a64..9cb4f579 100644 --- a/lib/core/updater/installers/update_install_context.dart +++ b/lib/core/updater/installers/update_install_context.dart @@ -1,6 +1,8 @@ import 'dart:io'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/platform/macos_sandbox.dart'; import '../app_updater_service.dart'; @@ -9,11 +11,22 @@ class UpdateInstallContext { const UpdateInstallContext({ required this.environment, required this.resolvedExecutable, + this.fileExists, + this.listDirectory, + this.isDirectoryWritable, }); final Map environment; final String resolvedExecutable; + /// File-system probes, replaceable in tests; default to the real disk. + final bool Function(String path)? fileExists; + final List Function(String dir)? listDirectory; + final bool Function(String dir)? isDirectoryWritable; + + /// Where the `.deb`, `.rpm` and AUR packages put the bundle. + static const linuxSystemBundleRoot = '/opt/querya-desktop'; + factory UpdateInstallContext.current() { return UpdateInstallContext( environment: Map.unmodifiable(Platform.environment), @@ -35,13 +48,156 @@ class UpdateInstallContext { bool get isFlatpak => Platform.isLinux && environment.containsKey('FLATPAK_ID'); - bool get isManagedPackage => isSnap || isFlatpak; + /// The system package manager that owns the running bundle: `pacman` (AUR), + /// `dpkg` (`.deb`) or `rpm` (`.rpm`); null for a portable bundle, an + /// AppImage, Snap or Flatpak. + /// + /// Those packages install the bundle into [linuxSystemBundleRoot], owned by + /// root: an in-place update cannot write there, quits the app and leaves it + /// not running (seen with the AUR package, 0.4.18 -> 0.4.19). + String? get linuxSystemPackageManager { + if (!Platform.isLinux || isSnap || isFlatpak || isLinuxAppImage) { + return null; + } + if (linuxBundleRoot != linuxSystemBundleRoot) return null; + final exists = fileExists ?? (path) => File(path).existsSync(); + final list = listDirectory ?? _listDirectory; + if (list('/var/lib/pacman/local') + .any((name) => name.startsWith('querya-desktop-'))) { + return 'pacman'; + } + if (exists('/var/lib/dpkg/info/querya-desktop.list')) return 'dpkg'; + if (exists('/usr/bin/rpm') || exists('/bin/rpm')) return 'rpm'; + return null; + } - /// Command the user should run when [isManagedPackage] is true. + /// True when the Linux bundle directory exists but this user cannot write + /// it, whoever installed it: an in-place update would fail half-way. + bool get isLinuxBundleReadOnly { + if (!Platform.isLinux || isSnap || isFlatpak || isLinuxAppImage) { + return false; + } + final root = linuxBundleRoot; + if (root == null) return false; + return !(isDirectoryWritable ?? _isDirectoryWritable)(root); + } + + /// Why the running `.app` cannot be replaced in place, or null when it can + /// (and off macOS). The replace script quits the app first, so this must be + /// known before (#1587). + String? get macAppBundleReplaceProblem { + final bundle = macAppBundlePath; + return bundle == null ? null : macAppBundleReplaceProblemFor(bundle); + } + + /// [macAppBundleReplaceProblem] for the bundle at [bundle]: Gatekeeper's + /// translocated copy, or a folder this user cannot write (`/Applications` + /// for a standard user, a mounted disk image). + String? macAppBundleReplaceProblemFor(String bundle) { + // The sandbox cannot write outside its container, so the replace script + // would quit the app and fail (#1593). + if (MacOsSandbox.detect(environment)) { + return 'Querya Desktop is running in the macOS App Sandbox and cannot ' + 'replace itself. Download the new version from the release page.'; + } + if (bundle.contains('/AppTranslocation/')) { + return 'macOS is running Querya Desktop from a temporary copy, because ' + 'it was opened where it was downloaded. Move Querya Desktop to the ' + 'Applications folder, open it from there and update again, or ' + 'download the new version from the release page.'; + } + final folder = p.dirname(bundle); + if (!(isDirectoryWritable ?? _isDirectoryWritable)(folder)) { + return 'Querya Desktop is in $folder, which this user cannot write. ' + 'Download the new version from the release page and replace the ' + 'app there.'; + } + return null; + } + + /// Installed by a package manager (Snap, Flatpak, AUR, `.deb`, `.rpm`): + /// it brings the updates, and they are always stable releases, so the + /// Release channel has no effect there (#1497). + bool get isPackageManagerInstall => + isSnap || isFlatpak || linuxSystemPackageManager != null; + + /// Forces [currentIsPackageManagerInstall] in tests; null detects. + @visibleForTesting + static bool? debugPackageManagerInstall; + + /// [isPackageManagerInstall] for this process. + static bool get currentIsPackageManagerInstall => + debugPackageManagerInstall ?? + UpdateInstallContext.current().isPackageManagerInstall; + + bool get isManagedPackage => + isSnap || + isFlatpak || + linuxSystemPackageManager != null || + isLinuxBundleReadOnly || + macAppBundleReplaceProblem != null; + + /// Command the user should run when [isManagedPackage] is true; null when + /// there is no single command (a read-only bundle of unknown origin). String? get packageManagerUpdateCommand { if (isSnap) return 'snap refresh'; if (isFlatpak) return 'flatpak update'; - return null; + return switch (linuxSystemPackageManager) { + 'pacman' => 'yay -Syu querya-desktop', + 'dpkg' => 'sudo apt update && sudo apt install --only-upgrade querya-desktop', + 'rpm' => 'sudo dnf upgrade querya-desktop', + _ => null, + }; + } + + /// Why in-app install is blocked and what to do, for messages. + String get managedUpdateHint { + if (isSnap) { + return 'Updates for the Snap build must be installed with: snap refresh'; + } + if (isFlatpak) { + return 'Updates for the Flatpak build must be installed with: flatpak update'; + } + final command = packageManagerUpdateCommand; + final manager = switch (linuxSystemPackageManager) { + 'pacman' => 'the AUR package', + 'dpkg' => 'the .deb package', + 'rpm' => 'the .rpm package', + _ => null, + }; + if (manager != null && command != null) { + return 'Querya Desktop was installed from $manager; update it with: $command'; + } + final macProblem = macAppBundleReplaceProblem; + if (macProblem != null) return macProblem; + return 'Querya Desktop is installed in ${linuxBundleRoot ?? 'a folder'}, ' + 'which this user cannot write. Update it the way it was installed, ' + 'or download the new version from the release page.'; + } + + static List _listDirectory(String dir) { + try { + return [ + for (final e in Directory(dir).listSync(followLinks: false)) + p.basename(e.path), + ]; + } on FileSystemException { + return const []; + } + } + + /// A missing directory counts as writable: nothing to protect, and tests + /// use made-up paths. + static bool _isDirectoryWritable(String dir) { + if (!Directory(dir).existsSync()) return true; + final probe = File(p.join(dir, '.querya-write-probe-$pid')); + try { + probe.writeAsStringSync(''); + probe.deleteSync(); + return true; + } on FileSystemException { + return false; + } } String? get linuxBundleRoot { diff --git a/lib/core/updater/installers/update_install_utils.dart b/lib/core/updater/installers/update_install_utils.dart index 70264f64..56fb70d9 100644 --- a/lib/core/updater/installers/update_install_utils.dart +++ b/lib/core/updater/installers/update_install_utils.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; @@ -43,7 +44,13 @@ Future extractZipSecurely({ if (entry.isFile) { final out = File(targetPath); await out.parent.create(recursive: true); - await out.writeAsBytes(entry.content as List); + final Uint8List bytes; + try { + bytes = SafeZipExtractor.readEntry(entry); + } on SafeZipException catch (error) { + throw AppUpdaterException(error.message); + } + await out.writeAsBytes(bytes); } else { await Directory(targetPath).create(recursive: true); } @@ -153,20 +160,33 @@ SRC='${_shellQuote(sourceDir)}' DST='${_shellQuote(targetDir)}' EXE='${_shellQuote(executable)}' EXE_NAME=\$(basename "\$EXE") +LOG="\${XDG_STATE_HOME:-\$HOME/.local/state}/querya-update.log" +# Any failure is logged and the app is started again (the old bundle when the +# replace did not happen), instead of leaving it closed. +fail() { + mkdir -p "\$(dirname "\$LOG")" 2>/dev/null || true + echo "\$(date) querya-update: \$1" >> "\$LOG" 2>/dev/null || true + echo "querya-update: \$1" >&2 + if [ -x "\$EXE" ]; then exec "\$EXE"; fi + exit 1 +} while kill -0 "\$PID" 2>/dev/null; do sleep 0.2; done if [ ! -f "\$SRC/\$EXE_NAME" ]; then - echo "querya-update: refusing replace, \$SRC is not a Flutter bundle" >&2 - exit 1 + fail "refusing replace, \$SRC is not a Flutter bundle" fi if [ ! -d "\$SRC/lib" ] && [ ! -d "\$SRC/data" ]; then - echo "querya-update: refusing replace, missing lib/ or data/" >&2 - exit 1 + fail "refusing replace, missing lib/ or data/" +fi +if [ ! -w "\$DST" ]; then + fail "refusing replace, \$DST is not writable (installed by a package manager?)" fi if command -v rsync >/dev/null 2>&1; then - rsync -a --delete --exclude='QueryaData' --exclude='.portable' "\$SRC"/ "\$DST"/ + rsync -a --delete --exclude='QueryaData' --exclude='.portable' "\$SRC"/ "\$DST"/ \\ + || fail "rsync into \$DST failed" else - find "\$DST" -mindepth 1 -maxdepth 1 ! -name 'QueryaData' ! -name '.portable' -exec rm -rf {} + - cp -a "\$SRC"/. "\$DST"/ + find "\$DST" -mindepth 1 -maxdepth 1 ! -name 'QueryaData' ! -name '.portable' -exec rm -rf {} + \\ + || fail "could not remove the old bundle in \$DST" + cp -a "\$SRC"/. "\$DST"/ || fail "could not copy the new bundle into \$DST" fi chmod +x "\$EXE" 2>/dev/null || true rm -f "\$0" @@ -222,24 +242,59 @@ del "%~f0" '''; } +/// Shell script that waits for [pid], swaps [targetAppBundle] for +/// [newAppBundle] and opens it, then removes [workDir] (the extracted update). +/// +/// The old bundle is moved aside to `.old` and restored when the copy +/// fails, so a failure never leaves a missing or half-copied app; it is +/// logged to `~/Library/Logs/Querya/querya-update.log` and the app that is +/// in place is opened again (#1587). String buildMacAppReplaceScript({ required int pid, required String newAppBundle, required String targetAppBundle, required String executable, + required String workDir, }) { return ''' #!/bin/sh -set -e PID="$pid" NEW='${_shellQuote(newAppBundle)}' TARGET='${_shellQuote(targetAppBundle)}' EXE='${_shellQuote(executable)}' +WORK='${_shellQuote(workDir)}' +LOG="\$HOME/Library/Logs/Querya/querya-update.log" +fail() { + mkdir -p "\$(dirname "\$LOG")" 2>/dev/null + echo "\$(date) querya-update: \$1" >> "\$LOG" 2>/dev/null + echo "querya-update: \$1" >&2 + rm -rf "\$WORK" + rm -f "\$0" + if [ -d "\$TARGET" ]; then open "\$TARGET"; fi + exit 1 +} while kill -0 "\$PID" 2>/dev/null; do sleep 0.2; done -rm -rf "\$TARGET.old" 2>/dev/null || true -mv "\$TARGET" "\$TARGET.old" 2>/dev/null || true -cp -R "\$NEW" "\$TARGET" -chmod +x "\$EXE" 2>/dev/null || true +if [ ! -d "\$NEW" ]; then + fail "refusing replace, \$NEW is missing" +fi +if [ -e "\$TARGET.old" ]; then + rm -rf "\$TARGET.old" || fail "could not remove \$TARGET.old" +fi +mv "\$TARGET" "\$TARGET.old" \\ + || fail "could not move \$TARGET aside (is its folder writable?)" +if command -v ditto >/dev/null 2>&1; then + ditto "\$NEW" "\$TARGET" +else + cp -R "\$NEW" "\$TARGET" +fi +if [ \$? -ne 0 ]; then + rm -rf "\$TARGET" + mv "\$TARGET.old" "\$TARGET" \\ + || fail "could not copy the new app, nor restore \$TARGET.old" + fail "could not copy the new app into \$TARGET; the old one is restored" +fi +chmod +x "\$EXE" 2>/dev/null +rm -rf "\$TARGET.old" "\$WORK" rm -f "\$0" open "\$TARGET" '''; diff --git a/lib/core/updater/update_platform_installer.dart b/lib/core/updater/update_platform_installer.dart index d9acfcaf..5a783a93 100644 --- a/lib/core/updater/update_platform_installer.dart +++ b/lib/core/updater/update_platform_installer.dart @@ -24,10 +24,12 @@ class UpdatePlatformInstaller { if (ctx.isManagedPackage) { return UpdatePlatformInstaller._((file) async { throw PackageManagerUpdateRequiredException( - manager: ctx.isSnap ? 'snap' : 'flatpak', - hint: ctx.isSnap - ? 'Updates for the Snap build must be installed with: snap refresh' - : 'Updates for the Flatpak build must be installed with: flatpak update', + manager: ctx.isSnap + ? 'snap' + : ctx.isFlatpak + ? 'flatpak' + : ctx.linuxSystemPackageManager ?? 'system', + hint: ctx.managedUpdateHint, ); }); } diff --git a/lib/core/util/log_ignored.dart b/lib/core/util/log_ignored.dart new file mode 100644 index 00000000..b7bd8de7 --- /dev/null +++ b/lib/core/util/log_ignored.dart @@ -0,0 +1,10 @@ +import 'package:flutter/foundation.dart'; + +/// Records an error that the caller deliberately goes on from: a best-effort +/// close or delete, a cache that can be rebuilt. [where] names the place +/// (`'LocalExtensionInstaller.backup'`), so a failure that matters later has a +/// trace in the debug log instead of none. +void logIgnored(String where, Object error, [StackTrace? stack]) { + debugPrint('[$where] ignored: $error'); + if (stack != null && kDebugMode) debugPrint('$stack'); +} diff --git a/lib/core/widgets/virtual_selectable_text_view.dart b/lib/core/widgets/virtual_selectable_text_view.dart index 0cbca49b..648df5aa 100644 --- a/lib/core/widgets/virtual_selectable_text_view.dart +++ b/lib/core/widgets/virtual_selectable_text_view.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart' as material; /// the entire block works seamlessly. /// If line count > [threshold], virtualizes lines using `ListView.builder` to /// ensure 60 FPS scrolling and rendering without UI jank. -class VirtualSelectableTextView extends material.StatelessWidget { +class VirtualSelectableTextView extends material.StatefulWidget { const VirtualSelectableTextView({ super.key, required this.text, @@ -19,26 +19,46 @@ class VirtualSelectableTextView extends material.StatelessWidget { final int threshold; final material.EdgeInsets padding; + @override + material.State createState() => + _VirtualSelectableTextViewState(); +} + +class _VirtualSelectableTextViewState + extends material.State { + /// `widget.text` split on `\n`. Computed when the text changes, not in + /// `build`: resize, theme and focus rebuilds must not re-split a text of + /// thousands of lines on every frame (#1532). + late List _lines = widget.text.split('\n'); + + @override + void didUpdateWidget(covariant VirtualSelectableTextView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.text != widget.text) { + _lines = widget.text.split('\n'); + } + } + @override material.Widget build(material.BuildContext context) { - final lines = text.split('\n'); - if (lines.length <= threshold) { + final lines = _lines; + if (lines.length <= widget.threshold) { return material.SingleChildScrollView( - padding: padding, + padding: widget.padding, child: material.SelectableText( - text, - style: style, + widget.text, + style: widget.style, ), ); } return material.ListView.builder( - padding: padding, + padding: widget.padding, itemCount: lines.length, itemBuilder: (context, index) { return material.SelectableText( lines[index], - style: style, + style: widget.style, ); }, ); diff --git a/lib/features/command_palette/command_match_highlight.dart b/lib/features/command_palette/command_match_highlight.dart index 39ba1047..38c680e4 100644 --- a/lib/features/command_palette/command_match_highlight.dart +++ b/lib/features/command_palette/command_match_highlight.dart @@ -19,7 +19,7 @@ class CommandMatchHighlight extends StatelessWidget { final needle = q.toLowerCase(); final index = lower.indexOf(needle); if (index < 0) return Text(text); - final theme = Theme.of(context).colorScheme; + final colors = context.colors; return Text.rich( TextSpan( children: [ @@ -28,7 +28,7 @@ class CommandMatchHighlight extends StatelessWidget { text: text.substring(index, index + needle.length), style: TextStyle( fontWeight: FontWeight.w600, - color: theme.primary, + color: colors.primary, ), ), TextSpan(text: text.substring(index + needle.length)), diff --git a/lib/features/command_palette/command_palette_dialog.dart b/lib/features/command_palette/command_palette_dialog.dart index d1b7dc06..0e9436d1 100644 --- a/lib/features/command_palette/command_palette_dialog.dart +++ b/lib/features/command_palette/command_palette_dialog.dart @@ -156,7 +156,7 @@ class _CommandPaletteDialogState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final hits = _hits; final selectedIndex = hits.isEmpty ? 0 : _selected.clamp(0, hits.length - 1); @@ -187,11 +187,11 @@ class _CommandPaletteDialogState extends State { children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), - child: TextField( + child: QueryaTextField( controller: _controller, focusNode: _focusNode, autofocus: true, - placeholder: const Text('Type a command…'), + placeholder: 'Type a command…', onSubmitted: (_) { if (_objectPrefix) { _openQuickSwitcher(); @@ -238,9 +238,9 @@ class _CommandPaletteDialogState extends State { child: DecoratedBox( decoration: BoxDecoration( color: active - ? theme.colorScheme.primary + ? colors.primary .withValues(alpha: 0.12) - : Colors.transparent, + : material.Colors.transparent, borderRadius: BorderRadius.circular(6), ), child: Padding( diff --git a/lib/features/command_palette/quick_switcher_dialog.dart b/lib/features/command_palette/quick_switcher_dialog.dart index 3ddacdf7..81dfaa56 100644 --- a/lib/features/command_palette/quick_switcher_dialog.dart +++ b/lib/features/command_palette/quick_switcher_dialog.dart @@ -171,7 +171,6 @@ class _QuickSwitcherDialogState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final hits = _hits; final selectedIndex = hits.isEmpty ? 0 : _selected.clamp(0, hits.length - 1); @@ -198,18 +197,18 @@ class _QuickSwitcherDialogState extends State { children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), - child: TextField( + child: QueryaTextField( controller: _controller, focusNode: _focusNode, autofocus: true, - placeholder: const Text('Go to table, view, collection…'), + placeholder: 'Go to table, view, collection…', onSubmitted: (_) { if (hits.isNotEmpty) _open(hits[selectedIndex]); }, ), ), const QueryaDivider(), - Expanded(child: _body(theme.colorScheme, hits, selectedIndex)), + Expanded(child: _body(hits, selectedIndex)), ], ), ), @@ -218,7 +217,6 @@ class _QuickSwitcherDialogState extends State { } Widget _body( - ColorScheme colors, List hits, int selectedIndex, ) { @@ -276,8 +274,8 @@ class _QuickSwitcherDialogState extends State { child: DecoratedBox( decoration: BoxDecoration( color: active - ? colors.primary.withValues(alpha: 0.12) - : Colors.transparent, + ? context.colors.primary.withValues(alpha: 0.12) + : material.Colors.transparent, borderRadius: BorderRadius.circular(6), ), child: Padding( diff --git a/lib/features/connections/connection_databases_folder.dart b/lib/features/connections/connection_databases_folder.dart index 716a0e8f..e69b821e 100644 --- a/lib/features/connections/connection_databases_folder.dart +++ b/lib/features/connections/connection_databases_folder.dart @@ -57,9 +57,9 @@ class _ConnectionDatabasesFolderState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final primary = theme.colorScheme.primary; - final muted = theme.colorScheme.mutedForeground; + final colors = context.colors; + final primary = colors.primary; + final muted = colors.mutedForeground; final label = 'Databases (${widget.databaseCount})'; final row = material.CallbackShortcuts( @@ -116,7 +116,7 @@ class _ConnectionDatabasesFolderState maxLines: 1, style: material.TextStyle( fontSize: 12, - color: theme.colorScheme.foreground, + color: colors.foreground, ), ), ), @@ -128,10 +128,10 @@ class _ConnectionDatabasesFolderState ), ); - final menu = ContextMenu( + final menu = QueryaContextMenu( items: [ if (widget.onRefresh != null) - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.refresh_rounded, size: 18, @@ -140,7 +140,7 @@ class _ConnectionDatabasesFolderState onPressed: (_) => widget.onRefresh!(), child: const Text('Refresh'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.copy_rounded, size: 18, @@ -152,7 +152,7 @@ class _ConnectionDatabasesFolderState child: const Text('Copy name'), ), if (widget.onOpenSql != null) - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.terminal_rounded, size: 18, diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 095190c5..376f9ee9 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -58,9 +58,6 @@ import 'package:flutter/material.dart' as material ClampingScrollPhysics, CallbackShortcuts, SingleActivator, - TextField, - InputDecoration, - InputBorder, TextEditingController, FocusNode; import 'package:flutter/services.dart' @@ -83,6 +80,7 @@ import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/core/ui/querya_tree_indent_guide.dart'; @@ -220,7 +218,7 @@ class TreeObjectFilterBar extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final isFiltered = controller.text.trim().isNotEmpty; return material.Padding( @@ -234,12 +232,12 @@ class TreeObjectFilterBar extends material.StatelessWidget { height: 24, padding: const material.EdgeInsets.symmetric(horizontal: 6), decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.22), + color: colors.muted.withValues(alpha: 0.22), borderRadius: material.BorderRadius.circular(4), border: material.Border.all( color: isFiltered - ? theme.colorScheme.primary.withValues(alpha: 0.5) - : theme.colorScheme.border.withValues(alpha: 0.25), + ? colors.primary.withValues(alpha: 0.5) + : colors.border.withValues(alpha: 0.25), width: 1, ), ), @@ -249,28 +247,20 @@ class TreeObjectFilterBar extends material.StatelessWidget { material.Icons.search_rounded, size: 12, color: isFiltered - ? theme.colorScheme.primary - : theme.colorScheme.mutedForeground, + ? colors.primary + : colors.mutedForeground, ), const Gap(5), material.Expanded( - child: material.TextField( + child: QueryaTextField( + framed: false, controller: controller, onChanged: onChanged, style: material.TextStyle( fontSize: 11, - color: theme.colorScheme.foreground, - ), - decoration: material.InputDecoration( - hintText: hintText, - hintStyle: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground.withValues(alpha: 0.6), - ), - border: material.InputBorder.none, - isDense: true, - contentPadding: material.EdgeInsets.zero, + color: colors.foreground, ), + placeholder: hintText, ), ), if (isFiltered) ...[ @@ -279,7 +269,7 @@ class TreeObjectFilterBar extends material.StatelessWidget { style: material.TextStyle( fontSize: 10, fontWeight: material.FontWeight.w600, - color: theme.colorScheme.primary, + color: colors.primary, ), ), const Gap(4), @@ -289,7 +279,7 @@ class TreeObjectFilterBar extends material.StatelessWidget { child: material.Icon( material.Icons.close_rounded, size: 12, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ], @@ -762,31 +752,31 @@ class ConnectionsPanelState extends State { /// The "Move to folder" submenu of a connection: every other folder and, /// when the connection is in one, "No folder". Empty when there is nowhere to /// move it. - List _moveItems(ConnectionRow conn) { - final theme = Theme.of(context); + List _moveItems(ConnectionRow conn) { + final colors = context.colors; final current = conn.folderId; - final targets = [ + final targets = [ for (final name in _folders) if (_folderIdByName[name] != current) - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.folder_outlined, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => _moveConnection(conn, _folderIdByName[name]), child: Text(name), ), if (current != null) - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.folder_off_outlined, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => _moveConnection(conn, null), child: const Text('No folder'), ), ]; if (targets.isEmpty) return const []; return [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.drive_file_move_outlined, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), subMenu: targets, child: const Text('Move to folder'), ), @@ -907,7 +897,7 @@ class ConnectionsPanelState extends State { final tile = _connectionTileCore(conn); final id = conn.id; if (id == null) return tile; - final theme = Theme.of(context); + final colors = context.colors; return material.Draggable( data: id, maxSimultaneousDrags: 1, @@ -924,19 +914,19 @@ class ConnectionsPanelState extends State { padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: material.BoxDecoration( - color: theme.colorScheme.popover, + color: colors.popover, borderRadius: material.BorderRadius.circular(8), - border: material.Border.all(color: theme.colorScheme.primary), + border: material.Border.all(color: colors.primary), ), child: material.Row( mainAxisSize: material.MainAxisSize.min, children: [ material.Icon(QueryaIcons.connectionIcon(conn.type), - size: 16, color: theme.colorScheme.primary), + size: 16, color: colors.primary), const material.SizedBox(width: 8), material.Text(conn.name, style: material.TextStyle( - fontSize: 13, color: theme.colorScheme.foreground)), + fontSize: 13, color: colors.foreground)), ], ), ), @@ -1092,7 +1082,7 @@ class ConnectionsPanelState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final q = _searchQuery.trim().toLowerCase(); final filteredConnections = q.isEmpty @@ -1139,10 +1129,10 @@ class ConnectionsPanelState extends State { }, child: material.Container( decoration: material.BoxDecoration( - color: theme.colorScheme.background, + color: colors.background, border: material.Border( right: material.BorderSide( - color: theme.colorScheme.border.withValues(alpha: 0.28), + color: colors.border.withValues(alpha: 0.28), width: 1, ), ), @@ -1168,7 +1158,7 @@ class ConnectionsPanelState extends State { fontSize: 11, letterSpacing: 0.85, fontWeight: material.FontWeight.w600, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), @@ -1179,7 +1169,7 @@ class ConnectionsPanelState extends State { vertical: 1, ), decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.28), + color: colors.muted.withValues(alpha: 0.28), borderRadius: material.BorderRadius.circular(8), ), child: material.Text( @@ -1189,7 +1179,7 @@ class ConnectionsPanelState extends State { style: material.TextStyle( fontSize: 10, fontWeight: material.FontWeight.w600, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), @@ -1202,12 +1192,12 @@ class ConnectionsPanelState extends State { height: 28, padding: const material.EdgeInsets.symmetric(horizontal: 8), decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.22), + color: colors.muted.withValues(alpha: 0.22), borderRadius: material.BorderRadius.circular(6), border: material.Border.all( color: q.isNotEmpty - ? theme.colorScheme.primary.withValues(alpha: 0.5) - : theme.colorScheme.border.withValues(alpha: 0.25), + ? colors.primary.withValues(alpha: 0.5) + : colors.border.withValues(alpha: 0.25), width: 1, ), ), @@ -1221,13 +1211,14 @@ class ConnectionsPanelState extends State { material.Icons.search_rounded, size: 14, color: value.text.trim().isNotEmpty - ? theme.colorScheme.primary - : theme.colorScheme.mutedForeground, + ? colors.primary + : colors.mutedForeground, ), ), const Gap(6), material.Expanded( - child: material.TextField( + child: QueryaTextField( + framed: false, controller: _searchController, focusNode: _searchFocusNode, onChanged: (val) => _searchDebouncer.run(() { @@ -1235,19 +1226,9 @@ class ConnectionsPanelState extends State { }), style: material.TextStyle( fontSize: 12, - color: theme.colorScheme.foreground, - ), - decoration: material.InputDecoration( - hintText: 'Filter connections... (Ctrl+F)', - hintStyle: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.mutedForeground - .withValues(alpha: 0.6), - ), - border: material.InputBorder.none, - isDense: true, - contentPadding: material.EdgeInsets.zero, + color: colors.foreground, ), + placeholder: 'Filter connections… (Ctrl+F)', ), ), material.ValueListenableBuilder( @@ -1265,7 +1246,7 @@ class ConnectionsPanelState extends State { child: material.Icon( material.Icons.close_rounded, size: 14, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), @@ -1275,7 +1256,7 @@ class ConnectionsPanelState extends State { ), QueryaDivider( height: 1, - color: theme.colorScheme.border.withValues(alpha: 0.22), + color: colors.border.withValues(alpha: 0.22), ), // While a connection from a folder is dragged, a labelled place // to drop it: the empty part of the list works too, but nobody @@ -1292,11 +1273,11 @@ class ConnectionsPanelState extends State { margin: const material.EdgeInsets.fromLTRB(12, 8, 12, 0), padding: const material.EdgeInsets.symmetric(vertical: 10), decoration: material.BoxDecoration( - color: theme.colorScheme.primary.withValues( + color: colors.primary.withValues( alpha: candidates.isEmpty ? 0.06 : 0.18), borderRadius: material.BorderRadius.circular(8), border: material.Border.all( - color: theme.colorScheme.primary + color: colors.primary .withValues(alpha: candidates.isEmpty ? 0.4 : 1), width: 1.5, ), @@ -1306,7 +1287,7 @@ class ConnectionsPanelState extends State { 'Drop here to take it out of the folder', style: material.TextStyle( fontSize: 12, - color: theme.colorScheme.primary, + color: colors.primary, ), ), ), @@ -1384,18 +1365,18 @@ class ConnectionsPanelState extends State { ), material.SliverFillRemaining( hasScrollBody: false, - child: ContextMenu( + child: QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.add_rounded, size: 18, - color: theme.colorScheme.mutedForeground), + color: colors.mutedForeground), subMenu: [ - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.settings_ethernet_rounded, size: 18, - color: theme.colorScheme.mutedForeground), + color: colors.mutedForeground), onPressed: (menuContext) async { await Future.delayed( const Duration(milliseconds: 100)); @@ -1404,11 +1385,11 @@ class ConnectionsPanelState extends State { }, child: const Text('New Connection'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.folder_rounded, size: 18, - color: theme.colorScheme.mutedForeground), + color: colors.mutedForeground), onPressed: (menuContext) => _createFolder(menuContext), child: const Text('New Folder'), diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 6c10711b..ca5b991a 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -24,7 +24,7 @@ class _ExtensionConnectionTile extends StatefulWidget { final VoidCallback onEdit; /// "Move to folder" menu entries; empty when there is nowhere to move. - final List moveItems; + final List moveItems; final VoidCallback? onTap; /// Fires when a table/view node is clicked in the schema tree. @@ -150,7 +150,7 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final material.Widget iconWidget; if (_iconFilePath != null) { iconWidget = DriverIconImage( @@ -169,42 +169,42 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { errorBuilder: (_, __, ___) => material.Icon( widget.icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary, + color: colors.primary, ), ); } else { iconWidget = material.Icon( widget.icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary, + color: colors.primary, ); } - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) { setState(() => _schema = null); _loadTree(); }, child: const Text('Refresh tree'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.edit_outlined, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => widget.onEdit(), child: const Text('Edit connection…'), ), ...widget.moveItems, - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.destructive), + size: 18, color: colors.destructive), onPressed: (_) => widget.onRemove(), child: Text( 'Remove connection', - style: material.TextStyle(color: theme.colorScheme.destructive), + style: material.TextStyle(color: colors.destructive), ), ), ], @@ -235,7 +235,7 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), @@ -268,7 +268,7 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 13, - color: theme.colorScheme.foreground, + color: colors.foreground, ), ), if (widget.connection.host != null) @@ -278,7 +278,7 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 11, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ], diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index 18ed1acc..e2e311a1 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -26,7 +26,7 @@ class _MongoConnectionTile extends StatefulWidget { final VoidCallback onEdit; /// "Move to folder" menu entries; empty when there is nowhere to move. - final List moveItems; + final List moveItems; final VoidCallback? onTap; final void Function(String database)? onDatabaseTap; final void Function(String database, String collection)? onCollectionTap; @@ -134,7 +134,7 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, @@ -146,44 +146,44 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { errorBuilder: (_, __, ___) => material.Icon( widget.icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary, + color: colors.primary, ), ) : material.Icon(widget.icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary); + color: colors.primary); - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.add_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => _createDatabase(), child: const Text('Create database'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) { _databases = []; _loadDatabases(); }, child: const Text('Refresh databases'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.edit_outlined, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => widget.onEdit(), child: const Text('Edit connection…'), ), ...widget.moveItems, - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.destructive), + size: 18, color: colors.destructive), onPressed: (_) => widget.onRemove(), child: Text( 'Remove connection', - style: material.TextStyle(color: theme.colorScheme.destructive), + style: material.TextStyle(color: colors.destructive), ), ), ], @@ -216,7 +216,7 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), @@ -248,7 +248,7 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 13, - color: theme.colorScheme.foreground, + color: colors.foreground, ), ), if (widget.connection.host != null) @@ -258,7 +258,7 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 11, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ], @@ -467,7 +467,7 @@ class _MongoDatabaseNodeState extends State<_MongoDatabaseNode> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final query = _filter.trim().toLowerCase(); final matching = query.isEmpty ? _collections @@ -502,17 +502,17 @@ class _MongoDatabaseNodeState extends State<_MongoDatabaseNode> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), ), icon: QueryaIcons.database, iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + iconColor: colors.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, - color: theme.colorScheme.foreground, + color: colors.foreground, ), verticalPadding: 3, expanded: _expanded, @@ -641,50 +641,50 @@ class _MongoCollectionNode extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; return _ConnectionsTreeSelectionBuilder( select: (sel) => sel.selectedConnectionId == connection.id && sel.selectedMongoDb == database && sel.selectedMongoCollection == name, builder: (context, isSelected) { - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.description_outlined, size: 18, - color: theme.colorScheme.primary, + color: colors.primary, ), onPressed: (_) => onTap(), child: const Text('Open collection'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.copy_rounded, size: 18, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), onPressed: (_) { Clipboard.setData(ClipboardData(text: name)); }, child: const Text('Copy name'), ), - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( leading: material.Icon( material.Icons.refresh_rounded, size: 18, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), onPressed: (_) => onRefreshCollections(), child: const Text('Refresh'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.delete_outline_rounded, size: 18, - color: theme.colorScheme.destructive, + color: colors.destructive, ), onPressed: (_) => onDropCollection(name), child: const Text('Drop collection'), @@ -695,10 +695,10 @@ class _MongoCollectionNode extends StatelessWidget { isSelected: isSelected, icon: material.Icons.table_chart_outlined, iconSize: QueryaIconSizes.treeLeaf, - iconColor: QueryaTreeTokens.leafIconColor(theme.colorScheme.primary), + iconColor: QueryaTreeTokens.leafIconColor(colors.primary), textStyle: material.TextStyle( fontSize: 11, - color: theme.colorScheme.foreground, + color: colors.foreground, ), verticalPadding: 2, onTap: onTap, diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index f073653e..8db4fb2f 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -26,7 +26,7 @@ class _MysqlConnectionTile extends StatefulWidget { final VoidCallback onEdit; /// "Move to folder" menu entries; empty when there is nowhere to move. - final List moveItems; + final List moveItems; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -109,7 +109,7 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, @@ -121,18 +121,18 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { errorBuilder: (_, __, ___) => material.Icon( widget.icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary, + color: colors.primary, ), ) : material.Icon(widget.icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary); + color: colors.primary); - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) { _databases = []; _loadDatabases(); @@ -140,27 +140,27 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { child: const Text('Refresh databases'), ), if (widget.onMysqlOpenSqlWorkspace != null) - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.terminal_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => widget.onMysqlOpenSqlWorkspace!(widget.connection), child: const Text('Open in SQL'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.edit_outlined, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => widget.onEdit(), child: const Text('Edit connection…'), ), ...widget.moveItems, - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.destructive), + size: 18, color: colors.destructive), onPressed: (_) => widget.onRemove(), child: Text( 'Remove connection', - style: material.TextStyle(color: theme.colorScheme.destructive), + style: material.TextStyle(color: colors.destructive), ), ), ], @@ -191,7 +191,7 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), @@ -222,7 +222,7 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 13, - color: theme.colorScheme.foreground, + color: colors.foreground, ), ), if (widget.connection.host != null) @@ -232,7 +232,7 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 11, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ], @@ -438,7 +438,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; return QueryaTreeIndentGuide( depth: 1, child: material.Column( @@ -454,15 +454,15 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), icon: QueryaIcons.database, iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + iconColor: colors.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, - color: theme.colorScheme.foreground, + color: colors.foreground, ), verticalPadding: 4, expanded: _expanded, @@ -634,7 +634,7 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final query = _filter.trim().toLowerCase(); final matching = query.isEmpty ? widget.items @@ -663,15 +663,15 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), icon: widget.icon, iconSize: QueryaIconSizes.treeGroup, - iconColor: theme.colorScheme.mutedForeground, + iconColor: colors.mutedForeground, textStyle: material.TextStyle( fontSize: 11, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), @@ -748,11 +748,11 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, iconColor: QueryaTreeTokens.leafIconColor( - theme.colorScheme.primary, + colors.primary, ), textStyle: material.TextStyle( fontSize: 11, - color: theme.colorScheme.foreground, + color: colors.foreground, ), verticalPadding: 2, onTap: widget.onItemTap != null diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 8fb15a07..1a2a30f9 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -119,7 +119,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; return QueryaTreeIndentGuide( depth: 1, child: material.Column( @@ -135,15 +135,15 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), icon: QueryaIcons.database, iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + iconColor: colors.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, - color: theme.colorScheme.foreground, + color: colors.foreground, ), verticalPadding: 4, expanded: _expanded, @@ -234,8 +234,8 @@ class _PgDbToolRow extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final muted = theme.colorScheme.mutedForeground; + final colors = context.colors; + final muted = colors.mutedForeground; return QueryaTreeIndentGuide( depth: 1, padding: const material.EdgeInsets.only(top: 2, bottom: 2), @@ -302,7 +302,7 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; return QueryaTreeIndentGuide( depth: 1, child: material.Column( @@ -318,15 +318,15 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), icon: QueryaIcons.schemasFolder, iconSize: QueryaIconSizes.treeGroup, - iconColor: theme.colorScheme.mutedForeground, + iconColor: colors.mutedForeground, textStyle: material.TextStyle( fontSize: 11, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), @@ -490,7 +490,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; return QueryaTreeIndentGuide( depth: 1, child: material.Column( @@ -506,15 +506,15 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), icon: QueryaIcons.schema, iconSize: QueryaIconSizes.treeGroup, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.6), + iconColor: colors.primary.withValues(alpha: 0.6), textStyle: material.TextStyle( fontSize: 12, - color: theme.colorScheme.foreground, + color: colors.foreground, ), expanded: _expanded, onTap: _toggle, @@ -724,8 +724,8 @@ class _PgSchemaToolRow extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final muted = theme.colorScheme.mutedForeground; + final colors = context.colors; + final muted = colors.mutedForeground; return QueryaTreeIndentGuide( depth: 1, padding: const material.EdgeInsets.only(top: 2, bottom: 2), @@ -807,7 +807,7 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final query = _filter.trim().toLowerCase(); final matching = query.isEmpty ? widget.items @@ -836,15 +836,15 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), icon: widget.icon, iconSize: QueryaIconSizes.treeGroup, - iconColor: theme.colorScheme.mutedForeground, + iconColor: colors.mutedForeground, textStyle: material.TextStyle( fontSize: 11, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), @@ -925,11 +925,11 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, iconColor: QueryaTreeTokens.leafIconColor( - theme.colorScheme.primary, + colors.primary, ), textStyle: material.TextStyle( fontSize: 11, - color: theme.colorScheme.foreground, + color: colors.foreground, ), verticalPadding: 2, onTap: widget.onItemTap != null diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 0e8cd8e6..4afea84e 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -26,7 +26,7 @@ class _PostgresConnectionTile extends StatefulWidget { final VoidCallback onEdit; /// "Move to folder" menu entries; empty when there is nowhere to move. - final List moveItems; + final List moveItems; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -112,7 +112,7 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, @@ -124,38 +124,38 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { errorBuilder: (_, __, ___) => material.Icon( widget.icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary, + color: colors.primary, ), ) : material.Icon(widget.icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary); + color: colors.primary); - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) { _databases = []; _loadDatabases(); }, child: const Text('Refresh databases'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.edit_outlined, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => widget.onEdit(), child: const Text('Edit connection…'), ), ...widget.moveItems, - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.destructive), + size: 18, color: colors.destructive), onPressed: (_) => widget.onRemove(), child: Text( 'Remove connection', - style: material.TextStyle(color: theme.colorScheme.destructive), + style: material.TextStyle(color: colors.destructive), ), ), ], @@ -186,7 +186,7 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), @@ -217,7 +217,7 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 13, - color: theme.colorScheme.foreground, + color: colors.foreground, ), ), if (widget.connection.host != null) @@ -227,7 +227,7 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 11, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ], diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index 06ef3e8c..ef3776fb 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -25,7 +25,7 @@ class _RedisConnectionTile extends StatefulWidget { final VoidCallback onEdit; /// "Move to folder" menu entries; empty when there is nowhere to move. - final List moveItems; + final List moveItems; final VoidCallback? onTap; final void Function(int database)? onDatabaseTap; final bool isExpanded; @@ -122,7 +122,7 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, @@ -134,38 +134,38 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { errorBuilder: (_, __, ___) => material.Icon( widget.icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary, + color: colors.primary, ), ) : material.Icon(widget.icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary); + color: colors.primary); - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) { _databases = []; _loadDatabases(); }, child: const Text('Refresh databases'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.edit_outlined, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => widget.onEdit(), child: const Text('Edit connection…'), ), ...widget.moveItems, - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.destructive), + size: 18, color: colors.destructive), onPressed: (_) => widget.onRemove(), child: Text( 'Remove connection', - style: material.TextStyle(color: theme.colorScheme.destructive), + style: material.TextStyle(color: colors.destructive), ), ), ], @@ -198,7 +198,7 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), @@ -230,7 +230,7 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 13, - color: theme.colorScheme.foreground, + color: colors.foreground, ), ), if (widget.connection.host != null) @@ -240,7 +240,7 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 11, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ], @@ -344,7 +344,7 @@ class _RedisDatabaseNode extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; return _ConnectionsTreeSelectionBuilder( select: (sel) => sel.selectedConnectionId == connection.id && @@ -358,22 +358,22 @@ class _RedisDatabaseNode extends StatelessWidget { icon: QueryaIcons.database, iconSize: QueryaIconSizes.treeConnection, iconColor: keys > 0 - ? theme.colorScheme.primary.withValues(alpha: 0.7) - : theme.colorScheme.mutedForeground.withValues(alpha: 0.5), + ? colors.primary.withValues(alpha: 0.7) + : colors.mutedForeground.withValues(alpha: 0.5), trailing: keys > 0 ? material.Text( '$keys', style: material.TextStyle( fontSize: 10, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ) : null, textStyle: material.TextStyle( fontSize: 12, color: keys > 0 - ? theme.colorScheme.foreground - : theme.colorScheme.mutedForeground, + ? colors.foreground + : colors.mutedForeground, ), verticalPadding: 3, onTap: onTap, diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 48dac237..9464f0e5 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -36,15 +36,15 @@ class _EmptyState extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 14), decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.25), + color: colors.muted.withValues(alpha: 0.25), borderRadius: material.BorderRadius.circular(8), border: material.Border.all( - color: theme.colorScheme.border.withValues(alpha: 0.2), + color: colors.border.withValues(alpha: 0.2), width: 1, ), ), @@ -54,7 +54,7 @@ class _EmptyState extends StatelessWidget { material.Icon( material.Icons.info_outline_rounded, size: 16, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), const Gap(10), material.Expanded( @@ -88,12 +88,12 @@ class _ConnectionTile extends StatelessWidget { final VoidCallback onEdit; /// "Move to folder" menu entries; empty when there is nowhere to move. - final List moveItems; + final List moveItems; final VoidCallback? onTap; @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final iconWidget = iconAsset != null ? material.Image.asset( iconAsset!, @@ -105,30 +105,30 @@ class _ConnectionTile extends StatelessWidget { errorBuilder: (_, __, ___) => material.Icon( icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary, + color: colors.primary, ), ) : material.Icon( icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary, + color: colors.primary, ); - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.edit_outlined, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => onEdit(), child: const Text('Edit connection…'), ), ...moveItems, - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.destructive), + size: 18, color: colors.destructive), onPressed: (_) => onRemove(), child: Text( 'Remove connection', - style: material.TextStyle(color: theme.colorScheme.destructive), + style: material.TextStyle(color: colors.destructive), ), ), ], @@ -156,7 +156,7 @@ class _ConnectionTile extends StatelessWidget { maxLines: 1, style: material.TextStyle( fontSize: 13, - color: theme.colorScheme.foreground, + color: colors.foreground, ), ), if (connection.host != null) @@ -166,7 +166,7 @@ class _ConnectionTile extends StatelessWidget { maxLines: 1, style: material.TextStyle( fontSize: 11, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ], @@ -249,9 +249,9 @@ class _FolderTileState extends State<_FolderTile> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final drop = widget.onDrop; - final menu = _buildMenu(context, theme); + final menu = _buildMenu(context, colors); if (drop == null) return menu; return material.DragTarget( onWillAcceptWithDetails: (d) => widget.acceptsDrop?.call(d.data) ?? true, @@ -264,7 +264,7 @@ class _FolderTileState extends State<_FolderTile> { border: material.Border.all( color: candidates.isEmpty ? material.Colors.transparent - : theme.colorScheme.primary, + : colors.primary, width: 1.5, ), ), @@ -273,18 +273,18 @@ class _FolderTileState extends State<_FolderTile> { ); } - Widget _buildMenu(BuildContext context, ThemeData theme) { - return ContextMenu( + Widget _buildMenu(BuildContext context, QueryaUiColors colors) { + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.settings_ethernet_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (menuContext) => widget.onNewConnection(widget.name), child: const Text('New connection'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => widget.onRemove(), child: const Text('Remove folder'), ), @@ -317,13 +317,13 @@ class _FolderTileState extends State<_FolderTile> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), const Gap(2), material.Icon(QueryaIcons.folder, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary), + color: colors.primary), const Gap(8), material.Expanded( child: material.Text( @@ -332,7 +332,7 @@ class _FolderTileState extends State<_FolderTile> { maxLines: 1, style: material.TextStyle( fontSize: 13, - color: theme.colorScheme.foreground, + color: colors.foreground, ), ), ), diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 2fbde8b8..0a8c9540 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -29,7 +29,7 @@ class _SqliteConnectionTile extends StatefulWidget { final VoidCallback onEdit; /// "Move to folder" menu entries; empty when there is nowhere to move. - final List moveItems; + final List moveItems; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -132,7 +132,7 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, @@ -144,18 +144,18 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { errorBuilder: (_, __, ___) => material.Icon( widget.icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary, + color: colors.primary, ), ) : material.Icon(widget.icon, size: QueryaIconSizes.sidebarConnectionIcon, - color: theme.colorScheme.primary); + color: colors.primary); - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) { setState(() { _tables = []; @@ -166,27 +166,27 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { child: const Text('Refresh database'), ), if (widget.onSqliteOpenSqlWorkspace != null) - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.terminal_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => widget.onSqliteOpenSqlWorkspace!(widget.connection), child: const Text('Open in SQL'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.edit_outlined, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) => widget.onEdit(), child: const Text('Edit connection…'), ), ...widget.moveItems, - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.delete_outline_rounded, - size: 18, color: theme.colorScheme.destructive), + size: 18, color: colors.destructive), onPressed: (_) => widget.onRemove(), child: Text( 'Remove connection', - style: material.TextStyle(color: theme.colorScheme.destructive), + style: material.TextStyle(color: colors.destructive), ), ), ], @@ -217,7 +217,7 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.sidebarExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), @@ -248,7 +248,7 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 13, - color: theme.colorScheme.foreground, + color: colors.foreground, ), ), if ((widget.connection.host ?? @@ -261,7 +261,7 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { maxLines: 1, style: material.TextStyle( fontSize: 11, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ], @@ -398,7 +398,7 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final query = _filter.trim().toLowerCase(); final matching = query.isEmpty ? widget.items @@ -427,15 +427,15 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), icon: widget.icon, iconSize: QueryaIconSizes.treeGroup, - iconColor: theme.colorScheme.mutedForeground, + iconColor: colors.mutedForeground, textStyle: material.TextStyle( fontSize: 11, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), @@ -510,11 +510,11 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { icon: widget.itemIcon, iconSize: QueryaIconSizes.treeLeaf, iconColor: QueryaTreeTokens.leafIconColor( - theme.colorScheme.primary, + colors.primary, ), textStyle: material.TextStyle( fontSize: 11, - color: theme.colorScheme.foreground, + color: colors.foreground, ), verticalPadding: 2, onTap: widget.onItemTap != null diff --git a/lib/features/connections/driver_icon.dart b/lib/features/connections/driver_icon.dart index 4628fea8..aa0459cc 100644 --- a/lib/features/connections/driver_icon.dart +++ b/lib/features/connections/driver_icon.dart @@ -67,11 +67,10 @@ class DriverIconImage extends StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = context.theme; final fallback = material.Icon( fallbackIcon, size: size, - color: theme.colorScheme.primary, + color: context.colors.primary, ); final file = File(path); diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index 4cdba0e8..c777f080 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; @@ -147,7 +148,7 @@ class _DriverRow extends material.StatelessWidget { }); final _DriverInfo info; - final ColorScheme theme; + final QueryaUiColors theme; @override material.Widget build(material.BuildContext context) { diff --git a/lib/features/connections/environment_section.dart b/lib/features/connections/environment_section.dart index f11ee075..c9781d19 100644 --- a/lib/features/connections/environment_section.dart +++ b/lib/features/connections/environment_section.dart @@ -17,7 +17,7 @@ class EnvironmentSection extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final muted = Theme.of(context).colorScheme.mutedForeground; + final muted = context.colors.mutedForeground; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart index 6cda1a20..d33a2b53 100644 --- a/lib/features/connections/extension_connection_form.dart +++ b/lib/features/connections/extension_connection_form.dart @@ -195,6 +195,7 @@ class _ExtensionConnectionFormContentState @override material.Widget build(material.BuildContext context) { final theme = context.colors; + final wb = context.workbench; final title = _isEditing ? 'Edit ${widget.driver.displayName}' : widget.driver.displayName; @@ -234,9 +235,9 @@ class _ExtensionConnectionFormContentState children: [ const Text('Connection name').small().muted(), const material.SizedBox(height: 4), - TextField( + QueryaTextField( controller: _nameController, - placeholder: const Text('My ClickHouse'), + placeholder: 'My ClickHouse', ), const material.SizedBox(height: 16), EnvironmentSection( @@ -267,7 +268,7 @@ class _ExtensionConnectionFormContentState style: material.TextStyle( fontSize: 12, color: _testSucceeded - ? material.Colors.green + ? wb.success : theme.destructive, ), ), diff --git a/lib/features/connections/forms/connection_auth_section.dart b/lib/features/connections/forms/connection_auth_section.dart index 1a21a726..7653f72e 100644 --- a/lib/features/connections/forms/connection_auth_section.dart +++ b/lib/features/connections/forms/connection_auth_section.dart @@ -43,9 +43,9 @@ class _ConnectionAuthSectionState extends material.State children: [ Text(widget.usernameLabel).small().semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: widget.usernameController, - placeholder: Text(widget.usernamePlaceholder), + placeholder: widget.usernamePlaceholder, ), ], ); @@ -65,12 +65,10 @@ class _ConnectionAuthSectionState extends material.State const Gap(8), material.Stack( children: [ - TextField( + QueryaTextField( controller: widget.passwordController, enabled: !widget.removeSavedPassword, - placeholder: Text( - widget.isEditing ? 'Leave blank to keep existing' : 'Password', - ), + placeholder: widget.isEditing ? 'Leave blank to keep existing' : 'Password', obscureText: !_showPassword, ), material.Positioned( diff --git a/lib/features/connections/forms/connection_database_section.dart b/lib/features/connections/forms/connection_database_section.dart index afd8ff1b..0eea43bf 100644 --- a/lib/features/connections/forms/connection_database_section.dart +++ b/lib/features/connections/forms/connection_database_section.dart @@ -28,9 +28,9 @@ class ConnectionDatabaseSection extends StatelessWidget { Text(hint!).muted().small(), ], const Gap(8), - TextField( + QueryaTextField( controller: databaseController, - placeholder: Text(placeholder), + placeholder: placeholder, ), ], ); diff --git a/lib/features/connections/forms/connection_host_port_section.dart b/lib/features/connections/forms/connection_host_port_section.dart index d1ea1929..5a15885f 100644 --- a/lib/features/connections/forms/connection_host_port_section.dart +++ b/lib/features/connections/forms/connection_host_port_section.dart @@ -39,9 +39,9 @@ class ConnectionHostPortSection extends StatelessWidget { children: [ Text(hostLabel).small().semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: hostController, - placeholder: Text(hostPlaceholder), + placeholder: hostPlaceholder, ), ], ), @@ -55,9 +55,9 @@ class ConnectionHostPortSection extends StatelessWidget { children: [ Text(portLabel).small().semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: portController, - placeholder: Text(portPlaceholder), + placeholder: portPlaceholder, ), ], ), diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 567ec748..de82c4bf 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/connection_type_choice.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; @@ -357,7 +358,7 @@ class _DbTypeCard extends material.StatelessWidget { }); final ConnectionTypeChoice choice; - final ColorScheme theme; + final QueryaUiColors theme; final bool selected; final VoidCallback onTap; diff --git a/lib/features/connections/new_connection_url_dialog.dart b/lib/features/connections/new_connection_url_dialog.dart index a57d1175..c48a7115 100644 --- a/lib/features/connections/new_connection_url_dialog.dart +++ b/lib/features/connections/new_connection_url_dialog.dart @@ -140,10 +140,9 @@ class _NewConnectionUrlDialogContentState ), const material.SizedBox(width: 10), material.Expanded( - child: TextField( + child: QueryaTextField( controller: _urlController, - placeholder: - const Text('database://user:pass@host:port/db'), + placeholder: 'database://user:pass@host:port/db', onSubmitted: (_) => _validateAndSubmit(), ), ), diff --git a/lib/features/connections/new_folder_dialog.dart b/lib/features/connections/new_folder_dialog.dart index 95df99d6..14d334b7 100644 --- a/lib/features/connections/new_folder_dialog.dart +++ b/lib/features/connections/new_folder_dialog.dart @@ -78,9 +78,9 @@ class _NewFolderDialogContentState ), const material.SizedBox(width: 10), material.Expanded( - child: TextField( + child: QueryaTextField( controller: _nameController, - placeholder: const Text('Folder name'), + placeholder: 'Folder name', ), ), ], diff --git a/lib/features/connections/querya_connection_tree_row.dart b/lib/features/connections/querya_connection_tree_row.dart index 6d836029..cdb743cf 100644 --- a/lib/features/connections/querya_connection_tree_row.dart +++ b/lib/features/connections/querya_connection_tree_row.dart @@ -103,9 +103,10 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final primary = theme.colorScheme.primary; - final muted = theme.colorScheme.mutedForeground; + final colors = context.colors; + final primary = colors.primary; + final muted = colors.mutedForeground; + final wb = context.workbench; final row = material.CallbackShortcuts( bindings: { if (expanded == false && onTap != null) @@ -157,7 +158,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { material.Icon( material.Icons.star_rounded, size: 13, - color: material.Colors.amber.shade600, + color: wb.warning, ), const Gap(4), ], @@ -167,7 +168,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { textStyle: isSelected ? textStyle.copyWith( fontWeight: material.FontWeight.w600, - color: theme.colorScheme.foreground, + color: colors.foreground, ) : textStyle, ), @@ -194,30 +195,30 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { child: row, ); } - final menu = ContextMenu( + final menu = QueryaContextMenu( items: [ if (openSqlName != null) ...[ if (onTogglePin != null) ...[ - MenuButton( + QueryaMenuItem( leading: material.Icon( isPinned ? material.Icons.star_rounded : material.Icons.star_border_rounded, size: 18, color: isPinned - ? material.Colors.amber.shade600 - : theme.colorScheme.mutedForeground, + ? wb.warning + : colors.mutedForeground, ), onPressed: (_) => onTogglePin!(), child: Text(isPinned ? 'Unpin from top' : 'Pin to top ⭐'), ), - const MenuDivider(), + const QueryaMenuDivider(), ], - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.table_rows_outlined, size: 18, - color: theme.colorScheme.primary, + color: colors.primary, ), onPressed: (_) { final qualified = openSqlSchema != null && openSqlSchema!.isNotEmpty @@ -238,11 +239,11 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { }, child: const Text('Select TOP 100'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.code_rounded, size: 18, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), onPressed: (_) { final qualified = openSqlSchema != null && openSqlSchema!.isNotEmpty @@ -252,23 +253,23 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { }, child: const Text('Copy SELECT statement'), ), - const MenuDivider(), + const QueryaMenuDivider(), ], if (onContextRefresh != null) - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.refresh_rounded, size: 18, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), onPressed: (_) => onContextRefresh!(), child: const Text('Refresh'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.copy_rounded, size: 18, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), onPressed: (_) { Clipboard.setData(ClipboardData(text: label)); @@ -278,11 +279,11 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { if (onOpenSqlWorkspace != null && openSqlName == null && connection != null) - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.terminal_rounded, size: 18, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), onPressed: (_) => onOpenSqlWorkspace!( connection!, @@ -294,12 +295,12 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { child: const Text('Open in SQL'), ), if (onContextDelete != null) ...[ - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( leading: material.Icon( material.Icons.delete_outline_rounded, size: 18, - color: theme.colorScheme.destructive, + color: colors.destructive, ), onPressed: (_) => onContextDelete!(), child: Text(contextDeleteLabel ?? 'Delete'), diff --git a/lib/features/connections/sdui_tree_builder.dart b/lib/features/connections/sdui_tree_builder.dart index ec4d346c..84872c86 100644 --- a/lib/features/connections/sdui_tree_builder.dart +++ b/lib/features/connections/sdui_tree_builder.dart @@ -229,9 +229,9 @@ class SduiTreeBuilderState extends material.State { } material.Widget _buildNodeRow(SduiTreeNode node, {required int depth}) { - final theme = Theme.of(context); - final primary = theme.colorScheme.primary; - final muted = theme.colorScheme.mutedForeground; + final colors = context.colors; + final primary = colors.primary; + final muted = colors.mutedForeground; final canExpand = node.expandable || node.hasChildren; final isExpanded = _expanded.contains(node.id); final isLoading = _loading.contains(node.id); @@ -284,7 +284,7 @@ class SduiTreeBuilderState extends material.State { iconColor: iconColor, textStyle: material.TextStyle( fontSize: 11, - color: isBrowsable ? theme.colorScheme.foreground : muted, + color: isBrowsable ? colors.foreground : muted, ), verticalPadding: 3, expanded: canExpand ? isExpanded : null, diff --git a/lib/features/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index 34f86cdd..8643bdba 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -251,9 +251,9 @@ class _SqliteConnectionFormContentState // Connection Name const Text('Connection name').small().semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: _nameController, - placeholder: const Text('e.g. Local Cache'), + placeholder: 'e.g. Local Cache', ), if (_nameTouched && _nameController.text.trim().isEmpty) ...[ const Gap(4), @@ -277,9 +277,9 @@ class _SqliteConnectionFormContentState material.Row( children: [ material.Expanded( - child: TextField( + child: QueryaTextField( controller: _pathController, - placeholder: const Text('/path/to/database.db'), + placeholder: '/path/to/database.db', ), ), const Gap(10), diff --git a/lib/features/connections/ssh_tunnel_section.dart b/lib/features/connections/ssh_tunnel_section.dart index e44def2d..b9bdb46a 100644 --- a/lib/features/connections/ssh_tunnel_section.dart +++ b/lib/features/connections/ssh_tunnel_section.dart @@ -194,8 +194,9 @@ class _SshTunnelSectionState extends material.State { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; + final colors = context.colors; + final cs = colors; + final wb = context.workbench; return material.Container( margin: const material.EdgeInsets.only(top: 16), @@ -245,9 +246,9 @@ class _SshTunnelSectionState extends material.State { children: [ const Text('Bastion Host').small().muted(), const Gap(4), - TextField( + QueryaTextField( controller: _hostController, - placeholder: const Text('bastion.corp.example.com'), + placeholder: 'bastion.corp.example.com', ), ], ), @@ -260,9 +261,9 @@ class _SshTunnelSectionState extends material.State { children: [ const Text('SSH Port').small().muted(), const Gap(4), - TextField( + QueryaTextField( controller: _portController, - placeholder: const Text('22'), + placeholder: '22', ), ], ), @@ -278,9 +279,9 @@ class _SshTunnelSectionState extends material.State { children: [ const Text('SSH Username').small().muted(), const Gap(4), - TextField( + QueryaTextField( controller: _usernameController, - placeholder: const Text('ubuntu / root / ec2-user'), + placeholder: 'ubuntu / root / ec2-user', ), ], ), @@ -340,13 +341,11 @@ class _SshTunnelSectionState extends material.State { children: [ const Text('SSH Password').small().muted(), const Gap(4), - TextField( + QueryaTextField( controller: _passwordController, obscureText: _obscurePassword, - placeholder: const Text('••••••••'), - features: [ - InputFeature.trailing( - QueryaButton.icon( + placeholder: '••••••••', + trailing: QueryaButton.icon( icon: _obscurePassword ? material.Icons.visibility_off_outlined : material.Icons.visibility_outlined, @@ -356,8 +355,6 @@ class _SshTunnelSectionState extends material.State { setState(() => _obscurePassword = !_obscurePassword); }, ), - ), - ], ), ], ), @@ -373,9 +370,9 @@ class _SshTunnelSectionState extends material.State { .small() .muted(), const Gap(4), - TextField( + QueryaTextField( controller: _keyPathController, - placeholder: const Text('~/.ssh/id_rsa or click Browse'), + placeholder: '~/.ssh/id_rsa or click Browse', ), ], ), @@ -394,13 +391,11 @@ class _SshTunnelSectionState extends material.State { children: [ const Text('Key Passphrase (optional)').small().muted(), const Gap(4), - TextField( + QueryaTextField( controller: _passphraseController, obscureText: _obscurePassphrase, - placeholder: const Text('Passphrase if key is encrypted'), - features: [ - InputFeature.trailing( - QueryaButton.icon( + placeholder: 'Passphrase if key is encrypted', + trailing: QueryaButton.icon( icon: _obscurePassphrase ? material.Icons.visibility_off_outlined : material.Icons.visibility_outlined, @@ -410,8 +405,6 @@ class _SshTunnelSectionState extends material.State { setState(() => _obscurePassphrase = !_obscurePassphrase); }, ), - ), - ], ), ], ), @@ -458,7 +451,7 @@ class _SshTunnelSectionState extends material.State { style: material.TextStyle( fontSize: 12, color: _testSuccess - ? material.Colors.green + ? wb.success : cs.destructive, ), ), @@ -481,11 +474,9 @@ class _SshTunnelSectionState extends material.State { .small() .muted(), const Gap(4), - TextField( + QueryaTextField( controller: _fingerprintController, - placeholder: const Text( - 'SHA256:... (leave blank to trust on first connect)', - ), + placeholder: 'SHA256:... (leave blank to trust on first connect)', ), const Gap(6), const Text( @@ -518,7 +509,7 @@ class _AuthTypeChip extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return material.InkWell( onTap: onTap, diff --git a/lib/features/connections/team_profile_actions.dart b/lib/features/connections/team_profile_actions.dart index 21d277d2..b6425d40 100644 --- a/lib/features/connections/team_profile_actions.dart +++ b/lib/features/connections/team_profile_actions.dart @@ -38,16 +38,21 @@ Future importTeamProfile(BuildContext context) async { final file = await openFile(acceptedTypeGroups: const [_typeGroup]); if (file == null || !context.mounted) return; try { - final rows = TeamProfileCodec.decode(utf8.decode(await file.readAsBytes())); - final added = await TeamProfileCodec.importInto(LocalDb.instance, rows); + final read = TeamProfileCodec.read(utf8.decode(await file.readAsBytes())); + final added = await TeamProfileCodec.importInto(LocalDb.instance, read.rows); if (!context.mounted) return; QueryaCommandHost.maybeOf(context)?.onReloadConnections?.call(); showAppToast( context: context, - message: added == 0 - ? 'Nothing new to import' - : 'Imported $added connection(s). Enter passwords when connecting.', - variant: AppToastVariant.success, + message: [ + added == 0 + ? 'Nothing new to import' + : 'Imported $added connection(s). Enter passwords when connecting.', + if (read.skipped > 0) + '${read.skipped} entr${read.skipped == 1 ? 'y was' : 'ies were'} ' + 'unreadable and skipped.', + ].join(' '), + variant: read.skipped > 0 ? AppToastVariant.info : AppToastVariant.success, ); } on TeamProfileFormatException catch (e) { if (!context.mounted) return; diff --git a/lib/features/erd/erd_export_data.dart b/lib/features/erd/erd_export_data.dart index e2561b99..8f7407d5 100644 --- a/lib/features/erd/erd_export_data.dart +++ b/lib/features/erd/erd_export_data.dart @@ -4,8 +4,6 @@ import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/erd/erd_export.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; /// Turns what the diagram shows on screen (header tints, notes, group frames, /// theme colours) into the plain values the SVG / DBML export takes (#1362). @@ -93,7 +91,7 @@ abstract final class ErdExportData { border: hex(wb.borderSubtle), header: hex(material.Color.alphaBlend( wb.accent.withValues(alpha: 0.10), wb.surface)), - text: hex(shadcn.Theme.of(context).colorScheme.foreground), + text: hex(context.colors.foreground), muted: hex(wb.mutedForeground), edge: hex(wb.mutedForeground), primaryKey: hex(palette.type1), diff --git a/lib/features/erd/erd_group_dialog.dart b/lib/features/erd/erd_group_dialog.dart index 9e5622e5..1cbcf319 100644 --- a/lib/features/erd/erd_group_dialog.dart +++ b/lib/features/erd/erd_group_dialog.dart @@ -88,18 +88,18 @@ class _ErdGroupDialogState extends material.State<_ErdGroupDialog> { .muted() .small(), const material.SizedBox(height: 16), - TextField( + QueryaTextField( key: const material.ValueKey('erd_group_name'), controller: _name, autofocus: true, - placeholder: const Text('Group name'), + placeholder: 'Group name', onSubmitted: (_) => _submit(), ), const material.SizedBox(height: 8), - TextField( + QueryaTextField( key: const material.ValueKey('erd_group_note'), controller: _note, - placeholder: const Text('Note (optional)'), + placeholder: 'Note (optional)', onSubmitted: (_) => _submit(), ), ], diff --git a/lib/features/erd/erd_note_dialog.dart b/lib/features/erd/erd_note_dialog.dart index 28e42801..81ae748c 100644 --- a/lib/features/erd/erd_note_dialog.dart +++ b/lib/features/erd/erd_note_dialog.dart @@ -70,13 +70,13 @@ class _ErdNoteDialogState extends material.State<_ErdNoteDialog> { .muted() .small(), const material.SizedBox(height: 16), - TextField( + QueryaTextField( key: const material.ValueKey('erd_note_text'), controller: _text, autofocus: true, minLines: 4, maxLines: 8, - placeholder: const Text('Note'), + placeholder: 'Note', ), ], ), diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 00fd4946..6c79285a 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:math' show max, min; +import 'dart:math' show max; import 'dart:typed_data'; import 'package:file_selector/file_selector.dart'; @@ -8,7 +8,6 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart' show HardwareKeyboard, LogicalKeyboardKey; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; -import 'package:querya_desktop/features/erd/erd_canvas_controls.dart'; import 'package:querya_desktop/features/erd/erd_card_measure.dart'; import 'package:querya_desktop/core/erd/erd_catalog.dart'; import 'package:querya_desktop/features/erd/erd_source.dart'; @@ -18,6 +17,8 @@ import 'package:querya_desktop/features/erd/erd_exporter.dart'; import 'package:querya_desktop/core/erd/erd_geometry.dart'; import 'package:querya_desktop/features/erd/erd_group_dialog.dart'; import 'package:querya_desktop/features/erd/erd_note_dialog.dart'; +import 'package:querya_desktop/features/erd/erd_view_controller.dart'; +import 'package:querya_desktop/features/erd/erd_viewport.dart'; import 'package:querya_desktop/features/erd/erd_view_name_dialog.dart'; import 'package:querya_desktop/core/erd/erd_group_ops.dart'; import 'package:querya_desktop/core/erd/erd_layout.dart'; @@ -26,23 +27,23 @@ import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/erd/erd_note_ops.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; import 'package:querya_desktop/core/erd/erd_router.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_canvas.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_card_cache.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_card_inputs.dart'; import 'package:querya_desktop/features/erd/widgets/erd_edge_tip_label.dart'; import 'package:querya_desktop/features/erd/widgets/erd_export_menu.dart'; import 'package:querya_desktop/features/erd/widgets/erd_group_frame.dart'; import 'package:querya_desktop/features/erd/widgets/erd_note_card.dart'; -import 'package:querya_desktop/features/erd/widgets/erd_relation_painter.dart'; import 'package:querya_desktop/features/erd/widgets/erd_search_panel.dart'; import 'package:querya_desktop/features/erd/widgets/erd_status_body.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_group_menu.dart'; import 'package:querya_desktop/features/erd/widgets/erd_table_menu.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_toolbar.dart'; import 'package:querya_desktop/features/erd/widgets/erd_views_menu.dart'; import 'package:querya_desktop/features/erd/widgets/erd_table_card.dart'; -import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; -import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; -import 'package:querya_desktop/shared/widgets/querya_button.dart'; import 'package:querya_desktop/shared/widgets/querya_spinner.dart'; -import 'package:querya_desktop/shared/widgets/querya_tab_strip.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:querya_desktop/shared/widgets/querya_context_menu.dart'; // Moved out of this file (#1362); re-exported for existing importers. export 'package:querya_desktop/features/erd/widgets/erd_relation_painter.dart' @@ -120,6 +121,7 @@ class ErdView extends material.StatefulWidget { class _ErdViewState extends material.State implements ErdExportSource { late final ErdExporter _exporter = ErdExporter(this); + final _c = ErdViewController(); final _boundaryKey = material.GlobalKey(); ErdSchema? _schema; @@ -132,8 +134,6 @@ class _ErdViewState extends material.State /// click or search: their edges are highlighted. A picked table stays /// highlighted when the mouse leaves. final _hovered = material.ValueNotifier(null); - String? _dragging; - String? _selected; bool _routeScheduled = false; /// The focus the edges draw. Every card has its own notifier in @@ -141,64 +141,37 @@ class _ErdViewState extends material.State /// hover rebuilds the cards it touches and not the whole canvas. final _focus = material.ValueNotifier(null); final _cardStates = >{}; - Map> _neighbours = const {}; /// Density: only PK and FK columns, cards collapsed to their header, and /// tables hidden from the diagram. Layout and routing follow the visible /// schema from [_visibleOf]. - ErdDetail _detail = ErdDetail.all; - final Set _collapsed = {}; - final Set _hidden = {}; /// A relation picked by a click on its edge (#1281): its two tables and /// columns are highlighted and its label stays until Esc. - ErdRelation? _pickedRelation; /// Many-to-many link tables and what they link: `users and roles` (#1281). - Map _junctions = const {}; /// Header colour picked per table (palette slot names, #1276). - final Map _headerColors = {}; /// The user's table groups, saved with the layout (#1282). - final List _groups = []; /// Tables marked with Shift / Ctrl / Cmd + click, to be grouped. - final Set _marked = {}; /// The group whose frame is being dragged by its title. - String? _draggingGroup; /// Saved views of the diagram (#1284), the one the user is in, and the /// *All tables* state while a view is active. - final List _views = []; - String? _activeView; - ErdSavedLayout _base = const ErdSavedLayout(); /// Sticky notes on the canvas, saved with the layout (#1283). - final List _notes = []; - String? _draggingNote; /// Relation under the pointer and the pointer position in canvas space. final _edgeTipNotifier = material.ValueNotifier<(ErdRelation, material.Offset)?>(null); - bool _searchOpen = false; - String _query = ''; final _searchController = material.TextEditingController(); final _searchFocus = material.FocusNode(); final _canvasFocus = material.FocusNode(); - final _viewportKey = material.GlobalKey(); - - final _transform = material.TransformationController(); - - static const double _minScale = 0.2; - static const double _maxScale = 3; - static const double _zoomStep = 1.25; - - /// Lowest zoom: [_minScale], or lower when the whole diagram needs it to - /// fit the viewport (a schema of a few hundred tables). - double _zoomFloor = _minScale; + final _viewport = ErdViewport(); /// Saves of the layout wait for a pause in the changes. Timer? _saveTimer; @@ -215,8 +188,8 @@ class _ErdViewState extends material.State _saveTimer!.cancel(); unawaited(_saveNow()); } - _transform.removeListener(_scheduleSave); - _transform.dispose(); + _viewport.transform.removeListener(_scheduleSave); + _viewport.dispose(); _hovered.dispose(); _focus.dispose(); for (final card in _cardStates.values) { @@ -234,7 +207,7 @@ class _ErdViewState extends material.State super.initState(); _searchFocus.addListener(_onSearchFocus); _hovered.addListener(_syncFocus); - _transform.addListener(_scheduleSave); + _viewport.transform.addListener(_scheduleSave); _load(); } @@ -245,13 +218,13 @@ class _ErdViewState extends material.State void _onSearchFocus() { if (!mounted || _searchFocus.hasFocus || - !_searchOpen || + !_c.searchOpen || _searchController.text.isNotEmpty) { return; } setState(() { - _searchOpen = false; - _query = ''; + _c.searchOpen = false; + _c.query = ''; }); _canvasFocus.requestFocus(); } @@ -272,12 +245,12 @@ class _ErdViewState extends material.State // The state to show: the diagram's own, or the view it was left in. var saved = stored; if (stored != null) { - _base = stored; - _views + _c.base = stored; + _c.views ..clear() ..addAll(stored.views); - _activeView = stored.activeView; - final view = _viewById(_activeView); + _c.activeView = stored.activeView; + final view = _c.viewById(_c.activeView); if (view != null) { saved = view.asLayout( {for (final t in schema.tables) t.name}, stored.headerColors); @@ -285,13 +258,14 @@ class _ErdViewState extends material.State } setState(() { _schema = schema; - _neighbours = ErdLayoutEngine.neighbourMap(schema); - _junctions = ErdLayoutEngine.junctionMap(schema); - _pickedRelation = null; - if (saved != null) _applyState(saved, colours: true); - _marked.clear(); + _c.neighbours = ErdLayoutEngine.neighbourMap(schema); + _c.junctions = ErdLayoutEngine.junctionMap(schema); + _c.pickedRelation = null; + if (saved != null) _c.applyState(saved, colours: true); + _c.marked.clear(); _setLayout(ErdLayoutEngine.withSaved(_compute(_visibleOf(schema)), saved)); _loading = false; + _cardCache.reset(schema.tables.length); }); if (schema.truncated) { showAppToast( @@ -309,9 +283,7 @@ class _ErdViewState extends material.State final translation = saved?.translation; if (scale != null && translation != null) { _restoring = true; - _transform.value = Matrix4.identity() - ..translate(translation.dx, translation.dy) - ..scale(scale); + _viewport.set(scale: scale, translation: translation); _restoring = false; } else { _fit(); @@ -328,30 +300,6 @@ class _ErdViewState extends material.State } } - /// The state of the diagram a [ErdSavedLayout] holds: what is collapsed - /// and hidden, the detail, groups and notes. Header colours belong to the - /// diagram, so a view switch leaves them ([colours] false). - void _applyState(ErdSavedLayout s, {required bool colours}) { - _collapsed - ..clear() - ..addAll(s.collapsed); - _hidden - ..clear() - ..addAll(s.hidden); - _detail = s.detail; - if (colours) { - _headerColors - ..clear() - ..addAll(s.headerColors); - } - _groups - ..clear() - ..addAll(s.groups); - _notes - ..clear() - ..addAll(s.notes); - } - /// Layout with cards measured in this view's fonts and text scale. ErdLayout _compute(ErdSchema schema) { final measure = ErdCardMeasure( @@ -360,47 +308,26 @@ class _ErdViewState extends material.State ); return ErdLayout.compute(schema, measure: measure.width, - groups: [for (final g in _allGroups()) g.tables]); - } - - /// The user's groups, then one per schema for the tables in none when the - /// diagram spans several schemas. - List _allGroups() { - final schema = _schema; - if (schema == null) return List.of(_groups); - return [ - ..._groups, - ...erdSchemaGroups([for (final t in schema.tables) t.name], _groups), - ]; - } - - ErdGroup? _groupOf(String table) => ErdGroupOps.groupOf(_groups, table); - - void _replaceGroups(List groups) { - final next = List.of(groups); - _groups - ..clear() - ..addAll(next); + groups: [for (final g in _c.allGroups(_schema)) g.tables]); } /// Asks for a name and groups [tables], taking them out of other groups. Future _newGroup(Set tables) async { if (tables.isEmpty) return; - final n = ErdGroupOps.nextNameNumber(_groups); + final n = ErdGroupOps.nextNameNumber(_c.groups); final text = await showErdGroupDialog(context, title: 'New group', action: 'Create', name: 'Group $n'); if (text == null || !mounted) return; + late final String id; setState(() { - _replaceGroups(ErdGroupOps.withNewGroup( - _groups, + id = _c.createGroup( tables: tables, tableNames: [for (final t in _schema?.tables ?? const []) t.name], name: text.name, note: text.note, - )); - _marked.clear(); + ); }); - _gatherIfCovering(_groups.last.id); + _gatherIfCovering(id); _scheduleSave(); } @@ -408,26 +335,23 @@ class _ErdViewState extends material.State /// packed into one block, the other cards stay (#1282). void _gatherIfCovering(String id) { final layout = _layout; - final i = _groups.indexWhere((g) => g.id == id); + final i = _c.groups.indexWhere((g) => g.id == id); if (layout == null || i < 0) return; - final tables = _groups[i].tables; + final tables = _c.groups[i].tables; if (!layout.frameCoversOthers(tables)) return; setState(() { _setLayout(layout.gather(tables)); - _followTables(layout); + _c.followTables(layout, _layout); }); showAppToast( context: context, - message: 'Moved the tables of "${_groups[i].name}" together so its ' + message: 'Moved the tables of "${_c.groups[i].name}" together so its ' 'frame covers no other table.', ); } void _addToGroup(String id, Set tables) { - setState(() { - _replaceGroups(ErdGroupOps.addTables(_groups, id, tables)); - if (_groups.any((g) => g.id == id)) _marked.clear(); - }); + setState(() => _c.addToGroup(id, tables)); _gatherIfCovering(id); _scheduleSave(); } @@ -444,19 +368,18 @@ class _ErdViewState extends material.State } void _updateGroup(String id, ErdGroup Function(ErdGroup) change) { - final i = _groups.indexWhere((g) => g.id == id); - if (i < 0) return; - setState(() => _groups[i] = change(_groups[i])); - _scheduleSave(); + var found = false; + setState(() => found = _c.updateGroup(id, change)); + if (found) _scheduleSave(); } void _ungroup(String id) { - setState(() => _groups.removeWhere((g) => g.id == id)); + setState(() => _c.ungroup(id)); _scheduleSave(); } void _removeFromGroup(String table) { - setState(() => _replaceGroups(ErdGroupOps.ungroupTables(_groups, {table}))); + setState(() => _c.removeFromGroup(table)); _scheduleSave(); } @@ -466,12 +389,10 @@ class _ErdViewState extends material.State final keys = HardwareKeyboard.instance; if (keys.isShiftPressed || keys.isControlPressed || keys.isMetaPressed) { _canvasFocus.requestFocus(); - setState(() { - if (!_marked.remove(table)) _marked.add(table); - }); + setState(() => _c.toggleMarked(table)); return; } - if (_marked.isNotEmpty) setState(_marked.clear); + if (_c.marked.isNotEmpty) setState(_c.marked.clear); _pick(table); } @@ -481,11 +402,11 @@ class _ErdViewState extends material.State final schema = _schema; if (schema == null) return; setState(() { - _activeView = viewId; - _selected = null; - _pickedRelation = null; - _marked.clear(); - _applyState(target, colours: false); + _c.activeView = viewId; + _c.selected = null; + _c.pickedRelation = null; + _c.marked.clear(); + _c.applyState(target, colours: false); _setLayout(ErdLayoutEngine.withSaved(_compute(_visibleOf(schema)), target)); }); _syncFocus(); @@ -494,9 +415,7 @@ class _ErdViewState extends material.State final scale = target.scale, translation = target.translation; if (scale != null && translation != null) { _restoring = true; - _transform.value = Matrix4.identity() - ..translate(translation.dx, translation.dy) - ..scale(scale); + _viewport.set(scale: scale, translation: translation); _restoring = false; } else { _fit(); @@ -508,17 +427,11 @@ class _ErdViewState extends material.State /// Switches to the view [id], or to *All tables* for null. void _switchView(String? id) { final schema = _schema; - if (schema == null || id == _activeView) return; + if (schema == null || id == _c.activeView) return; _captureCurrent(); - if (id == null) { - _showState(null, _base); - return; - } - final view = _viewById(id); - if (view == null) return; - _showState( - id, - view.asLayout({for (final t in schema.tables) t.name}, _headerColors)); + final target = + _c.stateOfView(id, {for (final t in schema.tables) t.name}); + if (target != null) _showState(id, target); } /// Saves what the diagram shows as a new view and moves into it. @@ -528,76 +441,34 @@ class _ErdViewState extends material.State final name = await showErdViewNameDialog(context, title: 'New view', action: 'Save', - name: 'View ${_views.length + 1}'); + name: 'View ${_c.views.length + 1}'); if (name == null || !mounted) return; - final fromAll = _viewById(_activeView) == null; _captureCurrent(); - // The tables hidden by hand now belong to the view; All tables shows - // every table, always. - if (fromAll) _base = _base.showingAll(); - var n = 1; - while (_views.any((v) => v.id == 'v$n')) { - n++; - } - setState(() { - _views.add(ErdSavedView( - id: 'v$n', - name: name, - tables: { - for (final t in schema.tables) - if (!_hidden.contains(t.name)) t.name, - }, - layout: _current(), - )); - _activeView = 'v$n'; - }); + setState(() => _c.addViewFromCurrent( + name: name, + current: _current(), + tableNames: [for (final t in schema.tables) t.name], + )); _scheduleSave(); } Future _renameView() async { - final view = _viewById(_activeView); + final view = _c.viewById(_c.activeView); if (view == null) return; final name = await showErdViewNameDialog(context, title: 'Rename view', action: 'Rename', name: view.name); if (name == null || !mounted) return; - final i = _views.indexWhere((v) => v.id == view.id); - if (i < 0) return; - setState(() => _views[i] = _views[i].copyWith(name: name)); - _scheduleSave(); + var found = false; + setState(() => found = _c.renameView(view.id, name)); + if (found) _scheduleSave(); } /// Deletes the active view and goes back to *All tables*. void _deleteView() { - final id = _activeView; + final id = _c.activeView; if (id == null) return; - _views.removeWhere((v) => v.id == id); - _showState(null, _base); - } - - /// Moves the notes attached to a table by what the table moved, from - /// [before] to the current layout. - void _followTables(ErdLayout before) { - final after = _layout; - if (after == null || _notes.every((n) => n.attachedTo == null)) return; - for (var i = 0; i < _notes.length; i++) { - final t = _notes[i].attachedTo; - if (t == null) continue; - final a = before.positions[t], b = after.positions[t]; - if (a == null || b == null || a == b) continue; - final d = b - a; - _notes[i] = _notes[i] - .copyWith(x: _notes[i].x + d.dx, y: _notes[i].y + d.dy); - } - } - - /// Canvas point at the middle of what the viewport shows. - material.Offset _viewCentre() { - final vs = _viewportSize(); - if (vs == null) return const material.Offset(60, 60); - final inverse = Matrix4.inverted(_transform.value); - final p = material.MatrixUtils.transformPoint( - inverse, vs.center(material.Offset.zero)); - return p; + _c.removeView(id); + _showState(null, _c.base); } Future _addNote() async { @@ -605,8 +476,8 @@ class _ErdViewState extends material.State title: 'New note', action: 'Add'); if (text == null || !mounted) return; final note = - ErdNoteOps.create(_notes, text: text, centre: _viewCentre()); - setState(() => _notes.add(note)); + ErdNoteOps.create(_c.notes, text: text, centre: _viewport.centre()); + setState(() => _c.addNote(note)); _scheduleSave(); } @@ -618,29 +489,22 @@ class _ErdViewState extends material.State } void _updateNote(String id, ErdNote Function(ErdNote) change) { - final i = _notes.indexWhere((n) => n.id == id); - if (i < 0) return; - setState(() => _notes[i] = change(_notes[i])); - _scheduleSave(); + var found = false; + setState(() => found = _c.updateNote(id, change)); + if (found) _scheduleSave(); } void _deleteNote(String id) { - setState(() => _notes.removeWhere((n) => n.id == id)); + setState(() => _c.deleteNote(id)); _scheduleSave(); } void _noteDragMove(String id, material.Offset screenDelta) { - final i = _notes.indexWhere((n) => n.id == id); - if (i < 0) return; - final moved = ErdNoteOps.moved(_notes[i], screenDelta, _scale); - setState(() => _notes[i] = moved); + setState(() => _c.moveNote(id, screenDelta, _viewport.scale)); } void _noteResize(String id, material.Offset screenDelta) { - final i = _notes.indexWhere((n) => n.id == id); - if (i < 0) return; - final resized = ErdNoteOps.resized(_notes[i], screenDelta, _scale); - setState(() => _notes[i] = resized); + setState(() => _c.resizeNote(id, screenDelta, _viewport.scale)); } /// The table nearest to a note's middle, to attach it to. @@ -651,16 +515,6 @@ class _ErdViewState extends material.State return ErdNoteOps.nearestTable(n, layout, _visibleOf(schema)); } - /// Canvas size: the cards and every note. - material.Size _canvasSize(ErdLayout layout) { - var w = layout.size.width, h = layout.size.height; - for (final n in _notes) { - w = max(w, n.x + n.width + ErdLayout.margin); - h = max(h, n.y + n.height + ErdLayout.margin); - } - return material.Size(w, h); - } - /// Tint of a note without a colour over the canvas, on screen and in the /// exports. static const double _noteTint = 0.07; @@ -669,15 +523,15 @@ class _ErdViewState extends material.State note: n, tint: _noteTint, slotColor: _slotColor, - dragging: _draggingNote == n.id, + dragging: _c.draggingNote == n.id, onFocusCanvas: _canvasFocus.requestFocus, - onDragStart: () => setState(() => _draggingNote = n.id), + onDragStart: () => setState(() => _c.draggingNote = n.id), onDragMove: (d) => _noteDragMove(n.id, d), onDragEnd: () { - setState(() => _draggingNote = null); + setState(() => _c.draggingNote = null); _scheduleSave(); }, - onDragCancel: () => setState(() => _draggingNote = null), + onDragCancel: () => setState(() => _c.draggingNote = null), onEdit: () => unawaited(_editNote(n)), onChange: (change) => _updateNote(n.id, change), onAttachNearest: () { @@ -691,16 +545,16 @@ class _ErdViewState extends material.State onResizeEnd: _scheduleSave, ); - void _groupDragStart(String id) => setState(() => _draggingGroup = id); + void _groupDragStart(String id) => setState(() => _c.draggingGroup = id); /// Moves every visible table of [group] by [screenDelta], keeping its frame /// inside the canvas. void _groupDragMove(ErdGroup group, material.Offset screenDelta) { final layout = _layout; - if (_draggingGroup != group.id || layout == null) return; + if (_c.draggingGroup != group.id || layout == null) return; final frame = layout.frameOf(group.tables); if (frame == null) return; - final scale = _scale; + final scale = _viewport.scale; var delta = screenDelta / (scale == 0 ? 1 : scale); delta = material.Offset( max(delta.dx, -frame.left), max(delta.dy, -frame.top)); @@ -709,14 +563,14 @@ class _ErdViewState extends material.State for (final t in group.tables) if (layout.positions[t] case final p?) t: p + delta, }); - _followTables(layout); + _c.followTables(layout, _layout); }); _scheduleRoutes(); } void _groupDragEnd() { - if (_draggingGroup == null) return; - setState(() => _draggingGroup = null); + if (_c.draggingGroup == null) return; + setState(() => _c.draggingGroup = null); _scheduleSave(); } @@ -742,49 +596,18 @@ class _ErdViewState extends material.State } /// What the diagram shows and how it is arranged right now. - ErdSavedLayout _current() { - final m = _transform.value; - final t = m.getTranslation(); - return ErdSavedLayout( - positions: Map.of(_layout?.positions ?? const {}), - collapsed: Set.of(_collapsed), - hidden: Set.of(_hidden), - detail: _detail, - headerColors: Map.of(_headerColors), - groups: List.of(_groups), - notes: List.of(_notes), - scale: m.getMaxScaleOnAxis(), - translation: material.Offset(t.x, t.y), - ); - } + ErdSavedLayout _current() => _c.snapshot( + positions: _layout?.positions ?? const {}, + scale: _viewport.scale, + translation: _viewport.translation, + ); /// Writes the current state to where it belongs: the active view, or the /// diagram's own (*All tables*) state. void _captureCurrent() { final schema = _schema; if (schema == null || _layout == null) return; - final cur = _current(); - final i = _views.indexWhere((v) => v.id == _activeView); - if (i < 0) { - _base = cur; - return; - } - _views[i] = ErdSavedView( - id: _views[i].id, - name: _views[i].name, - tables: { - for (final t in schema.tables) - if (!_hidden.contains(t.name)) t.name, - }, - layout: cur, - ); - } - - ErdSavedView? _viewById(String? id) { - for (final v in _views) { - if (v.id == id) return v; - } - return null; + _c.capture(_current(), [for (final t in schema.tables) t.name]); } Future _saveNow() async { @@ -792,12 +615,7 @@ class _ErdViewState extends material.State if (!_persists || layout == null) return; _captureCurrent(); try { - await widget.layoutStore!.write( - widget.layoutKey!, - // The diagram's own state, with the header colours as they are now. - _base.withViews(List.of(_views), _activeView, - headerColors: Map.of(_headerColors)), - ); + await widget.layoutStore!.write(widget.layoutKey!, _c.toStore()); } catch (_) { // Keeping the layout is a convenience: a failed write is not an error // the user can act on. @@ -815,9 +633,9 @@ class _ErdViewState extends material.State /// The schema as drawn, for the current density and visibility. ErdSchema _visibleOf(ErdSchema schema) => ErdLayoutEngine.visibleOf( schema, - hidden: _hidden, - collapsed: _collapsed, - detail: _detail, + hidden: _c.hidden, + collapsed: _c.collapsed, + detail: _c.detail, ); /// Recomputes the layout for the current density and visibility. @@ -834,7 +652,7 @@ class _ErdViewState extends material.State _setLayout(arrange || current == null ? computed : ErdLayoutEngine.withSaved(computed, ErdSavedLayout(positions: current.positions))); - if (current != null) _followTables(current); + if (current != null) _c.followTables(current, _layout); }); _scheduleSave(); } @@ -842,44 +660,37 @@ class _ErdViewState extends material.State void _autoLayout() => _reflow(arrange: true); void _toggleCollapsed(String table) { - setState(() { - if (!_collapsed.remove(table)) _collapsed.add(table); - }); + setState(() => _c.toggleCollapsed(table)); _reflow(); } void _hide(String table) { - setState(() { - _hidden.add(table); - _marked.remove(table); - // A pick on a hidden table would leave every card faded. - if (_selected == table) _selected = null; - }); + setState(() => _c.hide(table)); _syncFocus(); _reflow(); } void _showAllTables() { - setState(_hidden.clear); + setState(_c.hidden.clear); _reflow(); } void _dragStart(String table) { - setState(() => _dragging = table); + setState(() => _c.dragging = table); _syncFocus(); } /// [screenDelta] is in screen pixels; the canvas may be zoomed. void _dragMove(String table, material.Offset screenDelta) { final layout = _layout; - if (_dragging != table || layout == null) return; - final scale = _transform.value.getMaxScaleOnAxis(); + if (_c.dragging != table || layout == null) return; + final scale = _viewport.scale; final delta = screenDelta / (scale == 0 ? 1 : scale); // The card follows the pointer at once; the edges are routed once per frame. setState(() { _layout = layout.withPosition(table, layout.positions[table]! + delta); - _followTables(layout); + _c.followTables(layout, _layout); }); _scheduleRoutes(); } @@ -899,8 +710,8 @@ class _ErdViewState extends material.State } void _dragEnd() { - if (_dragging == null) return; - setState(() => _dragging = null); + if (_c.dragging == null) return; + setState(() => _c.dragging = null); _syncFocus(); _scheduleSave(); } @@ -911,42 +722,15 @@ class _ErdViewState extends material.State /// The table whose relations are highlighted: the one being dragged, then /// the picked one, then the one under the mouse. - double get _scale => _transform.value.getMaxScaleOnAxis(); - - /// Size of the diagram viewport, once it has been laid out. - material.Size? _viewportSize() { - final box = _viewportKey.currentContext?.findRenderObject() as RenderBox?; - return box != null && box.hasSize ? box.size : null; - } - - /// Sets the zoom to [target], keeping the scene point under [focal] still. - void _setScale(double target, material.Offset focal) { - final s = target.clamp(_zoomFloor, _maxScale).toDouble(); - final t = _transform.value.getTranslation(); - final current = material.Offset(t.x, t.y); - final next = focal - (focal - current) * (s / _scale); - _transform.value = Matrix4.identity()..translate(next.dx, next.dy)..scale(s); - } - - void _zoomBy(double factor) { - final vs = _viewportSize(); - if (vs == null) return; - _setScale(_scale * factor, vs.center(material.Offset.zero)); - } + void _zoomBy(double factor) => _viewport.zoomBy(factor); /// Fits the whole diagram into the viewport, centred, never enlarged. void _fit() { final layout = _layout; - final vs = _viewportSize(); - if (layout == null || vs == null) return; + if (layout == null) return; // The cards and every note: a note placed away from the cards counts. - final size = _canvasSize(layout); - final s = ErdLayout.fitScale(size, vs); - final floor = min(_minScale, s); - if (floor != _zoomFloor) setState(() => _zoomFloor = floor); - final tx = (vs.width - size.width * s) / 2; - final ty = (vs.height - size.height * s) / 2; - _transform.value = Matrix4.identity()..translate(tx, ty)..scale(s); + final floorMoved = _viewport.fit(_c.canvasSize(layout)); + if (floorMoved ?? false) setState(() {}); } /// Picks a table from the search: centres it, closes the search and gives @@ -955,8 +739,8 @@ class _ErdViewState extends material.State _selectTable(schema, name); _searchController.clear(); setState(() { - _searchOpen = false; - _query = ''; + _c.searchOpen = false; + _c.query = ''; }); _canvasFocus.requestFocus(); } @@ -964,17 +748,13 @@ class _ErdViewState extends material.State /// Picks a table and centres the view on it, keeping the zoom. void _selectTable(ErdSchema schema, String name) { final layout = _layout; - final vs = _viewportSize(); final table = schema.tables.firstWhere((t) => t.name == name); - if (layout != null && vs != null) { + if (layout != null) { final p = layout.positions[name]!; - final centre = material.Offset(p.dx + layout.widthFor(name) / 2, - p.dy + ErdLayout.cardHeight(table) / 2); - final s = _scale; - final t = vs.center(material.Offset.zero) - centre * s; - _transform.value = Matrix4.identity()..translate(t.dx, t.dy)..scale(s); + _viewport.centreOn(material.Offset(p.dx + layout.widthFor(name) / 2, + p.dy + ErdLayout.cardHeight(table) / 2)); } - setState(() => _selected = name); + setState(() => _c.selected = name); _syncFocus(); } @@ -982,14 +762,8 @@ class _ErdViewState extends material.State /// the zoom and search shortcuts work after a click anywhere on it. void _clearSelection() { _canvasFocus.requestFocus(); - if (_selected == null && _pickedRelation == null && _marked.isEmpty) { - return; - } - setState(() { - _selected = null; - _pickedRelation = null; - _marked.clear(); - }); + if (!_c.clearPicks()) return; + setState(() {}); _edgeTipNotifier.value = null; _syncFocus(); } @@ -1003,10 +777,7 @@ class _ErdViewState extends material.State return; } _canvasFocus.requestFocus(); - setState(() { - _pickedRelation = hit; - _selected = null; - }); + setState(() => _c.pickRelation(hit)); _edgeTipNotifier.value = (hit, p); _syncFocus(); } @@ -1014,50 +785,42 @@ class _ErdViewState extends material.State /// A click on a card picks it. void _pick(String name) { _canvasFocus.requestFocus(); - setState(() { - _selected = name; - _pickedRelation = null; - }); + setState(() => _c.pick(name)); _edgeTipNotifier.value = null; _syncFocus(); } void _openSearch() { - setState(() => _searchOpen = true); + setState(() => _c.searchOpen = true); material.WidgetsBinding.instance .addPostFrameCallback((_) => _searchFocus.requestFocus()); } - /// Escape: closes the search when it is open, else clears the pick. One - /// Esc never does both, so closing the search keeps the table found. + /// Escape: one step per press (see [ErdViewController.escape]); closing + /// the search keeps the table found. void _escape() { - if (_searchOpen) { - _searchController.clear(); - setState(() { - _searchOpen = false; - _query = ''; - }); - _canvasFocus.requestFocus(); - return; - } - if (_pickedRelation != null) { - setState(() => _pickedRelation = null); - _edgeTipNotifier.value = null; - _syncFocus(); - return; - } - if (_marked.isNotEmpty) { - setState(_marked.clear); - return; - } - if (_selected != null) { - setState(() => _selected = null); - _syncFocus(); + final step = _c.escape(); + switch (step) { + case ErdEscape.closedSearch: + _searchController.clear(); + setState(() {}); + _canvasFocus.requestFocus(); + case ErdEscape.droppedRelation: + setState(() {}); + _edgeTipNotifier.value = null; + _syncFocus(); + case ErdEscape.clearedMarks: + setState(() {}); + case ErdEscape.clearedPick: + setState(() {}); + _syncFocus(); + case ErdEscape.nothing: + break; } } List _matches(ErdSchema schema) => - ErdLayoutEngine.matches(schema, _query); + ErdLayoutEngine.matches(schema, _c.query); /// Keyboard shortcuts for both Ctrl (Linux, Windows) and Cmd (macOS). Map _bindings() { @@ -1068,39 +831,23 @@ class _ErdViewState extends material.State out[material.SingleActivator(key, meta: true, shift: shift)] = action; } - both(LogicalKeyboardKey.equal, () => _zoomBy(_zoomStep)); + both(LogicalKeyboardKey.equal, () => _zoomBy(ErdViewport.zoomStep)); // "+" is Shift+= on most layouts. - both(LogicalKeyboardKey.equal, () => _zoomBy(_zoomStep), shift: true); - both(LogicalKeyboardKey.add, () => _zoomBy(_zoomStep)); - both(LogicalKeyboardKey.numpadAdd, () => _zoomBy(_zoomStep)); - both(LogicalKeyboardKey.minus, () => _zoomBy(1 / _zoomStep)); - both(LogicalKeyboardKey.numpadSubtract, () => _zoomBy(1 / _zoomStep)); + both(LogicalKeyboardKey.equal, () => _zoomBy(ErdViewport.zoomStep), shift: true); + both(LogicalKeyboardKey.add, () => _zoomBy(ErdViewport.zoomStep)); + both(LogicalKeyboardKey.numpadAdd, () => _zoomBy(ErdViewport.zoomStep)); + both(LogicalKeyboardKey.minus, () => _zoomBy(1 / ErdViewport.zoomStep)); + both(LogicalKeyboardKey.numpadSubtract, () => _zoomBy(1 / ErdViewport.zoomStep)); both(LogicalKeyboardKey.digit0, _fit); both(LogicalKeyboardKey.keyF, _openSearch); out[const material.SingleActivator(LogicalKeyboardKey.escape)] = _escape; return out; } - /// The table the diagram focuses: the dragged one, then the picked one, then - /// the one under the mouse. - String? _currentFocus() => _dragging ?? _selected ?? _hovered.value; - - ErdCardFocus _cardFocusFor(String table, String? focus) { - final p = _pickedRelation; - if (p != null) { - final on = table == p.fromTable || table == p.toTable; - final column = table == p.fromTable - ? p.fromColumn - : (table == p.toTable ? p.toColumn : null); - return (highlighted: on, faded: !on, column: column); - } - final highlighted = ErdLayoutEngine.isFocusedIn(_neighbours, focus, table); - return ( - highlighted: highlighted, - faded: _selected != null && !highlighted, - column: null, - ); - } + String? _currentFocus() => _c.currentFocus(_hovered.value); + + ErdCardFocus _cardFocusFor(String table, String? focus) => + _c.cardFocusFor(table, focus); /// The notifier of card [table], made on first use with its current focus. material.ValueNotifier _cardState(String table) => @@ -1111,7 +858,7 @@ class _ErdViewState extends material.State /// Pushes the focus to the edges and to each card. A notifier only fires /// when its own highlight or fade changed, so the rest are not rebuilt. - /// Call after any change to [_selected], [_dragging] or [_hovered]. + /// Call after any change to [_c.selected], [_c.dragging] or [_hovered]. void _syncFocus() { final focus = _currentFocus(); _focus.value = focus; @@ -1127,7 +874,7 @@ class _ErdViewState extends material.State /// Header colour of [table] on screen: the picked slot, the schema's slot, /// or the accent for a table of the current schema. material.Color _headerColor(String table) { - final slot = erdHeaderSlot(table, _headerColors); + final slot = erdHeaderSlot(table, _c.headerColors); if (slot == null) return context.workbench.accent; return _slotColor(slot); } @@ -1144,13 +891,7 @@ class _ErdViewState extends material.State } void _setHeaderColor(String table, String? slot) { - setState(() { - if (slot == null) { - _headerColors.remove(table); - } else { - _headerColors[table] = slot; - } - }); + setState(() => _c.setHeaderColor(table, slot)); _scheduleSave(); } @@ -1159,15 +900,15 @@ class _ErdViewState extends material.State ErdExportData.headerFills( schema, cardHex, - headerColors: _headerColors, + headerColors: _c.headerColors, headerColor: _headerColor, ); /// The toolbar's view switcher: All tables, the saved views, and the /// commands to save, rename and delete one. material.Widget _viewsMenu() => ErdViewsMenu( - views: _views, - active: _viewById(_activeView), + views: _c.views, + active: _c.viewById(_c.activeView), onShowAll: () => _switchView(null), onSwitch: _switchView, onNew: () => unawaited(_newView()), @@ -1175,49 +916,9 @@ class _ErdViewState extends material.State onDelete: _deleteView, ); - /// Group entries of a card's menu: group it (with the marked tables), add - /// it to a group, take it out of its group. - List _groupMenu(String table) { - final tables = {..._marked, table}; - final current = _groupOf(table); - return [ - MenuButton( - key: material.ValueKey('erd_menu_group_$table'), - onPressed: (_) => unawaited(_newGroup(tables)), - // The way to put more tables in is not obvious: say it here. - trailing: tables.length == 1 - ? Text('Shift+click marks more', - style: material.TextStyle( - fontSize: 11, color: context.workbench.mutedForeground)) - : null, - child: Text(tables.length == 1 - ? 'New group…' - : 'Group ${tables.length} tables…'), - ), - if (_groups.any((g) => g.id != current?.id)) - MenuButton( - subMenu: [ - for (final g in _groups) - if (g.id != current?.id) - MenuButton( - onPressed: (_) => _addToGroup(g.id, tables), - child: Text(g.name), - ), - ], - child: const Text('Add to group'), - ), - if (current != null) - MenuButton( - key: material.ValueKey('erd_menu_ungroup_$table'), - onPressed: (_) => _removeFromGroup(table), - child: Text('Remove from ${current.name}'), - ), - ]; - } - /// Notes for the SVG, tinted as on screen over the export background. List _svgNotes(ErdSvgColors colors) => ErdExportData.notes( - _notes, + _c.notes, colors, slotColor: _slotColor, noteTint: _noteTint, @@ -1226,7 +927,7 @@ class _ErdViewState extends material.State /// Group frames for the SVG: the screen's tint over the export background. List _svgGroups(ErdLayout layout, String backgroundHex) => ErdExportData.groups( - _allGroups(), + _c.allGroups(_schema), layout, backgroundHex, slotColor: _slotColor, @@ -1239,7 +940,7 @@ class _ErdViewState extends material.State group: g, layout: layout, slotColor: _slotColor, - dragging: _draggingGroup == g.id, + dragging: _c.draggingGroup == g.id, onFocusCanvas: _canvasFocus.requestFocus, onDragStart: () => _groupDragStart(g.id), onDragMove: (delta) => _groupDragMove(g, delta), @@ -1273,19 +974,19 @@ class _ErdViewState extends material.State List get routes => _routes; @override - List get groups => _groups; + List get groups => _c.groups; @override - List get notes => _notes; + List get notes => _c.notes; @override - Map get headerColorSlots => _headerColors; + Map get headerColorSlots => _c.headerColors; @override material.Color slotColor(String slot) => _slotColor(slot); @override - material.Size canvasSize(ErdLayout layout) => _canvasSize(layout); + material.Size canvasSize(ErdLayout layout) => _c.canvasSize(layout); @override ErdSvgColors currentThemeColors() => _svgColors(); @@ -1309,9 +1010,9 @@ class _ErdViewState extends material.State Future withCleanCanvas(Future Function() capture) async { // The pick, a picked relation and the marks for a group are screen // state: none of them belongs in the image, and all come back after it. - final picked = _selected; - final pickedRelation = _pickedRelation; - final marked = Set.of(_marked); + final picked = _c.selected; + final pickedRelation = _c.pickedRelation; + final marked = Set.of(_c.marked); final hadScreenState = picked != null || pickedRelation != null || marked.isNotEmpty; @@ -1322,9 +1023,9 @@ class _ErdViewState extends material.State _edgeTipNotifier.value = null; if (hadScreenState) { setState(() { - _selected = null; - _pickedRelation = null; - _marked.clear(); + _c.selected = null; + _c.pickedRelation = null; + _c.marked.clear(); }); _syncFocus(); } @@ -1336,11 +1037,11 @@ class _ErdViewState extends material.State if (mounted && hadScreenState) { setState(() { // What the user did during the capture wins. - if (_selected == null && _pickedRelation == null) { - _selected = picked; - _pickedRelation = pickedRelation; + if (_c.selected == null && _c.pickedRelation == null) { + _c.selected = picked; + _c.pickedRelation = pickedRelation; } - if (_marked.isEmpty) _marked.addAll(marked); + if (_c.marked.isEmpty) _c.marked.addAll(marked); }); _syncFocus(); } @@ -1354,7 +1055,7 @@ class _ErdViewState extends material.State /// 6 screen px of it, if any, whatever the zoom. void _hoverCanvas(material.Offset p) { // A picked relation keeps its label until Esc or another click. - if (_pickedRelation != null) return; + if (_c.pickedRelation != null) return; final hit = _relationAt(p); // Only the tip layer listens: no rebuild of the view for a hover move. _edgeTipNotifier.value = hit == null ? null : (hit, p); @@ -1363,7 +1064,7 @@ class _ErdViewState extends material.State /// The relation within 6 screen px of [p] (canvas space), whatever the zoom. ErdRelation? _relationAt(material.Offset p) { ErdRelation? hit; - final scale = _scale; + final scale = _viewport.scale; var best = 6.0 / (scale <= 0 ? 1 : scale); for (final r in _routes) { final d = ErdGeometry.distanceToRoute(r.points, p); @@ -1376,30 +1077,127 @@ class _ErdViewState extends material.State } void _leaveCanvas() { - if (_pickedRelation != null) return; + if (_c.pickedRelation != null) return; _edgeTipNotifier.value = null; } - /// "orders.customer_id → customers.id" next to the pointer. - material.Widget _edgeTipLabel(material.BuildContext context, ErdRelation r) => - ErdEdgeTipLabel(relation: r); - /// Find-a-table field and the tables it matches. Enter picks the first. material.Widget _searchPanel(ErdSchema schema) { - if (!_searchOpen) return const material.SizedBox.shrink(); + if (!_c.searchOpen) return const material.SizedBox.shrink(); return ErdSearchPanel( controller: _searchController, focusNode: _searchFocus, - query: _query, + query: _c.query, matches: _matches(schema), - onChanged: (v) => setState(() => _query = v.trim()), + onChanged: (v) => setState(() => _c.query = v.trim()), onPick: (name) => _pickFromSearch(schema, name), ); } + late final _cardCache = ErdCardCache( + isMounted: () => mounted, + rebuild: () => setState(() {}), + ); + + List _cards(ErdSchema schema, ErdLayout layout) { + final menuKey = Object.hash( + Object.hashAll(_c.marked), + Object.hashAll([ + for (final g in _c.groups) + Object.hash(g.id, g.name, Object.hashAll(g.tables)), + ]), + ); + // The palette is built anew on each read; its slot colours are what the + // cards and the menu use. + final p = context.semanticPalette; + final palette = (p.type1, p.type2, p.type3, p.type4, p.type5); + return _cardCache.cards( + tables: schema.tables, + inputsFor: (t) => ErdCardInputs( + columns: t.columns, + comment: t.comment, + position: layout.positions[t.name]!, + width: layout.widthFor(t.name), + headerColor: _headerColor(t.name), + collapsed: _c.collapsed.contains(t.name), + marked: _c.marked.contains(t.name), + dragging: _c.dragging == t.name, + junctionOf: _c.junctions[t.name], + menuKey: menuKey, + palette: palette, + onOpenTable: widget.onOpenTable, + onOpenInSql: widget.onOpenInSql, + onShowRelations: widget.onShowRelations, + ), + build: (t) => _card(t, layout), + ); + } + + material.Widget _card(ErdTable t, ErdLayout layout) { + return material.Positioned( + left: layout.positions[t.name]!.dx, + top: layout.positions[t.name]!.dy, + // Each card listens to its own focus, so a hover + // rebuilds only the cards it changes. + child: material.ValueListenableBuilder( + valueListenable: _cardState(t.name), + builder: (context, focus, _) => material.Opacity( + // Unrelated cards fade while a table is picked. + opacity: focus.faded ? 0.35 : 1, + child: QueryaContextMenu( + items: ErdTableMenu.items( + table: t.name, + onOpenData: widget.onOpenTable, + onOpenInSql: widget.onOpenInSql, + onShowRelations: widget.onShowRelations, + collapsed: _c.collapsed.contains(t.name), + onToggleCollapsed: () => + _toggleCollapsed(t.name), + onHide: () => _hide(t.name), + groupItems: erdGroupMenu( + table: t.name, + marked: _c.marked, + groups: _c.groups, + current: _c.groupOf(t.name), + mutedColor: context.workbench.mutedForeground, + onNewGroup: (tables) => unawaited(_newGroup(tables)), + onAddToGroup: _addToGroup, + onRemoveFromGroup: _removeFromGroup, + ), + slotColor: _slotColor, + onHeaderColor: (slot) => + _setHeaderColor(t.name, slot), + ), + child: ErdTableCard( + table: t, + width: layout.widthFor(t.name), + headerColor: _headerColor(t.name), + highlighted: focus.highlighted, + focusColumn: focus.column, + junctionOf: _c.junctions[t.name], + dragging: _c.dragging == t.name, + onOpen: widget.onOpenTable == null + ? null + : () => widget.onOpenTable!(t.name), + marked: _c.marked.contains(t.name), + onSelect: () => _tapCard(t.name), + onHover: (inside) => _hovered.value = inside + ? t.name + : (_hovered.value == t.name + ? null + : _hovered.value), + onDragStart: () => _dragStart(t.name), + onDragMove: (d) => _dragMove(t.name, d), + onDragEnd: _dragEnd, + ), + ), + ), + ), + ); + } + @override material.Widget build(material.BuildContext context) { - final wb = context.workbench; final full = _schema; final schema = full == null ? null : _visibleOf(full); final layout = _layout; @@ -1413,7 +1211,7 @@ class _ErdViewState extends material.State // Every table was hidden from the diagram: they exist, so say so and // offer them back instead of "No tables found". body = ErdStatusBody.allHidden(context, - hiddenCount: _hidden.length, onShowAll: _showAllTables); + hiddenCount: _c.hidden.length, onShowAll: _showAllTables); } else if (schema == null || layout == null || schema.isEmpty) { body = ErdStatusBody.noTables(context, databaseName: widget.databaseName); @@ -1424,279 +1222,61 @@ class _ErdViewState extends material.State focusTable: widget.focusTable, onOpenFullDiagram: widget.onOpenFullDiagram); } else { - body = material.CallbackShortcuts( + body = ErdCanvas( + viewport: _viewport, + boundaryKey: _boundaryKey, + canvasFocus: _canvasFocus, bindings: _bindings(), - child: material.Focus( - focusNode: _canvasFocus, - autofocus: true, - child: material.Stack( - key: _viewportKey, - children: [ - material.InteractiveViewer( - constrained: false, - transformationController: _transform, - // A card or group drag must not pan the canvas. - panEnabled: _dragging == null && _draggingGroup == null, - minScale: _zoomFloor, - maxScale: _maxScale, - boundaryMargin: const material.EdgeInsets.all(400), - child: material.RepaintBoundary( - key: _boundaryKey, - child: material.Container( - color: wb.surface, - width: _canvasSize(layout).width, - height: _canvasSize(layout).height, - child: material.GestureDetector( - // A tap on an edge picks its relation, elsewhere it clears - // the selection (#1281). - behavior: material.HitTestBehavior.translucent, - onTapUp: (d) => _tapCanvas(d.localPosition), - child: material.Stack( - children: [ - // Lowest layer: sees the pointer wherever no card is, - // so edges can name themselves on hover. - material.Positioned.fill( - child: material.MouseRegion( - opaque: false, - onHover: (e) => _hoverCanvas(e.localPosition), - onExit: (_) => _leaveCanvas(), - child: const material.SizedBox.expand(), - ), - ), - // Group frames under the edges and cards (#1282). - for (final g in _allGroups()) - ..._groupFrame(g, layout), - material.Positioned.fill( - child: material.ValueListenableBuilder( - valueListenable: _focus, - builder: (context, focus, _) => - material.CustomPaint( - painter: ErdRelationPainter( - routes: _routes, - color: wb.mutedForeground, - highlight: wb.accent, - focus: focus, - picked: _pickedRelation, - ), - ), - ), - ), - for (final t in schema.tables) - material.Positioned( - left: layout.positions[t.name]!.dx, - top: layout.positions[t.name]!.dy, - // Each card listens to its own focus, so a hover - // rebuilds only the cards it changes. - child: material.ValueListenableBuilder( - valueListenable: _cardState(t.name), - builder: (context, focus, _) => material.Opacity( - // Unrelated cards fade while a table is picked. - opacity: focus.faded ? 0.35 : 1, - child: ContextMenu( - items: ErdTableMenu.items( - table: t.name, - onOpenData: widget.onOpenTable, - onOpenInSql: widget.onOpenInSql, - onShowRelations: widget.onShowRelations, - collapsed: _collapsed.contains(t.name), - onToggleCollapsed: () => - _toggleCollapsed(t.name), - onHide: () => _hide(t.name), - groupItems: _groupMenu(t.name), - slotColor: _slotColor, - onHeaderColor: (slot) => - _setHeaderColor(t.name, slot), - ), - child: ErdTableCard( - table: t, - width: layout.widthFor(t.name), - headerColor: _headerColor(t.name), - highlighted: focus.highlighted, - focusColumn: focus.column, - junctionOf: _junctions[t.name], - dragging: _dragging == t.name, - onOpen: widget.onOpenTable == null - ? null - : () => widget.onOpenTable!(t.name), - marked: _marked.contains(t.name), - onSelect: () => _tapCard(t.name), - onHover: (inside) => _hovered.value = inside - ? t.name - : (_hovered.value == t.name - ? null - : _hovered.value), - onDragStart: () => _dragStart(t.name), - onDragMove: (d) => _dragMove(t.name, d), - onDragEnd: _dragEnd, - ), - ), - ), - ), - ), - // Sticky notes above the cards (#1283). - for (final n in _notes) _noteWidget(n), - ], - ), - ), - ), - ), - ), - // The edge label sits above the zoomed canvas, in screen space, - // so it stays readable at any zoom and is never in a PNG. - material.Positioned.fill( - child: material.IgnorePointer( - child: material.ListenableBuilder( - listenable: material.Listenable.merge( - [_edgeTipNotifier, _transform]), - builder: (context, _) { - final tip = _edgeTipNotifier.value; - if (tip == null) return const material.SizedBox.shrink(); - final at = material.MatrixUtils.transformPoint( - _transform.value, tip.$2); - return material.Stack( - children: [ - material.Positioned( - key: const material.ValueKey('erd_edge_tip'), - left: at.dx + 12, - top: at.dy + 12, - child: _edgeTipLabel(context, tip.$1), - ), - ], - ); - }, - ), - ), - ), - material.Positioned( - left: 8, - top: 8, - width: 260, - child: _searchPanel(schema), - ), - material.Positioned( - right: 8, - bottom: 8, - child: material.ValueListenableBuilder( - valueListenable: _transform, - builder: (context, m, _) => ErdZoomControls( - scale: m.getMaxScaleOnAxis(), - onZoomIn: () => _zoomBy(_zoomStep), - onZoomOut: () => _zoomBy(1 / _zoomStep), - onFit: _fit, - ), - ), - ), - ], - ), - ), + canvasSize: _c.canvasSize(layout), + panEnabled: _c.dragging == null && _c.draggingGroup == null, + routes: _routes, + focus: _focus, + pickedRelation: _c.pickedRelation, + edgeTip: _edgeTipNotifier, + edgeTipLabel: (relation) => ErdEdgeTipLabel(relation: relation), + onTapUp: _tapCanvas, + onHover: _hoverCanvas, + onLeave: _leaveCanvas, + groupFrames: [ + for (final g in _c.allGroups(_schema)) ..._groupFrame(g, layout), + ], + cards: _cards(schema, layout), + notes: [for (final n in _c.notes) _noteWidget(n)], + searchPanel: _searchPanel(schema), + onZoomIn: () => _zoomBy(ErdViewport.zoomStep), + onZoomOut: () => _zoomBy(1 / ErdViewport.zoomStep), + onFit: _fit, ); } final ready = schema != null && layout != null && !schema.isEmpty; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - material.Container( - decoration: SqlEditorChrome.sqlToolbarDecoration(context), - padding: const material.EdgeInsets.all(8), - child: material.LayoutBuilder( - builder: (context, constraints) { - // Below ~1100 px the labels of Note, Auto layout and the hidden - // tables button give way to icons (the tooltips keep the names) - // so the toolbar stays on one row. - final compact = constraints.maxWidth < 1100; - return QueryaControlScope( - size: QueryaControlSize.md, - child: material.Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: material.WrapCrossAlignment.center, - children: [ - QueryaButton.secondary( - key: const material.ValueKey('erd_refresh'), - label: 'Refresh', - icon: material.Icons.refresh_rounded, - onPressed: _loading ? null : _load, - ), - // Detail level for every card; a card collapsed by hand stays - // collapsed (#1278). - material.IgnorePointer( - ignoring: !ready, - child: material.Opacity( - opacity: ready ? 1 : 0.5, - child: material.Tooltip( - message: 'Table names only, key columns, or every column', - child: QueryaTabStrip( - key: const material.ValueKey('erd_detail'), - labels: const ['Names', 'Keys', 'All'], - selectedIndex: ErdDetail.values.indexOf(_detail), - onSelected: (i) { - if (ErdDetail.values[i] == _detail) return; - setState(() => _detail = ErdDetail.values[i]); - _reflow(); - }, - ), - ), - ), - ), - // Also with every table hidden: a view may be that empty, and - // the way out is another view. - if (_persists && + ErdToolbar( + ready: ready, + loading: _loading, + detail: _c.detail, + markedCount: _c.marked.length, + hiddenCount: _c.hidden.length, + viewsMenu: _persists && !_loading && _error == null && - (_schema?.tables.isNotEmpty ?? false)) - _viewsMenu(), - QueryaButton.secondary( - key: const material.ValueKey('erd_add_note'), - compact: compact, - label: 'Note', - icon: material.Icons.sticky_note_2_outlined, - tooltip: 'Add a sticky note to the diagram', - onPressed: ready ? () => unawaited(_addNote()) : null, - ), - if (_marked.isNotEmpty) - QueryaButton.secondary( - key: const material.ValueKey('erd_group_marked'), - compact: compact, - label: 'Group ${_marked.length} ' - '${_marked.length == 1 ? 'table' : 'tables'}', - icon: material.Icons.folder_open_rounded, - tooltip: 'Shift / Ctrl + click marks more tables', - onPressed: () => unawaited(_newGroup({..._marked})), - ), - if (_hidden.isNotEmpty) - QueryaButton.secondary( - key: const material.ValueKey('erd_show_all'), - compact: compact, - label: 'Show ${_hidden.length} hidden', - icon: material.Icons.visibility_outlined, - onPressed: _showAllTables, - ), - QueryaButton.secondary( - key: const material.ValueKey('erd_auto_layout'), - compact: compact, - label: 'Auto layout', - icon: material.Icons.auto_fix_high_rounded, - tooltip: 'Arrange the tables again (undoes manual moves)', - onPressed: ready ? _autoLayout : null, - ), - material.IgnorePointer( - ignoring: !ready, - child: material.Opacity( - opacity: ready ? 1 : 0.5, - child: ErdExportMenu( - onSelected: (action) { - if (schema != null && layout != null) { - _export(action, schema, layout); - } - }, - ), - ), - ), - ], - ), - ); - }, - ), + (_schema?.tables.isNotEmpty ?? false) + ? _viewsMenu() + : null, + onRefresh: _load, + onDetail: (d) { + if (d == _c.detail) return; + setState(() => _c.detail = d); + _reflow(); + }, + onAddNote: () => unawaited(_addNote()), + onGroupMarked: () => unawaited(_newGroup({..._c.marked})), + onShowHidden: _showAllTables, + onAutoLayout: _autoLayout, + onExport: (action) { + if (schema != null && layout != null) _export(action, schema, layout); + }, ), material.Expanded(child: material.ClipRect(child: body)), ], diff --git a/lib/features/erd/erd_view_controller.dart b/lib/features/erd/erd_view_controller.dart new file mode 100644 index 00000000..2410fcc9 --- /dev/null +++ b/lib/features/erd/erd_view_controller.dart @@ -0,0 +1,413 @@ +import 'dart:math' show max; +import 'dart:ui' show Offset, Size; + +import 'package:querya_desktop/core/erd/erd_group_ops.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_layout_engine.dart'; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_note_ops.dart'; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; + +/// What the user has done to a diagram, apart from where the cards are: +/// the picked table and relation, marks, density, groups, notes and saved +/// views. No widgets and no layout geometry, so each rule is a plain method +/// that a unit test can call (#1690). +/// +/// The view owns the layout, the routes and the viewport, and asks this for +/// the rest. Mutators change the state; the view decides when to rebuild and +/// to save. +class ErdViewController { + /// The table being dragged. + String? dragging; + + /// The table picked by a click or search: its edges stay highlighted. + String? selected; + + /// A relation picked by a click on its edge (#1281): its two tables and + /// columns are highlighted and its label stays until Esc. + ErdRelation? pickedRelation; + + /// Tables marked with Shift / Ctrl / Cmd + click, to be grouped. + final Set marked = {}; + + /// Density: only PK and FK columns, cards collapsed to their header, and + /// tables hidden from the diagram. + ErdDetail detail = ErdDetail.all; + final Set collapsed = {}; + final Set hidden = {}; + + /// Header colour picked per table (palette slot names, #1276). + final Map headerColors = {}; + + /// The user's table groups, saved with the layout (#1282). + final List groups = []; + + /// The group whose frame is being dragged by its title. + String? draggingGroup; + + /// Sticky notes on the canvas, saved with the layout (#1283). + final List notes = []; + String? draggingNote; + + /// Saved views of the diagram (#1284), the one the user is in, and the + /// *All tables* state while a view is active. + final List views = []; + String? activeView; + ErdSavedLayout base = const ErdSavedLayout(); + + /// Neighbours per table, for the focus of a hover or a pick. + Map> neighbours = const {}; + + /// Many-to-many link tables and what they link: `users and roles` (#1281). + Map junctions = const {}; + + bool searchOpen = false; + String query = ''; + + // ---- Groups -------------------------------------------------------------- + + /// The user's groups, then one per schema for the tables in none when the + /// diagram spans several schemas. + List allGroups(ErdSchema? schema) { + if (schema == null) return List.of(groups); + return [ + ...groups, + ...erdSchemaGroups([for (final t in schema.tables) t.name], groups), + ]; + } + + ErdGroup? groupOf(String table) => ErdGroupOps.groupOf(groups, table); + + void _replaceGroups(List next) { + final copy = List.of(next); + groups + ..clear() + ..addAll(copy); + } + + /// Groups [tables] under [name], taking them out of other groups, and + /// clears the marks. Returns the id of the new group. + String createGroup({ + required Set tables, + required Iterable tableNames, + required String name, + String? note, + }) { + _replaceGroups(ErdGroupOps.withNewGroup( + groups, + tables: tables, + tableNames: tableNames, + name: name, + note: note, + )); + marked.clear(); + return groups.last.id; + } + + /// Adds [tables] to the group [id]; the marks go when the group exists. + void addToGroup(String id, Set tables) { + _replaceGroups(ErdGroupOps.addTables(groups, id, tables)); + if (groups.any((g) => g.id == id)) marked.clear(); + } + + /// Changes the group [id]; false when there is none. + bool updateGroup(String id, ErdGroup Function(ErdGroup) change) { + final i = groups.indexWhere((g) => g.id == id); + if (i < 0) return false; + groups[i] = change(groups[i]); + return true; + } + + void ungroup(String id) => groups.removeWhere((g) => g.id == id); + + void removeFromGroup(String table) => + _replaceGroups(ErdGroupOps.ungroupTables(groups, {table})); + + // ---- Marks, density, colours -------------------------------------------- + + void toggleMarked(String table) { + if (!marked.remove(table)) marked.add(table); + } + + void toggleCollapsed(String table) { + if (!collapsed.remove(table)) collapsed.add(table); + } + + /// Hides [table] from the diagram. + void hide(String table) { + hidden.add(table); + marked.remove(table); + // A pick on a hidden table would leave every card faded. + if (selected == table) selected = null; + } + + /// [slot] null puts the table back to the default header colour. + void setHeaderColor(String table, String? slot) { + if (slot == null) { + headerColors.remove(table); + } else { + headerColors[table] = slot; + } + } + + /// Applies the state a [ErdSavedLayout] holds: what is collapsed and hidden, + /// the detail, groups and notes. Header colours belong to the diagram, so a + /// view switch leaves them ([colours] false). + void applyState(ErdSavedLayout s, {required bool colours}) { + collapsed + ..clear() + ..addAll(s.collapsed); + hidden + ..clear() + ..addAll(s.hidden); + detail = s.detail; + if (colours) { + headerColors + ..clear() + ..addAll(s.headerColors); + } + groups + ..clear() + ..addAll(s.groups); + notes + ..clear() + ..addAll(s.notes); + } + + // ---- Notes --------------------------------------------------------------- + + void addNote(ErdNote note) => notes.add(note); + + bool updateNote(String id, ErdNote Function(ErdNote) change) { + final i = notes.indexWhere((n) => n.id == id); + if (i < 0) return false; + notes[i] = change(notes[i]); + return true; + } + + void deleteNote(String id) => notes.removeWhere((n) => n.id == id); + + void moveNote(String id, Offset screenDelta, double scale) { + final i = notes.indexWhere((n) => n.id == id); + if (i < 0) return; + notes[i] = ErdNoteOps.moved(notes[i], screenDelta, scale); + } + + void resizeNote(String id, Offset screenDelta, double scale) { + final i = notes.indexWhere((n) => n.id == id); + if (i < 0) return; + notes[i] = ErdNoteOps.resized(notes[i], screenDelta, scale); + } + + /// Moves the notes attached to a table by what the table moved, from + /// [before] to [after]. + void followTables(ErdLayout before, ErdLayout? after) { + if (after == null || notes.every((n) => n.attachedTo == null)) return; + for (var i = 0; i < notes.length; i++) { + final t = notes[i].attachedTo; + if (t == null) continue; + final a = before.positions[t], b = after.positions[t]; + if (a == null || b == null || a == b) continue; + final d = b - a; + notes[i] = notes[i].copyWith(x: notes[i].x + d.dx, y: notes[i].y + d.dy); + } + } + + /// Canvas size: the cards and every note. + Size canvasSize(ErdLayout layout) { + var w = layout.size.width, h = layout.size.height; + for (final n in notes) { + w = max(w, n.x + n.width + ErdLayout.margin); + h = max(h, n.y + n.height + ErdLayout.margin); + } + return Size(w, h); + } + + // ---- Saved views --------------------------------------------------------- + + ErdSavedView? viewById(String? id) { + for (final v in views) { + if (v.id == id) return v; + } + return null; + } + + /// The id for the next view, `v`. + String nextViewId() { + var n = 1; + while (views.any((v) => v.id == 'v$n')) { + n++; + } + return 'v$n'; + } + + /// Renames the view [id]; false when there is none. + bool renameView(String id, String name) { + final i = views.indexWhere((v) => v.id == id); + if (i < 0) return false; + views[i] = views[i].copyWith(name: name); + return true; + } + + void removeView(String id) => views.removeWhere((v) => v.id == id); + + /// What the diagram shows and how it is arranged: this state with the card + /// [positions] and the viewport. + ErdSavedLayout snapshot({ + required Map positions, + required double scale, + required Offset translation, + }) => + ErdSavedLayout( + positions: Map.of(positions), + collapsed: Set.of(collapsed), + hidden: Set.of(hidden), + detail: detail, + headerColors: Map.of(headerColors), + groups: List.of(groups), + notes: List.of(notes), + scale: scale, + translation: translation, + ); + + /// Writes [current] to where it belongs: the active view, or the diagram's + /// own (*All tables*) state. [tableNames] is every table of the diagram, to + /// keep the view's tables in step with what is hidden. + void capture(ErdSavedLayout current, Iterable tableNames) { + final i = views.indexWhere((v) => v.id == activeView); + if (i < 0) { + base = current; + return; + } + views[i] = ErdSavedView( + id: views[i].id, + name: views[i].name, + tables: { + for (final t in tableNames) + if (!hidden.contains(t)) t, + }, + layout: current, + ); + } + + /// The document to store: the diagram's own state with its views and the + /// header colours as they are now. + ErdSavedLayout toStore() => base.withViews(List.of(views), activeView, + headerColors: Map.of(headerColors)); + + /// The state to show for the view [id] (null: *All tables*), or null when + /// there is no such view. Call [capture] first. + ErdSavedLayout? stateOfView(String? id, Set tableNames) { + if (id == null) return base; + final view = viewById(id); + return view?.asLayout(tableNames, headerColors); + } + + /// Saves what the diagram shows as a new view named [name] and moves into + /// it; returns its id. Call [capture] first. + String addViewFromCurrent({ + required String name, + required ErdSavedLayout current, + required Iterable tableNames, + }) { + final fromAll = viewById(activeView) == null; + // The tables hidden by hand now belong to the view; All tables shows + // every table, always. + if (fromAll) base = base.showingAll(); + final id = nextViewId(); + views.add(ErdSavedView( + id: id, + name: name, + tables: { + for (final t in tableNames) + if (!hidden.contains(t)) t, + }, + layout: current, + )); + activeView = id; + return id; + } + + // ---- Pick and focus ------------------------------------------------------ + + /// A click on a card with no modifier: picks it and drops a picked relation. + void pick(String name) { + selected = name; + pickedRelation = null; + } + + /// A click on an edge: picks the relation, drops the table pick. + void pickRelation(ErdRelation relation) { + pickedRelation = relation; + selected = null; + } + + /// Drops the picks and the marks. False when there was nothing to drop. + bool clearPicks() { + if (selected == null && pickedRelation == null && marked.isEmpty) { + return false; + } + selected = null; + pickedRelation = null; + marked.clear(); + return true; + } + + /// What Esc does, one step per press: close the search, else drop the + /// picked relation, else the marks, else the pick. The state is changed; the + /// result says which step it was. + ErdEscape escape() { + if (searchOpen) { + searchOpen = false; + query = ''; + return ErdEscape.closedSearch; + } + if (pickedRelation != null) { + pickedRelation = null; + return ErdEscape.droppedRelation; + } + if (marked.isNotEmpty) { + marked.clear(); + return ErdEscape.clearedMarks; + } + if (selected != null) { + selected = null; + return ErdEscape.clearedPick; + } + return ErdEscape.nothing; + } + + /// The table the diagram focuses: the dragged one, then the picked one, then + /// [hovered]. + String? currentFocus(String? hovered) => dragging ?? selected ?? hovered; + + /// How the card [table] looks for the focused table [focus]: highlighted + /// when it is the focus or next to it, faded when something is picked and + /// it is not, and the column of a picked relation. + ({bool highlighted, bool faded, String? column}) cardFocusFor( + String table, String? focus) { + final p = pickedRelation; + if (p != null) { + final on = table == p.fromTable || table == p.toTable; + final column = table == p.fromTable + ? p.fromColumn + : (table == p.toTable ? p.toColumn : null); + return (highlighted: on, faded: !on, column: column); + } + final highlighted = ErdLayoutEngine.isFocusedIn(neighbours, focus, table); + return ( + highlighted: highlighted, + faded: selected != null && !highlighted, + column: null, + ); + } +} + +/// Which step an Esc press took. +enum ErdEscape { + closedSearch, + droppedRelation, + clearedMarks, + clearedPick, + nothing, +} diff --git a/lib/features/erd/erd_view_name_dialog.dart b/lib/features/erd/erd_view_name_dialog.dart index 8856f02a..c55a6cb7 100644 --- a/lib/features/erd/erd_view_name_dialog.dart +++ b/lib/features/erd/erd_view_name_dialog.dart @@ -77,11 +77,11 @@ class _ErdViewNameDialogState extends material.State<_ErdViewNameDialog> { .muted() .small(), const material.SizedBox(height: 16), - TextField( + QueryaTextField( key: const material.ValueKey('erd_view_name'), controller: _name, autofocus: true, - placeholder: const Text('View name'), + placeholder: 'View name', onSubmitted: (_) => _submit(), ), ], diff --git a/lib/features/erd/erd_viewport.dart b/lib/features/erd/erd_viewport.dart new file mode 100644 index 00000000..00f3eeea --- /dev/null +++ b/lib/features/erd/erd_viewport.dart @@ -0,0 +1,109 @@ +import 'dart:math' show min; + +import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; + +/// Where the canvas is: zoom and pan of the diagram in its viewport (#1690). +/// +/// Wraps the [TransformationController] the `InteractiveViewer` drives and +/// the maths on it (zoom about a point, fit, centre on a point), so the view +/// asks for what it wants and not for matrices. +class ErdViewport { + ErdViewport(); + + static const double minScale = 0.2; + static const double maxScale = 3; + static const double zoomStep = 1.25; + + final transform = TransformationController(); + + /// Marks the viewport box, to read its size once it has been laid out. + final key = GlobalKey(); + + /// Lowest zoom: [minScale], or lower when the whole diagram needs it to fit + /// the viewport (a schema of a few hundred tables). + double zoomFloor = minScale; + + double get scale => transform.value.getMaxScaleOnAxis(); + + /// Where the canvas origin sits in the viewport. + Offset get translation { + final t = transform.value.getTranslation(); + return Offset(t.x, t.y); + } + + /// Size of the viewport, once it has been laid out. + Size? get size { + final box = key.currentContext?.findRenderObject() as RenderBox?; + return box != null && box.hasSize ? box.size : null; + } + + void dispose() => transform.dispose(); + + /// Zoom [scale], with the canvas origin at [translation]. + void set({required double scale, required Offset translation}) { + transform.value = Matrix4.identity() + ..translate(translation.dx, translation.dy) + ..scale(scale); + } + + /// Sets the zoom to [target] (within the floor and [maxScale]), keeping the + /// scene point under [focal] still. + void zoomTo(double target, Offset focal) { + final s = target.clamp(zoomFloor, maxScale).toDouble(); + final next = focal - (focal - translation) * (s / scale); + set(scale: s, translation: next); + } + + /// Zooms by [factor] about the middle of the viewport. + void zoomBy(double factor) { + final vs = size; + if (vs == null) return; + zoomTo(scale * factor, vs.center(Offset.zero)); + } + + /// Fits [content] into the viewport, centred, never enlarged. Returns + /// whether [zoomFloor] changed (the viewer's lower limit moves with it), or + /// null when the viewport is not laid out yet. + bool? fit(Size content) { + final vs = size; + if (vs == null) return null; + final s = ErdLayout.fitScale(content, vs); + final floor = min(minScale, s); + final changed = floor != zoomFloor; + zoomFloor = floor; + set( + scale: s, + translation: Offset( + (vs.width - content.width * s) / 2, + (vs.height - content.height * s) / 2, + ), + ); + return changed; + } + + /// Centres the canvas point [point] in the viewport, keeping the zoom. + void centreOn(Offset point) { + final vs = size; + if (vs == null) return; + final s = scale; + set(scale: s, translation: vs.center(Offset.zero) - point * s); + } + + /// Canvas point at the middle of what the viewport shows, or [fallback] + /// before the viewport is laid out. + Offset centre({Offset fallback = const Offset(60, 60)}) { + final vs = size; + if (vs == null) return fallback; + return MatrixUtils.transformPoint( + Matrix4.inverted(transform.value), vs.center(Offset.zero)); + } + + /// A screen position as a canvas position. + Offset toCanvas(Offset screen) => + MatrixUtils.transformPoint(Matrix4.inverted(transform.value), screen); + + /// A canvas position as a screen position. + Offset toScreen(Offset canvas) => + MatrixUtils.transformPoint(transform.value, canvas); +} diff --git a/lib/features/erd/widgets/erd_canvas.dart b/lib/features/erd/widgets/erd_canvas.dart new file mode 100644 index 00000000..0f05f2e8 --- /dev/null +++ b/lib/features/erd/widgets/erd_canvas.dart @@ -0,0 +1,188 @@ +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_router.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/features/erd/erd_canvas_controls.dart'; +import 'package:querya_desktop/features/erd/erd_viewport.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_relation_painter.dart'; + +/// The diagram's canvas: a zoomable, pannable sheet with the group frames, +/// the edges, the cards and the notes on it, and over it, in screen space, the +/// edge label, the search panel and the zoom controls (#1690). +/// +/// The layers are built by the view and handed in; this puts them in order and +/// wires the viewport, the pointer and the keyboard to them. +class ErdCanvas extends material.StatelessWidget { + const ErdCanvas({ + super.key, + required this.viewport, + required this.boundaryKey, + required this.canvasFocus, + required this.bindings, + required this.canvasSize, + required this.panEnabled, + required this.routes, + required this.focus, + required this.pickedRelation, + required this.edgeTip, + required this.edgeTipLabel, + required this.onTapUp, + required this.onHover, + required this.onLeave, + required this.groupFrames, + required this.cards, + required this.notes, + required this.searchPanel, + required this.onZoomIn, + required this.onZoomOut, + required this.onFit, + }); + + final ErdViewport viewport; + + /// The `RepaintBoundary` the PNG export captures. + final material.GlobalKey boundaryKey; + final material.FocusNode canvasFocus; + final Map bindings; + final material.Size canvasSize; + + /// False while a card or a group is dragged: that drag must not pan. + final bool panEnabled; + final List routes; + + /// The table whose edges are highlighted. + final ValueListenable focus; + final ErdRelation? pickedRelation; + + /// The relation under the pointer and the pointer, in canvas space. + final ValueListenable<(ErdRelation, material.Offset)?> edgeTip; + final material.Widget Function(ErdRelation relation) edgeTipLabel; + final void Function(material.Offset canvasPoint) onTapUp; + final void Function(material.Offset canvasPoint) onHover; + final material.VoidCallback onLeave; + final List groupFrames; + final List cards; + final List notes; + final material.Widget searchPanel; + final material.VoidCallback onZoomIn; + final material.VoidCallback onZoomOut; + final material.VoidCallback onFit; + + @override + material.Widget build(material.BuildContext context) { + final wb = context.workbench; + return material.CallbackShortcuts( + bindings: bindings, + child: material.Focus( + focusNode: canvasFocus, + autofocus: true, + child: material.Stack( + key: viewport.key, + children: [ + material.InteractiveViewer( + constrained: false, + transformationController: viewport.transform, + panEnabled: panEnabled, + minScale: viewport.zoomFloor, + maxScale: ErdViewport.maxScale, + boundaryMargin: const material.EdgeInsets.all(400), + child: material.RepaintBoundary( + key: boundaryKey, + child: material.Container( + color: wb.surface, + width: canvasSize.width, + height: canvasSize.height, + child: material.GestureDetector( + // A tap on an edge picks its relation, elsewhere it clears + // the selection (#1281). + behavior: material.HitTestBehavior.translucent, + onTapUp: (d) => onTapUp(d.localPosition), + child: material.Stack( + children: [ + // Lowest layer: sees the pointer wherever no card is, + // so edges can name themselves on hover. + material.Positioned.fill( + child: material.MouseRegion( + opaque: false, + onHover: (e) => onHover(e.localPosition), + onExit: (_) => onLeave(), + child: const material.SizedBox.expand(), + ), + ), + // Group frames under the edges and cards (#1282). + ...groupFrames, + material.Positioned.fill( + child: material.ValueListenableBuilder( + valueListenable: focus, + builder: (context, focus, _) => + material.CustomPaint( + painter: ErdRelationPainter( + routes: routes, + color: wb.mutedForeground, + highlight: wb.accent, + focus: focus, + picked: pickedRelation, + ), + ), + ), + ), + ...cards, + // Sticky notes above the cards (#1283). + ...notes, + ], + ), + ), + ), + ), + ), + // The edge label sits above the zoomed canvas, in screen space, so + // it stays readable at any zoom and is never in a PNG. + material.Positioned.fill( + child: material.IgnorePointer( + child: material.ListenableBuilder( + listenable: + material.Listenable.merge([edgeTip, viewport.transform]), + builder: (context, _) { + final tip = edgeTip.value; + if (tip == null) return const material.SizedBox.shrink(); + final at = viewport.toScreen(tip.$2); + return material.Stack( + children: [ + material.Positioned( + key: const material.ValueKey('erd_edge_tip'), + left: at.dx + 12, + top: at.dy + 12, + child: edgeTipLabel(tip.$1), + ), + ], + ); + }, + ), + ), + ), + material.Positioned( + left: 8, + top: 8, + width: 260, + child: searchPanel, + ), + material.Positioned( + right: 8, + bottom: 8, + child: material.ValueListenableBuilder( + valueListenable: viewport.transform, + builder: (context, m, _) => ErdZoomControls( + scale: m.getMaxScaleOnAxis(), + onZoomIn: onZoomIn, + onZoomOut: onZoomOut, + onFit: onFit, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/erd/widgets/erd_card_cache.dart b/lib/features/erd/widgets/erd_card_cache.dart new file mode 100644 index 00000000..804e6bea --- /dev/null +++ b/lib/features/erd/widgets/erd_card_cache.dart @@ -0,0 +1,66 @@ +import 'dart:math' show min; + +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_card_inputs.dart'; + +/// The table cards of an open diagram, built only when something they show +/// changed, and a chunk per frame while a big diagram opens (#1546). +/// +/// A rebuild of the diagram (a pick, a drag, a toolbar change) hands back the +/// same widget for every card whose [ErdCardInputs] are equal to the last +/// build's, so Flutter skips its subtree instead of rebuilding 200 cards. +class ErdCardCache { + ErdCardCache({required this.isMounted, required this.rebuild}); + + /// Above this many tables the first frame carries [mountChunk] of them and + /// each following frame mounts [mountChunk] more. + static const progressiveFrom = 60; + static const mountChunk = 24; + + final bool Function() isMounted; + final VoidCallback rebuild; + + var _byTable = {}; + int _mounted = 1 << 30; + bool _pending = false; + + /// A diagram of [tableCount] tables was loaded: start over. + void reset(int tableCount) { + _byTable = {}; + _mounted = tableCount > progressiveFrom ? mountChunk : 1 << 30; + } + + /// The cards of [tables] that are mounted so far, in order. + List cards({ + required List tables, + required ErdCardInputs Function(ErdTable table) inputsFor, + required Widget Function(ErdTable table) build, + }) { + final shown = min(tables.length, _mounted); + final next = {}; + final cards = []; + for (var i = 0; i < shown; i++) { + final t = tables[i]; + final inputs = inputsFor(t); + final cached = _byTable[t.name]; + final card = cached != null && cached.$1 == inputs ? cached.$2 : build(t); + next[t.name] = (inputs, card); + cards.add(card); + } + _byTable = next; + if (shown < tables.length) _mountMore(); + return cards; + } + + void _mountMore() { + if (_pending) return; + _pending = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _pending = false; + if (!isMounted()) return; + _mounted += mountChunk; + rebuild(); + }); + } +} diff --git a/lib/features/erd/widgets/erd_card_inputs.dart b/lib/features/erd/widgets/erd_card_inputs.dart new file mode 100644 index 00000000..b56d728d --- /dev/null +++ b/lib/features/erd/widgets/erd_card_inputs.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_model.dart'; + +/// What a table card is built from outside its own focus: when all of it is +/// equal to the last build's, the card is reused as it is. +@immutable +class ErdCardInputs { + const ErdCardInputs({ + required this.columns, + required this.comment, + required this.position, + required this.width, + required this.headerColor, + required this.collapsed, + required this.marked, + required this.dragging, + required this.junctionOf, + required this.menuKey, + required this.palette, + required this.onOpenTable, + required this.onOpenInSql, + required this.onShowRelations, + }); + + final List columns; + final String? comment; + final material.Offset position; + final double width; + final material.Color headerColor; + final bool collapsed; + final bool marked; + final bool dragging; + final String? junctionOf; + + /// Hash of what the card's menu lists: the marked tables and the groups. + final int menuKey; + final Object palette; + final Object? onOpenTable; + final Object? onOpenInSql; + final Object? onShowRelations; + + @override + bool operator ==(Object other) { + if (other is! ErdCardInputs) return false; + if (columns.length != other.columns.length) return false; + for (var i = 0; i < columns.length; i++) { + if (!identical(columns[i], other.columns[i])) return false; + } + return comment == other.comment && + position == other.position && + width == other.width && + headerColor == other.headerColor && + collapsed == other.collapsed && + marked == other.marked && + dragging == other.dragging && + junctionOf == other.junctionOf && + menuKey == other.menuKey && + palette == other.palette && + onOpenTable == other.onOpenTable && + onOpenInSql == other.onOpenInSql && + onShowRelations == other.onShowRelations; + } + + @override + int get hashCode => Object.hash(columns.length, position, width, menuKey); +} diff --git a/lib/features/erd/widgets/erd_edge_tip_label.dart b/lib/features/erd/widgets/erd_edge_tip_label.dart index c0eaa35b..14554bee 100644 --- a/lib/features/erd/widgets/erd_edge_tip_label.dart +++ b/lib/features/erd/widgets/erd_edge_tip_label.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/erd/erd_model.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; /// "orders.customer_id → customers.id" next to the pointer. class ErdEdgeTipLabel extends material.StatelessWidget { @@ -11,7 +11,7 @@ class ErdEdgeTipLabel extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { final r = relation; - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return material.DecoratedBox( decoration: material.BoxDecoration( color: cs.popover, diff --git a/lib/features/erd/widgets/erd_group_frame.dart b/lib/features/erd/widgets/erd_group_frame.dart index 0eccb960..3df2c10e 100644 --- a/lib/features/erd/widgets/erd_group_frame.dart +++ b/lib/features/erd/widgets/erd_group_frame.dart @@ -4,7 +4,8 @@ import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/erd/erd_layout.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/shared/widgets/querya_context_menu.dart'; /// A group's frame on the canvas: a tinted box that takes no pointer, and its /// title, which drags the whole group and has the group's menu. @@ -98,17 +99,17 @@ abstract final class ErdGroupFrame { alignment: material.Alignment.centerLeft, child: g.isSchema ? title - : ContextMenu( + : QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( key: material.ValueKey('erd_group_edit_${g.id}'), onPressed: (_) => onEdit(), child: const Text('Rename / note…'), ), - MenuButton( + QueryaMenuItem( subMenu: [ for (var i = 0; i < erdHeaderSlots.length; i++) - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.circle, size: 12, color: slotColor(erdHeaderSlots[i])), onPressed: (_) => onColor(erdHeaderSlots[i]), @@ -117,7 +118,7 @@ abstract final class ErdGroupFrame { ], child: const Text('Colour'), ), - MenuButton( + QueryaMenuItem( key: material.ValueKey('erd_group_ungroup_${g.id}'), onPressed: (_) => onUngroup(), child: const Text('Ungroup'), diff --git a/lib/features/erd/widgets/erd_group_menu.dart b/lib/features/erd/widgets/erd_group_menu.dart new file mode 100644 index 00000000..fd441987 --- /dev/null +++ b/lib/features/erd/widgets/erd_group_menu.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:querya_desktop/shared/widgets/querya_context_menu.dart'; + +/// Group entries of a card's menu: group it (with the marked tables), add it +/// to a group, take it out of its group. +List erdGroupMenu({ + required String table, + required Set marked, + required List groups, + required ErdGroup? current, + required material.Color mutedColor, + required void Function(Set tables) onNewGroup, + required void Function(String id, Set tables) onAddToGroup, + required void Function(String table) onRemoveFromGroup, +}) { + final tables = {...marked, table}; + return [ + QueryaMenuItem( + key: material.ValueKey('erd_menu_group_$table'), + onPressed: (_) => onNewGroup(tables), + // The way to put more tables in is not obvious: say it here. + trailing: tables.length == 1 + ? Text('Shift+click marks more', + style: material.TextStyle(fontSize: 11, color: mutedColor)) + : null, + child: Text(tables.length == 1 + ? 'New group…' + : 'Group ${tables.length} tables…'), + ), + if (groups.any((g) => g.id != current?.id)) + QueryaMenuItem( + subMenu: [ + for (final g in groups) + if (g.id != current?.id) + QueryaMenuItem( + onPressed: (_) => onAddToGroup(g.id, tables), + child: Text(g.name), + ), + ], + child: const Text('Add to group'), + ), + if (current != null) + QueryaMenuItem( + key: material.ValueKey('erd_menu_ungroup_$table'), + onPressed: (_) => onRemoveFromGroup(table), + child: Text('Remove from ${current.name}'), + ), + ]; +} diff --git a/lib/features/erd/widgets/erd_note_card.dart b/lib/features/erd/widgets/erd_note_card.dart index ebc9f87a..44eb9cb5 100644 --- a/lib/features/erd/widgets/erd_note_card.dart +++ b/lib/features/erd/widgets/erd_note_card.dart @@ -2,8 +2,9 @@ import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/erd/erd_note_text.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:flutter/widgets.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:querya_desktop/shared/widgets/querya_context_menu.dart'; /// A sticky note on the canvas: draggable by its body, resizable by its /// corner, with an edit / colour / attach / delete menu (#1283). @@ -59,27 +60,27 @@ class ErdNoteCard extends material.StatelessWidget { final color = n.color == null ? null : slotColor(n.color!); final lines = parseErdNote(n.text); final base = material.TextStyle( - fontSize: 12, color: Theme.of(context).colorScheme.foreground); + fontSize: 12, color: context.colors.foreground); return material.Positioned( left: n.x, top: n.y, width: n.width, height: n.height, - child: ContextMenu( + child: QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( key: material.ValueKey('erd_note_edit_${n.id}'), onPressed: (_) => onEdit(), child: const Text('Edit note…'), ), - MenuButton( + QueryaMenuItem( subMenu: [ - MenuButton( + QueryaMenuItem( onPressed: (_) => onChange((o) => o.copyWith(color: () => null)), child: const Text('None'), ), for (var i = 0; i < erdHeaderSlots.length; i++) - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.circle, size: 12, color: slotColor(erdHeaderSlots[i])), onPressed: (_) => onChange( @@ -90,19 +91,19 @@ class ErdNoteCard extends material.StatelessWidget { child: const Text('Colour'), ), if (n.attachedTo == null) - MenuButton( + QueryaMenuItem( key: material.ValueKey('erd_note_attach_${n.id}'), onPressed: (_) => onAttachNearest(), child: const Text('Attach to nearest table'), ) else - MenuButton( + QueryaMenuItem( key: material.ValueKey('erd_note_detach_${n.id}'), onPressed: (_) => onChange((o) => o.copyWith(attachedTo: () => null)), child: Text('Detach from ${n.attachedTo}'), ), - MenuButton( + QueryaMenuItem( key: material.ValueKey('erd_note_delete_${n.id}'), onPressed: (_) => onDelete(), child: const Text('Delete note'), diff --git a/lib/features/erd/widgets/erd_relation_painter.dart b/lib/features/erd/widgets/erd_relation_painter.dart index 264189ed..a32ccbea 100644 --- a/lib/features/erd/widgets/erd_relation_painter.dart +++ b/lib/features/erd/widgets/erd_relation_painter.dart @@ -33,34 +33,58 @@ class ErdRelationPainter extends material.CustomPainter { (r.relation.fromTable == focus || r.relation.toTable == focus); } - void _label(material.Canvas canvas, String text, material.Offset at, - material.Color c) { - final tp = material.TextPainter( + /// The markers are only ever `1` and `*`, in at most three colours (others, + /// others while something is focused, highlight), so each is laid out once + /// and reused by every relation and every repaint (#1518). A theme change + /// brings new colours; past a handful of entries the cache starts over. + static final _labels = <(String, int), material.TextPainter>{}; + static const _maxLabels = 16; + + static material.TextPainter _labelPainter(String text, material.Color c) { + final key = (text, c.toARGB32()); + final cached = _labels[key]; + if (cached != null) return cached; + if (_labels.length >= _maxLabels) { + for (final old in _labels.values) { + old.dispose(); + } + _labels.clear(); + } + return _labels[key] = material.TextPainter( text: material.TextSpan( text: text, style: material.TextStyle( fontSize: 10, color: c, fontWeight: material.FontWeight.w600)), textDirection: material.TextDirection.ltr, )..layout(); + } + + /// Rounded outline of a route, built once per [ErdRoute]: a repaint that + /// only changes the focus or the picked relation reuses the same routes. + static final _paths = Expando('erdRoundedPath'); + + void _label(material.Canvas canvas, String text, material.Offset at, + material.Color c) { + final tp = _labelPainter(text, c); tp.paint(canvas, at - material.Offset(tp.width / 2, tp.height / 2)); - tp.dispose(); } @override void paint(material.Canvas canvas, material.Size size) { + // One Paint for every route; only its colour and width change per pass. + final paint = material.Paint() + ..style = material.PaintingStyle.stroke + ..strokeCap = material.StrokeCap.round + ..strokeJoin = material.StrokeJoin.round; + final dimmed = color.withValues(alpha: _anyFocus ? 0.35 : 0.85); // Others first, focused on top. - for (final pass in [false, true]) { + for (final pass in const [false, true]) { + paint + ..color = pass ? highlight : dimmed + ..strokeWidth = pass ? 2 : 1.4; for (final r in routes) { if (_focused(r) != pass || r.points.length < 2) continue; - final paint = material.Paint() - ..color = pass - ? highlight - : color.withValues(alpha: _anyFocus ? 0.35 : 0.85) - ..style = material.PaintingStyle.stroke - ..strokeWidth = pass ? 2 : 1.4 - ..strokeCap = material.StrokeCap.round - ..strokeJoin = material.StrokeJoin.round; - canvas.drawPath(roundedPath(r.points), paint); + canvas.drawPath(_paths[r] ??= roundedPath(r.points), paint); // FK end: a crow's foot ("many"), or a bar when the FK column is // unique on its own ("one", #1281). if (r.relation.oneToOne) { diff --git a/lib/features/erd/widgets/erd_search_panel.dart b/lib/features/erd/widgets/erd_search_panel.dart index ec8eb8dc..f708b87c 100644 --- a/lib/features/erd/widgets/erd_search_panel.dart +++ b/lib/features/erd/widgets/erd_search_panel.dart @@ -1,9 +1,9 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/querya_search_field.dart'; import 'package:querya_desktop/shared/widgets/querya_text.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; /// Find-a-table field and the tables it matches. Enter picks the first. class ErdSearchPanel extends material.StatelessWidget { diff --git a/lib/features/erd/widgets/erd_table_card.dart b/lib/features/erd/widgets/erd_table_card.dart index a292d7a9..1150e735 100644 --- a/lib/features/erd/widgets/erd_table_card.dart +++ b/lib/features/erd/widgets/erd_table_card.dart @@ -1,10 +1,10 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/core/erd/erd_layout.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; /// A card's share of the focus: highlighted, faded, and the column of a /// picked relation on it, if any. diff --git a/lib/features/erd/widgets/erd_table_menu.dart b/lib/features/erd/widgets/erd_table_menu.dart index 4802b980..69c09aaa 100644 --- a/lib/features/erd/widgets/erd_table_menu.dart +++ b/lib/features/erd/widgets/erd_table_menu.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:flutter/widgets.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:querya_desktop/shared/widgets/querya_context_menu.dart'; /// The entries of a table card's context menu. abstract final class ErdTableMenu { - static List items({ + static List items({ required String table, required void Function(String table)? onOpenData, required void Function(String table)? onOpenInSql, @@ -13,53 +14,53 @@ abstract final class ErdTableMenu { required bool collapsed, required material.VoidCallback onToggleCollapsed, required material.VoidCallback onHide, - required List groupItems, + required List groupItems, required material.Color Function(String slot) slotColor, required void Function(String? slot) onHeaderColor, }) { return [ if (onOpenData case final open?) - MenuButton( + QueryaMenuItem( key: material.ValueKey('erd_menu_open_$table'), onPressed: (_) => open(table), child: const Text('Open data'), ), if (onOpenInSql case final inSql?) - MenuButton( + QueryaMenuItem( key: material.ValueKey('erd_menu_sql_$table'), onPressed: (_) => inSql(table), child: const Text('Open in SQL'), ), if (onShowRelations case final relations?) - MenuButton( + QueryaMenuItem( key: material.ValueKey('erd_menu_relations_$table'), onPressed: (_) => relations(table), child: const Text('Show relations'), ), - MenuButton( + QueryaMenuItem( onPressed: (_) { Clipboard.setData(ClipboardData(text: table)); }, child: const Text('Copy name'), ), - MenuButton( + QueryaMenuItem( onPressed: (_) => onToggleCollapsed(), child: Text(collapsed ? 'Expand' : 'Collapse'), ), - MenuButton( + QueryaMenuItem( onPressed: (_) => onHide(), child: const Text('Hide from diagram'), ), ...groupItems, - MenuButton( + QueryaMenuItem( key: material.ValueKey('erd_menu_colour_$table'), subMenu: [ - MenuButton( + QueryaMenuItem( onPressed: (_) => onHeaderColor(null), child: const Text('Default'), ), for (var i = 0; i < erdHeaderSlots.length; i++) - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.circle, size: 12, diff --git a/lib/features/erd/widgets/erd_toolbar.dart b/lib/features/erd/widgets/erd_toolbar.dart new file mode 100644 index 00000000..027da332 --- /dev/null +++ b/lib/features/erd/widgets/erd_toolbar.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_export_menu.dart'; +import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; +import 'package:querya_desktop/shared/widgets/querya_button.dart'; +import 'package:querya_desktop/shared/widgets/querya_tab_strip.dart'; + +/// The row above the diagram: refresh, detail level, views, notes, groups, +/// hidden tables, auto layout and export. +/// +/// [ready] is whether there is a diagram to act on; the buttons that need one +/// are off without it. [viewsMenu] is null where views do not apply. +class ErdToolbar extends material.StatelessWidget { + const ErdToolbar({ + super.key, + required this.ready, + required this.loading, + required this.detail, + required this.markedCount, + required this.hiddenCount, + required this.viewsMenu, + required this.onRefresh, + required this.onDetail, + required this.onAddNote, + required this.onGroupMarked, + required this.onShowHidden, + required this.onAutoLayout, + required this.onExport, + }); + + final bool ready; + final bool loading; + final ErdDetail detail; + final int markedCount; + final int hiddenCount; + final material.Widget? viewsMenu; + final material.VoidCallback onRefresh; + final void Function(ErdDetail detail) onDetail; + final material.VoidCallback onAddNote; + final material.VoidCallback onGroupMarked; + final material.VoidCallback onShowHidden; + final material.VoidCallback onAutoLayout; + final void Function(ErdExportAction action) onExport; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + decoration: SqlEditorChrome.sqlToolbarDecoration(context), + padding: const material.EdgeInsets.all(8), + child: material.LayoutBuilder( + builder: (context, constraints) { + // Below ~1100 px the labels of Note, Auto layout and the hidden + // tables button give way to icons (the tooltips keep the names) so + // the toolbar stays on one row. + final compact = constraints.maxWidth < 1100; + return QueryaControlScope( + size: QueryaControlSize.md, + child: material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + QueryaButton.secondary( + key: const material.ValueKey('erd_refresh'), + label: 'Refresh', + icon: material.Icons.refresh_rounded, + onPressed: loading ? null : onRefresh, + ), + // Detail level for every card; a card collapsed by hand stays + // collapsed (#1278). + _dimmed( + material.Tooltip( + message: 'Table names only, key columns, or every column', + child: QueryaTabStrip( + key: const material.ValueKey('erd_detail'), + labels: const ['Names', 'Keys', 'All'], + selectedIndex: ErdDetail.values.indexOf(detail), + onSelected: (i) => onDetail(ErdDetail.values[i]), + ), + ), + ), + // Also with every table hidden: a view may be that empty, and + // the way out is another view. + if (viewsMenu case final menu?) menu, + QueryaButton.secondary( + key: const material.ValueKey('erd_add_note'), + compact: compact, + label: 'Note', + icon: material.Icons.sticky_note_2_outlined, + tooltip: 'Add a sticky note to the diagram', + onPressed: ready ? onAddNote : null, + ), + if (markedCount > 0) + QueryaButton.secondary( + key: const material.ValueKey('erd_group_marked'), + compact: compact, + label: 'Group $markedCount ' + '${markedCount == 1 ? 'table' : 'tables'}', + icon: material.Icons.folder_open_rounded, + tooltip: 'Shift / Ctrl + click marks more tables', + onPressed: onGroupMarked, + ), + if (hiddenCount > 0) + QueryaButton.secondary( + key: const material.ValueKey('erd_show_all'), + compact: compact, + label: 'Show $hiddenCount hidden', + icon: material.Icons.visibility_outlined, + onPressed: onShowHidden, + ), + QueryaButton.secondary( + key: const material.ValueKey('erd_auto_layout'), + compact: compact, + label: 'Auto layout', + icon: material.Icons.auto_fix_high_rounded, + tooltip: 'Arrange the tables again (undoes manual moves)', + onPressed: ready ? onAutoLayout : null, + ), + _dimmed(ErdExportMenu(onSelected: onExport)), + ], + ), + ); + }, + ), + ); + } + + /// [child] half transparent and inert while there is no diagram. + material.Widget _dimmed(material.Widget child) => material.IgnorePointer( + ignoring: !ready, + child: material.Opacity(opacity: ready ? 1 : 0.5, child: child), + ); +} diff --git a/lib/features/extensions/extension_driver_recovery_banner.dart b/lib/features/extensions/extension_driver_recovery_banner.dart index ace33501..865939e9 100644 --- a/lib/features/extensions/extension_driver_recovery_banner.dart +++ b/lib/features/extensions/extension_driver_recovery_banner.dart @@ -19,14 +19,14 @@ class ExtensionDriverRecoveryBanner extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final isDark = theme.brightness == material.Brightness.dark; + final colors = context.colors; + final isDark = context.colors.brightness == material.Brightness.dark; final bgColor = isDark - ? theme.colorScheme.destructive.withValues(alpha: 0.12) - : theme.colorScheme.destructive.withValues(alpha: 0.08); + ? colors.destructive.withValues(alpha: 0.12) + : colors.destructive.withValues(alpha: 0.08); - final borderColor = theme.colorScheme.destructive.withValues(alpha: 0.35); + final borderColor = colors.destructive.withValues(alpha: 0.35); return material.Container( margin: const material.EdgeInsets.fromLTRB(12, 8, 12, 8), @@ -41,7 +41,7 @@ class ExtensionDriverRecoveryBanner extends material.StatelessWidget { material.Icon( material.Icons.power_off_rounded, size: 20, - color: theme.colorScheme.destructive, + color: colors.destructive, ), const Gap(12), material.Expanded( diff --git a/lib/features/extensions/extension_stats_view.dart b/lib/features/extensions/extension_stats_view.dart index e274e5fd..96d525ee 100644 --- a/lib/features/extensions/extension_stats_view.dart +++ b/lib/features/extensions/extension_stats_view.dart @@ -1,15 +1,13 @@ -import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/extensions/models/extension_server_stats.dart'; -import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; const _pollInterval = Duration(seconds: 5); @@ -31,11 +29,11 @@ class ExtensionStatsView extends material.StatefulWidget { material.State createState() => _ExtensionStatsViewState(); } -class _ExtensionStatsViewState extends material.State { +class _ExtensionStatsViewState extends material.State + with PollingState { ExtensionServerStats? _stats; bool _loading = true; String? _error; - Timer? _timer; @override void initState() { @@ -47,19 +45,13 @@ class _ExtensionStatsViewState extends material.State { void didUpdateWidget(covariant ExtensionStatsView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { - _timer?.cancel(); + stopPolling(); _load(); } } - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - Future _load() async { - _timer?.cancel(); + stopPolling(); if (!mounted) return; setState(() { _loading = true; @@ -68,13 +60,14 @@ class _ExtensionStatsViewState extends material.State { }); try { await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); } catch (e) { if (mounted) { setState(() { _error = e.toString(); _loading = false; }); + syncPolling(); } } } @@ -97,36 +90,29 @@ class _ExtensionStatsViewState extends material.State { _error = e.toString(); _loading = false; }); + syncPolling(); } } } - Future _onPollTick() async { - try { - final stats = await ExtensionDriverSession.instance - .getServerStats(widget.connectionRow); - if (!mounted) return; - if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() => _stats = stats); - } catch (_) {} - } + @override + Duration? get pollInterval => _pollInterval; + + @override + bool get pollShouldRun => !_loading && _error == null; - void _startTimer() { - _timer?.cancel(); - _timer = Timer.periodic(_pollInterval, (_) => unawaited(_onPollTick())); + @override + Future pollTick() async { + final stats = await ExtensionDriverSession.instance + .getServerStats(widget.connectionRow); + if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() => _stats = stats); } @override material.Widget build(material.BuildContext context) { - _timer = syncTickerGatedPeriodicTimer( - context: context, - timer: _timer, - shouldRun: !_loading && _error == null, - interval: _pollInterval, - onTick: () => unawaited(_onPollTick()), - ); - - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading) { return const material.Center( @@ -168,44 +154,47 @@ class _ExtensionStatsViewState extends material.State { final stats = _stats; if (stats == null) return material.Container(color: cs.background); - return material.Container( - color: cs.background, - child: material.RefreshIndicator( - onRefresh: _fetch, - child: material.LayoutBuilder( - builder: (context, constraints) { - final contentWidth = math.max(0.0, constraints.maxWidth - 48); - return material.SingleChildScrollView( - physics: const material.AlwaysScrollableScrollPhysics(), - padding: const material.EdgeInsets.all(24), - child: material.SizedBox( - width: contentWidth, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _header(context), - const Gap(24), - _summaryChips(context, stats), - const Gap(24), - if (stats.databaseSizes.isNotEmpty) ...[ - _databasesCard(context, stats), + return PollStaleOverlay( + status: polling.status, + child: material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.LayoutBuilder( + builder: (context, constraints) { + final contentWidth = math.max(0.0, constraints.maxWidth - 48); + return material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: contentWidth, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, stats), const Gap(24), + if (stats.databaseSizes.isNotEmpty) ...[ + _databasesCard(context, stats), + const Gap(24), + ], + if (stats.extraMetrics.isNotEmpty) + _extraMetricsCard(context, stats), ], - if (stats.extraMetrics.isNotEmpty) - _extraMetricsCard(context, stats), - ], + ), ), - ), - ); - }, + ); + }, + ), ), - ), + ), ); } material.Widget _header(material.BuildContext context) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.LayoutBuilder( builder: (context, constraints) { return material.SingleChildScrollView( @@ -261,7 +250,7 @@ class _ExtensionStatsViewState extends material.State { material.Widget _summaryChips( material.BuildContext context, ExtensionServerStats stats) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; final versionFull = stats.serverVersion ?? '—'; final uptimeSec = stats.uptimeSeconds; final uptimeStr = uptimeSec == null @@ -324,8 +313,8 @@ class _ExtensionStatsViewState extends material.State { } material.Widget _buildChip( - shadcn.ColorScheme cs, ({String label, String value, material.IconData icon}) chip) { - return Card( + QueryaUiColors cs, ({String label, String value, material.IconData icon}) chip) { + return QueryaCard( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: material.Row( children: [ @@ -366,13 +355,13 @@ class _ExtensionStatsViewState extends material.State { material.Widget _databasesCard( material.BuildContext context, ExtensionServerStats stats) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; final sizes = stats.databaseSizes; final totalBytes = sizes.values.fold(0, (a, b) => a + b); final sorted = sizes.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); - return Card( + return QueryaCard( padding: const material.EdgeInsets.all(20), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, @@ -467,10 +456,10 @@ class _ExtensionStatsViewState extends material.State { material.Widget _extraMetricsCard( material.BuildContext context, ExtensionServerStats stats) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; final extra = stats.extraMetrics.entries.toList(); - return Card( + return QueryaCard( padding: const material.EdgeInsets.all(20), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, diff --git a/lib/features/extensions/extension_table_toolbar.dart b/lib/features/extensions/extension_table_toolbar.dart index 2636f663..11ef28ba 100644 --- a/lib/features/extensions/extension_table_toolbar.dart +++ b/lib/features/extensions/extension_table_toolbar.dart @@ -54,7 +54,7 @@ class ExtensionTableToolbar extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final isFiltered = filterActive || filterText.trim().isNotEmpty; return material.Container( diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index 91125a24..cc6f1ee7 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -484,13 +484,10 @@ class _ExtensionTableViewState extends material.State { horizontal: 16, vertical: 8), decoration: material.BoxDecoration( color: - Theme.of(context).colorScheme.muted.withValues(alpha: 0.3), + context.colors.muted.withValues(alpha: 0.3), border: material.Border( bottom: material.BorderSide( - color: Theme.of(context) - .colorScheme - .border - .withValues(alpha: 0.3), + color: context.colors.border.withValues(alpha: 0.3), ), ), ), @@ -499,13 +496,9 @@ class _ExtensionTableViewState extends material.State { const material.Text('WHERE ').semiBold().small(), const Gap(8), material.Expanded( - child: material.TextField( + child: QueryaTextField( controller: _filterController, - decoration: const material.InputDecoration( - hintText: "e.g. id > 100 AND status = 'active'", - isDense: true, - border: material.OutlineInputBorder(), - ), + placeholder: "e.g. id > 100 AND status = 'active'", onSubmitted: (_) { final st = _genericKey.currentState; if (st != null) _applyFilter(st); diff --git a/lib/features/extensions/extension_workspace_home.dart b/lib/features/extensions/extension_workspace_home.dart index 142334a7..6a9b5cec 100644 --- a/lib/features/extensions/extension_workspace_home.dart +++ b/lib/features/extensions/extension_workspace_home.dart @@ -81,7 +81,7 @@ class _ExtensionWorkspaceHomeState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; return Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ @@ -89,7 +89,7 @@ class _ExtensionWorkspaceHomeState constraints: const material.BoxConstraints(minHeight: 44), padding: const material.EdgeInsets.symmetric(horizontal: 12), decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), + color: colors.muted.withValues(alpha: 0.6), ), child: material.Row( children: [ @@ -104,7 +104,7 @@ class _ExtensionWorkspaceHomeState material.Icon( material.Icons.lock_outline_rounded, size: 14, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ], if (widget.lastSelectedExtensionObject != null && diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 86c576e8..7680665e 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -41,6 +41,11 @@ class _ExtensionManagerContentState int _tabIndex = 0; List _installed = []; List _marketplace = []; + + /// Why the catalog could not be loaded (offline, registry down); null when it + /// loaded, even if it is empty. + String? _marketplaceError; + String _query = ''; bool _loading = true; final Map _installingProgress = {}; bool _sideloading = false; @@ -68,17 +73,27 @@ class _ExtensionManagerContentState setState(() => _loading = true); await LocalExtensionRegistry.instance.load(); final installed = LocalExtensionRegistry.instance.manifests; - final market = await MarketplaceRepository.instance.getTrending(); + var market = const []; + String? error; + try { + market = await MarketplaceRepository.instance.getTrending(); + } catch (e) { + error = e is MarketplaceException + ? e.message + : 'Could not load the Marketplace.'; + } if (mounted) { setState(() { _installed = installed; _marketplace = market; + _marketplaceError = error; _loading = false; }); } } void _onSearchChanged(String query) { + _query = query.trim(); _searchDebounce?.cancel(); final seq = ++_searchSeq; _searchDebounce = Timer( @@ -93,7 +108,10 @@ class _ExtensionManagerContentState ? await MarketplaceRepository.instance.getTrending() : await MarketplaceRepository.instance.search(query); if (!mounted || seq != _searchSeq) return; - setState(() => _marketplace = market); + setState(() { + _marketplace = market; + _marketplaceError = null; + }); } catch (e) { if (!mounted || seq != _searchSeq) return; showAppToast( @@ -193,7 +211,7 @@ class _ExtensionManagerContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; final onPopover = theme.popoverForeground; return material.DefaultTextStyle( @@ -346,7 +364,7 @@ class _ExtensionManagerContentState child: QueryaSpinner(size: QueryaSpinnerSize.lg), ); } - final theme = Theme.of(context).colorScheme; + final theme = context.colors; return material.Column( children: [ material.Padding( @@ -380,28 +398,34 @@ class _ExtensionManagerContentState ), material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 16, 24, 8), - child: TextField( - placeholder: const material.Text( - 'Search extensions by name, tag, or description...'), + child: QueryaTextField( + placeholder: 'Search extensions by name, tag, or description…', onChanged: _onSearchChanged, - features: const [ - InputFeature.leading( - Padding( - padding: material.EdgeInsets.only(right: 8), - child: material.Icon(material.Icons.search_rounded, size: 18), - ), - ), - ], + leading: const material.Icon(material.Icons.search_rounded), ), ), material.Expanded( - child: _marketplace.isEmpty - ? const material.Center( - child: material.Padding( - padding: material.EdgeInsets.all(32.0), - child: Text('No extensions found matching your search.'), - ), + child: _marketplaceError != null && _marketplace.isEmpty + ? _buildMarketplaceMessage( + 'Could not load the Marketplace', + '$_marketplaceError', + retry: true, ) + : _marketplace.isEmpty + ? (_query.isEmpty + ? _buildMarketplaceMessage( + 'No extensions in the registry yet', + 'Authors publish by adding a record to the extension ' + 'registry. You can still install an extension from a ' + 'file.', + ) + : const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32.0), + child: + Text('No extensions found matching your search.'), + ), + )) : material.ListView.separated( padding: const material.EdgeInsets.all(24), itemCount: _marketplace.length, @@ -429,13 +453,45 @@ class _ExtensionManagerContentState ); } + material.Widget _buildMarketplaceMessage( + String title, + String detail, { + bool retry = false, + }) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32.0), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(maxWidth: 420), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(title).semiBold(), + const material.SizedBox(height: 8), + Text(detail, textAlign: material.TextAlign.center) + .muted() + .small(), + if (retry) ...[ + const material.SizedBox(height: 16), + QueryaButton.secondary( + label: 'Retry', + onPressed: () => unawaited(_loadData()), + ), + ], + ], + ), + ), + ), + ); + } + material.Widget _buildUpdatesTab() { if (_loading) { return const material.Center( child: QueryaSpinner(size: QueryaSpinnerSize.lg), ); } - final theme = Theme.of(context).colorScheme; + final theme = context.colors; return material.Center( child: material.Padding( padding: const material.EdgeInsets.all(32.0), diff --git a/lib/features/extensions/presentation/widgets/extension_card.dart b/lib/features/extensions/presentation/widgets/extension_card.dart index 5c508119..bf484346 100644 --- a/lib/features/extensions/presentation/widgets/extension_card.dart +++ b/lib/features/extensions/presentation/widgets/extension_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -29,8 +30,8 @@ class ExtensionCard extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusMd; + final theme = context.colors; + const radius = 7.0; final isPreview = ExtensionSupport.isPreviewOnlyManifest(manifest); return material.Container( @@ -161,7 +162,7 @@ class ExtensionCard extends material.StatelessWidget { ); } - material.Widget _buildIcon(ColorScheme theme, double radius) { + material.Widget _buildIcon(QueryaUiColors theme, double radius) { return material.Container( width: 48, height: 48, @@ -179,7 +180,7 @@ class ExtensionCard extends material.StatelessWidget { ); } - material.Widget _buildPreviewBadge(ColorScheme theme) { + material.Widget _buildPreviewBadge(QueryaUiColors theme) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: material.BoxDecoration( @@ -198,7 +199,7 @@ class ExtensionCard extends material.StatelessWidget { ); } - material.Widget _buildTagBadge(ColorScheme theme, String tag) { + material.Widget _buildTagBadge(QueryaUiColors theme, String tag) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: material.BoxDecoration( diff --git a/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart b/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart index ce8d2552..b2112cb3 100644 --- a/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart +++ b/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart @@ -58,7 +58,7 @@ class _ExtensionSideloadDialogState @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final fileName = p.basename(widget.archivePath); return QueryaModalDialog( @@ -103,12 +103,11 @@ class _ExtensionSideloadDialogState ), ), const material.SizedBox(height: 12), - material.TextField( + const Text('SHA-256 checksum (optional)').small().semiBold(), + const Gap(6), + QueryaTextField( controller: _checksumController, - decoration: const material.InputDecoration( - labelText: 'SHA-256 checksum (optional)', - hintText: '64-character hex digest from publisher', - ), + placeholder: '64-character hex digest from publisher', autocorrect: false, ), ], diff --git a/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart b/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart index 44811a35..43a31364 100644 --- a/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart +++ b/lib/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart @@ -24,7 +24,7 @@ Future showUnsandboxedDriverConsentDialog( details.installHint!, style: material.TextStyle( fontSize: 13, - color: Theme.of(dialogContext).colorScheme.mutedForeground, + color: dialogContext.colors.mutedForeground, ), ), ], diff --git a/lib/features/help/about_dialog.dart b/lib/features/help/about_dialog.dart index 59c29445..69e98289 100644 --- a/lib/features/help/about_dialog.dart +++ b/lib/features/help/about_dialog.dart @@ -31,7 +31,7 @@ class _AboutDialogContentState extends material.State<_AboutDialogContent> { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final colors = context.colors; final wb = context.workbench; return QueryaDialogCard( @@ -40,7 +40,7 @@ class _AboutDialogContentState extends material.State<_AboutDialogContent> { maxWidth: 420, minWidth: 320, ), - borderColor: theme.muted, + borderColor: colors.muted, child: material.Column( mainAxisSize: material.MainAxisSize.min, children: [ @@ -48,10 +48,16 @@ class _AboutDialogContentState extends material.State<_AboutDialogContent> { padding: const material.EdgeInsets.fromLTRB(24, 28, 24, 8), child: material.Column( children: [ - material.Icon( - QueryaIcons.database, - size: 48, - color: wb.accent, + material.Image.asset( + 'assets/images/querya_logo.png', + width: 56, + height: 56, + filterQuality: material.FilterQuality.medium, + errorBuilder: (_, __, ___) => material.Icon( + QueryaIcons.database, + size: 48, + color: wb.accent, + ), ), const material.SizedBox(height: 16), const Text('Querya').large().semiBold(), diff --git a/lib/features/macos/querya_platform_menu_bar.dart b/lib/features/macos/querya_platform_menu_bar.dart index 591e9609..ebcf7425 100644 --- a/lib/features/macos/querya_platform_menu_bar.dart +++ b/lib/features/macos/querya_platform_menu_bar.dart @@ -232,12 +232,21 @@ class QueryaPlatformMenuBar extends StatelessWidget { ); }, ), - const PlatformMenuItem( + // Without onSelected the item is drawn disabled (#1590). + PlatformMenuItem( label: 'Copy', - shortcut: SingleActivator( + shortcut: const SingleActivator( LogicalKeyboardKey.keyC, meta: true, ), + onSelected: () { + final focusCtx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke( + focusCtx, + CopySelectionTextIntent.copy, + ); + }, ), PlatformMenuItem( label: 'Paste', diff --git a/lib/features/main_screen/main_content_split.dart b/lib/features/main_screen/main_content_split.dart new file mode 100644 index 00000000..0fa096db --- /dev/null +++ b/lib/features/main_screen/main_content_split.dart @@ -0,0 +1,596 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/querya_split_handle.dart'; +import 'package:querya_desktop/core/motion/querya_spring.dart'; +import 'package:querya_desktop/core/motion/querya_spring_controller.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart'; +import 'package:querya_desktop/core/ui/querya_shell_status.dart'; +import 'package:querya_desktop/features/main_screen/connections_panel_width_persist.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.dart'; +import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_sql_tx_guard.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_sql_tx_guard.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'main_screen_workspace_state.dart'; +import 'workspace_panel.dart'; + +/// Owns splitter width and collapsible spring motion. +class MainContentSplit extends StatefulWidget { + const MainContentSplit({ + super.key, + required this.connectionsPanelKey, + required this.workspace, + required this.onConnectionSelected, + required this.onPostgresObjectSelected, + required this.onMysqlObjectSelected, + required this.onSqliteObjectSelected, + required this.onExtensionObjectSelected, + required this.onRedisDatabaseSelected, + required this.onMongoDBDatabaseSelected, + required this.onMongoCollectionSelected, + required this.onPostgresOpenSqlWorkspace, + required this.onMysqlOpenSqlWorkspace, + required this.onSqliteOpenSqlWorkspace, + required this.onRequestNewConnection, + required this.onRequestNewConnectionFromUrl, + required this.onRequestOpenSqlite, + this.onRequestLaunchDemo, + this.onRequestOpenTour, + required this.onOpenConnection, + this.onSidebarVisibilityChanged, + this.initialMongoCollection, + }); + + final GlobalKey connectionsPanelKey; + final ValueNotifier workspace; + final ValueChanged? onSidebarVisibilityChanged; + final void Function(ConnectionRow) onConnectionSelected; + final void Function( + ConnectionRow, + String database, + String schema, + String name, + PostgresObjectKind kind, + ) onPostgresObjectSelected; + final void Function( + ConnectionRow, + String database, + String name, + MysqlObjectKind kind, + ) onMysqlObjectSelected; + final void Function( + ConnectionRow, + String name, + SqliteObjectKind kind, + ) onSqliteObjectSelected; + final void Function( + ConnectionRow, + String database, + String name, + ) onExtensionObjectSelected; + final void Function(ConnectionRow, int) onRedisDatabaseSelected; + final void Function(ConnectionRow, String) onMongoDBDatabaseSelected; + final void Function(ConnectionRow, String, String) onMongoCollectionSelected; + final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; + final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; + final void Function(ConnectionRow) onSqliteOpenSqlWorkspace; + final VoidCallback onRequestNewConnection; + final VoidCallback onRequestNewConnectionFromUrl; + final VoidCallback onRequestOpenSqlite; + final String? initialMongoCollection; + final VoidCallback? onRequestLaunchDemo; + final VoidCallback? onRequestOpenTour; + final void Function(ConnectionRow) onOpenConnection; + + @override + State createState() => MainContentSplitState(); +} + +class MainContentSplitState extends State + with SingleTickerProviderStateMixin { + static const double _minLeftWidth = 180; + static const double _maxLeftWidth = 500; + static const double _minWorkspaceWidth = 64; + static const double _resizeHandleWidth = 6; + static const double _collapseThreshold = 1.0; + + /// High-responsiveness critically damped spring for fluid sidebar toggling (~0.22s). + static const SpringDescription _sidebarSpring = SpringDescription( + mass: 1.0, + stiffness: 580.0, + damping: 48.0, + ); + + late final QueryaSpringController _widthSpring; + late final ConnectionsPanelWidthPersist _widthPersist; + VoidCallback? _persistWhenSettled; + double _lastMaxWidth = 1200; + double _lastExpandedWidth = kDefaultConnectionsPanelWidth; + bool _sidebarVisible = true; + + @override + void initState() { + super.initState(); + _widthPersist = ConnectionsPanelWidthPersist( + write: AppSettings.instance.setConnectionsPanelWidth, + ); + _widthSpring = QueryaSpringController( + vsync: this, + value: kDefaultConnectionsPanelWidth, + spring: _sidebarSpring, + cubicDuration: QueryaMotion.sidebarCubic, + cubicCurve: QueryaMotion.enter, + ); + _widthSpring.addListener(_onWidthSpringChanged); + unawaited(_restoreState()); + } + + Future _restoreLastSelectedObject() async { + final ws = widget.workspace.value; + final conn = ws.activeConnection; + final pg = ws.lastSelectedPostgresObject; + if (conn != null && conn.type == 'postgresql' && pg != null) { + if (!await confirmOpenPostgresTableIfSqlTx( + context, + connection: conn, + database: pg.database, + kind: pg.kind, + )) { + return; + } + } + if (!mounted) return; + final sq = ws.lastSelectedSqliteObject; + if (conn != null && conn.type == 'sqlite' && sq != null) { + if (!await confirmOpenSqliteTableIfSqlTx( + context, + connection: conn, + kind: sq.kind, + )) { + return; + } + } + if (!mounted) return; + final my = ws.lastSelectedMysqlObject; + if (conn != null && conn.type == 'mysql' && my != null) { + if (!await confirmOpenMysqlTableIfSqlTx( + context, + connection: conn, + database: my.database, + kind: my.kind, + )) { + return; + } + } + if (!mounted) return; + widget.workspace.value = widget.workspace.value.restoreLastSelectedObject(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _widthSpring.cubicDuration = + context.motionDuration(QueryaMotion.sidebarCubic); + _widthSpring.cubicCurve = context.motionCurve(QueryaMotion.enter); + _widthSpring.useSprings = QueryaSpring.springsEnabled(context); + } + + Future _restoreState() async { + final width = await AppSettings.instance.getConnectionsPanelWidth(); + final visible = await AppSettings.instance.getSidebarVisible(); + if (!mounted) return; + final clamped = _clampLeftWidth(width, _lastMaxWidth); + _lastExpandedWidth = clamped; + _sidebarVisible = visible; + widget.onSidebarVisibilityChanged?.call(visible); + final targetW = visible ? clamped : 0.0; + _widthSpring.jumpTo(targetW); + } + + void toggleSidebar({bool? visible}) { + final target = visible ?? !_sidebarVisible; + _sidebarVisible = target; + widget.onSidebarVisibilityChanged?.call(target); + _widthSpring.useSprings = QueryaSpring.springsEnabled(context); + final targetWidth = target ? _lastExpandedWidth : 0.0; + _widthSpring.animateTo(targetWidth); + unawaited(AppSettings.instance.setSidebarVisible(target)); + } + + void _onWidthSpringChanged() { + if (mounted) { + setState(() {}); + } + } + + void _schedulePersistAfterSettle() { + _widthPersist.markDirty(); + final pending = _persistWhenSettled; + if (pending != null) { + _widthSpring.removeListener(pending); + } + void listener() { + if (_widthSpring.isAnimating) return; + _widthSpring.removeListener(listener); + _persistWhenSettled = null; + if (_sidebarVisible) { + unawaited(_widthPersist.persist(_lastExpandedWidth)); + } + } + + _persistWhenSettled = listener; + if (_widthSpring.isAnimating) { + _widthSpring.addListener(listener); + } else { + listener(); + } + } + + @override + void dispose() { + final pending = _persistWhenSettled; + if (pending != null) { + _widthSpring.removeListener(pending); + _persistWhenSettled = null; + } + if (_sidebarVisible) { + _widthPersist.disposeFlush(_lastExpandedWidth); + } + _widthSpring.removeListener(_onWidthSpringChanged); + _widthSpring.dispose(); + super.dispose(); + } + + double _clampLeftWidth(double raw, double maxWidth) { + final maxLeft = maxWidth - _resizeHandleWidth - _minWorkspaceWidth; + if (maxLeft <= 0) return 0; + if (maxLeft < _minLeftWidth) return raw.clamp(0, maxLeft); + return raw.clamp(_minLeftWidth, math.min(_maxLeftWidth, maxLeft)); + } + + @override + material.Widget build(material.BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + _lastMaxWidth = constraints.maxWidth; + final currentW = _widthSpring.value.clamp(0.0, constraints.maxWidth); + final progress = _lastExpandedWidth > 0 + ? (currentW / _lastExpandedWidth).clamp(0.0, 1.0) + : 0.0; + final currentHandleW = + (progress * _resizeHandleWidth).clamp(0.0, _resizeHandleWidth); + final handleOpacity = (progress * 5.0).clamp(0.0, 1.0); + final contentOpacity = (progress * 2.2).clamp(0.0, 1.0); + final parallaxOffset = (progress - 1.0) * 36.0; + final isCollapsed = + currentW <= _collapseThreshold || contentOpacity <= 0.0; + + final slot = material.RepaintBoundary( + child: ConnectionsPanelSlot( + connectionsPanelKey: widget.connectionsPanelKey, + workspace: widget.workspace, + onConnectionSelected: widget.onConnectionSelected, + onRedisDatabaseSelected: + widget.onRedisDatabaseSelected, + onMongoDBDatabaseSelected: + widget.onMongoDBDatabaseSelected, + onMongoCollectionSelected: + widget.onMongoCollectionSelected, + onPostgresObjectSelected: + widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: + widget.onPostgresOpenSqlWorkspace, + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: + widget.onMysqlOpenSqlWorkspace, + onSqliteObjectSelected: + widget.onSqliteObjectSelected, + onSqliteOpenSqlWorkspace: + widget.onSqliteOpenSqlWorkspace, + onExtensionObjectSelected: + widget.onExtensionObjectSelected, + ), + ); + + final material.Widget slotContent; + if (isCollapsed) { + slotContent = Offstage( + offstage: true, + child: TickerMode( + enabled: false, + child: slot, + ), + ); + } else if (contentOpacity < 1.0) { + slotContent = Opacity( + opacity: contentOpacity, + child: slot, + ); + } else { + slotContent = slot; + } + + final material.Widget positionedSlot = + (isCollapsed || parallaxOffset.abs() <= 0.001) + ? slotContent + : Transform.translate( + offset: Offset(parallaxOffset, 0), + child: slotContent, + ); + + final handleWidget = IgnorePointer( + ignoring: currentW <= 20.0 || !_sidebarVisible, + child: QueryaSplitHandle( + key: const Key('main_content_resize_handle'), + axis: Axis.horizontal, + semanticsLabel: + 'Resize connections and workspace panes', + onDragDelta: (dx) { + final ml = constraints.maxWidth - + _resizeHandleWidth - + _minWorkspaceWidth; + if (ml <= 0) return; + final nextW = _clampLeftWidth( + _widthSpring.value + dx, + constraints.maxWidth, + ); + _lastExpandedWidth = nextW; + _widthSpring.jumpTo(nextW); + }, + onDiscreteResize: () => _widthPersist + .onDiscreteResize(() => _lastExpandedWidth), + onDragEnd: (details) { + _widthPersist.cancelDiscreteTimer(); + final velocity = details.primaryVelocity ?? + details.velocity.pixelsPerSecond.dx; + final useSprings = + QueryaSpring.springsEnabled(context); + _widthSpring.useSprings = useSprings; + if (_widthSpring.value < 100 && velocity < -50) { + _sidebarVisible = false; + widget.onSidebarVisibilityChanged?.call(false); + _widthSpring.animateTo(0.0, velocity: velocity); + unawaited( + AppSettings.instance.setSidebarVisible(false)); + } else { + _sidebarVisible = true; + widget.onSidebarVisibilityChanged?.call(true); + _lastExpandedWidth = _clampLeftWidth( + _widthSpring.value, + constraints.maxWidth, + ); + _widthSpring.animateTo( + _lastExpandedWidth, + velocity: velocity, + ); + unawaited( + AppSettings.instance.setSidebarVisible(true)); + _schedulePersistAfterSettle(); + } + }, + ), + ); + + final material.Widget handleContent; + if (handleOpacity >= 1.0) { + handleContent = handleWidget; + } else if (handleOpacity <= 0.0) { + handleContent = const material.SizedBox.shrink(); + } else { + handleContent = Opacity( + opacity: handleOpacity, + child: handleWidget, + ); + } + + final isAnimating = _widthSpring.isAnimating; + final remainingWidth = + math.max(0.0, constraints.maxWidth - currentW - currentHandleW); + final workspaceLaidOutWidth = + isAnimating ? constraints.maxWidth : remainingWidth; + + return Row( + children: [ + ClipRect( + child: SizedBox( + width: currentW, + child: OverflowBox( + minWidth: _lastExpandedWidth, + maxWidth: _lastExpandedWidth, + alignment: Alignment.topLeft, + child: positionedSlot, + ), + ), + ), + if (!isCollapsed) + SizedBox( + width: currentHandleW, + child: ClipRect( + child: OverflowBox( + minWidth: _resizeHandleWidth, + maxWidth: _resizeHandleWidth, + alignment: Alignment.center, + child: handleContent, + ), + ), + ), + Expanded( + key: const ValueKey('main_content_workspace_expanded'), + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topLeft, + minWidth: workspaceLaidOutWidth, + maxWidth: workspaceLaidOutWidth, + child: material.RepaintBoundary( + child: ValueListenableBuilder( + valueListenable: widget.workspace, + builder: (context, ws, _) { + return WorkspacePanel( + activeConnection: ws.activeConnection, + selectedRedisDb: ws.activeRedisDb, + selectedMongoDb: ws.activeMongoDB, + selectedPostgresObject: ws.selectedPostgresObject, + postgresSqlTabRequestToken: + ws.postgresSqlTabRequestToken, + postgresSqlEditorContext: + ws.postgresSqlEditorContext, + postgresSqlEditorContextToken: + ws.postgresSqlEditorContextToken, + selectedMysqlObject: ws.selectedMysqlObject, + mysqlSqlTabRequestToken: ws.mysqlSqlTabRequestToken, + selectedSqliteObject: ws.selectedSqliteObject, + sqliteSqlTabRequestToken: ws.sqliteSqlTabRequestToken, + selectedExtensionObject: ws.selectedExtensionObject, + lastSelectedPostgresObject: + ws.lastSelectedPostgresObject, + lastSelectedMysqlObject: ws.lastSelectedMysqlObject, + lastSelectedSqliteObject: ws.lastSelectedSqliteObject, + lastSelectedExtensionObject: + ws.lastSelectedExtensionObject, + lastSelectedMongoDb: ws.lastSelectedMongoDb, + lastSelectedMongoCollection: + ws.lastSelectedMongoCollection, + lastSelectedRedisDb: ws.lastSelectedRedisDb, + selectedRedisKey: ws.selectedRedisKey, + lastSelectedRedisKey: ws.lastSelectedRedisKey, + onNavigateHome: () { + widget.workspace.value = + widget.workspace.value.unselectActiveObject(); + }, + onRestoreLastSelectedObject: () { + unawaited(_restoreLastSelectedObject()); + }, + isReadOnly: ws.isReadOnly, + onRequestNewConnection: widget.onRequestNewConnection, + onRequestNewConnectionFromUrl: + widget.onRequestNewConnectionFromUrl, + onRequestOpenSqlite: widget.onRequestOpenSqlite, + onRequestLaunchDemo: widget.onRequestLaunchDemo, + onRequestOpenTour: widget.onRequestOpenTour, + onOpenConnection: widget.onOpenConnection, + initialMongoCollection: widget.initialMongoCollection, + selectedMongoCollection: ws.selectedMongoCollection, + ); + }, + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +/// Rebuilds [ConnectionsPanel] only when the selected connection id changes. +class ConnectionsPanelSlot extends StatefulWidget { + const ConnectionsPanelSlot({ + super.key, + required this.connectionsPanelKey, + required this.workspace, + required this.onConnectionSelected, + required this.onPostgresObjectSelected, + required this.onMysqlObjectSelected, + required this.onSqliteObjectSelected, + required this.onRedisDatabaseSelected, + required this.onMongoDBDatabaseSelected, + required this.onMongoCollectionSelected, + required this.onPostgresOpenSqlWorkspace, + required this.onMysqlOpenSqlWorkspace, + required this.onSqliteOpenSqlWorkspace, + required this.onExtensionObjectSelected, + }); + + final GlobalKey connectionsPanelKey; + final ValueNotifier workspace; + final void Function(ConnectionRow) onConnectionSelected; + final void Function( + ConnectionRow, + String database, + String schema, + String name, + PostgresObjectKind kind, + ) onPostgresObjectSelected; + final void Function( + ConnectionRow, + String database, + String name, + MysqlObjectKind kind, + ) onMysqlObjectSelected; + final void Function( + ConnectionRow, + String name, + SqliteObjectKind kind, + ) onSqliteObjectSelected; + final void Function(ConnectionRow, int) onRedisDatabaseSelected; + final void Function(ConnectionRow, String) onMongoDBDatabaseSelected; + final void Function(ConnectionRow, String, String) onMongoCollectionSelected; + final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; + final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; + final void Function(ConnectionRow) onSqliteOpenSqlWorkspace; + final void Function( + ConnectionRow, + String database, + String name, + ) onExtensionObjectSelected; + + @override + State createState() => ConnectionsPanelSlotState(); +} + +class ConnectionsPanelSlotState extends State { + @override + void initState() { + super.initState(); + widget.workspace.addListener(_onWorkspaceChanged); + } + + @override + void dispose() { + widget.workspace.removeListener(_onWorkspaceChanged); + super.dispose(); + } + + void _onWorkspaceChanged() { + setState(() {}); + } + + @override + material.Widget build(material.BuildContext context) { + final ws = widget.workspace.value; + return ConnectionsPanel( + key: widget.connectionsPanelKey, + selectedConnectionId: ws.activeConnection?.id, + selectedPostgresObject: ws.selectedPostgresObject, + selectedMysqlObject: ws.selectedMysqlObject, + selectedSqliteObject: ws.selectedSqliteObject, + selectedExtensionObject: ws.selectedExtensionObject, + selectedRedisDb: ws.activeRedisDb, + selectedMongoDb: ws.activeMongoDB, + selectedMongoCollection: ws.selectedMongoCollection, + onConnectionSelected: widget.onConnectionSelected, + onRedisDatabaseSelected: widget.onRedisDatabaseSelected, + onMongoDBDatabaseSelected: widget.onMongoDBDatabaseSelected, + onMongoCollectionSelected: widget.onMongoCollectionSelected, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + onSqliteObjectSelected: widget.onSqliteObjectSelected, + onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, + onExtensionObjectSelected: widget.onExtensionObjectSelected, + onConnectionRemoved: (id) { + // The open workspace belonged to the removed connection: go home. + if (widget.workspace.value.activeConnection?.id != id) return; + widget.workspace.value = MainScreenWorkspaceState.empty; + QueryaShellStatus.instance.clear(); + }, + ); + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 8a86649f..a0e65506 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -1,50 +1,29 @@ import 'dart:async'; import 'dart:io'; -import 'dart:math' as math; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/actions/querya_command_host.dart'; -import 'package:querya_desktop/core/actions/querya_schema_object.dart'; -import 'package:querya_desktop/core/actions/querya_schema_object_loader.dart'; import 'package:querya_desktop/features/command_palette/command_palette_dialog.dart'; -import 'package:querya_desktop/features/command_palette/quick_switcher_dialog.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/actions/sql_editor_global_actions.dart'; -import 'package:querya_desktop/core/demo/demo_playground_service.dart'; -import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; -import 'package:querya_desktop/core/layout/querya_split_handle.dart'; -import 'package:querya_desktop/core/motion/querya_spring.dart'; -import 'package:querya_desktop/core/motion/querya_spring_controller.dart'; import 'package:querya_desktop/core/platform/file_launch_service.dart'; -import 'package:querya_desktop/core/security/safe_mode.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart'; import 'package:querya_desktop/features/extensions/presentation/widgets/unsandboxed_driver_consent_dialog.dart'; -import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; -import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; -import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; -import 'package:querya_desktop/core/ui/querya_shell_status.dart'; -import 'package:querya_desktop/core/unsaved_work_guard.dart'; -import 'package:querya_desktop/features/main_screen/connections_panel_width_persist.dart'; -import 'package:querya_desktop/features/main_screen/querya_status_bar.dart'; -import 'package:querya_desktop/features/main_screen/safe_mode_unlock_dialog.dart'; -import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; import 'package:querya_desktop/features/macos/querya_platform_menu_bar.dart'; -import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; -import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.dart'; -import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; -import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; -import 'package:querya_desktop/features/postgresql/postgres_sql_tx_guard.dart'; -import 'package:querya_desktop/features/sqlite/sqlite_sql_tx_guard.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'main_screen_workspace_state.dart'; -import 'workspace_panel.dart'; +import 'main_content_split.dart'; +import 'main_screen_chrome.dart'; +import 'main_screen_dialogs.dart'; +import 'main_screen_navigator.dart'; +import 'main_screen_shortcuts.dart'; class MainScreen extends StatefulWidget { const MainScreen({super.key}); @@ -56,14 +35,27 @@ class MainScreen extends StatefulWidget { class _MainScreenState extends State { final GlobalKey _connectionsPanelKey = GlobalKey(); - final GlobalKey<_MainContentSplitState> _splitKey = - GlobalKey<_MainContentSplitState>(); + final GlobalKey _splitKey = + GlobalKey(); final ValueNotifier _workspace = ValueNotifier(MainScreenWorkspaceState.empty); final ValueNotifier _isSidebarVisible = ValueNotifier(true); - bool _openingConnectionDialog = false; - String? _pendingMongoCollection; + late final MainScreenDialogs _dialogs = MainScreenDialogs( + workspace: _workspace, + nav: _nav, + connectionsPanelKey: _connectionsPanelKey, + context: () => context, + mounted: () => mounted, + ); + late final MainScreenNavigator _nav = MainScreenNavigator( + workspace: _workspace, + context: () => context, + mounted: () => mounted, + onPendingMongoCollectionChanged: () { + if (mounted) setState(() {}); + }, + ); @override void initState() { @@ -74,799 +66,104 @@ class _MainScreenState extends State { return showUnsandboxedDriverConsentDialog(context, details); }; + // Files Finder opens while the window is up (#1589). + FileLaunchService.instance.pendingRevision.addListener(_onFilesQueued); + WidgetsBinding.instance.addPostFrameCallback((_) async { final completed = await AppSettings.instance.getHasCompletedWelcomeTour(); if (!completed && mounted) { final connections = await LocalDb.instance.getConnections(); if (connections.isEmpty && mounted && - !FileLaunchService.instance.hasPendingTarget) { - _onOpenWelcomeTour(); + !FileLaunchService.instance.hasPendingTargets) { + _dialogs.onOpenWelcomeTour(); } } if (mounted) { - await _handlePendingFileLaunch(); + await _dialogs.handlePendingFileLaunch(); } }); } + void _onFilesQueued() { + if (mounted) unawaited(_dialogs.handlePendingFileLaunch()); + } + @override void dispose() { + FileLaunchService.instance.pendingRevision.removeListener(_onFilesQueued); HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); UnsandboxedLaunchConsentGate.instance.handler = null; - _safeModeRelockTimer?.cancel(); + _dialogs.dispose(); _workspace.dispose(); _isSidebarVisible.dispose(); super.dispose(); } - Timer? _safeModeRelockTimer; - - /// Toggles the read-only lock. Turning it off for a Production connection - /// (Safe Mode) needs a typed confirmation and re-locks after - /// [kSafeModeUnlockDuration]. - Future _toggleReadOnly() async { - final ws = _workspace.value; - final connection = ws.activeConnection; - if (connection == null) return; - - if (ws.isReadOnly && - safeModeRequiresConfirmation(connection.environment)) { - final confirmed = await showSafeModeUnlockDialog( - context: context, - connectionName: connection.name, - ); - if (!confirmed || !mounted) return; - final current = _workspace.value; - if (current.activeConnection?.id != connection.id || !current.isReadOnly) { - return; - } - _workspace.value = current.toggleReadOnly(); - _safeModeRelockTimer?.cancel(); - _safeModeRelockTimer = Timer(kSafeModeUnlockDuration, () { - if (!mounted) return; - final latest = _workspace.value; - if (latest.activeConnection?.id == connection.id && - !latest.isReadOnly) { - _workspace.value = latest.toggleReadOnly(); - showAppToast( - context: context, - message: '${connection.name} is read-only again', - ); - } - }); - return; - } - - _safeModeRelockTimer?.cancel(); - _workspace.value = ws.toggleReadOnly(); - } - bool _handleGlobalKeyEvent(KeyEvent event) { if (event is! KeyDownEvent) return false; - final isMac = Platform.isMacOS; - final isCmdOrCtrl = isMac - ? (HardwareKeyboard.instance.isMetaPressed || - HardwareKeyboard.instance.isControlPressed) - : HardwareKeyboard.instance.isControlPressed; - final isShift = HardwareKeyboard.instance.isShiftPressed; - final isAlt = HardwareKeyboard.instance.isAltPressed; - - final physical = event.physicalKey; - final logical = event.logicalKey; - - // 1. Execute SQL, as in the editor: Ctrl/Cmd+Enter and Ctrl/Cmd+R run the - // selection or the statement at the cursor; F5 and Ctrl/Cmd+Shift+Enter - // run the whole script. - final isEnter = physical == PhysicalKeyboardKey.enter || - physical == PhysicalKeyboardKey.numpadEnter || - logical == LogicalKeyboardKey.enter || - logical == LogicalKeyboardKey.numpadEnter; - final isF5 = physical == PhysicalKeyboardKey.f5 || - logical == LogicalKeyboardKey.f5; - final isKeyR = physical == PhysicalKeyboardKey.keyR || - logical == LogicalKeyboardKey.keyR || - logical == const LogicalKeyboardKey(0x0000043a); // Russian 'к' - - final runScript = isF5 || (isCmdOrCtrl && isShift && !isAlt && isEnter); - final runStatement = isCmdOrCtrl && - !isShift && - !isAlt && - (isEnter || isKeyR); - if ((runScript || runStatement) && - SqlEditorCommandBridge.instance.canExecute) { - if (runScript) { - SqlEditorCommandBridge.instance.invokeExecute(); - } else { - SqlEditorCommandBridge.instance.invokeExecuteStatement(); - } - return true; - } - - // Preferences: Ctrl+, / Cmd+, (Physical comma, Logical comma, Russian 'б') - final isComma = physical == PhysicalKeyboardKey.comma || - logical == LogicalKeyboardKey.comma || - logical == const LogicalKeyboardKey(0x00000431); // Russian 'б' - if (isCmdOrCtrl && !isShift && !isAlt && isComma) { - showPreferencesDialog(context); - return true; - } - - // 2. Toggle Left Sidebar: Ctrl+B / Cmd+B (Physical B, Logical B, Russian 'и') - final isKeyB = physical == PhysicalKeyboardKey.keyB || - logical == LogicalKeyboardKey.keyB || - logical == const LogicalKeyboardKey(0x00000438); // Russian 'и' - if (isCmdOrCtrl && !isShift && !isAlt && isKeyB) { - _splitKey.currentState?.toggleSidebar(); - return true; - } - - // Command Palette: Ctrl+P / Cmd+P (Physical P, Logical P, Russian 'з') - final isKeyP = physical == PhysicalKeyboardKey.keyP || - logical == LogicalKeyboardKey.keyP || - logical == const LogicalKeyboardKey(0x00000437) || // з - logical == const LogicalKeyboardKey(0x00000417); // З - if (isCmdOrCtrl && !isShift && !isAlt && isKeyP) { - final ctx = FocusManager.instance.primaryFocus?.context ?? context; - unawaited(showCommandPalette(ctx)); - return true; - } - - // Quick Switcher: Ctrl+K / Cmd+K (Physical K, Logical K, Russian 'л') - final isKeyK = physical == PhysicalKeyboardKey.keyK || - logical == LogicalKeyboardKey.keyK || - logical == const LogicalKeyboardKey(0x0000043b) || // л - logical == const LogicalKeyboardKey(0x0000041b); // Л - if (isCmdOrCtrl && !isShift && !isAlt && isKeyK) { - _openQuickSwitcher(); - return true; - } - - // 3. New Connection / New Query Tab: (Physical N, Logical N, Russian 'т') - final isKeyN = physical == PhysicalKeyboardKey.keyN || - logical == LogicalKeyboardKey.keyN || - logical == const LogicalKeyboardKey(0x00000442); // Russian 'т' - if (isCmdOrCtrl && !isAlt && isKeyN) { - if (isShift) { - // Ctrl+Shift+N: New Query - final ctx = FocusManager.instance.primaryFocus?.context ?? context; - Actions.maybeInvoke(ctx, const NewSqlIntent()); - } else { - // Ctrl+N: New Database Connection - unawaited(_onNewDatabaseConnectionFromMenu()); - } - return true; - } - - // New Query Tab: Ctrl+T / Cmd+T (Physical T, Logical T, Russian 'е') - final isKeyT = physical == PhysicalKeyboardKey.keyT || - logical == LogicalKeyboardKey.keyT || - logical == const LogicalKeyboardKey(0x00000435); // Russian 'е' - if (isCmdOrCtrl && !isShift && !isAlt && isKeyT) { - final ctx = FocusManager.instance.primaryFocus?.context ?? context; - final handled = Actions.maybeInvoke(ctx, const NewSqlIntent()); - if (handled == null) { - SqlEditorCommandBridge.instance.invokeNew(); - } - return true; - } - - // Close Query Tab: Ctrl+W / Cmd+W (Physical W, Logical W, Russian 'ц') - final isKeyW = physical == PhysicalKeyboardKey.keyW || - logical == LogicalKeyboardKey.keyW || - logical == const LogicalKeyboardKey(0x00000446); // Russian 'ц' - if (isCmdOrCtrl && !isShift && !isAlt && isKeyW) { - final ctx = FocusManager.instance.primaryFocus?.context ?? context; - final handled = Actions.maybeInvoke(ctx, const CloseSqlTabIntent()); - if (handled == null) { - SqlEditorCommandBridge.instance.invokeCloseTab(); - } - return true; - } - - // Next / Prev Tab: Ctrl+Tab / Ctrl+Shift+Tab - final isTab = physical == PhysicalKeyboardKey.tab || - logical == LogicalKeyboardKey.tab; - if (isCmdOrCtrl && !isAlt && isTab) { - final ctx = FocusManager.instance.primaryFocus?.context ?? context; - if (isShift) { - final handled = Actions.maybeInvoke(ctx, const PrevSqlTabIntent()); - if (handled == null) { - SqlEditorCommandBridge.instance.invokePrevTab(); - } - } else { - final handled = Actions.maybeInvoke(ctx, const NextSqlTabIntent()); - if (handled == null) { - SqlEditorCommandBridge.instance.invokeNextTab(); - } - } - return true; - } - - // 4. Open SQL: Ctrl+O / Cmd+O (Physical O, Logical O, Russian 'щ') - final isKeyO = physical == PhysicalKeyboardKey.keyO || - logical == LogicalKeyboardKey.keyO || - logical == const LogicalKeyboardKey(0x00000449); // Russian 'щ' - if (isCmdOrCtrl && !isShift && !isAlt && isKeyO) { - final ctx = FocusManager.instance.primaryFocus?.context ?? context; - Actions.maybeInvoke(ctx, const OpenSqlIntent()); - return true; - } - - // 5. Save SQL: Ctrl+S / Cmd+S (Physical S, Logical S, Russian 'ы') - final isKeyS = physical == PhysicalKeyboardKey.keyS || - logical == LogicalKeyboardKey.keyS || - logical == const LogicalKeyboardKey(0x0000044b); // Russian 'ы' - if (isCmdOrCtrl && !isShift && !isAlt && isKeyS) { - final ctx = FocusManager.instance.primaryFocus?.context ?? context; - Actions.maybeInvoke(ctx, const SaveSqlIntent()); - return true; - } - - // 6. Tutorial & Welcome: F1 or Ctrl+Shift+H / Cmd+Shift+H (Physical H, Logical H, Russian 'р') - final isF1 = physical == PhysicalKeyboardKey.f1 || - logical == LogicalKeyboardKey.f1; - final isKeyH = physical == PhysicalKeyboardKey.keyH || - logical == LogicalKeyboardKey.keyH || - logical == const LogicalKeyboardKey(0x00000440); // Russian 'р' - if (isF1 || (isCmdOrCtrl && isShift && isKeyH)) { - _onOpenWelcomeTour(); - return true; - } - - // 7. Home / Start Screen: Ctrl+Shift+0 / Cmd+Shift+0 - final isDigit0 = physical == PhysicalKeyboardKey.digit0 || - physical == PhysicalKeyboardKey.numpad0 || - logical == LogicalKeyboardKey.digit0 || - logical == LogicalKeyboardKey.numpad0; - if (isCmdOrCtrl && isShift && isDigit0) { - _onGoHome(); - return true; - } - - return false; - } - - Future _allowUnsavedNavigation() { - return confirmDiscardUnsavedWorkIfNeeded(context); - } - - void _onConnectionSelected(ConnectionRow connection) { - unawaited(_selectConnection(connection)); - } - - Future _selectConnection(ConnectionRow connection) async { - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - final prevId = _workspace.value.activeConnection?.id; - _workspace.value = _workspace.value.selectConnection(connection); - if (prevId != connection.id) { - QueryaShellStatus.instance.clear(); - _pendingMongoCollection = null; - } - final id = connection.id; - if (id != null) { - unawaited(AppSettings.instance.recordRecentConnection(id)); - } - } - - void _onPostgresObjectSelected( - ConnectionRow connection, - String database, - String schema, - String name, - PostgresObjectKind kind, - ) { - unawaited(_selectPostgresObject( - connection, - database, - schema, - name, - kind, - )); - } - - Future _selectPostgresObject( - ConnectionRow connection, - String database, - String schema, - String name, - PostgresObjectKind kind, - ) async { - final ws = _workspace.value; - final cur = ws.selectedPostgresObject; - final same = ws.activeConnection?.id == connection.id && - cur != null && - cur.database == database && - cur.schema == schema && - cur.name == name && - cur.kind == kind; - if (same) return; - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - if (!await confirmOpenPostgresTableIfSqlTx( - context, - connection: connection, - database: database, - kind: kind, - )) { - return; - } - if (!mounted) return; - _workspace.value = _workspace.value.selectPostgresObject( - connection, - database, - schema, - name, - kind, + final keyboard = HardwareKeyboard.instance; + final shortcut = MainScreenShortcuts.resolve( + event, + cmdOrCtrl: Platform.isMacOS + ? (keyboard.isMetaPressed || keyboard.isControlPressed) + : keyboard.isControlPressed, + shift: keyboard.isShiftPressed, + alt: keyboard.isAltPressed, ); - } - - void _onMysqlObjectSelected( - ConnectionRow connection, - String database, - String name, - MysqlObjectKind kind, - ) { - unawaited(_selectMysqlObject(connection, database, name, kind)); - } - - Future _selectMysqlObject( - ConnectionRow connection, - String database, - String name, - MysqlObjectKind kind, - ) async { - final ws = _workspace.value; - final cur = ws.selectedMysqlObject; - final same = ws.activeConnection?.id == connection.id && - cur != null && - cur.database == database && - cur.name == name && - cur.kind == kind; - if (same) return; - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - if (!await confirmOpenMysqlTableIfSqlTx( - context, - connection: connection, - database: database, - kind: kind, - )) { - return; - } - if (!mounted) return; - _workspace.value = _workspace.value.selectMysqlObject( - connection, - database, - name, - kind, - ); - } - - void _onRedisDatabaseSelected(ConnectionRow connection, int database) { - unawaited(_selectRedisDatabase(connection, database)); - } - - Future _selectRedisDatabase( - ConnectionRow connection, - int database, - ) async { - final ws = _workspace.value; - if (ws.activeConnection?.id == connection.id && - ws.activeRedisDb == database) { - return; - } - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - _workspace.value = _workspace.value.selectRedisDb(connection, database); - } - - void _onMongoDBDatabaseSelected(ConnectionRow connection, String database) { - unawaited(_selectMongoDatabase(connection, database)); - } - - Future _selectMongoDatabase( - ConnectionRow connection, - String database, - ) async { - final ws = _workspace.value; - if (ws.activeConnection?.id == connection.id && - ws.activeMongoDB == database && - ws.selectedMongoCollection == null) { - return; - } - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - setState(() => _pendingMongoCollection = null); - _workspace.value = _workspace.value.selectMongoDb(connection, database); - } - - void _onMongoCollectionSelected( - ConnectionRow connection, - String database, - String collection, - ) { - unawaited(_selectMongoCollection(connection, database, collection)); - } - - Future _selectMongoCollection( - ConnectionRow connection, - String database, - String collection, - ) async { - final ws = _workspace.value; - if (ws.activeConnection?.id == connection.id && - ws.activeMongoDB == database && - ws.selectedMongoCollection == collection) { - return; - } - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - setState(() => _pendingMongoCollection = collection); - _workspace.value = - _workspace.value.selectMongoCollection(connection, database, collection); - } - - void _onPostgresOpenSqlWorkspace( - ConnectionRow connection, { - String? database, - String? schema, - String? name, - PostgresObjectKind? kind, - }) { - unawaited(_openPostgresSqlWorkspace( - connection, - database: database, - schema: schema, - name: name, - kind: kind, - )); - } - - Future _openPostgresSqlWorkspace( - ConnectionRow connection, { - String? database, - String? schema, - String? name, - PostgresObjectKind? kind, - }) async { - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - _workspace.value = _workspace.value.openPostgresSqlWorkspace( - connection, - seedDatabase: database, - seedSchema: schema, - seedName: name, - seedKind: kind, - ); - } - - void _onMysqlOpenSqlWorkspace(ConnectionRow connection) { - unawaited(_openMysqlSqlWorkspace(connection)); - } - - Future _openMysqlSqlWorkspace(ConnectionRow connection) async { - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - _workspace.value = _workspace.value.openMysqlSqlWorkspace(connection); - } - - void _onSqliteObjectSelected( - ConnectionRow connection, - String name, - SqliteObjectKind kind, - ) { - unawaited(_selectSqliteObject(connection, name, kind)); - } - - Future _selectSqliteObject( - ConnectionRow connection, - String name, - SqliteObjectKind kind, - ) async { - final ws = _workspace.value; - final cur = ws.selectedSqliteObject; - final same = ws.activeConnection?.id == connection.id && - cur != null && - cur.name == name && - cur.kind == kind; - if (same) return; - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - if (!await confirmOpenSqliteTableIfSqlTx( - context, - connection: connection, - kind: kind, - )) { - return; - } - if (!mounted) return; - _workspace.value = _workspace.value.selectSqliteObject( - connection, - name, - kind, - ); - } - - void _onSqliteOpenSqlWorkspace(ConnectionRow connection) { - unawaited(_openSqliteSqlWorkspace(connection)); - } - - Future _openSqliteSqlWorkspace(ConnectionRow connection) async { - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - _workspace.value = _workspace.value.openSqliteSqlWorkspace(connection); - } - - void _onExtensionObjectSelected( - ConnectionRow connection, - String database, - String name, - ) { - unawaited(_selectExtensionObject(connection, database, name)); - } - - Future _selectExtensionObject( - ConnectionRow connection, - String database, - String name, - ) async { - final ws = _workspace.value; - final cur = ws.selectedExtensionObject; - final same = ws.activeConnection?.id == connection.id && - cur != null && - cur.database == database && - cur.name == name; - if (same) return; - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - _workspace.value = _workspace.value.selectExtensionObject( - connection, - database, - name, - ); - } - - void _openQuickSwitcher([String query = '']) { - final ctx = FocusManager.instance.primaryFocus?.context ?? context; - final ws = _workspace.value; - final conn = ws.activeConnection; - unawaited(showQuickSwitcher( - ctx, - initialQuery: query, - load: () => QueryaSchemaObjectLoader.load( - connection: conn, - database: ws.selectedPostgresObject?.database ?? - ws.selectedMysqlObject?.database ?? - ws.selectedExtensionObject?.database ?? - conn?.databaseName, - mongoDatabase: ws.activeMongoDB ?? conn?.databaseName, - ), - )); - } - - void _onOpenSchemaObject(QueryaSchemaObject object) { - final conn = _workspace.value.activeConnection; - if (conn == null) return; - final type = conn.type.toLowerCase(); - switch (object.kind) { - case QueryaSchemaObjectKind.table: - case QueryaSchemaObjectKind.view: - case QueryaSchemaObjectKind.materializedView: - if (type == 'postgres' || type == 'postgresql') { - _onPostgresObjectSelected( - conn, - object.database ?? conn.databaseName ?? '', - object.schema ?? 'public', - object.name, - switch (object.kind) { - QueryaSchemaObjectKind.view => PostgresObjectKind.view, - QueryaSchemaObjectKind.materializedView => - PostgresObjectKind.materializedView, - _ => PostgresObjectKind.table, - }, - ); - return; - } - if (type == 'mysql') { - _onMysqlObjectSelected( - conn, - object.database ?? conn.databaseName ?? '', - object.name, - object.kind == QueryaSchemaObjectKind.view - ? MysqlObjectKind.view - : MysqlObjectKind.table, - ); - return; - } - if (type == 'sqlite') { - _onSqliteObjectSelected( - conn, - object.name, - object.kind == QueryaSchemaObjectKind.view - ? SqliteObjectKind.view - : SqliteObjectKind.table, - ); - return; - } - if (conn.isExtensionDriver) { - _onExtensionObjectSelected( - conn, - object.database ?? conn.databaseName ?? '', - object.name, - ); - } - case QueryaSchemaObjectKind.collection: - _onMongoCollectionSelected( - conn, - object.database ?? conn.databaseName ?? '', - object.name, - ); - } - } - - void _openSqlWorkspaceForConnection(ConnectionRow connection) { - switch (connection.type) { - case 'postgresql': - _onPostgresOpenSqlWorkspace(connection); - case 'mysql': - _onMysqlOpenSqlWorkspace(connection); - case 'sqlite': - _onSqliteOpenSqlWorkspace(connection); - default: - if (ExtensionDriverCatalog.isExtensionDriverConnection(connection)) { - unawaited(_selectConnection(connection)); - } - } - } - - Future _openNewConnectionFromHero() async { - final row = await promptCreateConnection(context); - if (!mounted || row == null) return; - await LocalDb.instance.addConnection(row); - await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); - } - - Future _onNewDatabaseConnectionFromMenu() async { - if (_openingConnectionDialog) return; - _openingConnectionDialog = true; - try { - // Menu overlay context is torn down before the connection form opens. - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - final row = await promptCreateConnection(context, folderId: null); - if (!mounted || row == null) return; - await LocalDb.instance.addConnection(row); - await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); - } finally { - _openingConnectionDialog = false; - } - } - - Future _onNewDatabaseConnectionFromUrl() async { - if (_openingConnectionDialog) return; - _openingConnectionDialog = true; - try { - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - final row = await showNewConnectionUrlDialog(context); - if (!mounted || row == null) return; - await LocalDb.instance.addConnection(row); - await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); - } finally { - _openingConnectionDialog = false; - } - } - - Future _openSqliteFromHero() async { - final row = await showSqliteConnectionForm(context); - if (!mounted || row == null) return; - await LocalDb.instance.addConnection(row); - await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); - } - - void _onGoHome() { - unawaited(_goHome()); - } - - Future _goHome() async { - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - _workspace.value = MainScreenWorkspaceState.empty; - QueryaShellStatus.instance.clear(); - } - - Future _closeWorkspace() async { - if (!await _allowUnsavedNavigation()) return; - if (!mounted) return; - _workspace.value = _workspace.value.unselectActiveObject(); - } - - bool _hasCloseableWorkspace(MainScreenWorkspaceState ws) { - return ws.selectedPostgresObject != null || - ws.selectedMysqlObject != null || - ws.selectedSqliteObject != null || - ws.selectedExtensionObject != null || - ws.activeMongoDB != null || - ws.activeRedisDb != null; - } - - void _onOpenWelcomeTour() { - showWelcomeTourDialog( - context, - onLaunchDemo: _onLaunchDemoPlayground, - onGoHome: _workspace.value.activeConnection != null ? _onGoHome : null, - ); - } - - Future _onLaunchDemoPlayground() async { - try { - final demoConn = await DemoPlaygroundService.getOrCreateDemoConnection(); - if (!mounted) return; - await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); - _workspace.value = _workspace.value.selectConnection(demoConn); - _onSqliteOpenSqlWorkspace(demoConn); - } catch (_) {} - } - - Future _handlePendingFileLaunch() async { - final targets = FileLaunchService.instance.consumePendingTargets(); - if (targets.isEmpty || !mounted) return; - - for (final target in targets) { - if (!mounted) return; - try { - if (target.kind == FileLaunchKind.sqlite) { - final conn = await FileLaunchService.instance - .resolveOrRegisterSqliteConnection(target); - if (!mounted) return; - await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); - _workspace.value = _workspace.value.selectConnection(conn); - _onSqliteOpenSqlWorkspace(conn); - } else if (target.kind == FileLaunchKind.sql) { - final sql = await FileLaunchService.instance.readSqlContent(target); - if (!mounted) return; - - var conn = _workspace.value.activeConnection; - if (conn == null) { - final connections = await LocalDb.instance.getConnections(); - if (connections.isNotEmpty) { - conn = connections.first; - } else { - final scratchRow = ConnectionRow( - name: 'Local Scratch (${target.fileName})', - type: 'sqlite', - host: '', - createdAt: DateTime.now().toUtc().toIso8601String(), - ); - final id = await LocalDb.instance.addConnection(scratchRow); - conn = scratchRow.copyWith(id: id); - await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); - } - } - - if (mounted) { - _workspace.value = _workspace.value.selectConnection(conn); - _openSqlWorkspaceForConnection(conn); - SqlEditorCommandBridge.instance.openFileWithContent( - sql: sql, - filePath: target.path, - title: target.fileName, - ); - } - } - } catch (e) { - if (!mounted) return; - showAppToast( - context: context, - message: 'Failed to open file "${target.fileName}": $e', - variant: AppToastVariant.error, - ); - } - } - } + if (shortcut == null) return false; + + BuildContext focused() => + FocusManager.instance.primaryFocus?.context ?? context; + // An intent the focused editor handles, else the bridge to the open one. + void intentOr(Intent intent, void Function() fallback) { + if (Actions.maybeInvoke(focused(), intent) == null) fallback(); + } + + final bridge = SqlEditorCommandBridge.instance; + switch (shortcut) { + case MainShortcut.runScript: + if (!bridge.canExecute) return false; + bridge.invokeExecute(); + case MainShortcut.runStatement: + if (!bridge.canExecute) return false; + bridge.invokeExecuteStatement(); + case MainShortcut.preferences: + showPreferencesDialog(context); + case MainShortcut.toggleSidebar: + _splitKey.currentState?.toggleSidebar(); + case MainShortcut.commandPalette: + unawaited(showCommandPalette(focused())); + case MainShortcut.quickSwitcher: + _nav.openQuickSwitcher(); + case MainShortcut.newConnection: + unawaited(_dialogs.onNewDatabaseConnectionFromMenu()); + case MainShortcut.newQuery: + Actions.maybeInvoke(focused(), const NewSqlIntent()); + case MainShortcut.newQueryTab: + intentOr(const NewSqlIntent(), bridge.invokeNew); + case MainShortcut.closeQueryTab: + intentOr(const CloseSqlTabIntent(), bridge.invokeCloseTab); + case MainShortcut.nextQueryTab: + intentOr(const NextSqlTabIntent(), bridge.invokeNextTab); + case MainShortcut.prevQueryTab: + intentOr(const PrevSqlTabIntent(), bridge.invokePrevTab); + case MainShortcut.openSql: + Actions.maybeInvoke(focused(), const OpenSqlIntent()); + case MainShortcut.saveSql: + Actions.maybeInvoke(focused(), const SaveSqlIntent()); + case MainShortcut.welcomeTour: + _dialogs.onOpenWelcomeTour(); + case MainShortcut.goHome: + _nav.onGoHome(); + } + return true; + } + + /// Runs [intent] on the focused editor, if one handles it. + void _invoke(Intent intent) => Actions.maybeInvoke( + FocusManager.instance.primaryFocus?.context ?? context, intent); @override material.Widget build(material.BuildContext context) { @@ -876,16 +173,16 @@ class _MainScreenState extends State { builder: (context, workspace, _) { return QueryaCommandHost( onToggleSidebar: () => _splitKey.currentState?.toggleSidebar(), - onNewConnection: () => unawaited(_onNewDatabaseConnectionFromMenu()), + onNewConnection: () => unawaited(_dialogs.onNewDatabaseConnectionFromMenu()), onReloadConnections: () => unawaited( _connectionsPanelKey.currentState?.reloadConnectionsFromDb() ?? Future.value()), - onShowQuickSwitcher: _openQuickSwitcher, - onOpenSchemaObject: _onOpenSchemaObject, + onShowQuickSwitcher: _nav.openQuickSwitcher, + onOpenSchemaObject: _nav.onOpenSchemaObject, onGoHome: - workspace.activeConnection != null ? _onGoHome : null, - onCloseWorkspace: _hasCloseableWorkspace(workspace) - ? () => unawaited(_closeWorkspace()) + workspace.activeConnection != null ? _nav.onGoHome : null, + onCloseWorkspace: _nav.hasCloseableWorkspace(workspace) + ? () => unawaited(_nav.closeWorkspace()) : null, onConnect: workspace.activeConnection?.id != null ? () => _connectionsPanelKey.currentState @@ -906,9 +203,9 @@ class _MainScreenState extends State { ) : null, onToggleReadOnly: workspace.activeConnection != null - ? () => unawaited(_toggleReadOnly()) + ? () => unawaited(_dialogs.toggleReadOnly()) : null, - onOpenWelcomeTour: _onOpenWelcomeTour, + onOpenWelcomeTour: _dialogs.onOpenWelcomeTour, selectedConnectionId: workspace.activeConnection?.id, child: FocusScope( autofocus: true, @@ -918,67 +215,43 @@ class _MainScreenState extends State { const material.SingleActivator( LogicalKeyboardKey.keyN, meta: true, - ): () => unawaited(_onNewDatabaseConnectionFromMenu()), + ): () => unawaited(_dialogs.onNewDatabaseConnectionFromMenu()), const material.SingleActivator( LogicalKeyboardKey.keyN, control: true, - ): () => unawaited(_onNewDatabaseConnectionFromMenu()), + ): () => unawaited(_dialogs.onNewDatabaseConnectionFromMenu()), // New Query Tab: Ctrl+Shift+N / Cmd+Shift+N const material.SingleActivator( LogicalKeyboardKey.keyN, meta: true, shift: true, - ): () { - final ctx = - FocusManager.instance.primaryFocus?.context ?? context; - Actions.maybeInvoke(ctx, const NewSqlIntent()); - }, + ): () => _invoke(const NewSqlIntent()), const material.SingleActivator( LogicalKeyboardKey.keyN, control: true, shift: true, - ): () { - final ctx = - FocusManager.instance.primaryFocus?.context ?? context; - Actions.maybeInvoke(ctx, const NewSqlIntent()); - }, + ): () => _invoke(const NewSqlIntent()), // Open SQL File: Ctrl+O / Cmd+O const material.SingleActivator( LogicalKeyboardKey.keyO, meta: true, - ): () { - final ctx = - FocusManager.instance.primaryFocus?.context ?? context; - Actions.maybeInvoke(ctx, const OpenSqlIntent()); - }, + ): () => _invoke(const OpenSqlIntent()), const material.SingleActivator( LogicalKeyboardKey.keyO, control: true, - ): () { - final ctx = - FocusManager.instance.primaryFocus?.context ?? context; - Actions.maybeInvoke(ctx, const OpenSqlIntent()); - }, + ): () => _invoke(const OpenSqlIntent()), // Save SQL File: Ctrl+S / Cmd+S const material.SingleActivator( LogicalKeyboardKey.keyS, meta: true, - ): () { - final ctx = - FocusManager.instance.primaryFocus?.context ?? context; - Actions.maybeInvoke(ctx, const SaveSqlIntent()); - }, + ): () => _invoke(const SaveSqlIntent()), const material.SingleActivator( LogicalKeyboardKey.keyS, control: true, - ): () { - final ctx = - FocusManager.instance.primaryFocus?.context ?? context; - Actions.maybeInvoke(ctx, const SaveSqlIntent()); - }, + ): () => _invoke(const SaveSqlIntent()), // Toggle Left Sidebar: Ctrl+B / Cmd+B const material.SingleActivator( @@ -1001,66 +274,54 @@ class _MainScreenState extends State { const material.SingleActivator( LogicalKeyboardKey.keyK, control: true, - ): () => _openQuickSwitcher(), + ): () => _nav.openQuickSwitcher(), const material.SingleActivator( LogicalKeyboardKey.keyK, meta: true, - ): () => _openQuickSwitcher(), + ): () => _nav.openQuickSwitcher(), // Welcome Tour & Tutorial: F1 / Cmd+Shift+H / Ctrl+Shift+H const material.SingleActivator( LogicalKeyboardKey.f1, - ): () => _onOpenWelcomeTour(), + ): () => _dialogs.onOpenWelcomeTour(), const material.SingleActivator( LogicalKeyboardKey.keyH, meta: true, shift: true, - ): () => _onOpenWelcomeTour(), + ): () => _dialogs.onOpenWelcomeTour(), const material.SingleActivator( LogicalKeyboardKey.keyH, control: true, shift: true, - ): () => _onOpenWelcomeTour(), + ): () => _dialogs.onOpenWelcomeTour(), // Return to Start / Home Screen: Cmd+Shift+0 / Ctrl+Shift+0 const material.SingleActivator( LogicalKeyboardKey.digit0, meta: true, shift: true, - ): _onGoHome, + ): _nav.onGoHome, const material.SingleActivator( LogicalKeyboardKey.digit0, control: true, shift: true, - ): _onGoHome, + ): _nav.onGoHome, }, child: QueryaPlatformMenuBar( onNewConnection: () => - unawaited(_onNewDatabaseConnectionFromMenu()), - onNewQueryTab: () { - final ctx = - FocusManager.instance.primaryFocus?.context ?? context; - Actions.maybeInvoke(ctx, const NewSqlIntent()); - }, - onOpenSqlScript: () { - final ctx = - FocusManager.instance.primaryFocus?.context ?? context; - Actions.maybeInvoke(ctx, const OpenSqlIntent()); - }, - onSaveQuery: () { - final ctx = - FocusManager.instance.primaryFocus?.context ?? context; - Actions.maybeInvoke(ctx, const SaveSqlIntent()); - }, + unawaited(_dialogs.onNewDatabaseConnectionFromMenu()), + onNewQueryTab: () => _invoke(const NewSqlIntent()), + onOpenSqlScript: () => _invoke(const OpenSqlIntent()), + onSaveQuery: () => _invoke(const SaveSqlIntent()), onExecuteQuery: () => SqlEditorCommandBridge.instance.invokeExecute(), onToggleSidebar: () => _splitKey.currentState?.toggleSidebar(), onOpenPreferences: () => showPreferencesDialog(context), - onOpenWelcomeTour: () => _onOpenWelcomeTour(), - onGoHome: _onGoHome, + onOpenWelcomeTour: () => _dialogs.onOpenWelcomeTour(), + onGoHome: _nav.onGoHome, child: SqlEditorGlobalActions( activeConnection: workspace.activeConnection, - onOpenSqlWorkspace: _openSqlWorkspaceForConnection, + onOpenSqlWorkspace: _nav.openSqlWorkspaceForConnection, child: material.Scaffold( backgroundColor: wb.canvas, body: WindowBorder( @@ -1068,111 +329,51 @@ class _MainScreenState extends State { width: 1, child: Column( children: [ - ValueListenableBuilder( - valueListenable: _isSidebarVisible, - builder: (context, isSidebarVisible, _) { - return QueryaWindowTitleBar( - onNewDatabaseConnection: - _onNewDatabaseConnectionFromMenu, - onNewDatabaseConnectionFromUrl: - _onNewDatabaseConnectionFromUrl, - activeConnection: workspace.activeConnection, - isReadOnly: workspace.isReadOnly, - isSidebarVisible: isSidebarVisible, - onToggleSidebar: () => - _splitKey.currentState?.toggleSidebar(), - onOpenWelcomeTour: _onOpenWelcomeTour, - onGoHome: _onGoHome, - onReadOnlyChanged: () => unawaited(_toggleReadOnly()), - onConnect: () { - final active = workspace.activeConnection; - if (active != null && active.id != null) { - _connectionsPanelKey.currentState - ?.connect(active.id!); - } - }, - onReconnect: () { - final active = workspace.activeConnection; - if (active != null) { - _connectionsPanelKey.currentState - ?.reconnect(active); - } - }, - onDisconnect: () { - final active = workspace.activeConnection; - if (active != null) { - _connectionsPanelKey.currentState - ?.disconnect(active); - } - }, - onDisconnectAll: () { - _connectionsPanelKey.currentState?.disconnectAll(); - }, - onDisconnectOthers: () { - final active = workspace.activeConnection; - if (active != null) { - _connectionsPanelKey.currentState - ?.disconnectOthers(active); - } - }, - ); - }, + ShellTitleBar( + workspace: workspace, + isSidebarVisible: _isSidebarVisible, + connectionsPanelKey: _connectionsPanelKey, + onToggleSidebar: () => + _splitKey.currentState?.toggleSidebar(), + dialogs: _dialogs, + nav: _nav, ), QueryaDivider( height: 1, color: wb.borderSubtle.withValues(alpha: 0.22)), Expanded( - child: _MainContentSplit( + child: MainContentSplit( key: _splitKey, connectionsPanelKey: _connectionsPanelKey, workspace: _workspace, onSidebarVisibilityChanged: (v) => _isSidebarVisible.value = v, - onConnectionSelected: _onConnectionSelected, - onPostgresObjectSelected: _onPostgresObjectSelected, - onMysqlObjectSelected: _onMysqlObjectSelected, - onSqliteObjectSelected: _onSqliteObjectSelected, - onExtensionObjectSelected: _onExtensionObjectSelected, - onRedisDatabaseSelected: _onRedisDatabaseSelected, - onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, - onMongoCollectionSelected: _onMongoCollectionSelected, - onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, - onMysqlOpenSqlWorkspace: _onMysqlOpenSqlWorkspace, - onSqliteOpenSqlWorkspace: _onSqliteOpenSqlWorkspace, - onRequestNewConnection: _openNewConnectionFromHero, + onConnectionSelected: _nav.onConnectionSelected, + onPostgresObjectSelected: _nav.onPostgresObjectSelected, + onMysqlObjectSelected: _nav.onMysqlObjectSelected, + onSqliteObjectSelected: _nav.onSqliteObjectSelected, + onExtensionObjectSelected: _nav.onExtensionObjectSelected, + onRedisDatabaseSelected: _nav.onRedisDatabaseSelected, + onMongoDBDatabaseSelected: _nav.onMongoDBDatabaseSelected, + onMongoCollectionSelected: _nav.onMongoCollectionSelected, + onPostgresOpenSqlWorkspace: _nav.onPostgresOpenSqlWorkspace, + onMysqlOpenSqlWorkspace: _nav.onMysqlOpenSqlWorkspace, + onSqliteOpenSqlWorkspace: _nav.onSqliteOpenSqlWorkspace, + onRequestNewConnection: _dialogs.openNewConnectionFromHero, onRequestNewConnectionFromUrl: - _onNewDatabaseConnectionFromUrl, - onRequestOpenSqlite: _openSqliteFromHero, - onRequestLaunchDemo: _onLaunchDemoPlayground, - onRequestOpenTour: _onOpenWelcomeTour, - onOpenConnection: _onConnectionSelected, - initialMongoCollection: _pendingMongoCollection, + _dialogs.onNewDatabaseConnectionFromUrl, + onRequestOpenSqlite: _dialogs.openSqliteFromHero, + onRequestLaunchDemo: _dialogs.onLaunchDemoPlayground, + onRequestOpenTour: _dialogs.onOpenWelcomeTour, + onOpenConnection: _nav.onConnectionSelected, + initialMongoCollection: _nav.pendingMongoCollection, ), ), - ValueListenableBuilder( - valueListenable: _isSidebarVisible, - builder: (context, isSidebarVisible, _) { - return ListenableBuilder( - listenable: QueryaShellStatus.instance, - builder: (context, _) { - final status = QueryaShellStatus.instance; - return QueryaStatusBar( - activeConnection: workspace.activeConnection, - isReadOnly: workspace.isReadOnly, - isSidebarVisible: isSidebarVisible, - onToggleSidebar: () => - _splitKey.currentState?.toggleSidebar(), - onOpenPreferences: () => - showPreferencesDialog(context), - isBusy: status.isBusy, - statusMessage: status.statusMessage, - rowCount: status.rowCount, - columnCount: status.columnCount, - lastQueryDuration: status.lastQueryDuration, - ); - }, - ); - }, + ShellStatusBar( + workspace: workspace, + isSidebarVisible: _isSidebarVisible, + onToggleSidebar: () => + _splitKey.currentState?.toggleSidebar(), ), ], ), @@ -1187,579 +388,3 @@ class _MainScreenState extends State { ); } } - -/// Owns splitter width and collapsible spring motion. -class _MainContentSplit extends StatefulWidget { - const _MainContentSplit({ - super.key, - required this.connectionsPanelKey, - required this.workspace, - required this.onConnectionSelected, - required this.onPostgresObjectSelected, - required this.onMysqlObjectSelected, - required this.onSqliteObjectSelected, - required this.onExtensionObjectSelected, - required this.onRedisDatabaseSelected, - required this.onMongoDBDatabaseSelected, - required this.onMongoCollectionSelected, - required this.onPostgresOpenSqlWorkspace, - required this.onMysqlOpenSqlWorkspace, - required this.onSqliteOpenSqlWorkspace, - required this.onRequestNewConnection, - required this.onRequestNewConnectionFromUrl, - required this.onRequestOpenSqlite, - this.onRequestLaunchDemo, - this.onRequestOpenTour, - required this.onOpenConnection, - this.onSidebarVisibilityChanged, - this.initialMongoCollection, - }); - - final GlobalKey connectionsPanelKey; - final ValueNotifier workspace; - final ValueChanged? onSidebarVisibilityChanged; - final void Function(ConnectionRow) onConnectionSelected; - final void Function( - ConnectionRow, - String database, - String schema, - String name, - PostgresObjectKind kind, - ) onPostgresObjectSelected; - final void Function( - ConnectionRow, - String database, - String name, - MysqlObjectKind kind, - ) onMysqlObjectSelected; - final void Function( - ConnectionRow, - String name, - SqliteObjectKind kind, - ) onSqliteObjectSelected; - final void Function( - ConnectionRow, - String database, - String name, - ) onExtensionObjectSelected; - final void Function(ConnectionRow, int) onRedisDatabaseSelected; - final void Function(ConnectionRow, String) onMongoDBDatabaseSelected; - final void Function(ConnectionRow, String, String) onMongoCollectionSelected; - final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; - final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; - final void Function(ConnectionRow) onSqliteOpenSqlWorkspace; - final VoidCallback onRequestNewConnection; - final VoidCallback onRequestNewConnectionFromUrl; - final VoidCallback onRequestOpenSqlite; - final String? initialMongoCollection; - final VoidCallback? onRequestLaunchDemo; - final VoidCallback? onRequestOpenTour; - final void Function(ConnectionRow) onOpenConnection; - - @override - State<_MainContentSplit> createState() => _MainContentSplitState(); -} - -class _MainContentSplitState extends State<_MainContentSplit> - with SingleTickerProviderStateMixin { - static const double _minLeftWidth = 180; - static const double _maxLeftWidth = 500; - static const double _minWorkspaceWidth = 64; - static const double _resizeHandleWidth = 6; - static const double _collapseThreshold = 1.0; - - /// High-responsiveness critically damped spring for fluid sidebar toggling (~0.22s). - static const SpringDescription _sidebarSpring = SpringDescription( - mass: 1.0, - stiffness: 580.0, - damping: 48.0, - ); - - late final QueryaSpringController _widthSpring; - late final ConnectionsPanelWidthPersist _widthPersist; - VoidCallback? _persistWhenSettled; - double _lastMaxWidth = 1200; - double _lastExpandedWidth = kDefaultConnectionsPanelWidth; - bool _sidebarVisible = true; - - @override - void initState() { - super.initState(); - _widthPersist = ConnectionsPanelWidthPersist( - write: AppSettings.instance.setConnectionsPanelWidth, - ); - _widthSpring = QueryaSpringController( - vsync: this, - value: kDefaultConnectionsPanelWidth, - spring: _sidebarSpring, - cubicDuration: QueryaMotion.sidebarCubic, - cubicCurve: QueryaMotion.enter, - ); - _widthSpring.addListener(_onWidthSpringChanged); - unawaited(_restoreState()); - } - - Future _restoreLastSelectedObject() async { - final ws = widget.workspace.value; - final conn = ws.activeConnection; - final pg = ws.lastSelectedPostgresObject; - if (conn != null && conn.type == 'postgresql' && pg != null) { - if (!await confirmOpenPostgresTableIfSqlTx( - context, - connection: conn, - database: pg.database, - kind: pg.kind, - )) { - return; - } - } - if (!mounted) return; - final sq = ws.lastSelectedSqliteObject; - if (conn != null && conn.type == 'sqlite' && sq != null) { - if (!await confirmOpenSqliteTableIfSqlTx( - context, - connection: conn, - kind: sq.kind, - )) { - return; - } - } - if (!mounted) return; - final my = ws.lastSelectedMysqlObject; - if (conn != null && conn.type == 'mysql' && my != null) { - if (!await confirmOpenMysqlTableIfSqlTx( - context, - connection: conn, - database: my.database, - kind: my.kind, - )) { - return; - } - } - if (!mounted) return; - widget.workspace.value = widget.workspace.value.restoreLastSelectedObject(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _widthSpring.cubicDuration = - context.motionDuration(QueryaMotion.sidebarCubic); - _widthSpring.cubicCurve = context.motionCurve(QueryaMotion.enter); - _widthSpring.useSprings = QueryaSpring.springsEnabled(context); - } - - Future _restoreState() async { - final width = await AppSettings.instance.getConnectionsPanelWidth(); - final visible = await AppSettings.instance.getSidebarVisible(); - if (!mounted) return; - final clamped = _clampLeftWidth(width, _lastMaxWidth); - _lastExpandedWidth = clamped; - _sidebarVisible = visible; - widget.onSidebarVisibilityChanged?.call(visible); - final targetW = visible ? clamped : 0.0; - _widthSpring.jumpTo(targetW); - } - - void toggleSidebar({bool? visible}) { - final target = visible ?? !_sidebarVisible; - _sidebarVisible = target; - widget.onSidebarVisibilityChanged?.call(target); - _widthSpring.useSprings = QueryaSpring.springsEnabled(context); - final targetWidth = target ? _lastExpandedWidth : 0.0; - _widthSpring.animateTo(targetWidth); - unawaited(AppSettings.instance.setSidebarVisible(target)); - } - - void _onWidthSpringChanged() { - if (mounted) { - setState(() {}); - } - } - - void _schedulePersistAfterSettle() { - _widthPersist.markDirty(); - final pending = _persistWhenSettled; - if (pending != null) { - _widthSpring.removeListener(pending); - } - void listener() { - if (_widthSpring.isAnimating) return; - _widthSpring.removeListener(listener); - _persistWhenSettled = null; - if (_sidebarVisible) { - unawaited(_widthPersist.persist(_lastExpandedWidth)); - } - } - - _persistWhenSettled = listener; - if (_widthSpring.isAnimating) { - _widthSpring.addListener(listener); - } else { - listener(); - } - } - - @override - void dispose() { - final pending = _persistWhenSettled; - if (pending != null) { - _widthSpring.removeListener(pending); - _persistWhenSettled = null; - } - if (_sidebarVisible) { - _widthPersist.disposeFlush(_lastExpandedWidth); - } - _widthSpring.removeListener(_onWidthSpringChanged); - _widthSpring.dispose(); - super.dispose(); - } - - double _clampLeftWidth(double raw, double maxWidth) { - final maxLeft = maxWidth - _resizeHandleWidth - _minWorkspaceWidth; - if (maxLeft <= 0) return 0; - if (maxLeft < _minLeftWidth) return raw.clamp(0, maxLeft); - return raw.clamp(_minLeftWidth, math.min(_maxLeftWidth, maxLeft)); - } - - @override - material.Widget build(material.BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - _lastMaxWidth = constraints.maxWidth; - final currentW = _widthSpring.value.clamp(0.0, constraints.maxWidth); - final progress = _lastExpandedWidth > 0 - ? (currentW / _lastExpandedWidth).clamp(0.0, 1.0) - : 0.0; - final currentHandleW = - (progress * _resizeHandleWidth).clamp(0.0, _resizeHandleWidth); - final handleOpacity = (progress * 5.0).clamp(0.0, 1.0); - final contentOpacity = (progress * 2.2).clamp(0.0, 1.0); - final parallaxOffset = (progress - 1.0) * 36.0; - final isCollapsed = - currentW <= _collapseThreshold || contentOpacity <= 0.0; - - final slot = material.RepaintBoundary( - child: _ConnectionsPanelSlot( - connectionsPanelKey: widget.connectionsPanelKey, - workspace: widget.workspace, - onConnectionSelected: widget.onConnectionSelected, - onRedisDatabaseSelected: - widget.onRedisDatabaseSelected, - onMongoDBDatabaseSelected: - widget.onMongoDBDatabaseSelected, - onMongoCollectionSelected: - widget.onMongoCollectionSelected, - onPostgresObjectSelected: - widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: - widget.onPostgresOpenSqlWorkspace, - onMysqlObjectSelected: widget.onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: - widget.onMysqlOpenSqlWorkspace, - onSqliteObjectSelected: - widget.onSqliteObjectSelected, - onSqliteOpenSqlWorkspace: - widget.onSqliteOpenSqlWorkspace, - onExtensionObjectSelected: - widget.onExtensionObjectSelected, - ), - ); - - final material.Widget slotContent; - if (isCollapsed) { - slotContent = Offstage( - offstage: true, - child: TickerMode( - enabled: false, - child: slot, - ), - ); - } else if (contentOpacity < 1.0) { - slotContent = Opacity( - opacity: contentOpacity, - child: slot, - ); - } else { - slotContent = slot; - } - - final material.Widget positionedSlot = - (isCollapsed || parallaxOffset.abs() <= 0.001) - ? slotContent - : Transform.translate( - offset: Offset(parallaxOffset, 0), - child: slotContent, - ); - - final handleWidget = IgnorePointer( - ignoring: currentW <= 20.0 || !_sidebarVisible, - child: QueryaSplitHandle( - key: const Key('main_content_resize_handle'), - axis: Axis.horizontal, - semanticsLabel: - 'Resize connections and workspace panes', - onDragDelta: (dx) { - final ml = constraints.maxWidth - - _resizeHandleWidth - - _minWorkspaceWidth; - if (ml <= 0) return; - final nextW = _clampLeftWidth( - _widthSpring.value + dx, - constraints.maxWidth, - ); - _lastExpandedWidth = nextW; - _widthSpring.jumpTo(nextW); - }, - onDiscreteResize: () => _widthPersist - .onDiscreteResize(() => _lastExpandedWidth), - onDragEnd: (details) { - _widthPersist.cancelDiscreteTimer(); - final velocity = details.primaryVelocity ?? - details.velocity.pixelsPerSecond.dx; - final useSprings = - QueryaSpring.springsEnabled(context); - _widthSpring.useSprings = useSprings; - if (_widthSpring.value < 100 && velocity < -50) { - _sidebarVisible = false; - widget.onSidebarVisibilityChanged?.call(false); - _widthSpring.animateTo(0.0, velocity: velocity); - unawaited( - AppSettings.instance.setSidebarVisible(false)); - } else { - _sidebarVisible = true; - widget.onSidebarVisibilityChanged?.call(true); - _lastExpandedWidth = _clampLeftWidth( - _widthSpring.value, - constraints.maxWidth, - ); - _widthSpring.animateTo( - _lastExpandedWidth, - velocity: velocity, - ); - unawaited( - AppSettings.instance.setSidebarVisible(true)); - _schedulePersistAfterSettle(); - } - }, - ), - ); - - final material.Widget handleContent; - if (handleOpacity >= 1.0) { - handleContent = handleWidget; - } else if (handleOpacity <= 0.0) { - handleContent = const material.SizedBox.shrink(); - } else { - handleContent = Opacity( - opacity: handleOpacity, - child: handleWidget, - ); - } - - final isAnimating = _widthSpring.isAnimating; - final remainingWidth = - math.max(0.0, constraints.maxWidth - currentW - currentHandleW); - final workspaceLaidOutWidth = - isAnimating ? constraints.maxWidth : remainingWidth; - - return Row( - children: [ - ClipRect( - child: SizedBox( - width: currentW, - child: OverflowBox( - minWidth: _lastExpandedWidth, - maxWidth: _lastExpandedWidth, - alignment: Alignment.topLeft, - child: positionedSlot, - ), - ), - ), - if (!isCollapsed) - SizedBox( - width: currentHandleW, - child: ClipRect( - child: OverflowBox( - minWidth: _resizeHandleWidth, - maxWidth: _resizeHandleWidth, - alignment: Alignment.center, - child: handleContent, - ), - ), - ), - Expanded( - key: const ValueKey('main_content_workspace_expanded'), - child: ClipRect( - child: OverflowBox( - alignment: Alignment.topLeft, - minWidth: workspaceLaidOutWidth, - maxWidth: workspaceLaidOutWidth, - child: material.RepaintBoundary( - child: ValueListenableBuilder( - valueListenable: widget.workspace, - builder: (context, ws, _) { - return WorkspacePanel( - activeConnection: ws.activeConnection, - selectedRedisDb: ws.activeRedisDb, - selectedMongoDb: ws.activeMongoDB, - selectedPostgresObject: ws.selectedPostgresObject, - postgresSqlTabRequestToken: - ws.postgresSqlTabRequestToken, - postgresSqlEditorContext: - ws.postgresSqlEditorContext, - postgresSqlEditorContextToken: - ws.postgresSqlEditorContextToken, - selectedMysqlObject: ws.selectedMysqlObject, - mysqlSqlTabRequestToken: ws.mysqlSqlTabRequestToken, - selectedSqliteObject: ws.selectedSqliteObject, - sqliteSqlTabRequestToken: ws.sqliteSqlTabRequestToken, - selectedExtensionObject: ws.selectedExtensionObject, - lastSelectedPostgresObject: - ws.lastSelectedPostgresObject, - lastSelectedMysqlObject: ws.lastSelectedMysqlObject, - lastSelectedSqliteObject: ws.lastSelectedSqliteObject, - lastSelectedExtensionObject: - ws.lastSelectedExtensionObject, - lastSelectedMongoDb: ws.lastSelectedMongoDb, - lastSelectedMongoCollection: - ws.lastSelectedMongoCollection, - lastSelectedRedisDb: ws.lastSelectedRedisDb, - selectedRedisKey: ws.selectedRedisKey, - lastSelectedRedisKey: ws.lastSelectedRedisKey, - onNavigateHome: () { - widget.workspace.value = - widget.workspace.value.unselectActiveObject(); - }, - onRestoreLastSelectedObject: () { - unawaited(_restoreLastSelectedObject()); - }, - isReadOnly: ws.isReadOnly, - onRequestNewConnection: widget.onRequestNewConnection, - onRequestNewConnectionFromUrl: - widget.onRequestNewConnectionFromUrl, - onRequestOpenSqlite: widget.onRequestOpenSqlite, - onRequestLaunchDemo: widget.onRequestLaunchDemo, - onRequestOpenTour: widget.onRequestOpenTour, - onOpenConnection: widget.onOpenConnection, - initialMongoCollection: widget.initialMongoCollection, - selectedMongoCollection: ws.selectedMongoCollection, - ); - }, - ), - ), - ), - ), - ), - ], - ); - }, - ); - } -} - -/// Rebuilds [ConnectionsPanel] only when the selected connection id changes. -class _ConnectionsPanelSlot extends StatefulWidget { - const _ConnectionsPanelSlot({ - required this.connectionsPanelKey, - required this.workspace, - required this.onConnectionSelected, - required this.onPostgresObjectSelected, - required this.onMysqlObjectSelected, - required this.onSqliteObjectSelected, - required this.onRedisDatabaseSelected, - required this.onMongoDBDatabaseSelected, - required this.onMongoCollectionSelected, - required this.onPostgresOpenSqlWorkspace, - required this.onMysqlOpenSqlWorkspace, - required this.onSqliteOpenSqlWorkspace, - required this.onExtensionObjectSelected, - }); - - final GlobalKey connectionsPanelKey; - final ValueNotifier workspace; - final void Function(ConnectionRow) onConnectionSelected; - final void Function( - ConnectionRow, - String database, - String schema, - String name, - PostgresObjectKind kind, - ) onPostgresObjectSelected; - final void Function( - ConnectionRow, - String database, - String name, - MysqlObjectKind kind, - ) onMysqlObjectSelected; - final void Function( - ConnectionRow, - String name, - SqliteObjectKind kind, - ) onSqliteObjectSelected; - final void Function(ConnectionRow, int) onRedisDatabaseSelected; - final void Function(ConnectionRow, String) onMongoDBDatabaseSelected; - final void Function(ConnectionRow, String, String) onMongoCollectionSelected; - final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; - final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; - final void Function(ConnectionRow) onSqliteOpenSqlWorkspace; - final void Function( - ConnectionRow, - String database, - String name, - ) onExtensionObjectSelected; - - @override - State<_ConnectionsPanelSlot> createState() => _ConnectionsPanelSlotState(); -} - -class _ConnectionsPanelSlotState extends State<_ConnectionsPanelSlot> { - @override - void initState() { - super.initState(); - widget.workspace.addListener(_onWorkspaceChanged); - } - - @override - void dispose() { - widget.workspace.removeListener(_onWorkspaceChanged); - super.dispose(); - } - - void _onWorkspaceChanged() { - setState(() {}); - } - - @override - material.Widget build(material.BuildContext context) { - final ws = widget.workspace.value; - return ConnectionsPanel( - key: widget.connectionsPanelKey, - selectedConnectionId: ws.activeConnection?.id, - selectedPostgresObject: ws.selectedPostgresObject, - selectedMysqlObject: ws.selectedMysqlObject, - selectedSqliteObject: ws.selectedSqliteObject, - selectedExtensionObject: ws.selectedExtensionObject, - selectedRedisDb: ws.activeRedisDb, - selectedMongoDb: ws.activeMongoDB, - selectedMongoCollection: ws.selectedMongoCollection, - onConnectionSelected: widget.onConnectionSelected, - onRedisDatabaseSelected: widget.onRedisDatabaseSelected, - onMongoDBDatabaseSelected: widget.onMongoDBDatabaseSelected, - onMongoCollectionSelected: widget.onMongoCollectionSelected, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onMysqlObjectSelected: widget.onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, - onSqliteObjectSelected: widget.onSqliteObjectSelected, - onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, - onExtensionObjectSelected: widget.onExtensionObjectSelected, - onConnectionRemoved: (id) { - // The open workspace belonged to the removed connection: go home. - if (widget.workspace.value.activeConnection?.id != id) return; - widget.workspace.value = MainScreenWorkspaceState.empty; - QueryaShellStatus.instance.clear(); - }, - ); - } -} diff --git a/lib/features/main_screen/main_screen_chrome.dart b/lib/features/main_screen/main_screen_chrome.dart new file mode 100644 index 00000000..0a683b39 --- /dev/null +++ b/lib/features/main_screen/main_screen_chrome.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show ValueListenable; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/ui/querya_shell_status.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart'; +import 'package:querya_desktop/features/main_screen/main_screen_dialogs.dart'; +import 'package:querya_desktop/features/main_screen/main_screen_navigator.dart'; +import 'package:querya_desktop/features/main_screen/main_screen_workspace_state.dart'; +import 'package:querya_desktop/features/main_screen/querya_status_bar.dart'; +import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; + +/// The window title bar wired to the workspace: connect, disconnect, the +/// read-only lock, the new-connection entries and the tour. +class ShellTitleBar extends material.StatelessWidget { + const ShellTitleBar({ + super.key, + required this.workspace, + required this.isSidebarVisible, + required this.connectionsPanelKey, + required this.onToggleSidebar, + required this.dialogs, + required this.nav, + }); + + final MainScreenWorkspaceState workspace; + final ValueListenable isSidebarVisible; + final material.GlobalKey connectionsPanelKey; + final material.VoidCallback onToggleSidebar; + final MainScreenDialogs dialogs; + final MainScreenNavigator nav; + + @override + material.Widget build(material.BuildContext context) { + final panel = connectionsPanelKey; + return material.ValueListenableBuilder( + valueListenable: isSidebarVisible, + builder: (context, visible, _) { + return QueryaWindowTitleBar( + onNewDatabaseConnection: dialogs.onNewDatabaseConnectionFromMenu, + onNewDatabaseConnectionFromUrl: dialogs.onNewDatabaseConnectionFromUrl, + activeConnection: workspace.activeConnection, + isReadOnly: workspace.isReadOnly, + isSidebarVisible: visible, + onToggleSidebar: onToggleSidebar, + onOpenWelcomeTour: dialogs.onOpenWelcomeTour, + onGoHome: nav.onGoHome, + onReadOnlyChanged: () => unawaited(dialogs.toggleReadOnly()), + onConnect: () { + final id = workspace.activeConnection?.id; + if (id != null) panel.currentState?.connect(id); + }, + onReconnect: () { + final active = workspace.activeConnection; + if (active != null) panel.currentState?.reconnect(active); + }, + onDisconnect: () { + final active = workspace.activeConnection; + if (active != null) panel.currentState?.disconnect(active); + }, + onDisconnectAll: () => panel.currentState?.disconnectAll(), + onDisconnectOthers: () { + final active = workspace.activeConnection; + if (active != null) panel.currentState?.disconnectOthers(active); + }, + ); + }, + ); + } +} + +/// The status bar fed by [QueryaShellStatus]. +class ShellStatusBar extends material.StatelessWidget { + const ShellStatusBar({ + super.key, + required this.workspace, + required this.isSidebarVisible, + required this.onToggleSidebar, + }); + + final MainScreenWorkspaceState workspace; + final ValueListenable isSidebarVisible; + final material.VoidCallback onToggleSidebar; + + @override + material.Widget build(material.BuildContext context) { + return material.ValueListenableBuilder( + valueListenable: isSidebarVisible, + builder: (context, visible, _) { + return material.ListenableBuilder( + listenable: QueryaShellStatus.instance, + builder: (context, _) { + final status = QueryaShellStatus.instance; + return QueryaStatusBar( + activeConnection: workspace.activeConnection, + isReadOnly: workspace.isReadOnly, + isSidebarVisible: visible, + onToggleSidebar: onToggleSidebar, + onOpenPreferences: () => showPreferencesDialog(context), + isBusy: status.isBusy, + statusMessage: status.statusMessage, + rowCount: status.rowCount, + columnCount: status.columnCount, + lastQueryDuration: status.lastQueryDuration, + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/main_screen/main_screen_dialogs.dart b/lib/features/main_screen/main_screen_dialogs.dart new file mode 100644 index 00000000..5e1efc77 --- /dev/null +++ b/lib/features/main_screen/main_screen_dialogs.dart @@ -0,0 +1,204 @@ +import 'dart:async'; + +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/demo/demo_playground_service.dart'; +import 'package:querya_desktop/core/platform/file_launch_service.dart'; +import 'package:querya_desktop/core/security/safe_mode.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart'; +import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; +import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; +import 'package:querya_desktop/features/main_screen/main_screen_navigator.dart'; +import 'package:querya_desktop/features/main_screen/main_screen_workspace_state.dart'; +import 'package:querya_desktop/features/main_screen/safe_mode_unlock_dialog.dart'; +import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// The modal flows of the shell: a new connection (form, URL, SQLite file), +/// the welcome tour and the demo playground, files opened from the file +/// manager, and the read-only lock with its Safe Mode confirmation. +class MainScreenDialogs { + MainScreenDialogs({ + required this.workspace, + required this.nav, + required this.connectionsPanelKey, + required BuildContext Function() context, + required bool Function() mounted, + }) : _context = context, + _mounted = mounted; + + final ValueNotifier workspace; + final MainScreenNavigator nav; + final GlobalKey connectionsPanelKey; + final BuildContext Function() _context; + final bool Function() _mounted; + + bool _openingConnectionDialog = false; + + Timer? _safeModeRelockTimer; + + /// Toggles the read-only lock. Turning it off for a Production connection + /// (Safe Mode) needs a typed confirmation and re-locks after + /// [kSafeModeUnlockDuration]. + Future toggleReadOnly() async { + final ws = workspace.value; + final connection = ws.activeConnection; + if (connection == null) return; + + if (ws.isReadOnly && + safeModeRequiresConfirmation(connection.environment)) { + final confirmed = await showSafeModeUnlockDialog( + context: _context(), + connectionName: connection.name, + ); + if (!confirmed || !_mounted()) return; + final current = workspace.value; + if (current.activeConnection?.id != connection.id || !current.isReadOnly) { + return; + } + workspace.value = current.toggleReadOnly(); + _safeModeRelockTimer?.cancel(); + _safeModeRelockTimer = Timer(kSafeModeUnlockDuration, () { + if (!_mounted()) return; + final latest = workspace.value; + if (latest.activeConnection?.id == connection.id && + !latest.isReadOnly) { + workspace.value = latest.toggleReadOnly(); + showAppToast( + context: _context(), + message: '${connection.name} is read-only again', + ); + } + }); + return; + } + + _safeModeRelockTimer?.cancel(); + workspace.value = ws.toggleReadOnly(); + } + + Future openNewConnectionFromHero() async { + final row = await promptCreateConnection(_context()); + if (!_mounted() || row == null) return; + await LocalDb.instance.addConnection(row); + await connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } + + Future onNewDatabaseConnectionFromMenu() async { + if (_openingConnectionDialog) return; + _openingConnectionDialog = true; + try { + // Menu overlay _context() is torn down before the connection form opens. + await Future.delayed(const Duration(milliseconds: 100)); + if (!_mounted()) return; + final row = await promptCreateConnection(_context(), folderId: null); + if (!_mounted() || row == null) return; + await LocalDb.instance.addConnection(row); + await connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } finally { + _openingConnectionDialog = false; + } + } + + Future onNewDatabaseConnectionFromUrl() async { + if (_openingConnectionDialog) return; + _openingConnectionDialog = true; + try { + await Future.delayed(const Duration(milliseconds: 100)); + if (!_mounted()) return; + final row = await showNewConnectionUrlDialog(_context()); + if (!_mounted() || row == null) return; + await LocalDb.instance.addConnection(row); + await connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } finally { + _openingConnectionDialog = false; + } + } + + Future openSqliteFromHero() async { + final row = await showSqliteConnectionForm(_context()); + if (!_mounted() || row == null) return; + await LocalDb.instance.addConnection(row); + await connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } + + void onOpenWelcomeTour() { + showWelcomeTourDialog( + _context(), + onLaunchDemo: onLaunchDemoPlayground, + onGoHome: workspace.value.activeConnection != null ? nav.onGoHome : null, + ); + } + + Future onLaunchDemoPlayground() async { + try { + final demoConn = await DemoPlaygroundService.getOrCreateDemoConnection(); + if (!_mounted()) return; + await connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + workspace.value = workspace.value.selectConnection(demoConn); + nav.onSqliteOpenSqlWorkspace(demoConn); + } catch (_) {} + } + + Future handlePendingFileLaunch() async { + final targets = FileLaunchService.instance.consumePendingTargets(); + if (targets.isEmpty || !_mounted()) return; + + for (final target in targets) { + if (!_mounted()) return; + try { + if (target.kind == FileLaunchKind.sqlite) { + final conn = await FileLaunchService.instance + .resolveOrRegisterSqliteConnection(target); + if (!_mounted()) return; + await connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + workspace.value = workspace.value.selectConnection(conn); + nav.onSqliteOpenSqlWorkspace(conn); + } else if (target.kind == FileLaunchKind.sql) { + final sql = await FileLaunchService.instance.readSqlContent(target); + if (!_mounted()) return; + + var conn = workspace.value.activeConnection; + if (conn == null) { + final connections = await LocalDb.instance.getConnections(); + if (connections.isNotEmpty) { + conn = connections.first; + } else { + final scratchRow = ConnectionRow( + name: 'Local Scratch (${target.fileName})', + type: 'sqlite', + host: '', + createdAt: DateTime.now().toUtc().toIso8601String(), + ); + final id = await LocalDb.instance.addConnection(scratchRow); + conn = scratchRow.copyWith(id: id); + await connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } + } + + if (_mounted()) { + workspace.value = workspace.value.selectConnection(conn); + nav.openSqlWorkspaceForConnection(conn); + SqlEditorCommandBridge.instance.openFileWithContent( + sql: sql, + filePath: target.path, + title: target.fileName, + ); + } + } + } catch (e) { + if (!_mounted()) return; + showAppToast( + context: _context(), + message: 'Failed to open file "${target.fileName}": $e', + variant: AppToastVariant.error, + ); + } + } + } + + void dispose() { + _safeModeRelockTimer?.cancel(); + } +} diff --git a/lib/features/main_screen/main_screen_navigator.dart b/lib/features/main_screen/main_screen_navigator.dart new file mode 100644 index 00000000..8d254dd0 --- /dev/null +++ b/lib/features/main_screen/main_screen_navigator.dart @@ -0,0 +1,468 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object_loader.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/ui/querya_shell_status.dart'; +import 'package:querya_desktop/core/unsaved_work_guard.dart'; +import 'package:querya_desktop/features/command_palette/quick_switcher_dialog.dart'; +import 'package:querya_desktop/features/main_screen/main_screen_workspace_state.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart' show SqliteObjectKind; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.dart'; +import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_sql_tx_guard.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_sql_tx_guard.dart'; + +/// What the user picked in the connections tree or the quick switcher, applied +/// to [workspace]: each pick first asks to keep or discard unsaved work, and a +/// pick of what is open already does nothing. +/// +/// The screen owns the notifier and the context; this holds the rules. +class MainScreenNavigator { + MainScreenNavigator({ + required this.workspace, + required BuildContext Function() context, + required bool Function() mounted, + required VoidCallback onPendingMongoCollectionChanged, + }) : _context = context, + _mounted = mounted, + _onPendingMongoCollectionChanged = onPendingMongoCollectionChanged; + + final ValueNotifier workspace; + final BuildContext Function() _context; + final bool Function() _mounted; + final VoidCallback _onPendingMongoCollectionChanged; + + /// The collection picked in the tree, until the workspace shows it. + String? pendingMongoCollection; + + void _setPendingMongoCollection(String? value) { + if (pendingMongoCollection == value) return; + pendingMongoCollection = value; + _onPendingMongoCollectionChanged(); + } + + Future allowUnsavedNavigation() { + return confirmDiscardUnsavedWorkIfNeeded(_context()); + } + + void onConnectionSelected(ConnectionRow connection) { + unawaited(selectConnection(connection)); + } + + Future selectConnection(ConnectionRow connection) async { + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + final prevId = workspace.value.activeConnection?.id; + workspace.value = workspace.value.selectConnection(connection); + if (prevId != connection.id) { + QueryaShellStatus.instance.clear(); + _setPendingMongoCollection(null); + } + final id = connection.id; + if (id != null) { + unawaited(AppSettings.instance.recordRecentConnection(id)); + } + } + + void onPostgresObjectSelected( + ConnectionRow connection, + String database, + String schema, + String name, + PostgresObjectKind kind, + ) { + unawaited(selectPostgresObject( + connection, + database, + schema, + name, + kind, + )); + } + + Future selectPostgresObject( + ConnectionRow connection, + String database, + String schema, + String name, + PostgresObjectKind kind, + ) async { + final ws = workspace.value; + final cur = ws.selectedPostgresObject; + final same = ws.activeConnection?.id == connection.id && + cur != null && + cur.database == database && + cur.schema == schema && + cur.name == name && + cur.kind == kind; + if (same) return; + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + if (!await confirmOpenPostgresTableIfSqlTx( + _context(), + connection: connection, + database: database, + kind: kind, + )) { + return; + } + if (!_mounted()) return; + workspace.value = workspace.value.selectPostgresObject( + connection, + database, + schema, + name, + kind, + ); + } + + void onMysqlObjectSelected( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + ) { + unawaited(selectMysqlObject(connection, database, name, kind)); + } + + Future selectMysqlObject( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + ) async { + final ws = workspace.value; + final cur = ws.selectedMysqlObject; + final same = ws.activeConnection?.id == connection.id && + cur != null && + cur.database == database && + cur.name == name && + cur.kind == kind; + if (same) return; + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + if (!await confirmOpenMysqlTableIfSqlTx( + _context(), + connection: connection, + database: database, + kind: kind, + )) { + return; + } + if (!_mounted()) return; + workspace.value = workspace.value.selectMysqlObject( + connection, + database, + name, + kind, + ); + } + + void onRedisDatabaseSelected(ConnectionRow connection, int database) { + unawaited(selectRedisDatabase(connection, database)); + } + + Future selectRedisDatabase( + ConnectionRow connection, + int database, + ) async { + final ws = workspace.value; + if (ws.activeConnection?.id == connection.id && + ws.activeRedisDb == database) { + return; + } + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + workspace.value = workspace.value.selectRedisDb(connection, database); + } + + void onMongoDBDatabaseSelected(ConnectionRow connection, String database) { + unawaited(selectMongoDatabase(connection, database)); + } + + Future selectMongoDatabase( + ConnectionRow connection, + String database, + ) async { + final ws = workspace.value; + if (ws.activeConnection?.id == connection.id && + ws.activeMongoDB == database && + ws.selectedMongoCollection == null) { + return; + } + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + _setPendingMongoCollection(null); + workspace.value = workspace.value.selectMongoDb(connection, database); + } + + void onMongoCollectionSelected( + ConnectionRow connection, + String database, + String collection, + ) { + unawaited(selectMongoCollection(connection, database, collection)); + } + + Future selectMongoCollection( + ConnectionRow connection, + String database, + String collection, + ) async { + final ws = workspace.value; + if (ws.activeConnection?.id == connection.id && + ws.activeMongoDB == database && + ws.selectedMongoCollection == collection) { + return; + } + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + _setPendingMongoCollection(collection); + workspace.value = + workspace.value.selectMongoCollection(connection, database, collection); + } + + void onPostgresOpenSqlWorkspace( + ConnectionRow connection, { + String? database, + String? schema, + String? name, + PostgresObjectKind? kind, + }) { + unawaited(openPostgresSqlWorkspace( + connection, + database: database, + schema: schema, + name: name, + kind: kind, + )); + } + + Future openPostgresSqlWorkspace( + ConnectionRow connection, { + String? database, + String? schema, + String? name, + PostgresObjectKind? kind, + }) async { + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + workspace.value = workspace.value.openPostgresSqlWorkspace( + connection, + seedDatabase: database, + seedSchema: schema, + seedName: name, + seedKind: kind, + ); + } + + void onMysqlOpenSqlWorkspace(ConnectionRow connection) { + unawaited(openMysqlSqlWorkspace(connection)); + } + + Future openMysqlSqlWorkspace(ConnectionRow connection) async { + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + workspace.value = workspace.value.openMysqlSqlWorkspace(connection); + } + + void onSqliteObjectSelected( + ConnectionRow connection, + String name, + SqliteObjectKind kind, + ) { + unawaited(selectSqliteObject(connection, name, kind)); + } + + Future selectSqliteObject( + ConnectionRow connection, + String name, + SqliteObjectKind kind, + ) async { + final ws = workspace.value; + final cur = ws.selectedSqliteObject; + final same = ws.activeConnection?.id == connection.id && + cur != null && + cur.name == name && + cur.kind == kind; + if (same) return; + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + if (!await confirmOpenSqliteTableIfSqlTx( + _context(), + connection: connection, + kind: kind, + )) { + return; + } + if (!_mounted()) return; + workspace.value = workspace.value.selectSqliteObject( + connection, + name, + kind, + ); + } + + void onSqliteOpenSqlWorkspace(ConnectionRow connection) { + unawaited(openSqliteSqlWorkspace(connection)); + } + + Future openSqliteSqlWorkspace(ConnectionRow connection) async { + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + workspace.value = workspace.value.openSqliteSqlWorkspace(connection); + } + + void onExtensionObjectSelected( + ConnectionRow connection, + String database, + String name, + ) { + unawaited(selectExtensionObject(connection, database, name)); + } + + Future selectExtensionObject( + ConnectionRow connection, + String database, + String name, + ) async { + final ws = workspace.value; + final cur = ws.selectedExtensionObject; + final same = ws.activeConnection?.id == connection.id && + cur != null && + cur.database == database && + cur.name == name; + if (same) return; + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + workspace.value = workspace.value.selectExtensionObject( + connection, + database, + name, + ); + } + + void openQuickSwitcher([String query = '']) { + final ctx = FocusManager.instance.primaryFocus?.context ?? _context(); + final ws = workspace.value; + final conn = ws.activeConnection; + unawaited(showQuickSwitcher( + ctx, + initialQuery: query, + load: () => QueryaSchemaObjectLoader.load( + connection: conn, + database: ws.selectedPostgresObject?.database ?? + ws.selectedMysqlObject?.database ?? + ws.selectedExtensionObject?.database ?? + conn?.databaseName, + mongoDatabase: ws.activeMongoDB ?? conn?.databaseName, + ), + )); + } + + void onOpenSchemaObject(QueryaSchemaObject object) { + final conn = workspace.value.activeConnection; + if (conn == null) return; + final type = conn.type.toLowerCase(); + switch (object.kind) { + case QueryaSchemaObjectKind.table: + case QueryaSchemaObjectKind.view: + case QueryaSchemaObjectKind.materializedView: + if (type == 'postgres' || type == 'postgresql') { + onPostgresObjectSelected( + conn, + object.database ?? conn.databaseName ?? '', + object.schema ?? 'public', + object.name, + switch (object.kind) { + QueryaSchemaObjectKind.view => PostgresObjectKind.view, + QueryaSchemaObjectKind.materializedView => + PostgresObjectKind.materializedView, + _ => PostgresObjectKind.table, + }, + ); + return; + } + if (type == 'mysql') { + onMysqlObjectSelected( + conn, + object.database ?? conn.databaseName ?? '', + object.name, + object.kind == QueryaSchemaObjectKind.view + ? MysqlObjectKind.view + : MysqlObjectKind.table, + ); + return; + } + if (type == 'sqlite') { + onSqliteObjectSelected( + conn, + object.name, + object.kind == QueryaSchemaObjectKind.view + ? SqliteObjectKind.view + : SqliteObjectKind.table, + ); + return; + } + if (conn.isExtensionDriver) { + onExtensionObjectSelected( + conn, + object.database ?? conn.databaseName ?? '', + object.name, + ); + } + case QueryaSchemaObjectKind.collection: + onMongoCollectionSelected( + conn, + object.database ?? conn.databaseName ?? '', + object.name, + ); + } + } + + void openSqlWorkspaceForConnection(ConnectionRow connection) { + switch (connection.type) { + case 'postgresql': + onPostgresOpenSqlWorkspace(connection); + case 'mysql': + onMysqlOpenSqlWorkspace(connection); + case 'sqlite': + onSqliteOpenSqlWorkspace(connection); + default: + if (ExtensionDriverCatalog.isExtensionDriverConnection(connection)) { + unawaited(selectConnection(connection)); + } + } + } + + void onGoHome() { + unawaited(goHome()); + } + + Future goHome() async { + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + workspace.value = MainScreenWorkspaceState.empty; + QueryaShellStatus.instance.clear(); + } + + Future closeWorkspace() async { + if (!await allowUnsavedNavigation()) return; + if (!_mounted()) return; + workspace.value = workspace.value.unselectActiveObject(); + } + + bool hasCloseableWorkspace(MainScreenWorkspaceState ws) { + return ws.selectedPostgresObject != null || + ws.selectedMysqlObject != null || + ws.selectedSqliteObject != null || + ws.selectedExtensionObject != null || + ws.activeMongoDB != null || + ws.activeRedisDb != null; + } +} diff --git a/lib/features/main_screen/main_screen_shortcuts.dart b/lib/features/main_screen/main_screen_shortcuts.dart new file mode 100644 index 00000000..b3415b9f --- /dev/null +++ b/lib/features/main_screen/main_screen_shortcuts.dart @@ -0,0 +1,129 @@ +import 'package:flutter/services.dart'; + +/// A window-wide shortcut of the shell. +enum MainShortcut { + runScript, + runStatement, + preferences, + toggleSidebar, + commandPalette, + quickSwitcher, + newConnection, + newQuery, + newQueryTab, + closeQueryTab, + nextQueryTab, + prevQueryTab, + openSql, + saveSql, + welcomeTour, + goHome, +} + +/// Which [MainShortcut] a key press is. Pure: the screen decides what to do. +/// +/// A letter shortcut matches on the physical key, the logical key, or the +/// Russian-layout letter on the same key, so it works on both layouts. +abstract final class MainScreenShortcuts { + static const _ruR = LogicalKeyboardKey(0x0000043a); // к + static const _ruComma = LogicalKeyboardKey(0x00000431); // б + static const _ruB = LogicalKeyboardKey(0x00000438); // и + static const _ruP = [ + LogicalKeyboardKey(0x00000437), // з + LogicalKeyboardKey(0x00000417), // З + ]; + static const _ruK = [ + LogicalKeyboardKey(0x0000043b), // л + LogicalKeyboardKey(0x0000041b), // Л + ]; + static const _ruN = LogicalKeyboardKey(0x00000442); // т + static const _ruT = LogicalKeyboardKey(0x00000435); // е + static const _ruW = LogicalKeyboardKey(0x00000446); // ц + static const _ruO = LogicalKeyboardKey(0x00000449); // щ + static const _ruS = LogicalKeyboardKey(0x0000044b); // ы + static const _ruH = LogicalKeyboardKey(0x00000440); // р + + /// [cmdOrCtrl] is Cmd or Ctrl on macOS and Ctrl elsewhere. + static MainShortcut? resolve( + KeyEvent event, { + required bool cmdOrCtrl, + required bool shift, + required bool alt, + }) { + final physical = event.physicalKey; + final logical = event.logicalKey; + bool key(PhysicalKeyboardKey p, LogicalKeyboardKey l, + [List also = const []]) => + physical == p || logical == l || also.contains(logical); + + final isEnter = physical == PhysicalKeyboardKey.enter || + physical == PhysicalKeyboardKey.numpadEnter || + logical == LogicalKeyboardKey.enter || + logical == LogicalKeyboardKey.numpadEnter; + final isF5 = key(PhysicalKeyboardKey.f5, LogicalKeyboardKey.f5); + final isKeyR = key(PhysicalKeyboardKey.keyR, LogicalKeyboardKey.keyR, [_ruR]); + + // Execute, as in the editor: Ctrl/Cmd+Enter and Ctrl/Cmd+R run the + // selection or the statement at the cursor; F5 and Ctrl/Cmd+Shift+Enter + // run the whole script. + if (isF5 || (cmdOrCtrl && shift && !alt && isEnter)) { + return MainShortcut.runScript; + } + if (cmdOrCtrl && !shift && !alt && (isEnter || isKeyR)) { + return MainShortcut.runStatement; + } + + final plain = cmdOrCtrl && !shift && !alt; + if (plain && + key(PhysicalKeyboardKey.comma, LogicalKeyboardKey.comma, [_ruComma])) { + return MainShortcut.preferences; + } + if (plain && key(PhysicalKeyboardKey.keyB, LogicalKeyboardKey.keyB, [_ruB])) { + return MainShortcut.toggleSidebar; + } + if (plain && key(PhysicalKeyboardKey.keyP, LogicalKeyboardKey.keyP, _ruP)) { + return MainShortcut.commandPalette; + } + if (plain && key(PhysicalKeyboardKey.keyK, LogicalKeyboardKey.keyK, _ruK)) { + return MainShortcut.quickSwitcher; + } + if (cmdOrCtrl && + !alt && + key(PhysicalKeyboardKey.keyN, LogicalKeyboardKey.keyN, [_ruN])) { + return shift ? MainShortcut.newQuery : MainShortcut.newConnection; + } + if (plain && key(PhysicalKeyboardKey.keyT, LogicalKeyboardKey.keyT, [_ruT])) { + return MainShortcut.newQueryTab; + } + if (plain && key(PhysicalKeyboardKey.keyW, LogicalKeyboardKey.keyW, [_ruW])) { + return MainShortcut.closeQueryTab; + } + if (cmdOrCtrl && + !alt && + key(PhysicalKeyboardKey.tab, LogicalKeyboardKey.tab)) { + return shift ? MainShortcut.prevQueryTab : MainShortcut.nextQueryTab; + } + if (plain && key(PhysicalKeyboardKey.keyO, LogicalKeyboardKey.keyO, [_ruO])) { + return MainShortcut.openSql; + } + if (plain && key(PhysicalKeyboardKey.keyS, LogicalKeyboardKey.keyS, [_ruS])) { + return MainShortcut.saveSql; + } + // F1, or Ctrl/Cmd+Shift+H. + if (key(PhysicalKeyboardKey.f1, LogicalKeyboardKey.f1) || + (cmdOrCtrl && + shift && + key(PhysicalKeyboardKey.keyH, LogicalKeyboardKey.keyH, [_ruH]))) { + return MainShortcut.welcomeTour; + } + if (cmdOrCtrl && + shift && + (physical == PhysicalKeyboardKey.digit0 || + physical == PhysicalKeyboardKey.numpad0 || + logical == LogicalKeyboardKey.digit0 || + logical == LogicalKeyboardKey.numpad0)) { + return MainShortcut.goHome; + } + return null; + } +} diff --git a/lib/features/main_screen/querya_status_bar.dart b/lib/features/main_screen/querya_status_bar.dart index 5523ff52..8368a890 100644 --- a/lib/features/main_screen/querya_status_bar.dart +++ b/lib/features/main_screen/querya_status_bar.dart @@ -41,7 +41,7 @@ class QueryaStatusBar extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { final wb = context.workbench; - final theme = Theme.of(context); + final colors = context.colors; final isMac = Platform.isMacOS; final environment = activeConnection?.environment; @@ -114,7 +114,7 @@ class QueryaStatusBar extends material.StatelessWidget { style: material.TextStyle( fontSize: 11, fontWeight: material.FontWeight.w500, - color: theme.colorScheme.foreground, + color: colors.foreground, ), ), ), @@ -242,7 +242,8 @@ class QueryaStatusBar extends material.StatelessWidget { ), const Gap(3), material.Text( - '$rowCount rows${columnCount != null ? ", $columnCount cols" : ""}', + '$rowCount ${rowCount == 1 ? 'row' : 'rows'}' + '${columnCount != null ? ", $columnCount ${columnCount == 1 ? 'col' : 'cols'}" : ""}', style: material.TextStyle( fontSize: 10, fontFamily: QueryaTypography.mono, diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 0219599c..ed1bb1f2 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -20,7 +20,10 @@ import 'package:querya_desktop/features/updater/update_dialog.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/shared/widgets/querya_icon_button.dart'; import 'package:querya_desktop/shared/widgets/querya_text.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:gap/gap.dart'; +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/shared/widgets/querya_context_menu.dart'; +import 'package:querya_desktop/shared/widgets/querya_menu_bar.dart'; /// Custom bitsdojo title bar styled from [QueryaThemeScope] workbench tokens. class QueryaWindowTitleBar extends StatelessWidget { @@ -64,13 +67,13 @@ class QueryaWindowTitleBar extends StatelessWidget { @visibleForTesting static WindowButtonColors windowButtonColors(BuildContext context) { final wb = context.workbench; - final cs = Theme.of(context).colorScheme; + final colors = context.colors; return WindowButtonColors( iconNormal: wb.mutedForeground, mouseOver: wb.surface.withValues(alpha: 0.85), mouseDown: wb.borderSubtle.withValues(alpha: 0.55), - iconMouseOver: cs.foreground, - iconMouseDown: cs.foreground, + iconMouseOver: colors.foreground, + iconMouseDown: colors.foreground, ); } @@ -143,13 +146,11 @@ class QueryaWindowTitleBar extends StatelessWidget { const Gap(12), ] else const Gap(12), - Menubar( - border: false, - popoverOffset: const Offset(0, 8), + QueryaMenuBar( children: [ - MenuButton( + QueryaMenuItem( subMenu: [ - MenuButton( + QueryaMenuItem( trailing: Text( Platform.isMacOS ? 'Cmd+Shift+N' @@ -164,7 +165,7 @@ class QueryaWindowTitleBar extends StatelessWidget { ); }, child: const Text('New Query')), - MenuButton( + QueryaMenuItem( trailing: Text( Platform.isMacOS ? 'Cmd+O' : 'Ctrl+O', ).xSmall().muted(), @@ -177,7 +178,7 @@ class QueryaWindowTitleBar extends StatelessWidget { ); }, child: const Text('Open...')), - MenuButton( + QueryaMenuItem( trailing: Text( Platform.isMacOS ? 'Cmd+S' : 'Ctrl+S', ).xSmall().muted(), @@ -191,8 +192,8 @@ class QueryaWindowTitleBar extends StatelessWidget { }, child: const Text('Save')), if (activeConnection != null && onGoHome != null) ...[ - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( leading: const material.Icon( material.Icons.home_rounded, size: 18, @@ -204,16 +205,16 @@ class QueryaWindowTitleBar extends StatelessWidget { child: const Text('Close Workspace (Home)'), ), ], - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( onPressed: (_) => appWindow.close(), child: const Text('Exit')), ], child: const Text('File'), ), - MenuButton( + QueryaMenuItem( subMenu: [ - MenuButton( + QueryaMenuItem( leading: const material.Icon( material.Icons.search_rounded, size: 18, @@ -224,7 +225,7 @@ class QueryaWindowTitleBar extends StatelessWidget { onPressed: (ctx) => showCommandPalette(ctx), child: const Text('Command Palette…'), ), - MenuButton( + QueryaMenuItem( leading: const material.Icon( material.Icons.swap_horiz_rounded, size: 18, @@ -242,7 +243,7 @@ class QueryaWindowTitleBar extends StatelessWidget { }, child: const Text('Go to Object…'), ), - MenuButton( + QueryaMenuItem( leading: const material.Icon( material.Icons.tune_rounded, size: 18, @@ -250,8 +251,8 @@ class QueryaWindowTitleBar extends StatelessWidget { onPressed: (ctx) => showPreferencesDialog(ctx), child: const Text('Preferences…'), ), - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( leading: const material.Icon( material.Icons.extension_rounded, size: 18, @@ -263,9 +264,9 @@ class QueryaWindowTitleBar extends StatelessWidget { ], child: const Text('Edit'), ), - MenuButton( + QueryaMenuItem( subMenu: [ - MenuButton( + QueryaMenuItem( leading: const material.Icon( material.Icons.view_sidebar_rounded, size: 18, @@ -276,8 +277,8 @@ class QueryaWindowTitleBar extends StatelessWidget { onPressed: (_) => onToggleSidebar?.call(), child: const Text('Toggle Sidebar'), ), - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( leading: const material.Icon( material.Icons.auto_awesome_rounded, size: 18, @@ -289,8 +290,8 @@ class QueryaWindowTitleBar extends StatelessWidget { child: const Text('Welcome & Tutorial…'), ), if (activeConnection != null && onGoHome != null) ...[ - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( leading: const material.Icon( material.Icons.home_rounded, size: 18, @@ -307,9 +308,9 @@ class QueryaWindowTitleBar extends StatelessWidget { ], child: const Text('View'), ), - MenuButton( + QueryaMenuItem( subMenu: [ - MenuButton( + QueryaMenuItem( leading: const material.Icon( material.Icons.add_link_rounded, size: 18), @@ -319,7 +320,7 @@ class QueryaWindowTitleBar extends StatelessWidget { onPressed: (_) => onNewDatabaseConnection(), child: const Text('New Database Connection'), ), - MenuButton( + QueryaMenuItem( leading: const material.Icon( material.Icons.link_rounded, size: 18), @@ -327,15 +328,15 @@ class QueryaWindowTitleBar extends StatelessWidget { onNewDatabaseConnectionFromUrl(), child: const Text('New Connection from URL'), ), - MenuButton( + QueryaMenuItem( leading: const material.Icon( material.Icons.settings_rounded, size: 18), onPressed: (ctx) => showDriverManagerDialog(ctx), child: const Text('Driver Manager'), ), - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( enabled: activeConnection != null, leading: const material.Icon( material.Icons.power_rounded, @@ -343,7 +344,7 @@ class QueryaWindowTitleBar extends StatelessWidget { onPressed: (_) => onConnect?.call(), child: const Text('Connect'), ), - MenuButton( + QueryaMenuItem( enabled: activeConnection != null, leading: const material.Icon( material.Icons.refresh_rounded, @@ -351,7 +352,7 @@ class QueryaWindowTitleBar extends StatelessWidget { onPressed: (_) => onReconnect?.call(), child: const Text('Invalidate/Reconnect'), ), - MenuButton( + QueryaMenuItem( enabled: activeConnection != null, leading: const material.Icon( material.Icons.power_off_rounded, @@ -359,15 +360,15 @@ class QueryaWindowTitleBar extends StatelessWidget { onPressed: (_) => onDisconnect?.call(), child: const Text('Disconnect'), ), - MenuButton( + QueryaMenuItem( onPressed: (_) => onDisconnectAll?.call(), child: const Text('Disconnect All')), - MenuButton( + QueryaMenuItem( enabled: activeConnection != null, onPressed: (_) => onDisconnectOthers?.call(), child: const Text('Disconnect Others')), - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( enabled: activeConnection != null, leading: const material.Icon( material.Icons.lock_outline_rounded, @@ -383,9 +384,9 @@ class QueryaWindowTitleBar extends StatelessWidget { ], child: const Text('Connection'), ), - MenuButton( + QueryaMenuItem( subMenu: [ - MenuButton( + QueryaMenuItem( leading: const material.Icon( material.Icons.auto_awesome_rounded, size: 18, @@ -396,15 +397,15 @@ class QueryaWindowTitleBar extends StatelessWidget { onPressed: (_) => onOpenWelcomeTour?.call(), child: const Text('Welcome & Quick Tour…'), ), - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( onPressed: (ctx) => showAboutDialog(ctx), child: const Text('About')), - MenuButton( + QueryaMenuItem( onPressed: (ctx) => showUpdateDialog(ctx), child: const Text('Check for Updates…'), ), - MenuButton( + QueryaMenuItem( onPressed: (_) => openQueryaDocumentation(), child: const Text('Documentation')), ], diff --git a/lib/features/main_screen/safe_mode_unlock_dialog.dart b/lib/features/main_screen/safe_mode_unlock_dialog.dart index f506ca84..a08cc7b7 100644 --- a/lib/features/main_screen/safe_mode_unlock_dialog.dart +++ b/lib/features/main_screen/safe_mode_unlock_dialog.dart @@ -66,11 +66,11 @@ class _SafeModeUnlockDialogState extends material.State { children: [ Text('Type $phrase to confirm').small().muted(), const Gap(8), - TextField( + QueryaTextField( key: const material.Key('safe_mode_unlock_field'), controller: _controller, autofocus: true, - placeholder: Text(phrase), + placeholder: phrase, onChanged: (_) => setState(() {}), onSubmitted: (_) => _confirm(), ), diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index 9aa3bab1..7627eb11 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -98,7 +98,7 @@ class _WorkspaceEmptyHeroState extends State { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final wb = context.workbench; final showRecent = _loaded && _recent.isNotEmpty; @@ -325,7 +325,7 @@ class _RecentConnectionsSection extends StatelessWidget { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final wb = context.workbench; return material.Container( @@ -380,7 +380,7 @@ class _RecentConnectionRow extends StatelessWidget { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final subtitle = _connectionSubtitle(connection); return material.Material( @@ -473,7 +473,7 @@ class _InteractiveQuickStartRowState extends State<_InteractiveQuickStartRow> { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final wb = context.workbench; return material.MouseRegion( diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 54a75aa5..18ad7406 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -207,7 +207,7 @@ class _WorkspacePanelState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; final activeConn = widget.activeConnection; final empty = material.Align( @@ -227,7 +227,7 @@ class _WorkspacePanelState extends State { activeBody = _cachedActiveBody ?? const material.SizedBox.expand(); } else { _lastConnectedId = activeConn.id; - activeBody = _buildActiveConnectionBody(theme, activeConn); + activeBody = _buildActiveConnectionBody(activeConn); _cachedActiveBody = activeBody; } @@ -236,7 +236,7 @@ class _WorkspacePanelState extends State { final connKey = activeConn?.id ?? _lastConnectedId ?? 0; return material.Container( - color: theme.colorScheme.background, + color: colors.background, child: QueryaSwitchingBody( index: activeConn == null ? 0 : 1, children: [ @@ -253,7 +253,6 @@ class _WorkspacePanelState extends State { } material.Widget _buildActiveConnectionBody( - ThemeData theme, ConnectionRow activeConn, ) { material.Widget? driverWorkspace; @@ -436,7 +435,7 @@ class _WorkspacePanelState extends State { material.Icon( material.Icons.error_outline_rounded, size: 36, - color: theme.colorScheme.mutedForeground, + color: context.colors.mutedForeground, ), const material.SizedBox(height: 12), const Text('Unsupported connection type').semiBold(), diff --git a/lib/features/mongodb/mongo_aggregation_workspace.dart b/lib/features/mongodb/mongo_aggregation_workspace.dart index 09d55290..cd8bbc5a 100644 --- a/lib/features/mongodb/mongo_aggregation_workspace.dart +++ b/lib/features/mongodb/mongo_aggregation_workspace.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/mongodb/mongo_aggregation_stage.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_table_view.dart'; @@ -205,7 +207,7 @@ class _MongoAggregationWorkspaceState @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return material.Container( color: cs.background, @@ -259,7 +261,7 @@ class _MongoAggregationWorkspaceState ); } - material.Widget _buildActionBar(ColorScheme cs) { + material.Widget _buildActionBar(QueryaUiColors cs) { final enabledCount = _stages.where((s) => s.isEnabled).length; return material.Container( @@ -328,7 +330,7 @@ class _MongoAggregationWorkspaceState ); } - material.Widget _buildStagesPane(ColorScheme cs) { + material.Widget _buildStagesPane(QueryaUiColors cs) { return material.Container( color: cs.card.withValues(alpha: 0.35), child: material.Column( @@ -385,7 +387,7 @@ class _MongoAggregationWorkspaceState ); } - material.Widget _buildResultsPane(ColorScheme cs) { + material.Widget _buildResultsPane(QueryaUiColors cs) { final title = _executedUpToStageIndex == null ? 'Pipeline Results' : 'Results after Stage #${_executedUpToStageIndex! + 1} (${_stages[_executedUpToStageIndex!].operator})'; @@ -563,11 +565,11 @@ class _StageCardState extends material.State<_StageCard> { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final stage = widget.stage; final syntaxError = validateStage(stage); - return Card( + return QueryaCard( child: material.Padding( padding: const material.EdgeInsets.all(12), child: material.Column( @@ -601,7 +603,7 @@ class _StageCardState extends material.State<_StageCard> { ), ), const Gap(8), - Switch( + material.Switch( value: stage.isEnabled, onChanged: widget.onToggleEnabled, ), @@ -647,21 +649,21 @@ class _StageCardState extends material.State<_StageCard> { borderRadius: material.BorderRadius.circular(6), color: cs.background, ), - child: material.TextField( - controller: _textController, - maxLines: null, - minLines: 3, - style: const material.TextStyle( - fontFamily: 'monospace', - fontSize: 12.5, - height: 1.4, - ), - decoration: const material.InputDecoration( - isDense: true, - border: material.InputBorder.none, - contentPadding: material.EdgeInsets.all(10), + child: material.Padding( + padding: const material.EdgeInsets.all(10), + child: QueryaTextField( + framed: false, + controller: _textController, + maxLines: null, + minLines: 3, + style: const material.TextStyle( + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, + fontSize: 12.5, + height: 1.4, + ), + onChanged: widget.onQueryChanged, ), - onChanged: widget.onQueryChanged, ), ), if (syntaxError != null) ...[ @@ -759,7 +761,7 @@ class _ExportDialogState extends material.State<_ExportDialog> { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final code = _generateCode(); return QueryaModalDialog( @@ -810,7 +812,8 @@ class _ExportDialogState extends material.State<_ExportDialog> { child: material.SelectableText( code, style: const material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12.5, height: 1.45, ), diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 19937831..8602d1e9 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -1,6 +1,7 @@ import 'dart:math' show min; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/actions/querya_schema_object.dart'; import 'package:querya_desktop/core/actions/querya_schema_object_cache.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; @@ -8,8 +9,6 @@ import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; /// Displays the list of collections in a MongoDB database. @@ -214,7 +213,7 @@ class _MongoCollectionsViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading) { return const material.Center( @@ -259,8 +258,8 @@ class _MongoCollectionsViewState extends material.State { ); } - Widget _buildCard(ColorScheme cs) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; + Widget _buildCard(QueryaUiColors cs) { + final shadcnCs = context.colors; return material.Container( decoration: material.BoxDecoration( color: cs.card, @@ -317,9 +316,9 @@ class _MongoCollectionsViewState extends material.State { ], material.SizedBox( width: 180, - child: TextField( + child: QueryaTextField( controller: _newCollController, - placeholder: const Text('New collection...'), + placeholder: 'New collection...', ), ), const Gap(8), @@ -369,7 +368,7 @@ class _MongoCollectionsViewState extends material.State { itemBuilder: (context, i) { return _CollectionRow( collection: _collections[i], - colorScheme: cs, + colors: cs, onView: () => widget.onCollectionTap?.call(_collections[i].name), onDrop: () => _dropCollection(_collections[i].name), @@ -388,21 +387,21 @@ class _MongoCollectionsViewState extends material.State { class _CollectionRow extends StatelessWidget { const _CollectionRow({ required this.collection, - required this.colorScheme, + required this.colors, required this.onView, required this.onDrop, }); final _CollectionInfo collection; - final ColorScheme colorScheme; + final QueryaUiColors colors; final VoidCallback onView; final VoidCallback onDrop; @override Widget build(BuildContext context) { - final cs = colorScheme; + final cs = colors; return material.Material( - color: Colors.transparent, + color: material.Colors.transparent, child: material.InkWell( onTap: onView, hoverColor: cs.muted.withValues(alpha: 0.15), diff --git a/lib/features/mongodb/mongo_create_index_dialog.dart b/lib/features/mongodb/mongo_create_index_dialog.dart index d87d35f4..41f95419 100644 --- a/lib/features/mongodb/mongo_create_index_dialog.dart +++ b/lib/features/mongodb/mongo_create_index_dialog.dart @@ -189,7 +189,7 @@ class _MongoCreateIndexDialogState @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return QueryaModalDialog( title: const Text('Create Index'), @@ -226,18 +226,9 @@ class _MongoCreateIndexDialogState children: [ material.Expanded( flex: 3, - child: material.TextField( + child: QueryaTextField( controller: _fields[i].controller, - decoration: const material.InputDecoration( - hintText: 'Field name (e.g. email)', - isDense: true, - border: material.OutlineInputBorder(), - contentPadding: material.EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - ), - style: const material.TextStyle(fontSize: 13), + placeholder: 'Field name (e.g. email)', onChanged: (_) => setState(() {}), ), ), @@ -337,36 +328,20 @@ class _MongoCreateIndexDialogState ), const Gap(8), // TTL - material.TextField( + const Text('TTL: expire after seconds (optional)').small().semiBold(), + const Gap(6), + QueryaTextField( controller: _ttlController, keyboardType: material.TextInputType.number, - decoration: const material.InputDecoration( - labelText: 'TTL: Expire After Seconds (optional)', - hintText: 'e.g. 86400 (24 hours)', - isDense: true, - border: material.OutlineInputBorder(), - contentPadding: material.EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - ), - style: const material.TextStyle(fontSize: 13), + placeholder: 'e.g. 86400 (24 hours)', ), const Gap(12), // Index Name - material.TextField( + const Text('Index name (optional)').small().semiBold(), + const Gap(6), + QueryaTextField( controller: _nameController, - decoration: material.InputDecoration( - labelText: 'Index Name (optional)', - hintText: _computedIndexName(), - isDense: true, - border: const material.OutlineInputBorder(), - contentPadding: const material.EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - ), - style: const material.TextStyle(fontSize: 13), + placeholder: _computedIndexName(), ), ], ), diff --git a/lib/features/mongodb/mongo_database_dialog.dart b/lib/features/mongodb/mongo_database_dialog.dart index f76396ba..6786072b 100644 --- a/lib/features/mongodb/mongo_database_dialog.dart +++ b/lib/features/mongodb/mongo_database_dialog.dart @@ -79,9 +79,9 @@ class _CreateMongoDBDialogContentState children: [ const Text('Database Name').small().semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: _nameController, - placeholder: const Text('mydb'), + placeholder: 'mydb', ), ], ), diff --git a/lib/features/mongodb/mongo_databases_view.dart b/lib/features/mongodb/mongo_databases_view.dart index 53da224b..dbe856ca 100644 --- a/lib/features/mongodb/mongo_databases_view.dart +++ b/lib/features/mongodb/mongo_databases_view.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; /// View that displays MongoDB databases list and server status. @@ -220,7 +219,7 @@ class _MongoDatabasesViewState extends State { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_isLoading) { return const material.Center( @@ -272,8 +271,8 @@ class _MongoDatabasesViewState extends State { ); } - Widget _buildDatabasesCard(ColorScheme cs) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; + Widget _buildDatabasesCard(QueryaUiColors cs) { + final shadcnCs = context.colors; return material.Container( decoration: material.BoxDecoration( color: cs.card, @@ -305,9 +304,9 @@ class _MongoDatabasesViewState extends State { const Spacer(), material.SizedBox( width: 180, - child: TextField( + child: QueryaTextField( controller: _newDbController, - placeholder: const Text('New database...'), + placeholder: 'New database...', ), ), const Gap(8), @@ -344,7 +343,7 @@ class _MongoDatabasesViewState extends State { QueryaDivider(height: 1, color: cs.border.withValues(alpha: 0.15)), _DatabaseRow( database: _databases[i], - colorScheme: cs, + colors: cs, onView: () => widget.onDatabaseTap?.call(_databases[i].name), onDrop: () => _dropDatabase(_databases[i].name), ), @@ -367,21 +366,21 @@ class _MongoDatabasesViewState extends State { class _DatabaseRow extends StatelessWidget { const _DatabaseRow({ required this.database, - required this.colorScheme, + required this.colors, required this.onView, required this.onDrop, }); final _DatabaseInfo database; - final ColorScheme colorScheme; + final QueryaUiColors colors; final VoidCallback onView; final VoidCallback onDrop; @override Widget build(BuildContext context) { - final cs = colorScheme; + final cs = colors; return material.Material( - color: Colors.transparent, + color: material.Colors.transparent, child: material.InkWell( onTap: onView, hoverColor: cs.muted.withValues(alpha: 0.15), diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index 593c80e7..9f02f323 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -11,8 +11,6 @@ import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; /// Full-screen JSON editor for a single MongoDB document. @@ -247,8 +245,8 @@ class _MongoDocumentEditorState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - final shadcnCs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; + final shadcnCs = context.colors; final workbench = context.workbench; final editorTheme = context.editorTheme; final success = workbench.success; diff --git a/lib/features/mongodb/mongo_document_tree_view.dart b/lib/features/mongodb/mongo_document_tree_view.dart index 985da922..b82319e1 100644 --- a/lib/features/mongodb/mongo_document_tree_view.dart +++ b/lib/features/mongodb/mongo_document_tree_view.dart @@ -3,10 +3,16 @@ import 'dart:convert'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:mongo_dart/mongo_dart.dart' show ObjectId; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; +import 'package:querya_desktop/core/theme/querya_semantic_palette.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:gap/gap.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/shared/widgets/querya_button.dart'; import 'package:querya_desktop/shared/widgets/querya_divider.dart'; @@ -20,6 +26,20 @@ import 'package:querya_desktop/shared/widgets/querya_text.dart'; /// - parent = 'address', key = 'geo' => 'address.geo' /// - parent = 'address.geo', index = 0 => 'address.geo[0]' /// - parent = 'address.geo[0]', key = 'lat' => 'address.geo[0].lat' +/// `[a-zA-Z_$][a-zA-Z0-9_$]*`, read from the code units: this runs for every +/// node of every document in the tree, so no RegExp is built per call (#1525). +bool _isPlainJsIdentifier(String s) { + if (s.isEmpty) return false; + for (var i = 0; i < s.length; i++) { + final c = s.codeUnitAt(i); + final letter = (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A); + final extra = c == 0x5F || c == 0x24; // _ $ + final digit = c >= 0x30 && c <= 0x39; + if (!(letter || extra || (i > 0 && digit))) return false; + } + return true; +} + String appendJsonPath(String parentPath, Object keyOrIndex) { if (keyOrIndex is int) { return '$parentPath[$keyOrIndex]'; @@ -28,7 +48,7 @@ String appendJsonPath(String parentPath, Object keyOrIndex) { if (parentPath.isEmpty) { return keyStr; } - if (RegExp(r'^[a-zA-Z_$][a-zA-Z0-9_$]*$').hasMatch(keyStr)) { + if (_isPlainJsIdentifier(keyStr)) { return '$parentPath.$keyStr'; } final escaped = keyStr.replaceAll('"', r'\"'); @@ -152,7 +172,7 @@ class _MongoDocumentTreeViewState extends State { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (widget.documents.isEmpty) { return material.Center( @@ -181,9 +201,9 @@ class _MongoDocumentTreeViewState extends State { material.SizedBox( width: 200, height: 28, - child: TextField( + child: QueryaTextField( controller: _searchController, - placeholder: const Text('Filter keys / values...'), + placeholder: 'Filter keys / values...', onChanged: (val) { setState(() { _searchQuery = val.trim().toLowerCase(); @@ -238,7 +258,7 @@ class _MongoDocumentTreeViewState extends State { Widget _buildDocumentNode( int docIndex, Map doc, - ColorScheme cs, + QueryaUiColors cs, ) { final docNodeId = 'doc_$docIndex'; final isExpanded = _expandedNodeIds.contains(docNodeId); @@ -304,7 +324,9 @@ class _MongoDocumentTreeViewState extends State { '_id: $idString', style: material.TextStyle( fontSize: 11, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, color: cs.mutedForeground, ), ), @@ -384,7 +406,7 @@ class _MongoDocumentTreeViewState extends State { required Object keyOrIndex, required Object? value, required int depth, - required ColorScheme cs, + required QueryaUiColors cs, }) { final currentPath = appendJsonPath(parentPath, keyOrIndex); final nodeId = 'doc_${docIndex}_$currentPath'; @@ -499,7 +521,7 @@ class _MongoDocumentTreeViewState extends State { required bool isExpanded, required String jsonPath, required Object rawSubtree, - required ColorScheme cs, + required QueryaUiColors cs, required VoidCallback onToggle, required bool matchesSearch, }) { @@ -526,7 +548,8 @@ class _MongoDocumentTreeViewState extends State { Text( keyLabel, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12, fontWeight: material.FontWeight.w600, color: matchesSearch && _searchQuery.isNotEmpty @@ -538,7 +561,8 @@ class _MongoDocumentTreeViewState extends State { Text( badgeLabel, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 11, color: cs.mutedForeground, ), @@ -576,11 +600,12 @@ class _MongoDocumentTreeViewState extends State { required Object? value, required String jsonPath, required Map rootDoc, - required ColorScheme cs, + required QueryaUiColors cs, }) { final keyLabel = keyOrIndex is int ? '[$keyOrIndex]' : keyOrIndex.toString(); final valueDisplay = _formatLeafDisplay(value); - final valueColor = _leafValueColor(value, cs); + final palette = context.semanticPalette; + final valueColor = _leafValueColor(value, cs, palette); final typeName = _leafTypeName(value); final isMatch = _searchQuery.isNotEmpty && @@ -601,7 +626,8 @@ class _MongoDocumentTreeViewState extends State { Text( keyLabel, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12, fontWeight: material.FontWeight.w500, color: isMatch ? cs.primary : cs.foreground, @@ -610,7 +636,8 @@ class _MongoDocumentTreeViewState extends State { Text( ': ', style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12, color: cs.mutedForeground, ), @@ -620,7 +647,8 @@ class _MongoDocumentTreeViewState extends State { valueDisplay, overflow: TextOverflow.ellipsis, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12, fontStyle: value == null ? material.FontStyle.italic : material.FontStyle.normal, @@ -683,13 +711,17 @@ class _MongoDocumentTreeViewState extends State { return value.toString(); } - Color _leafValueColor(Object? value, ColorScheme cs) { - if (value == null) return cs.mutedForeground; - if (value is String) return const Color(0xFF22C55E); // Green - if (value is num) return const Color(0xFF3B82F6); // Blue - if (value is bool) return const Color(0xFFF97316); // Orange - if (value is ObjectId) return cs.primary; // Primary / Purple - if (value is DateTime) return const Color(0xFF06B6D4); // Cyan + Color _leafValueColor( + Object? value, + QueryaUiColors cs, + QueryaSemanticPalette palette, + ) { + if (value == null) return palette.muted; + if (value is String) return palette.type1; + if (value is num) return palette.type2; + if (value is bool) return palette.type3; + if (value is ObjectId) return cs.primary; + if (value is DateTime) return palette.type4; return cs.foreground; } diff --git a/lib/features/mongodb/mongo_documents_json_view.dart b/lib/features/mongodb/mongo_documents_json_view.dart index 793958d8..321d411e 100644 --- a/lib/features/mongodb/mongo_documents_json_view.dart +++ b/lib/features/mongodb/mongo_documents_json_view.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:gap/gap.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/shared/widgets/querya_button.dart'; import 'package:querya_desktop/shared/widgets/querya_text.dart'; @@ -62,7 +64,7 @@ class _MongoDocumentsJsonViewState extends State { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (widget.documents.isEmpty) { return material.Center( diff --git a/lib/features/mongodb/mongo_documents_table_view.dart b/lib/features/mongodb/mongo_documents_table_view.dart index e82a3982..54c01eb8 100644 --- a/lib/features/mongodb/mongo_documents_table_view.dart +++ b/lib/features/mongodb/mongo_documents_table_view.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:mongo_dart/mongo_dart.dart' show ObjectId; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:gap/gap.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/shared/widgets/querya_button.dart'; import 'package:querya_desktop/shared/widgets/querya_text.dart'; @@ -159,7 +161,7 @@ class _MongoDocumentsTableViewState extends State { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (widget.documents.isEmpty || _gridData.columns.isEmpty) { return material.Center( diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index 6dfd8ac7..7caf2c0a 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -1,10 +1,12 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/mongodb/mongo_add_document_dialog.dart'; import 'package:querya_desktop/features/mongodb/mongo_document_tree_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart'; @@ -14,8 +16,6 @@ import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/features/workspace/grid_cell_popover_inspector.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; const _defaultLimit = 25; @@ -368,7 +368,7 @@ class _MongoDocumentsViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading && _documents.isEmpty) { return const material.Center( @@ -424,11 +424,11 @@ class _MongoDocumentsViewState extends material.State { itemCount: _documents.length, separatorBuilder: (_, __) => const Gap(8), itemBuilder: (context, i) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; + final shadcnCs = context.colors; return _DocumentCard( document: _documents[i], index: _skip + i, - colorScheme: cs, + colors: cs, shadcnCs: shadcnCs, onView: () => widget.onDocumentTap?.call(_documents[i]), @@ -446,8 +446,8 @@ class _MongoDocumentsViewState extends material.State { ); } - Widget _buildFilterBar(ColorScheme cs) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; + Widget _buildFilterBar(QueryaUiColors cs) { + final shadcnCs = context.colors; return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), @@ -571,7 +571,7 @@ class _MongoDocumentsViewState extends material.State { ); } - Widget _buildErrorBanner(ColorScheme cs) { + Widget _buildErrorBanner(QueryaUiColors cs) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 8), color: cs.destructive.withValues(alpha: 0.1), @@ -596,8 +596,8 @@ class _MongoDocumentsViewState extends material.State { ); } - Widget _buildPaginationBar(ColorScheme cs) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; + Widget _buildPaginationBar(QueryaUiColors cs) { + final shadcnCs = context.colors; final currentPage = (_skip / _limit).floor() + 1; final total = _totalCount; final shown = _skip + _documents.length; @@ -666,7 +666,7 @@ class _DocumentCard extends StatefulWidget { const _DocumentCard({ required this.document, required this.index, - required this.colorScheme, + required this.colors, required this.shadcnCs, required this.onView, required this.onDelete, @@ -675,8 +675,8 @@ class _DocumentCard extends StatefulWidget { final Map document; final int index; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; final VoidCallback onView; final VoidCallback onDelete; final ValueChanged onInspectField; @@ -709,7 +709,7 @@ class _DocumentCardState extends State<_DocumentCard> { @override Widget build(BuildContext context) { - final cs = widget.colorScheme; + final cs = widget.colors; final scs = widget.shadcnCs; final idStr = widget.document['_id']?.toString() ?? '—'; @@ -787,7 +787,7 @@ class _DocumentCardState extends State<_DocumentCard> { child: _expanded ? MongoFieldList( document: widget.document, - colorScheme: cs, + colors: cs, shadcnCs: scs, onInspectField: widget.onInspectField, ) @@ -797,7 +797,9 @@ class _DocumentCardState extends State<_DocumentCard> { maxLines: 1, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, color: scs.mutedForeground, ), ), @@ -820,14 +822,14 @@ class MongoFieldList extends StatelessWidget { const MongoFieldList({ super.key, required this.document, - required this.colorScheme, + required this.colors, required this.shadcnCs, required this.onInspectField, }); final Map document; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; final ValueChanged onInspectField; Widget _buildFieldRow(String key) { @@ -850,9 +852,10 @@ class MongoFieldList extends StatelessWidget { overflow: TextOverflow.ellipsis, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontWeight: material.FontWeight.w600, - color: colorScheme.foreground, + color: colors.foreground, ), ), ), @@ -864,7 +867,8 @@ class MongoFieldList extends StatelessWidget { maxLines: 1, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, color: shadcnCs.mutedForeground, ), ), @@ -883,7 +887,7 @@ class MongoFieldList extends StatelessWidget { Widget _buildDivider() => QueryaDivider( height: 1, - color: colorScheme.border.withValues(alpha: 0.2), + color: colors.border.withValues(alpha: 0.2), ); @override @@ -892,10 +896,10 @@ class MongoFieldList extends StatelessWidget { if (fields.isEmpty) return const material.SizedBox.shrink(); final decoration = material.BoxDecoration( - color: colorScheme.muted.withValues(alpha: 0.15), + color: colors.muted.withValues(alpha: 0.15), borderRadius: material.BorderRadius.circular(6), border: material.Border.all( - color: colorScheme.border.withValues(alpha: 0.3), + color: colors.border.withValues(alpha: 0.3), ), ); diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index 8fe0e31b..ebb5b2f4 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -7,8 +7,6 @@ import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/unsaved_work_guard.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'mongo_aggregation_workspace.dart'; import 'mongo_collections_view.dart'; @@ -303,7 +301,7 @@ class _MongoExplorerViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; // Loading state if (_connecting) { @@ -513,7 +511,7 @@ class _BreadcrumbBar extends StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Container( constraints: const material.BoxConstraints(minHeight: 44), padding: const material.EdgeInsets.symmetric(horizontal: 16), @@ -599,7 +597,7 @@ class _CrumbChip extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; final child = isLast ? Text(label).semiBold().small() : Text(label, diff --git a/lib/features/mongodb/mongo_indexes_view.dart b/lib/features/mongodb/mongo_indexes_view.dart index 1c709b38..37187808 100644 --- a/lib/features/mongodb/mongo_indexes_view.dart +++ b/lib/features/mongodb/mongo_indexes_view.dart @@ -1,8 +1,10 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/mongodb/mongo_create_index_dialog.dart'; import 'package:querya_desktop/features/mongodb/mongo_index_info.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -187,7 +189,7 @@ class _MongoIndexesViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return material.Container( color: cs.background, @@ -262,7 +264,7 @@ class _MongoIndexesViewState extends material.State { ); } - material.Widget _buildHeader(ColorScheme cs) { + material.Widget _buildHeader(QueryaUiColors cs) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 8), color: cs.card, @@ -335,9 +337,9 @@ class _IndexCard extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; - return Card( + return QueryaCard( child: material.Padding( padding: const material.EdgeInsets.all(14), child: material.Row( @@ -406,7 +408,9 @@ class _IndexCard extends material.StatelessWidget { material.SelectableText( index.keysFormatted, style: const material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 12, fontWeight: material.FontWeight.w500, ), diff --git a/lib/features/mongodb/mongo_query_executor.dart b/lib/features/mongodb/mongo_query_executor.dart index 1c1a2b3f..d81393b6 100644 --- a/lib/features/mongodb/mongo_query_executor.dart +++ b/lib/features/mongodb/mongo_query_executor.dart @@ -19,6 +19,18 @@ class MongoQueryResult { int get count => documents.length; } +/// "Fetched N documents", and a note when `.limit(0)` (no limit in MongoDB) +/// was cut at [kMongoDefaultFindLimit] so the user knows there may be more. +String mongoFindSummary(int fetched, int? requestedLimit) { + final capped = requestedLimit != null && + requestedLimit <= 0 && + fetched >= kMongoDefaultFindLimit; + return capped + ? 'Fetched $fetched documents (capped at $kMongoDefaultFindLimit; ' + 'give .limit(n) for more)' + : 'Fetched $fetched documents'; +} + /// Abstract contract for executing parsed [MqlCommand]s against MongoDB. abstract class MongoQueryExecutor { Future execute({ @@ -66,7 +78,7 @@ class DefaultMongoQueryExecutor implements MongoQueryExecutor { return MongoQueryResult( documents: docs, elapsed: stopwatch.elapsed, - summary: 'Fetched ${docs.length} documents', + summary: mongoFindSummary(docs.length, command.limit), ); case MqlMethod.findOne: diff --git a/lib/features/mongodb/mongo_query_workspace.dart b/lib/features/mongodb/mongo_query_workspace.dart index fc61c8b5..b43431cd 100644 --- a/lib/features/mongodb/mongo_query_workspace.dart +++ b/lib/features/mongodb/mongo_query_workspace.dart @@ -2,14 +2,18 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_table_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_query_executor.dart'; import 'package:querya_desktop/features/mongodb/mql_parser.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; +import 'package:querya_desktop/shared/widgets/query_error_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -217,7 +221,7 @@ class _MongoQueryWorkspaceState extends material.State { await showAppDialog( context: context, builder: (ctx) { - final cs = Theme.of(ctx).colorScheme; + final cs = ctx.colors; return QueryaModalDialog( title: const Text('MQL Query History'), description: Text('${widget.database} — recent queries'), @@ -242,7 +246,9 @@ class _MongoQueryWorkspaceState extends material.State { maxLines: 2, overflow: material.TextOverflow.ellipsis, style: const material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 12, ), ), @@ -282,8 +288,7 @@ class _MongoQueryWorkspaceState extends material.State { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; + final cs = context.colors; final tab = _activeTab; return material.CallbackShortcuts( @@ -344,7 +349,7 @@ class _MongoQueryWorkspaceState extends material.State { ); } - material.Widget _buildTopBar(ColorScheme cs) { + material.Widget _buildTopBar(QueryaUiColors cs) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 8), color: cs.card, @@ -394,7 +399,7 @@ class _MongoQueryWorkspaceState extends material.State { ); } - material.Widget _buildTabStrip(ColorScheme cs) { + material.Widget _buildTabStrip(QueryaUiColors cs) { return material.Container( height: 38, padding: const material.EdgeInsets.symmetric(horizontal: 8), @@ -410,7 +415,7 @@ class _MongoQueryWorkspaceState extends material.State { ); } - material.Widget _buildResultBar(ColorScheme cs, _QueryTabSession tab) { + material.Widget _buildResultBar(QueryaUiColors cs, _QueryTabSession tab) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 6), color: cs.card, @@ -461,7 +466,7 @@ class _MongoQueryWorkspaceState extends material.State { ); } - material.Widget _buildResultsView(ColorScheme cs, _QueryTabSession tab) { + material.Widget _buildResultsView(QueryaUiColors cs, _QueryTabSession tab) { if (tab.isLoading) { return const material.Center( child: QueryaSpinner( @@ -496,14 +501,7 @@ class _MongoQueryWorkspaceState extends material.State { ], ), const Gap(8), - material.SelectableText( - tab.error!, - style: material.TextStyle( - color: cs.destructive, - fontFamily: 'monospace', - fontSize: 12.5, - ), - ), + QueryErrorView(error: QueryErrorText.parse(tab.error!), inline: true), ], ), ); diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index 8da265e3..2d3b9d16 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -1,15 +1,13 @@ -import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; const _defaultAutoRefresh = Duration(seconds: 3); @@ -47,12 +45,12 @@ class MongoStatsView extends material.StatefulWidget { material.State createState() => _MongoStatsViewState(); } -class _MongoStatsViewState extends material.State { +class _MongoStatsViewState extends material.State + with PollingState { MongoConnection? _connection; Map? _serverStatus; bool _loading = true; String? _error; - Timer? _timer; /// `null` = auto-refresh off. Default matches previous 3s polling. Duration? _autoRefreshInterval = _defaultAutoRefresh; @@ -70,7 +68,7 @@ class _MongoStatsViewState extends material.State { void didUpdateWidget(covariant MongoStatsView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { - _timer?.cancel(); + stopPolling(); _disconnectCurrent(); _load(); } @@ -78,7 +76,6 @@ class _MongoStatsViewState extends material.State { @override void dispose() { - _timer?.cancel(); _disconnectCurrent(); super.dispose(); } @@ -89,7 +86,7 @@ class _MongoStatsViewState extends material.State { } Future _load() async { - _timer?.cancel(); + stopPolling(); _disconnectCurrent(); if (!mounted) return; setState(() { @@ -112,13 +109,14 @@ class _MongoStatsViewState extends material.State { } _connection = conn; await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); } catch (e) { if (mounted) { setState(() { _error = e.toString(); _loading = false; }); + syncPolling(); } } } @@ -146,6 +144,7 @@ class _MongoStatsViewState extends material.State { _error = e.toString(); _loading = false; }); + syncPolling(); } } } @@ -165,30 +164,28 @@ class _MongoStatsViewState extends material.State { } } - Future _pollTick() async { + @override + Duration? get pollInterval => _autoRefreshInterval; + + @override + bool get pollShouldRun => !_loading && _error == null && _connection != null; + + /// A failed auto-refresh keeps the last good snapshot; the polling status + /// reports it. + @override + Future pollTick() async { final c = _connection; if (c == null || !c.isConnected) return; - try { - final status = await MongoService.instance.executeCommand( - c, - 'admin', - {'serverStatus': 1}, - ); - if (!mounted) return; - if (!replaceIfChanged(_serverStatus, status, (v) => _serverStatus = v)) { - return; - } - setState(() => _lastFetchedAt = DateTime.now()); - } catch (_) { - // Keep last good snapshot on transient errors during auto-refresh. + final status = await MongoService.instance.executeCommand( + c, + 'admin', + {'serverStatus': 1}, + ); + if (!mounted) return; + if (!replaceIfChanged(_serverStatus, status, (v) => _serverStatus = v)) { + return; } - } - - void _startTimer() { - _timer?.cancel(); - final interval = _autoRefreshInterval; - if (interval == null) return; - _timer = Timer.periodic(interval, (_) => unawaited(_pollTick())); + setState(() => _lastFetchedAt = DateTime.now()); } String _formatClock(DateTime t) { @@ -198,42 +195,31 @@ class _MongoStatsViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final interval = _autoRefreshInterval; - if (interval != null) { - _timer = syncTickerGatedPeriodicTimer( - context: context, - timer: _timer, - shouldRun: !_loading && _connection != null, - interval: interval, - onTick: () => unawaited(_pollTick()), - ); - } else { - _timer?.cancel(); - _timer = null; - } - - final cs = Theme.of(context).colorScheme; - - return material.Container( - color: cs.background, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 16), - child: _header(context), - ), - const QueryaDivider(height: 1), - material.Expanded( - child: _buildBody(context, cs), - ), - ], - ), + final cs = context.colors; + + return PollStaleOverlay( + status: polling.status, + child: material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 16), + child: _header(context), + ), + const QueryaDivider(height: 1), + material.Expanded( + child: _buildBody(context, cs), + ), + ], + ), + ), ); } material.Widget _buildBody( - material.BuildContext context, shadcn.ColorScheme cs) { + material.BuildContext context, QueryaUiColors cs) { if (_loading) { return const material.Center( child: QueryaSpinner( @@ -371,7 +357,7 @@ class _MongoStatsViewState extends material.State { } material.Widget _header(material.BuildContext context) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; final last = _lastFetchedAt; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, @@ -464,7 +450,7 @@ class _MongoStatsViewState extends material.State { ], onSelected: (value) { setState(() => _autoRefreshInterval = value); - _startTimer(); + syncPolling(); }, ), ), @@ -499,7 +485,7 @@ class _MongoStatsViewState extends material.State { material.Widget _summaryChips( material.BuildContext context, Map status) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; final version = _getString(status, 'version') ?? '—'; final uptime = _formatUptime(_getInt(status, 'uptime') ?? 0); final connections = _getNestedInt(status, 'connections', 'current') ?? 0; @@ -578,7 +564,7 @@ class _MongoStatsViewState extends material.State { double? minHeight, bool stretchBody = false, }) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Container( width: double.infinity, constraints: minHeight != null @@ -772,7 +758,7 @@ class _MongoStatsViewState extends material.State { material.Widget _metricRow( material.BuildContext context, String label, String value) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Padding( padding: const material.EdgeInsets.symmetric(vertical: 6), child: material.Row( diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index 30c1ddd0..bd912a5c 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -368,7 +368,7 @@ class _MongoConnectionFormContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( @@ -436,10 +436,9 @@ class _MongoConnectionFormContentState if (_useConnectionString) ...[ const Text('Connection String').small().semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: _connectionStringController, - placeholder: const Text( - 'mongodb://username:password@host:port/database'), + placeholder: 'mongodb://username:password@host:port/database', maxLines: 2, ), const Gap(16), @@ -467,9 +466,9 @@ class _MongoConnectionFormContentState // Connection name const Text('Connection Name').small().semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: _nameController, - placeholder: const Text('My MongoDB Server'), + placeholder: 'My MongoDB Server', ), const Gap(16), // Host and Port @@ -507,9 +506,9 @@ class _MongoConnectionFormContentState .small() .semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: _databaseController, - placeholder: const Text('admin'), + placeholder: 'admin', ), ], ), @@ -525,9 +524,9 @@ class _MongoConnectionFormContentState .small() .semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: _authSourceController, - placeholder: const Text('admin'), + placeholder: 'admin', ), ], ), diff --git a/lib/features/mongodb/mql_parser.dart b/lib/features/mongodb/mql_parser.dart index 116d4bf1..f6846ea7 100644 --- a/lib/features/mongodb/mql_parser.dart +++ b/lib/features/mongodb/mql_parser.dart @@ -539,7 +539,61 @@ class MqlParser { // ─── Relaxed JSON / JS Object Literal Parser ──────────────────────────────── - static dynamic _parseRelaxedJson(String text) { + /// [_parseValue] with the Extended JSON wrappers inside it turned into + /// BSON values (#1667): the relaxed syntax writes nested `ObjectId(...)` / + /// `ISODate(...)` as `{"$oid": ...}` / `{"$date": ...}`, and users type them + /// in JSON; sent as maps they matched nothing or were stored as + /// sub-documents. + static dynamic _parseRelaxedJson(String text) => + _bsonScalars(_parseValue(text)); + + /// `{"$oid": "<24 hex>"}` -> [ObjectId], `{"$date": ...}` -> UTC + /// [DateTime], `{"$numberLong": ""}` -> int, at any depth of [value]. + /// Only single-key maps of exactly these shapes: query operators such as + /// `$regex` / `$options` stay as written. + static Object? _bsonScalars(Object? value) { + if (value is List) return [for (final v in value) _bsonScalars(v)]; + if (value is! Map) return value; + if (value.length == 1) { + final MapEntry(:key, value: inner) = value.entries.single; + switch (key) { + case r'$oid' when inner is String && ObjectId.isValidHexId(inner): + return ObjectId.fromHexString(inner); + case r'$date': + final date = _ejsonDate(inner); + if (date != null) return date; + case r'$numberLong' when inner is String: + final n = int.tryParse(inner); + if (n != null) return n; + } + } + return { + for (final e in value.entries) e.key.toString(): _bsonScalars(e.value), + }; + } + + /// The value of `{"$date": ...}`: an ISO string, milliseconds, or + /// `{"$numberLong": ""}` (canonical Extended JSON). + static DateTime? _ejsonDate(Object? v) { + if (v is String) return _isoDateUtc(v); + if (v is int) return DateTime.fromMillisecondsSinceEpoch(v, isUtc: true); + if (v is Map && v.length == 1 && v[r'$numberLong'] is String) { + final ms = int.tryParse(v[r'$numberLong'] as String); + if (ms != null) return DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true); + } + return null; + } + + /// `ISODate(...)` as mongosh reads it: a time without a zone is UTC, not + /// the local time `DateTime.parse` would make of it. + static DateTime? _isoDateUtc(String s) { + final d = DateTime.tryParse(s.trim()); + if (d == null || d.isUtc) return d; + return DateTime.utc(d.year, d.month, d.day, d.hour, d.minute, d.second, + d.millisecond, d.microsecond); + } + + static dynamic _parseValue(String text) { final trimmed = text.trim(); if (trimmed.isEmpty) return null; @@ -554,7 +608,8 @@ class MqlParser { final iso = trimmed .substring(8, trimmed.length - 1) .replaceAll(RegExp(r'''['"]'''), ''); - return DateTime.parse(iso); + return _isoDateUtc(iso) ?? + (throw FormatException('Invalid ISODate: $iso')); } if ((trimmed.startsWith('NumberLong(') || trimmed.startsWith('NumberInt(')) && trimmed.endsWith(')')) { @@ -580,98 +635,205 @@ class MqlParser { return json.decode(normalized); } - /// Normalizes JavaScript object literal syntax into valid JSON: - /// - Quote unquoted keys: `{ foo: "bar" }` -> `{ "foo": "bar" }` - /// - Single quotes to double quotes: `'foo'` -> `"foo"` - /// - Constructor function handling inside objects/arrays + /// Normalizes a mongosh object literal into JSON, in one pass over [input] + /// (#1513, #1539): + /// - unquoted keys after `{`, `[` or `,` are quoted: `{ foo: 1 }`; + /// - `'single'` and `"double"` strings become JSON strings, with their + /// JavaScript escapes (`\n`, `\'`, `\"`, `\\`, `\xXX`, `\uXXXX`) decoded; + /// - outside strings only: `ObjectId("hex")` -> `{"$oid":"hex"}`, + /// `ISODate("...")` -> `{"$date":"..."}`, `NumberLong(n)` / `NumberInt(n)` + /// -> `n`. + /// + /// It used to swap strings for `@@@STR_n@@@` markers and put them back with + /// one `replaceAll` over the whole query per string: quadratic (an + /// `insertMany` of 1,000 documents took over a second), and a string that + /// contained a marker, a constructor call or an escape came out changed. static String _normalizeJsObject(String input) { - var s = input; - - // Replace ObjectId("hex") with {"$oid": "hex"} - s = s.replaceAllMapped( - RegExp(r'ObjectId\s*\(\s*["\x27]([0-9a-fA-F]{24})["\x27]\s*\)'), - (m) => '{"\$oid":"${m[1]}"}', - ); - - // Replace ISODate("...") with {"$date": "..."} - s = s.replaceAllMapped( - RegExp(r'ISODate\s*\(\s*["\x27]([^"\x27]+)["\x27]\s*\)'), - (m) => '{"\$date":"${m[1]}"}', - ); - - // Replace NumberLong(...) / NumberInt(...) with numeric literal - s = s.replaceAllMapped( - RegExp(r'(?:NumberLong|NumberInt)\s*\(\s*["\x27]?(\d+)["\x27]?\s*\)'), - (m) => '${m[1]}', - ); - - // Tokenize strings to preserve quotes and contents - final strings = []; - final tokenized = StringBuffer(); - var inSingle = false; - var inDouble = false; - var escaped = false; - var currentStr = StringBuffer(); - - for (var i = 0; i < s.length; i++) { - final char = s[i]; - if (escaped) { - currentStr.write(char); - escaped = false; - continue; - } - if (char == r'\') { - currentStr.write(char); - escaped = true; - continue; - } - if (char == "'" && !inDouble) { - if (inSingle) { - inSingle = false; - final placeholder = '@@@STR_${strings.length}@@@'; - strings.add(json.encode(currentStr.toString())); - tokenized.write(placeholder); - currentStr.clear(); - } else { - inSingle = true; + final out = StringBuffer(); + final n = input.length; + var i = 0; + var prev = -1; // the last non-blank code unit written + while (i < n) { + final c = input.codeUnitAt(i); + if (c == 0x22 || c == 0x27) { + final str = _scanJsString(input, i); + if (str == null) { + // Unterminated: left as typed, for json.decode to report. + out.write(input.substring(i)); + break; } + out.write(json.encode(str.value)); + prev = 0x22; + i = str.end; continue; } - if (char == '"' && !inSingle) { - if (inDouble) { - inDouble = false; - final placeholder = '@@@STR_${strings.length}@@@'; - strings.add(json.encode(currentStr.toString())); - tokenized.write(placeholder); - currentStr.clear(); + if (_isWordChar(c)) { + var j = i + 1; + while (j < n && _isWordChar(input.codeUnitAt(j))) { + j++; + } + final word = input.substring(i, j); + final call = _scanBsonCall(input, word, j); + if (call != null) { + out.write(call.json); + prev = 0x7D; + i = call.end; + continue; + } + final next = _skipBlanks(input, j); + final isKey = (prev == 0x7B || prev == 0x5B || prev == 0x2C) && + next < n && + input.codeUnitAt(next) == 0x3A; + if (isKey) { + out + ..write('"') + ..write(word) + ..write('"'); } else { - inDouble = true; + out.write(word); } + prev = word.codeUnitAt(word.length - 1); + i = j; continue; } - - if (inSingle || inDouble) { - currentStr.write(char); - } else { - tokenized.write(char); - } + out.writeCharCode(c); + if (!_isBlank(c)) prev = c; + i++; } + return out.toString(); + } - var processed = tokenized.toString(); - - // Quote unquoted object keys: e.g. `{ key: ` or `, key: ` - processed = processed.replaceAllMapped( - RegExp(r'([{\[,]\s*)([a-zA-Z0-9_\$]+)\s*:'), - (m) => '${m[1]}"${m[2]}":', - ); + /// `[A-Za-z0-9_$]`: what an unquoted key, `true` / `null` or a number's + /// digits are made of. + static bool _isWordChar(int c) => + (c >= 0x30 && c <= 0x39) || + (c >= 0x41 && c <= 0x5A) || + (c >= 0x61 && c <= 0x7A) || + c == 0x5F || + c == 0x24; + + static bool _isBlank(int c) => + c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D; + + static int _skipBlanks(String s, int i) { + while (i < s.length && _isBlank(s.codeUnitAt(i))) { + i++; + } + return i; + } - // Restore strings - for (var i = 0; i < strings.length; i++) { - processed = processed.replaceAll('@@@STR_$i@@@', strings[i]); + /// The JavaScript string literal that opens at [start] (`'` or `"`): its + /// value with the escapes decoded, and the offset after the closing quote. + /// Null when it is never closed. + static ({String value, int end})? _scanJsString(String s, int start) { + final quote = s.codeUnitAt(start); + final value = StringBuffer(); + var i = start + 1; + while (i < s.length) { + final c = s.codeUnitAt(i); + if (c == quote) return (value: value.toString(), end: i + 1); + if (c != 0x5C || i + 1 >= s.length) { + value.writeCharCode(c); + i++; + continue; + } + final e = s.codeUnitAt(i + 1); + i += 2; + switch (e) { + case 0x6E: // n + value.writeCharCode(0x0A); + case 0x74: // t + value.writeCharCode(0x09); + case 0x72: // r + value.writeCharCode(0x0D); + case 0x62: // b + value.writeCharCode(0x08); + case 0x66: // f + value.writeCharCode(0x0C); + case 0x76: // v + value.writeCharCode(0x0B); + case 0x30 when i >= s.length || !_isDigit(s.codeUnitAt(i)): // \0 + value.writeCharCode(0x00); + case 0x78 || 0x75: // x, u + final braced = e == 0x75 && i < s.length && s.codeUnitAt(i) == 0x7B; + final close = braced ? s.indexOf('}', i) : -1; + final from = braced ? i + 1 : i; + final to = braced ? close : i + (e == 0x78 ? 2 : 4); + final code = to > from && to <= s.length + ? int.tryParse(s.substring(from, to), radix: 16) + : null; + if (code == null) { + value.writeCharCode(e); // not a valid escape: the letter itself + } else { + value.writeCharCode(code); + i = braced ? close + 1 : to; + } + case 0x0D: // line continuation, \r\n or \r + if (i < s.length && s.codeUnitAt(i) == 0x0A) i++; + case 0x0A: // line continuation + break; + default: // \' \" \\ \/ and any other character stand for themselves + value.writeCharCode(e); + } } + return null; + } - return processed; + static bool _isDigit(int c) => c >= 0x30 && c <= 0x39; + + /// `ObjectId(...)`, `ISODate(...)`, `NumberLong(...)` or `NumberInt(...)` + /// right after [word] (which ends at [at]), as JSON, and the offset after + /// its `)`. Null when [word] is not one of them or the call does not have + /// the expected argument; the text is then kept as typed, as before. + static ({String json, int end})? _scanBsonCall( + String s, + String word, + int at, + ) { + if (word != 'ObjectId' && + word != 'ISODate' && + word != 'NumberLong' && + word != 'NumberInt') { + return null; + } + var i = _skipBlanks(s, at); + if (i >= s.length || s.codeUnitAt(i) != 0x28) return null; // ( + i = _skipBlanks(s, i + 1); + if (i >= s.length) return null; + String? arg; + final c = s.codeUnitAt(i); + if (c == 0x22 || c == 0x27) { + final str = _scanJsString(s, i); + if (str == null) return null; + arg = str.value; + i = str.end; + } else if (word.startsWith('Number')) { + final from = i; + if (s.codeUnitAt(i) == 0x2D) i++; // - + while (i < s.length && _isDigit(s.codeUnitAt(i))) { + i++; + } + arg = s.substring(from, i); + } + if (arg == null) return null; + i = _skipBlanks(s, i); + if (i >= s.length || s.codeUnitAt(i) != 0x29) return null; // ) + final end = i + 1; + switch (word) { + case 'ObjectId': + if (!_objectIdHex.hasMatch(arg)) return null; + return (json: '{"\$oid":${json.encode(arg)}}', end: end); + case 'ISODate': + if (arg.isEmpty) return null; + return (json: '{"\$date":${json.encode(arg)}}', end: end); + default: + if (!_integer.hasMatch(arg)) return null; + return (json: arg, end: end); + } } + + static final RegExp _objectIdHex = RegExp(r'^[0-9a-fA-F]{24}$'); + static final RegExp _integer = RegExp(r'^-?\d+$'); } class _CallInfo { diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 4238819f..cb737a69 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -332,7 +332,7 @@ class _MysqlConnectionFormContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( @@ -394,9 +394,9 @@ class _MysqlConnectionFormContentState children: [ const Text('Connection Name').small().semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: _nameController, - placeholder: const Text('My MySQL Server'), + placeholder: 'My MySQL Server', ), const Gap(16), const Text('Connection URI (optional)').small().semiBold(), @@ -412,11 +412,9 @@ class _MysqlConnectionFormContentState 'prefer/require encrypt; verify_* needs sslrootcert.', ).muted().small(), const Gap(8), - TextField( + QueryaTextField( controller: _connectionStringController, - placeholder: const Text( - 'mysql://user:pass@host:3306/dbname?ssl-mode=disable', - ), + placeholder: 'mysql://user:pass@host:3306/dbname?ssl-mode=disable', ), const Gap(16), ConnectionHostPortSection( diff --git a/lib/features/mysql/mysql_routine_view.dart b/lib/features/mysql/mysql_routine_view.dart index 2aeb4445..334c4ae7 100644 --- a/lib/features/mysql/mysql_routine_view.dart +++ b/lib/features/mysql/mysql_routine_view.dart @@ -123,8 +123,7 @@ class _MysqlRoutineViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; + final cs = context.colors; final kindTitle = widget.isFunction ? 'Function' : 'Procedure'; return material.Column( @@ -134,10 +133,10 @@ class _MysqlRoutineViewState extends material.State { padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: material.BoxDecoration( - color: theme.colorScheme.card, + color: cs.card, border: material.Border( bottom: material.BorderSide( - color: theme.colorScheme.border.withValues(alpha: 0.5), + color: cs.border.withValues(alpha: 0.5), ), ), ), @@ -146,7 +145,7 @@ class _MysqlRoutineViewState extends material.State { material.Icon( material.Icons.functions_rounded, size: 18, - color: theme.colorScheme.primary, + color: cs.primary, ), const Gap(8), material.Expanded( diff --git a/lib/features/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart index 09d03640..5079b087 100644 --- a/lib/features/mysql/mysql_sql_editor_dialog.dart +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -73,7 +73,7 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; return material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 3135af26..409b0a10 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/database/compact_result_dataset.dart'; +import 'package:querya_desktop/core/database/result_columns_builder.dart'; import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; @@ -98,32 +98,30 @@ class MysqlSqlExecutionDelegate extends BaseSqlExecutionDelegate { cols.add(c.name.isNotEmpty ? c.name : 'col_${cols.length}'); } - final taken = await takeThenDrain( + final colList = rs.cols.toList(); + final builder = ResultColumnsBuilder( + cols, + cellFormatter: (val, colIndex) { + final col = colIndex < colList.length ? colList[colIndex] : null; + return mysqlResultCellToDisplayString(val, column: col); + }, + ); + final taken = await forEachThenDrain( rs.rowsStream, cap, + onItem: (row) { + for (var i = 0; i < row.numOfColumns; i++) { + builder.addCell(i, row.colAt(i)); + } + builder.endRow(); + }, onProgress: (n) async { if (n % kResultStringConvertYieldEvery == 0) { await Future.delayed(Duration.zero); } }, ); - final colList = rs.cols.toList(); - final rawRows = >[]; - for (var r = 0; r < taken.items.length; r++) { - final row = taken.items[r]; - rawRows.add([ - for (var i = 0; i < row.numOfColumns; i++) row.colAt(i), - ]); - } - - final dataset = CompactResultDataset.fromRawRows( - cols, - rawRows, - cellFormatter: (val, colIndex) { - final col = colIndex < colList.length ? colList[colIndex] : null; - return mysqlResultCellToDisplayString(val, column: col); - }, - ); + final dataset = builder.build(); final truncated = taken.truncated || (effectiveSql != sql && dataset.rowCount >= cap); diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index dcb4bfd8..4b74e043 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -1,16 +1,13 @@ -import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; import 'package:querya_desktop/core/stats/metric_history.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/querya_sparkline.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; const _pollInterval = Duration(seconds: 5); @@ -31,7 +28,8 @@ class MysqlStatsView extends material.StatefulWidget { material.State createState() => _MysqlStatsViewState(); } -class _MysqlStatsViewState extends material.State { +class _MysqlStatsViewState extends material.State + with PollingState { MysqlLease? _lease; Map? _stats; @@ -39,7 +37,6 @@ class _MysqlStatsViewState extends material.State { final _history = MetricHistory(); bool _loading = true; String? _error; - Timer? _timer; @override void initState() { @@ -51,7 +48,7 @@ class _MysqlStatsViewState extends material.State { void didUpdateWidget(covariant MysqlStatsView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { - _timer?.cancel(); + stopPolling(); _disconnect(); _load(); } @@ -59,7 +56,6 @@ class _MysqlStatsViewState extends material.State { @override void dispose() { - _timer?.cancel(); _disconnect(); super.dispose(); } @@ -70,7 +66,7 @@ class _MysqlStatsViewState extends material.State { } Future _load() async { - _timer?.cancel(); + stopPolling(); _disconnect(); if (!mounted) return; setState(() { @@ -90,13 +86,14 @@ class _MysqlStatsViewState extends material.State { } _lease = lease; await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _loading = false; }); + syncPolling(); } } @@ -108,6 +105,7 @@ class _MysqlStatsViewState extends material.State { _error = 'Not connected'; _loading = false; }); + syncPolling(); return; } try { @@ -124,6 +122,7 @@ class _MysqlStatsViewState extends material.State { _error = e.toString(); _loading = false; }); + syncPolling(); } } @@ -140,36 +139,30 @@ class _MysqlStatsViewState extends material.State { 'queries_per_second', statusValue('Questions'), now); } - Future _onPollTick() async { + @override + Duration? get pollInterval => _pollInterval; + + @override + bool get pollShouldRun { final conn = _lease?.connection; - if (conn == null || !conn.isConnected) return; - try { - final stats = await conn.serverStats(); - if (!mounted) return; - _recordHistory(stats); - // The sparklines move with every sample, so rebuild even when the numbers - // did not change. - setState(() => replaceIfChanged(_stats, stats, (v) => _stats = v)); - } catch (_) {} + return conn != null && conn.isConnected && !_loading && _error == null; } - void _startTimer() { - _timer?.cancel(); - _timer = Timer.periodic(_pollInterval, (_) => unawaited(_onPollTick())); + @override + Future pollTick() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) return; + final stats = await conn.serverStats(); + if (!mounted) return; + _recordHistory(stats); + // The sparklines move with every sample, so rebuild even when the numbers + // did not change. + setState(() => replaceIfChanged(_stats, stats, (v) => _stats = v)); } @override material.Widget build(material.BuildContext context) { - final conn = _lease?.connection; - _timer = syncTickerGatedPeriodicTimer( - context: context, - timer: _timer, - shouldRun: conn != null && conn.isConnected && !_loading, - interval: _pollInterval, - onTick: () => unawaited(_onPollTick()), - ); - - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading) { return const material.Center( @@ -217,52 +210,55 @@ class _MysqlStatsViewState extends material.State { final stats = _stats; if (stats == null) return material.Container(color: cs.background); - return material.Container( - color: cs.background, - child: material.RefreshIndicator( - onRefresh: _fetch, - child: material.LayoutBuilder( - builder: (context, constraints) { - final contentWidth = math.max(0.0, constraints.maxWidth - 48); - final isNarrow = contentWidth < 560; - return material.SingleChildScrollView( - physics: const material.AlwaysScrollableScrollPhysics(), - padding: const material.EdgeInsets.all(24), - child: material.SizedBox( - width: contentWidth, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _header(context), - const Gap(24), - _summaryChips(context, stats), - const Gap(24), - _gridRow( - _connectionsCard(context, stats), - _queriesCard(context, stats), - isNarrow: isNarrow, - ), - const Gap(16), - _gridRow( - _networkCard(context, stats), - _settingsCard(context, stats), - isNarrow: isNarrow, - ), - const Gap(24), - _databasesCard(context, stats), - ], + return PollStaleOverlay( + status: polling.status, + child: material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.LayoutBuilder( + builder: (context, constraints) { + final contentWidth = math.max(0.0, constraints.maxWidth - 48); + final isNarrow = contentWidth < 560; + return material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: contentWidth, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, stats), + const Gap(24), + _gridRow( + _connectionsCard(context, stats), + _queriesCard(context, stats), + isNarrow: isNarrow, + ), + const Gap(16), + _gridRow( + _networkCard(context, stats), + _settingsCard(context, stats), + isNarrow: isNarrow, + ), + const Gap(24), + _databasesCard(context, stats), + ], + ), ), - ), - ); - }, + ); + }, + ), ), - ), + ), ); } material.Widget _header(material.BuildContext context) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Row( children: [ material.Container( @@ -356,7 +352,7 @@ class _MysqlStatsViewState extends material.State { material.Widget _summaryChips( material.BuildContext context, Map stats) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; final versionFull = stats['version'] as String? ?? '—'; final versionShort = _extractMysqlVersion(versionFull); final uptimeSec = stats['uptime_seconds'] as int? ?? 0; @@ -438,7 +434,7 @@ class _MysqlStatsViewState extends material.State { double? minHeight, bool stretchBody = false, }) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Container( width: double.infinity, constraints: minHeight != null @@ -580,7 +576,7 @@ class _MysqlStatsViewState extends material.State { final databases = stats['databases'] as List>? ?? []; if (databases.isEmpty) return const material.SizedBox.shrink(); - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return _card( context, 'Databases', @@ -639,7 +635,7 @@ class _MysqlStatsViewState extends material.State { material.Widget _metricRow( material.BuildContext context, String label, String value) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Padding( padding: const material.EdgeInsets.symmetric(vertical: 6), child: material.Row( diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index be64b60d..14cc512b 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -339,7 +339,7 @@ class _MysqlTableViewState extends material.State { onNavigateHome: widget.onNavigateHome, showExportToolbar: true, customToolbarBuilder: (ctx, state) { - final cs = Theme.of(ctx).colorScheme; + final cs = ctx.colors; final title = '$tableTitle${widget.isView ? ' (view)' : ''}'; return material.Container( diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart index a1dab598..5a56dbad 100644 --- a/lib/features/mysql/mysql_workspace_home.dart +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -86,7 +86,7 @@ class _MysqlWorkspaceHomeState extends material.State { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -94,7 +94,7 @@ class _MysqlWorkspaceHomeState extends material.State { constraints: const material.BoxConstraints(minHeight: 44), padding: const material.EdgeInsets.symmetric(horizontal: 12), decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), + color: colors.muted.withValues(alpha: 0.6), ), child: material.Row( children: [ @@ -104,7 +104,7 @@ class _MysqlWorkspaceHomeState extends material.State { material.Icon( material.Icons.lock_outline_rounded, size: 14, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ], if (widget.lastSelectedMysqlObject != null && diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index d425f5d2..48a36da8 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -47,7 +49,7 @@ class KbdBadge extends StatelessWidget { @override Widget build(BuildContext context) { final wb = context.workbench; - final isDark = Theme.of(context).brightness == Brightness.dark; + final isDark = context.colors.brightness == Brightness.dark; return material.Row( mainAxisSize: material.MainAxisSize.min, @@ -59,16 +61,14 @@ class KbdBadge extends StatelessWidget { vertical: 2, ), decoration: material.BoxDecoration( - color: isDark - ? const material.Color(0xFF1E2024) - : const material.Color(0xFFF1F5F9), + color: wb.surface, borderRadius: material.BorderRadius.circular(4), border: material.Border.all( color: wb.borderSubtle.withValues(alpha: 0.8), ), boxShadow: [ material.BoxShadow( - color: material.Colors.black.withValues(alpha: isDark ? 0.3 : 0.08), + color: wb.shadow.withValues(alpha: isDark ? 0.3 : 0.08), offset: const material.Offset(0, 1), blurRadius: 1, ), @@ -79,8 +79,9 @@ class KbdBadge extends StatelessWidget { style: TextStyle( fontSize: fontSize, fontWeight: material.FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.foreground, + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, + color: context.colors.foreground, ), ), ), @@ -146,15 +147,26 @@ class _WelcomeTourDialogState extends State { super.dispose(); } + /// Whether the last move went to a later step: the new slide then comes in + /// from the right and the old one leaves to the left, and the other way + /// round for Back or an earlier dot (#1645). + var _forward = true; + void _goToStep(int step) { if (step >= 0 && step < _totalSteps && step != _currentStep) { - setState(() => _currentStep = step); + setState(() { + _forward = step > _currentStep; + _currentStep = step; + }); } } void _next() { if (_currentStep < _totalSteps - 1) { - setState(() => _currentStep++); + setState(() { + _forward = true; + _currentStep++; + }); } else { _finish(); } @@ -162,7 +174,10 @@ class _WelcomeTourDialogState extends State { void _previous() { if (_currentStep > 0) { - setState(() => _currentStep--); + setState(() { + _forward = false; + _currentStep--; + }); } } @@ -191,7 +206,7 @@ class _WelcomeTourDialogState extends State { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final wb = context.workbench; final isMac = Platform.isMacOS; @@ -287,11 +302,17 @@ onPressed: _finish, switchInCurve: context.motionCurve(QueryaMotion.enter), switchOutCurve: context.motionCurve(QueryaMotion.exit), transitionBuilder: (child, animation) { + // The incoming slide starts on the side we move towards; + // the outgoing one (its animation runs back to 0) ends + // on the other side. + final incoming = + child.key == material.ValueKey(_currentStep); + final side = (_forward ? 1.0 : -1.0) * (incoming ? 1 : -1); return material.FadeTransition( opacity: animation, child: material.SlideTransition( position: material.Tween( - begin: const material.Offset(0.04, 0), + begin: material.Offset(0.04 * side, 0), end: material.Offset.zero, ).animate(animation), child: child, @@ -379,7 +400,7 @@ onPressed: _finish, Widget _buildSlideContent({ required int step, required String cmdCtrl, - required ColorScheme cs, + required QueryaUiColors cs, required QueryaWorkbenchTheme wb, }) { switch (step) { @@ -387,7 +408,7 @@ onPressed: _finish, return _TourSlideView( key: const ValueKey(0), icon: material.Icons.hub_rounded, - iconColor: const material.Color(0xFF06B6D4), // Cyan + iconColor: wb.accent, title: 'Connect in Seconds', description: 'Connect directly to PostgreSQL, MySQL, SQLite, Redis, MongoDB or custom extension drivers. Paste a connection URL or use our visual form with real-time field validation.', @@ -399,7 +420,7 @@ onPressed: _finish, return _TourSlideView( key: const ValueKey(1), icon: material.Icons.view_sidebar_rounded, - iconColor: const material.Color(0xFF8B5CF6), // Purple + iconColor: cs.chart4, title: 'Fluid Sidebar & Navigation', description: 'Browse database schemas, tables, and views with full keyboard navigation. Right-click any object for instant SELECT queries and DDL copy.', @@ -412,7 +433,7 @@ onPressed: _finish, return _TourSlideView( key: const ValueKey(2), icon: material.Icons.code_rounded, - iconColor: const material.Color(0xFF3B82F6), // Blue + iconColor: cs.chart1, title: 'Pro SQL Editor & Workspaces', description: 'Execute scripts with $cmdCtrl+Enter or F5. Open multiple query tabs, format SQL with $cmdCtrl+Shift+F, and access persistent query history.', @@ -425,7 +446,7 @@ onPressed: _finish, return _TourSlideView( key: const ValueKey(3), icon: material.Icons.table_chart_rounded, - iconColor: const material.Color(0xFF10B981), // Emerald + iconColor: cs.chart2, title: 'Interactive 2D Grid & DML Staging', description: 'Edit cells in-place, select ranges with Shift+Click, and copy TSV data with $cmdCtrl+C. Staged changes are safely previewed with DML inspection before committing.', @@ -438,7 +459,7 @@ onPressed: _finish, return _TourSlideView( key: const ValueKey(4), icon: material.Icons.filter_alt_rounded, - iconColor: const material.Color(0xFFF59E0B), // Amber + iconColor: cs.chart3, title: 'Compound Filter Bar & Quick Calc', description: 'Filter grid results with live autocomplete expressions. Toggle Groupings & Pivot panels with $cmdCtrl+G and inspect instant stats (Sum, Avg, Median) in the status bar.', @@ -581,9 +602,7 @@ class _TourSlideView extends StatelessWidget { tipText, style: TextStyle( fontSize: 12.5, - color: Theme.of(context) - .colorScheme - .foreground + color: context.colors.foreground .withValues(alpha: 0.9), height: 1.35, ), @@ -632,13 +651,13 @@ class _HotkeysMatrixSlideView extends StatelessWidget { width: 36, height: 36, decoration: material.BoxDecoration( - color: const material.Color(0xFFEC4899).withValues(alpha: 0.15), + color: context.colors.chart5.withValues(alpha: 0.15), shape: material.BoxShape.circle, ), - child: const material.Icon( + child: material.Icon( material.Icons.keyboard_rounded, size: 20, - color: material.Color(0xFFEC4899), + color: context.colors.chart5, ), ), const material.SizedBox(width: 12), @@ -730,7 +749,7 @@ class _HotkeysMatrixSlideView extends StatelessWidget { label, style: TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.foreground, + color: context.colors.foreground, fontWeight: material.FontWeight.w500, ), ), diff --git a/lib/features/postgresql/postgres_browser_views.dart b/lib/features/postgresql/postgres_browser_views.dart index 3572faf9..a8eb0f40 100644 --- a/lib/features/postgresql/postgres_browser_views.dart +++ b/lib/features/postgresql/postgres_browser_views.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_metadata.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -88,7 +89,7 @@ class _PostgresIndexListViewState @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading) { return material.Container( color: cs.background, @@ -165,7 +166,9 @@ class _PostgresIndexListViewState material.SelectableText( r.indexDef, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 11, height: 1.4, color: cs.foreground, @@ -261,7 +264,7 @@ class _PostgresTriggerListViewState @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading) { return material.Container( color: cs.background, @@ -331,7 +334,9 @@ class _PostgresTriggerListViewState material.SelectableText( r.definition, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 11, height: 1.4, color: cs.foreground, @@ -426,7 +431,7 @@ class _PostgresTypeListViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading) { return const material.Center( child: QueryaSpinner(size: QueryaSpinnerSize.md), @@ -470,7 +475,9 @@ class _PostgresTypeListViewState extends material.State { child: material.Text( r.name, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 12, color: cs.foreground, ), @@ -571,7 +578,7 @@ class _PostgresExtensionListViewState @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading) { return const material.Center( child: QueryaSpinner(size: QueryaSpinnerSize.md), @@ -616,7 +623,9 @@ class _PostgresExtensionListViewState child: material.Text( r.name, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 12, fontWeight: material.FontWeight.w600, color: cs.foreground, @@ -713,7 +722,7 @@ class _PostgresFdwListViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading) { return const material.Center( child: QueryaSpinner(size: QueryaSpinnerSize.md), @@ -772,7 +781,9 @@ class _PostgresFdwListViewState extends material.State { child: material.Text( '${r.name}${r.handler != null ? ' · ${r.handler}' : ''}', style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 12, color: cs.foreground, ), @@ -799,7 +810,9 @@ class _PostgresFdwListViewState extends material.State { child: material.Text( '${r.serverName} → ${r.fdwName}', style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 12, color: cs.foreground, ), @@ -819,7 +832,7 @@ material.Widget _browserToolbar( required String title, required VoidCallback onRefresh, }) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return material.Container( padding: const material.EdgeInsets.fromLTRB(16, 10, 16, 10), decoration: material.BoxDecoration( diff --git a/lib/features/postgresql/postgres_routine_view.dart b/lib/features/postgresql/postgres_routine_view.dart index fee87286..bc06d4f8 100644 --- a/lib/features/postgresql/postgres_routine_view.dart +++ b/lib/features/postgresql/postgres_routine_view.dart @@ -4,6 +4,7 @@ import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -128,7 +129,7 @@ class _PostgresRoutineViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading) { return material.Container( @@ -233,7 +234,9 @@ class _PostgresRoutineViewState extends material.State { fontSize: 12, fontWeight: material.FontWeight.w600, color: cs.foreground, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, ), ), ), diff --git a/lib/features/postgresql/postgres_sequence_view.dart b/lib/features/postgresql/postgres_sequence_view.dart index 2159c73d..381f7d1a 100644 --- a/lib/features/postgresql/postgres_sequence_view.dart +++ b/lib/features/postgresql/postgres_sequence_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -111,7 +112,7 @@ class _PostgresSequenceViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading) { return material.Container( @@ -214,7 +215,9 @@ class _PostgresSequenceViewState extends material.State { material.SelectableText( d.lastValue, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 16, color: cs.foreground, ), @@ -245,7 +248,9 @@ class _PostgresSequenceViewState extends material.State { child: material.SelectableText( d.ddl, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 12, height: 1.45, color: cs.foreground, @@ -276,7 +281,8 @@ class _PostgresSequenceViewState extends material.State { child: material.SelectableText( v, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12, color: cs.foreground, ), diff --git a/lib/features/postgresql/postgres_sql_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index 0d961fad..8f74db21 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -75,7 +75,7 @@ class _PostgresSqlEditorDialogState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; return material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 89decbb8..3d973b80 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/database/compact_result_dataset.dart'; +import 'package:querya_desktop/core/database/result_columns_builder.dart'; import 'package:querya_desktop/core/database/postgres_result_cells.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; @@ -122,17 +122,8 @@ class PostgresSqlExecutionDelegate extends BaseSqlExecutionDelegate { ); } - final rawRows = >[]; - var n = 0; - for (final row in result) { - if (n >= cap) break; - rawRows.add(row.toList()); - n++; - } - - final dataset = CompactResultDataset.fromRawRows( + final builder = ResultColumnsBuilder( cols, - rawRows, cellFormatter: (val, colIndex) { final typeOid = colIndex < schema.columns.length ? schema.columns[colIndex].typeOid @@ -140,6 +131,13 @@ class PostgresSqlExecutionDelegate extends BaseSqlExecutionDelegate { return postgresResultCellToDisplayString(val, typeOid: typeOid); }, ); + var n = 0; + for (final row in result) { + if (n >= cap) break; + builder.addRow(row); + n++; + } + final dataset = builder.build(); final isTruncated = result.length >= cap; final statusMsg = formatStatusMessage( diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 1a5a8e01..a5e79406 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -1,8 +1,7 @@ -import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; import 'package:querya_desktop/core/stats/metric_history.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; @@ -10,8 +9,6 @@ import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/querya_sparkline.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; const _pollInterval = Duration(seconds: 5); @@ -56,7 +53,8 @@ void recordServerStats( history.recordCounter('transactions_per_second', commits.toDouble(), now); } -class _PostgresStatsViewState extends material.State { +class _PostgresStatsViewState extends material.State + with PollingState { PgLease? _lease; PostgresConnection? get _connection => _lease?.connection; @@ -66,7 +64,6 @@ class _PostgresStatsViewState extends material.State { final _history = MetricHistory(); bool _loading = true; String? _error; - Timer? _timer; @override void initState() { @@ -78,7 +75,7 @@ class _PostgresStatsViewState extends material.State { void didUpdateWidget(covariant PostgresStatsView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { - _timer?.cancel(); + stopPolling(); _disconnectCurrent(); _load(); } @@ -86,7 +83,6 @@ class _PostgresStatsViewState extends material.State { @override void dispose() { - _timer?.cancel(); _disconnectCurrent(); super.dispose(); } @@ -99,7 +95,7 @@ class _PostgresStatsViewState extends material.State { } Future _load() async { - _timer?.cancel(); + stopPolling(); _disconnectCurrent(); if (!mounted) return; setState(() { @@ -109,7 +105,7 @@ class _PostgresStatsViewState extends material.State { }); if (widget.statsSource != null) { await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); return; } try { @@ -124,13 +120,14 @@ class _PostgresStatsViewState extends material.State { } _lease = lease; await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); } catch (e) { if (mounted) { setState(() { _error = e.toString(); _loading = false; }); + syncPolling(); } } } @@ -155,6 +152,7 @@ class _PostgresStatsViewState extends material.State { _error = 'Not connected'; _loading = false; }); + syncPolling(); return; } try { @@ -178,38 +176,31 @@ class _PostgresStatsViewState extends material.State { _error = e.toString(); _loading = false; }); + syncPolling(); } } } - Future _onPollTick() async { - if (!_canPoll) return; - try { - final stats = await _serverStats(); - if (!mounted) return; - _recordHistory(stats); - // The sparklines move with every sample, so rebuild even when the numbers - // did not change. - setState(() => replaceIfChanged(_stats, stats, (v) => _stats = v)); - } catch (_) {} - } + @override + Duration? get pollInterval => _pollInterval; + + @override + bool get pollShouldRun => _canPoll && !_loading && _error == null; - void _startTimer() { - _timer?.cancel(); - _timer = Timer.periodic(_pollInterval, (_) => unawaited(_onPollTick())); + @override + Future pollTick() async { + if (!_canPoll) return; + final stats = await _serverStats(); + if (!mounted) return; + _recordHistory(stats); + // The sparklines move with every sample, so rebuild even when the numbers + // did not change. + setState(() => replaceIfChanged(_stats, stats, (v) => _stats = v)); } @override material.Widget build(material.BuildContext context) { - _timer = syncTickerGatedPeriodicTimer( - context: context, - timer: _timer, - shouldRun: _canPoll && !_loading, - interval: _pollInterval, - onTick: () => unawaited(_onPollTick()), - ); - - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading) { return const material.Center( @@ -251,61 +242,64 @@ class _PostgresStatsViewState extends material.State { final stats = _stats; if (stats == null) return material.Container(color: cs.background); - return material.Container( - color: cs.background, - child: material.RefreshIndicator( - onRefresh: _fetch, - child: material.LayoutBuilder( - builder: (context, constraints) { - final contentWidth = math.max(0.0, constraints.maxWidth - 48); - final isNarrow = contentWidth < 560; - return material.SingleChildScrollView( - physics: const material.AlwaysScrollableScrollPhysics(), - padding: const material.EdgeInsets.all(24), - child: material.SizedBox( - width: contentWidth, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _header(context), - const Gap(24), - _summaryChips(context, stats), - const Gap(24), - if (isNarrow) - material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _connectionsCard(context, stats), - const Gap(16), - _serverSettingsCard(context, stats), - ], - ) - else - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded( - child: _connectionsCard(context, stats)), - const Gap(16), - material.Expanded( - child: _serverSettingsCard(context, stats)), - ], - ), - const Gap(16), - _databasesCard(context, stats), - ], + return PollStaleOverlay( + status: polling.status, + child: material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.LayoutBuilder( + builder: (context, constraints) { + final contentWidth = math.max(0.0, constraints.maxWidth - 48); + final isNarrow = contentWidth < 560; + return material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: contentWidth, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, stats), + const Gap(24), + if (isNarrow) + material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _connectionsCard(context, stats), + const Gap(16), + _serverSettingsCard(context, stats), + ], + ) + else + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: _connectionsCard(context, stats)), + const Gap(16), + material.Expanded( + child: _serverSettingsCard(context, stats)), + ], + ), + const Gap(16), + _databasesCard(context, stats), + ], + ), ), - ), - ); - }, + ); + }, + ), ), - ), + ), ); } material.Widget _header(material.BuildContext context) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.LayoutBuilder( builder: (context, constraints) { return material.SingleChildScrollView( @@ -373,7 +367,7 @@ class _PostgresStatsViewState extends material.State { material.Widget _summaryChips( material.BuildContext context, Map stats) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; final versionFull = stats['version'] as String? ?? '—'; final versionShort = _extractPgVersion(versionFull); final settings = stats['settings'] as Map? ?? {}; @@ -455,7 +449,7 @@ class _PostgresStatsViewState extends material.State { material.Widget _card( material.BuildContext context, String title, material.Widget body, {double? minHeight}) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Container( width: double.infinity, constraints: minHeight != null @@ -539,7 +533,7 @@ class _PostgresStatsViewState extends material.State { final databases = stats['databases'] as List>? ?? []; if (databases.isEmpty) return const material.SizedBox.shrink(); - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return _card( context, 'Databases', @@ -613,7 +607,7 @@ class _PostgresStatsViewState extends material.State { material.Widget _row( material.BuildContext context, String key, String value) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Padding( padding: const material.EdgeInsets.symmetric(vertical: 4), child: material.Row( diff --git a/lib/features/postgresql/postgres_table_privileges_dialog.dart b/lib/features/postgresql/postgres_table_privileges_dialog.dart index 253d8210..ac55f2e7 100644 --- a/lib/features/postgresql/postgres_table_privileges_dialog.dart +++ b/lib/features/postgresql/postgres_table_privileges_dialog.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/database/postgres_metadata.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -77,7 +79,7 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; final mq = material.MediaQuery.sizeOf(context); final hInset = WindowLayout.dialogVerticalInset(mq.height) * 2; final wInset = WindowLayout.dialogHorizontalInset(mq.width) * 2; @@ -119,7 +121,9 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { material.Text( '${widget.schema}.${widget.tableName}', style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 12, color: theme.mutedForeground, ), @@ -164,7 +168,7 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { ); } - material.Widget _buildListArea(ColorScheme theme) { + material.Widget _buildListArea(QueryaUiColors theme) { if (_loading) { return const material.Center( child: QueryaSpinner(size: QueryaSpinnerSize.md), @@ -214,7 +218,8 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { child: material.Text( r.privilegeType, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12, color: theme.foreground, ), diff --git a/lib/features/postgresql/postgres_table_toolbar.dart b/lib/features/postgresql/postgres_table_toolbar.dart index f9cf90fb..05295878 100644 --- a/lib/features/postgresql/postgres_table_toolbar.dart +++ b/lib/features/postgresql/postgres_table_toolbar.dart @@ -47,7 +47,7 @@ class PostgresTableToolbar extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return material.Container( padding: const material.EdgeInsets.fromLTRB(16, 10, 16, 10), decoration: material.BoxDecoration( diff --git a/lib/features/postgresql/postgres_table_utils.dart b/lib/features/postgresql/postgres_table_utils.dart index a91d252e..0d2d11b3 100644 --- a/lib/features/postgresql/postgres_table_utils.dart +++ b/lib/features/postgresql/postgres_table_utils.dart @@ -63,12 +63,23 @@ bool isAllowedPostgresSelectQuery(String sql) { return _postgresStatementIsReadOnlySelect(statements.first); } +/// Splits [sql] on the semicolons that PostgreSQL itself would treat as +/// statement ends. +/// +/// This is the gate that keeps writes out of the Table Browser, so it follows +/// PostgreSQL's lexical rules and not the editor's script splitter +/// (`SqlStatementSplitter`): there a backslash escapes a quote (MySQL style) +/// and a backtick quotes, so `SELECT 'a\'; DELETE FROM t; --'` would look like +/// one statement; PostgreSQL (standard strings) ends the literal at the second +/// quote and runs the DELETE. Block comments nest here, as in PostgreSQL. +/// [maskSqlLiteralsForLimitScan] already blanks comments, strings, quoted +/// identifiers and dollar quotes, so one masked copy is enough (#1517). List _splitPostgresStatements(String sql) { - final masked = _maskPostgresComments(maskSqlLiteralsForLimitScan(sql)); + final masked = maskSqlLiteralsForLimitScan(sql); final statements = []; var start = 0; for (var i = 0; i < masked.length; i++) { - if (masked[i] == ';') { + if (masked.codeUnitAt(i) == 0x3B /* ; */) { statements.add(sql.substring(start, i)); start = i + 1; } @@ -77,37 +88,6 @@ List _splitPostgresStatements(String sql) { return statements; } -String _maskPostgresComments(String sql) { - final out = StringBuffer(); - var i = 0; - while (i < sql.length) { - if (i + 1 < sql.length && sql[i] == '-' && sql[i + 1] == '-') { - while (i < sql.length && sql[i] != '\n') { - out.write(' '); - i++; - } - continue; - } - if (i + 1 < sql.length && sql[i] == '/' && sql[i + 1] == '*') { - out.write(' '); - i += 2; - while (i < sql.length) { - if (i + 1 < sql.length && sql[i] == '*' && sql[i + 1] == '/') { - out.write(' '); - i += 2; - break; - } - out.write(' '); - i++; - } - continue; - } - out.write(sql[i]); - i++; - } - return out.toString(); -} - bool _postgresFragmentIsOnlyComments(String sql) { var s = sql.trim(); while (s.isNotEmpty) { diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index 4c051c42..45ac297c 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -101,7 +101,7 @@ class _PostgresWorkspaceHomeState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; return Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ @@ -109,7 +109,7 @@ class _PostgresWorkspaceHomeState constraints: const material.BoxConstraints(minHeight: 44), padding: const material.EdgeInsets.symmetric(horizontal: 12), decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), + color: colors.muted.withValues(alpha: 0.6), ), child: material.Row( children: [ @@ -119,7 +119,7 @@ class _PostgresWorkspaceHomeState material.Icon( material.Icons.lock_outline_rounded, size: 14, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ], if (widget.lastSelectedPostgresObject != null && diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 5b5e7bf3..f7b3e7f5 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -431,10 +431,10 @@ class _PostgresConnectionFormContentState material.Row( children: [ material.Expanded( - child: TextField( + child: QueryaTextField( key: Key(label), controller: controller, - placeholder: const Text('/path/to/file.pem'), + placeholder: '/path/to/file.pem', onChanged: (_) => _syncUriSslParams(), ), ), @@ -484,7 +484,7 @@ class _PostgresConnectionFormContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( @@ -549,9 +549,9 @@ class _PostgresConnectionFormContentState // Connection Name const Text('Connection Name').small().semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: _nameController, - placeholder: const Text('My PostgreSQL Server'), + placeholder: 'My PostgreSQL Server', ), const Gap(16), const Text('Connection URI (optional)').small().semiBold(), @@ -566,11 +566,9 @@ class _PostgresConnectionFormContentState '(seconds). If sslmode is omitted, Use SSL/TLS below applies.', ).muted().small(), const Gap(8), - TextField( + QueryaTextField( controller: _connectionStringController, - placeholder: const Text( - 'postgresql://user:pass@host:5432/dbname?sslmode=require', - ), + placeholder: 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), const Gap(16), ConnectionHostPortSection( diff --git a/lib/features/recovery/querya_startup_recovery_app.dart b/lib/features/recovery/querya_startup_recovery_app.dart index 5b116102..fa0e2687 100644 --- a/lib/features/recovery/querya_startup_recovery_app.dart +++ b/lib/features/recovery/querya_startup_recovery_app.dart @@ -4,6 +4,9 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/app/app.dart'; import 'package:querya_desktop/core/storage/app_data_root.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -123,24 +126,27 @@ class _QueryaStartupRecoveryAppState @override material.Widget build(material.BuildContext context) { - final themeData = ThemeController.instance.darkShadcnTheme; - final colorScheme = themeData.colorScheme; + final colors = QueryaTheme.darkDefault.colors; - return ShadcnApp( + return material.MaterialApp( title: 'Querya - Startup Recovery', - theme: ThemeController.instance.darkShadcnTheme, - darkTheme: ThemeController.instance.darkShadcnTheme, - themeMode: ThemeMode.dark, + theme: ThemeController.instance.darkMaterialTheme, + darkTheme: ThemeController.instance.darkMaterialTheme, + themeMode: material.ThemeMode.dark, debugShowCheckedModeBanner: false, + builder: (context, child) => QueryaThemeScope( + data: QueryaTheme.darkDefault, + child: child ?? const material.SizedBox.shrink(), + ), home: material.Scaffold( - backgroundColor: colorScheme.background, + backgroundColor: colors.background, body: material.Center( child: material.SingleChildScrollView( padding: const material.EdgeInsets.all(32), child: material.ConstrainedBox( constraints: const material.BoxConstraints(maxWidth: 640), child: QueryaDialogCard( - borderColor: colorScheme.destructive, + borderColor: colors.destructive, child: material.Padding( padding: const material.EdgeInsets.all(28), child: material.Column( @@ -152,7 +158,7 @@ class _QueryaStartupRecoveryAppState material.Icon( QueryaIcons.treeError, size: 32, - color: colorScheme.destructive, + color: colors.destructive, ), const material.SizedBox(width: 12), const material.Expanded( @@ -173,16 +179,18 @@ class _QueryaStartupRecoveryAppState width: double.infinity, padding: const material.EdgeInsets.all(12), decoration: material.BoxDecoration( - color: colorScheme.muted.withOpacity(0.3), + color: colors.muted.withOpacity(0.3), borderRadius: material.BorderRadius.circular(6), border: material.Border.all( - color: colorScheme.border, + color: colors.border, ), ), child: material.SelectableText( widget.error.toString(), style: const material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 12, ), ), @@ -194,8 +202,8 @@ class _QueryaStartupRecoveryAppState padding: const material.EdgeInsets.all(10), decoration: material.BoxDecoration( color: (_isErrorStatus - ? colorScheme.destructive - : colorScheme.primary) + ? colors.destructive + : colors.primary) .withOpacity(0.15), borderRadius: material.BorderRadius.circular(6), ), @@ -203,8 +211,8 @@ class _QueryaStartupRecoveryAppState _statusMessage!, style: material.TextStyle( color: _isErrorStatus - ? colorScheme.destructive - : colorScheme.primary, + ? colors.destructive + : colors.primary, fontSize: 13, fontWeight: material.FontWeight.w500, ), diff --git a/lib/features/redis/editors/redis_hash_editor.dart b/lib/features/redis/editors/redis_hash_editor.dart index 4d144f17..5633e552 100644 --- a/lib/features/redis/editors/redis_hash_editor.dart +++ b/lib/features/redis/editors/redis_hash_editor.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; /// Editor for a Redis hash: HSET add bar plus a field/value list. @@ -14,7 +14,7 @@ class RedisHashEditor extends material.StatefulWidget { required this.isReadOnly, required this.onSet, required this.onDelete, - required this.colorScheme, + required this.colors, required this.shadcnCs, this.footer, }); @@ -24,8 +24,8 @@ class RedisHashEditor extends material.StatefulWidget { final bool isReadOnly; final void Function(String field, String value) onSet; final void Function(RedisBulkValue field) onDelete; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; final material.Widget? footer; @override @@ -55,16 +55,16 @@ class _RedisHashEditorState extends material.State { material.Row( children: [ material.Expanded( - child: TextField( + child: QueryaTextField( controller: _fieldController, - placeholder: const Text('Field'), + placeholder: 'Field', ), ), const Gap(8), material.Expanded( - child: TextField( + child: QueryaTextField( controller: _valueController, - placeholder: const Text('Value'), + placeholder: 'Value', ), ), const Gap(8), @@ -98,7 +98,7 @@ class _RedisHashEditorState extends material.State { onDelete: widget.isReadOnly ? null : () => widget.onDelete(entry.key), - colorScheme: widget.colorScheme, + colors: widget.colors, shadcnCs: widget.shadcnCs, ); }, @@ -116,25 +116,25 @@ class RedisFieldRow extends StatelessWidget { required this.field, required this.value, this.onDelete, - required this.colorScheme, + required this.colors, required this.shadcnCs, }); final String field; final String value; final VoidCallback? onDelete; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; @override material.Widget build(material.BuildContext context) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: material.BoxDecoration( - color: colorScheme.card, + color: colors.card, borderRadius: material.BorderRadius.circular(6), border: material.Border.all( - color: colorScheme.border.withValues(alpha: 0.3)), + color: colors.border.withValues(alpha: 0.3)), ), child: material.Row( children: [ @@ -145,7 +145,8 @@ class RedisFieldRow extends StatelessWidget { overflow: material.TextOverflow.ellipsis, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontWeight: material.FontWeight.w600, color: shadcnCs.primary, ), @@ -157,8 +158,9 @@ class RedisFieldRow extends StatelessWidget { value, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', - color: colorScheme.foreground, + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, + color: colors.foreground, ), ), ), diff --git a/lib/features/redis/editors/redis_list_editor.dart b/lib/features/redis/editors/redis_list_editor.dart index e491fae7..d45aae2b 100644 --- a/lib/features/redis/editors/redis_list_editor.dart +++ b/lib/features/redis/editors/redis_list_editor.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; /// Editor for a Redis list: RPUSH add bar plus an indexed item list with @@ -16,7 +16,7 @@ class RedisListEditor extends material.StatefulWidget { required this.onPush, required this.onSet, required this.onRemove, - required this.colorScheme, + required this.colors, required this.shadcnCs, this.footer, }); @@ -31,8 +31,8 @@ class RedisListEditor extends material.StatefulWidget { RedisBulkValue? expectedCurrent, }) onSet; final void Function(int index, RedisBulkValue item) onRemove; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; final material.Widget? footer; @override @@ -75,9 +75,9 @@ class _RedisListEditorState extends material.State { material.Row( children: [ material.Expanded( - child: TextField( + child: QueryaTextField( controller: _valueController, - placeholder: const Text('New item'), + placeholder: 'New item', ), ), const Gap(8), @@ -111,7 +111,7 @@ class _RedisListEditorState extends material.State { onDelete: widget.isReadOnly ? null : () => widget.onRemove(i, items[i]), - colorScheme: widget.colorScheme, + colors: widget.colors, shadcnCs: widget.shadcnCs, ), ), @@ -163,9 +163,9 @@ class RedisEditListDialogContentState children: [ const Text('Enter new value').muted().small(), const Gap(8), - TextField( + QueryaTextField( controller: _controller, - placeholder: const Text('Value'), + placeholder: 'Value', ), ], ), @@ -195,7 +195,7 @@ class RedisIndexedValueRow extends StatelessWidget { this.isBinary = false, this.onEdit, this.onDelete, - required this.colorScheme, + required this.colors, required this.shadcnCs, }); @@ -204,18 +204,18 @@ class RedisIndexedValueRow extends StatelessWidget { final bool isBinary; final VoidCallback? onEdit; final VoidCallback? onDelete; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; @override material.Widget build(material.BuildContext context) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: material.BoxDecoration( - color: colorScheme.card, + color: colors.card, borderRadius: material.BorderRadius.circular(6), border: material.Border.all( - color: colorScheme.border.withValues(alpha: 0.3)), + color: colors.border.withValues(alpha: 0.3)), ), child: material.Row( children: [ @@ -236,8 +236,9 @@ class RedisIndexedValueRow extends StatelessWidget { value, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', - color: colorScheme.foreground, + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, + color: colors.foreground, ), ), ), @@ -254,7 +255,8 @@ class RedisIndexedValueRow extends StatelessWidget { 'binary', style: material.TextStyle( fontSize: 10, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, color: shadcnCs.mutedForeground, ), ), diff --git a/lib/features/redis/editors/redis_set_editor.dart b/lib/features/redis/editors/redis_set_editor.dart index 82af10f8..dec0e973 100644 --- a/lib/features/redis/editors/redis_set_editor.dart +++ b/lib/features/redis/editors/redis_set_editor.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; /// Editor for a Redis set: SADD add bar plus a member list. @@ -14,7 +14,7 @@ class RedisSetEditor extends material.StatefulWidget { required this.isReadOnly, required this.onAdd, required this.onRemove, - required this.colorScheme, + required this.colors, required this.shadcnCs, this.footer, }); @@ -24,8 +24,8 @@ class RedisSetEditor extends material.StatefulWidget { final bool isReadOnly; final void Function(String member) onAdd; final void Function(RedisBulkValue member) onRemove; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; final material.Widget? footer; @override @@ -53,9 +53,9 @@ class _RedisSetEditorState extends material.State { material.Row( children: [ material.Expanded( - child: TextField( + child: QueryaTextField( controller: _valueController, - placeholder: const Text('New member'), + placeholder: 'New member', ), ), const Gap(8), @@ -84,7 +84,7 @@ class _RedisSetEditorState extends material.State { onDelete: widget.isReadOnly ? null : () => widget.onRemove(members[index]), - colorScheme: widget.colorScheme, + colors: widget.colors, shadcnCs: widget.shadcnCs, ), ), @@ -100,24 +100,24 @@ class RedisMemberRow extends StatelessWidget { super.key, required this.member, this.onDelete, - required this.colorScheme, + required this.colors, required this.shadcnCs, }); final String member; final VoidCallback? onDelete; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; @override material.Widget build(material.BuildContext context) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: material.BoxDecoration( - color: colorScheme.card, + color: colors.card, borderRadius: material.BorderRadius.circular(6), border: material.Border.all( - color: colorScheme.border.withValues(alpha: 0.3)), + color: colors.border.withValues(alpha: 0.3)), ), child: material.Row( children: [ @@ -126,8 +126,9 @@ class RedisMemberRow extends StatelessWidget { member, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', - color: colorScheme.foreground, + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, + color: colors.foreground, ), ), ), diff --git a/lib/features/redis/editors/redis_string_editor.dart b/lib/features/redis/editors/redis_string_editor.dart index 6c4d7d09..71454529 100644 --- a/lib/features/redis/editors/redis_string_editor.dart +++ b/lib/features/redis/editors/redis_string_editor.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -13,14 +15,14 @@ class RedisStringEditor extends material.StatelessWidget { required this.controller, required this.isReadOnly, required this.onSave, - required this.colorScheme, + required this.colors, }); final RedisBulkValue? value; final material.TextEditingController controller; final bool isReadOnly; final VoidCallback onSave; - final ColorScheme colorScheme; + final QueryaUiColors colors; @override material.Widget build(material.BuildContext context) { @@ -41,7 +43,8 @@ class RedisStringEditor extends material.StatelessWidget { bulk.toHex(), style: const material.TextStyle( fontSize: 13, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, ), ), const Gap(12), @@ -51,7 +54,8 @@ class RedisStringEditor extends material.StatelessWidget { bulk.toBase64(), style: const material.TextStyle( fontSize: 13, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, ), ), ], @@ -79,20 +83,20 @@ class RedisStringEditor extends material.StatelessWidget { constraints: const material.BoxConstraints(minHeight: 200), decoration: material.BoxDecoration( border: material.Border.all( - color: colorScheme.border.withValues(alpha: 0.3)), + color: colors.border.withValues(alpha: 0.3)), borderRadius: material.BorderRadius.circular(8), ), - child: material.TextField( - controller: controller, - readOnly: isReadOnly, - maxLines: null, - style: const material.TextStyle( - fontSize: 13, - fontFamily: 'monospace', - ), - decoration: const material.InputDecoration( - border: material.InputBorder.none, - contentPadding: material.EdgeInsets.all(12), + child: material.Padding( + padding: const material.EdgeInsets.all(12), + child: QueryaTextField( + framed: false, + controller: controller, + readOnly: isReadOnly, + maxLines: null, + style: const material.TextStyle( + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, + ), ), ), ), diff --git a/lib/features/redis/editors/redis_zset_editor.dart b/lib/features/redis/editors/redis_zset_editor.dart index f8a8e575..bef7a5a3 100644 --- a/lib/features/redis/editors/redis_zset_editor.dart +++ b/lib/features/redis/editors/redis_zset_editor.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; /// Editor for a Redis sorted set: ZADD add bar plus a scored member list. @@ -14,7 +14,7 @@ class RedisZsetEditor extends material.StatefulWidget { required this.isReadOnly, required this.onAdd, required this.onRemove, - required this.colorScheme, + required this.colors, required this.shadcnCs, this.footer, }); @@ -24,8 +24,8 @@ class RedisZsetEditor extends material.StatefulWidget { final bool isReadOnly; final void Function(String member, double score) onAdd; final void Function(RedisBulkValue member) onRemove; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; final material.Widget? footer; @override @@ -56,16 +56,16 @@ class _RedisZsetEditorState extends material.State { children: [ material.Expanded( flex: 2, - child: TextField( + child: QueryaTextField( controller: _memberController, - placeholder: const Text('Member'), + placeholder: 'Member', ), ), const Gap(8), material.Expanded( - child: TextField( + child: QueryaTextField( controller: _scoreController, - placeholder: const Text('Score'), + placeholder: 'Score', ), ), const Gap(8), @@ -99,7 +99,7 @@ class _RedisZsetEditorState extends material.State { onDelete: widget.isReadOnly ? null : () => widget.onRemove(member), - colorScheme: widget.colorScheme, + colors: widget.colors, shadcnCs: widget.shadcnCs, ); }, @@ -117,25 +117,25 @@ class RedisScoredMemberRow extends StatelessWidget { required this.member, required this.score, this.onDelete, - required this.colorScheme, + required this.colors, required this.shadcnCs, }); final String member; final double score; final VoidCallback? onDelete; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; @override material.Widget build(material.BuildContext context) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: material.BoxDecoration( - color: colorScheme.card, + color: colors.card, borderRadius: material.BorderRadius.circular(6), border: material.Border.all( - color: colorScheme.border.withValues(alpha: 0.3)), + color: colors.border.withValues(alpha: 0.3)), ), child: material.Row( children: [ @@ -161,8 +161,9 @@ class RedisScoredMemberRow extends StatelessWidget { member, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', - color: colorScheme.foreground, + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, + color: colors.foreground, ), ), ), diff --git a/lib/features/redis/redis_cli_workspace.dart b/lib/features/redis/redis_cli_workspace.dart index 8109de68..9bb553a0 100644 --- a/lib/features/redis/redis_cli_workspace.dart +++ b/lib/features/redis/redis_cli_workspace.dart @@ -3,10 +3,12 @@ import 'dart:convert'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -580,7 +582,7 @@ class _RedisCliWorkspaceState extends material.State { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return material.Container( color: cs.card, @@ -621,7 +623,9 @@ class _RedisCliWorkspaceState extends material.State { child: Text( '${widget.connection.host}:${widget.connection.port} [db$_currentDb]', style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 11, color: cs.mutedForeground, ), @@ -699,7 +703,9 @@ class _RedisCliWorkspaceState extends material.State { child: Text( suggestion, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 11, fontWeight: material.FontWeight.w600, color: cs.primary, @@ -730,7 +736,8 @@ class _RedisCliWorkspaceState extends material.State { Text( 'db$_currentDb >', style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 13, fontWeight: material.FontWeight.w600, color: cs.primary, @@ -756,11 +763,11 @@ class _RedisCliWorkspaceState extends material.State { } return KeyEventResult.ignored; }, - child: TextField( + child: QueryaTextField( controller: _inputController, focusNode: _inputFocusNode, autofocus: true, - placeholder: const Text('Enter Redis command (Tab to autocomplete)...'), + placeholder: 'Enter Redis command (Tab to autocomplete)...', onSubmitted: (_) => _submitCommand(), ), ), @@ -781,7 +788,7 @@ class _RedisCliWorkspaceState extends material.State { ); } - Widget _buildEntryItem(RedisCliEntry entry, ColorScheme cs) { + Widget _buildEntryItem(RedisCliEntry entry, QueryaUiColors cs) { return material.Container( margin: const material.EdgeInsets.only(bottom: 10), padding: const material.EdgeInsets.all(8), @@ -801,7 +808,8 @@ class _RedisCliWorkspaceState extends material.State { Text( 'db${entry.database} > ', style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12, fontWeight: material.FontWeight.w600, color: cs.primary, @@ -811,7 +819,8 @@ class _RedisCliWorkspaceState extends material.State { child: Text( entry.command, style: const material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12, fontWeight: material.FontWeight.w600, ), @@ -831,7 +840,9 @@ class _RedisCliWorkspaceState extends material.State { child: Text( '${entry.latencyMs} ms', style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 10, color: cs.mutedForeground, ), @@ -858,7 +869,8 @@ class _RedisCliWorkspaceState extends material.State { material.SelectableText( entry.output, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12, color: entry.isError ? cs.destructive diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index 91033492..d781f2b8 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -302,7 +302,7 @@ class _RedisConnectionFormContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( @@ -349,9 +349,9 @@ class _RedisConnectionFormContentState children: [ const Text('Connection Name').small().semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: _nameController, - placeholder: const Text('My Redis Server'), + placeholder: 'My Redis Server', ), const Gap(16), const Text('Connection URI (optional)').small().semiBold(), @@ -361,9 +361,9 @@ class _RedisConnectionFormContentState 'sslcert, sslkey.', ).muted().small(), const Gap(8), - TextField( + QueryaTextField( controller: _connectionStringController, - placeholder: const Text('rediss://user:pass@host:6379'), + placeholder: 'rediss://user:pass@host:6379', ), const Gap(16), material.Row( @@ -377,9 +377,9 @@ class _RedisConnectionFormContentState children: [ const Text('Host').small().semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: _hostController, - placeholder: const Text('localhost'), + placeholder: 'localhost', ), ], ), @@ -394,9 +394,9 @@ class _RedisConnectionFormContentState children: [ const Text('Port').small().semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: _portController, - placeholder: const Text('6379'), + placeholder: '6379', ), ], ), @@ -408,10 +408,9 @@ class _RedisConnectionFormContentState .small() .semiBold(), const Gap(8), - TextField( + QueryaTextField( controller: _usernameController, - placeholder: - const Text('default (leave empty for default user)'), + placeholder: 'default (leave empty for default user)', ), const Gap(16), const Text('Password (optional)').small().semiBold(), @@ -424,14 +423,12 @@ class _RedisConnectionFormContentState const Gap(8), material.Stack( children: [ - TextField( + QueryaTextField( controller: _passwordController, enabled: !_removeSavedPassword, - placeholder: Text( - _isEditing + placeholder: _isEditing ? 'Leave blank to keep existing' : 'Password', - ), obscureText: !_showPassword, ), material.Positioned( diff --git a/lib/features/redis/redis_databases_view.dart b/lib/features/redis/redis_databases_view.dart index e9a7968c..b61ecad0 100644 --- a/lib/features/redis/redis_databases_view.dart +++ b/lib/features/redis/redis_databases_view.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; /// Shows all Redis databases (db0–dbN) with key counts. @@ -95,8 +94,8 @@ class _RedisDatabasesViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - final shadcnCs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; + final shadcnCs = context.colors; if (_loading) { return const material.Center( @@ -186,7 +185,7 @@ class _RedisDatabasesViewState extends material.State { for (final db in dbsWithData) _DatabaseTile( db: db, - colorScheme: cs, + colors: cs, shadcnCs: shadcnCs, onTap: () => widget.onDatabaseTap?.call(db.index), ), @@ -200,7 +199,7 @@ class _RedisDatabasesViewState extends material.State { for (final db in dbsEmpty) _DatabaseTile( db: db, - colorScheme: cs, + colors: cs, shadcnCs: shadcnCs, onTap: () => widget.onDatabaseTap?.call(db.index), ), @@ -235,19 +234,19 @@ class _DbInfo { class _DatabaseTile extends material.StatelessWidget { const _DatabaseTile({ required this.db, - required this.colorScheme, + required this.colors, required this.shadcnCs, required this.onTap, }); final _DbInfo db; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; final material.VoidCallback onTap; @override material.Widget build(material.BuildContext context) { - final cs = colorScheme; + final cs = colors; final db = this.db; return material.Material( diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index 12752acf..69993981 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -7,8 +7,6 @@ import 'package:querya_desktop/core/database/redis_service.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'redis_keys_view.dart'; import 'redis_key_editor.dart'; @@ -51,6 +49,11 @@ class RedisExplorerView extends material.StatefulWidget { class _RedisExplorerViewState extends material.State { RedisConnection? _connection; + + /// The CLI's own socket for [RedisExplorerView.database], connected on the + /// first command: a `SELECT` typed there must not move the keys view and + /// key editor, which share [_connection] (#1569). + RedisConnection? _cliConnection; bool _connecting = true; String? _error; @@ -91,6 +94,7 @@ class _RedisExplorerViewState extends material.State { } else { if (oldWidget.isReadOnly != widget.isReadOnly) { unawaited(_connection?.applyClientReadOnly(widget.isReadOnly)); + unawaited(_cliConnection?.applyClientReadOnly(widget.isReadOnly)); } if (widget.initialKey != null && widget.initialKey != _selectedKey?.label) { @@ -112,6 +116,11 @@ class _RedisExplorerViewState extends material.State { if (conn != null) { unawaited(RedisService.instance.disconnect(conn)); } + final cli = _cliConnection; + _cliConnection = null; + if (cli != null) { + unawaited(RedisService.instance.disconnect(cli)); + } } Future _connect() async { @@ -120,6 +129,11 @@ class _RedisExplorerViewState extends material.State { if (old != null) { await RedisService.instance.disconnect(old); } + final oldCli = _cliConnection; + _cliConnection = null; + if (oldCli != null) { + await RedisService.instance.disconnect(oldCli); + } if (!mounted) return; setState(() { _connecting = true; @@ -134,17 +148,26 @@ class _RedisExplorerViewState extends material.State { final conn = RedisService.instance.acquire( widget.connectionRow, role: RedisSessionRole.explorer, + database: widget.database, + ); + final cli = RedisService.instance.acquire( + widget.connectionRow, + role: RedisSessionRole.cli, + database: widget.database, ); await conn.applyClientReadOnly(widget.isReadOnly); + await cli.applyClientReadOnly(widget.isReadOnly); if (!conn.isConnected) { await conn.connect(); } if (!mounted) { unawaited(RedisService.instance.disconnect(conn)); + unawaited(RedisService.instance.disconnect(cli)); return; } setState(() { _connection = conn; + _cliConnection = cli; _connecting = false; }); } catch (e) { @@ -234,7 +257,7 @@ class _RedisExplorerViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; // Loading state if (_connecting) { @@ -277,7 +300,8 @@ class _RedisExplorerViewState extends material.State { } final conn = _connection; - if (conn == null) return const material.SizedBox.shrink(); + final cli = _cliConnection; + if (conn == null || cli == null) return const material.SizedBox.shrink(); final int bodyIndex; if (_showCli) { @@ -350,7 +374,7 @@ class _RedisExplorerViewState extends material.State { key: ValueKey( 'cli_${widget.connectionRow.id}_${widget.database}'), connectionRow: widget.connectionRow, - connection: conn, + connection: cli, database: widget.database, onBack: () => setState(() => _showCli = false), ), @@ -418,7 +442,7 @@ class _BreadcrumbBar extends StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Container( constraints: const material.BoxConstraints(minHeight: 44), padding: const material.EdgeInsets.symmetric(horizontal: 16), @@ -539,7 +563,7 @@ class _CrumbChip extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; final child = isLast ? Text(label).semiBold().small() : Text(label, diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index 3725bfce..f83ac56b 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/redis/editors/redis_hash_editor.dart'; import 'package:querya_desktop/features/redis/editors/redis_list_editor.dart'; import 'package:querya_desktop/features/redis/editors/redis_set_editor.dart'; @@ -9,8 +11,6 @@ import 'package:querya_desktop/features/redis/editors/redis_string_editor.dart'; import 'package:querya_desktop/features/redis/editors/redis_zset_editor.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; /// Viewer / editor for a single Redis key. Type-aware: string, hash, @@ -182,7 +182,6 @@ class _RedisKeyEditorState extends material.State { _success = null; }); try { - await widget.connection.selectDatabase(widget.database); _ttl = await widget.connection.ttl(_cmdKey); _effectiveType = await _resolveType(); @@ -256,7 +255,6 @@ class _RedisKeyEditorState extends material.State { if (_loadingMore) return; if (!reset && mounted) setState(() => _loadingMore = true); try { - await widget.connection.selectDatabase(widget.database); switch (_effectiveType) { case 'hash': if (reset) { @@ -329,7 +327,6 @@ class _RedisKeyEditorState extends material.State { if (widget.isReadOnly) return; if (_effectiveType != 'string' || _stringIsBinary) return; try { - await widget.connection.selectDatabase(widget.database); await widget.connection.set( _cmdKey, _stringController.text, @@ -357,7 +354,6 @@ class _RedisKeyEditorState extends material.State { if (trimmedNew == _currentKeyName) return; try { - await widget.connection.selectDatabase(widget.database); final exists = (await widget.connection.exists(trimmedNew)) > 0; if (exists && mounted) { final confirmed = await confirmDestructiveAction( @@ -398,7 +394,6 @@ class _RedisKeyEditorState extends material.State { ); if (!mounted || !confirmed) return; try { - await widget.connection.selectDatabase(widget.database); await widget.connection.del(_cmdKey); widget.onKeyDeleted?.call(); } catch (e) { @@ -410,7 +405,6 @@ class _RedisKeyEditorState extends material.State { Future _setTtl(int seconds) async { if (widget.isReadOnly) return; try { - await widget.connection.selectDatabase(widget.database); if (seconds > 0) { await widget.connection.expire(_cmdKey, seconds); } else { @@ -432,7 +426,6 @@ class _RedisKeyEditorState extends material.State { Future _hashSet(String field, String value) async { if (widget.isReadOnly) return; try { - await widget.connection.selectDatabase(widget.database); await widget.connection.hset(_cmdKey, field, value); await _load(); } catch (e) { @@ -452,7 +445,6 @@ class _RedisKeyEditorState extends material.State { ); if (!mounted || !confirmed) return; try { - await widget.connection.selectDatabase(widget.database); await widget.connection.hdel(_cmdKey, field); await _load(); } catch (e) { @@ -465,7 +457,6 @@ class _RedisKeyEditorState extends material.State { Future _listPush(String value) async { if (widget.isReadOnly) return; try { - await widget.connection.selectDatabase(widget.database); await widget.connection.rpush(_cmdKey, value); await _load(); } catch (e) { @@ -481,7 +472,6 @@ class _RedisKeyEditorState extends material.State { }) async { if (widget.isReadOnly) return; try { - await widget.connection.selectDatabase(widget.database); if (expectedCurrent != null) { final current = await widget.connection.lindex(_cmdKey, index); if (current == null || current != expectedCurrent) { @@ -511,7 +501,6 @@ class _RedisKeyEditorState extends material.State { ); if (!mounted || !confirmed) return; try { - await widget.connection.selectDatabase(widget.database); final current = await widget.connection.lindex(_cmdKey, index); if (current == null || current != item) { await _load(); @@ -549,7 +538,6 @@ class _RedisKeyEditorState extends material.State { Future _setAdd(String member) async { if (widget.isReadOnly) return; try { - await widget.connection.selectDatabase(widget.database); await widget.connection.sadd(_cmdKey, member); await _load(); } catch (e) { @@ -569,7 +557,6 @@ class _RedisKeyEditorState extends material.State { ); if (!mounted || !confirmed) return; try { - await widget.connection.selectDatabase(widget.database); await widget.connection.srem(_cmdKey, member); await _load(); } catch (e) { @@ -582,7 +569,6 @@ class _RedisKeyEditorState extends material.State { Future _zsetAdd(String member, double score) async { if (widget.isReadOnly) return; try { - await widget.connection.selectDatabase(widget.database); await widget.connection.zadd(_cmdKey, score, member); await _load(); } catch (e) { @@ -602,7 +588,6 @@ class _RedisKeyEditorState extends material.State { ); if (!mounted || !confirmed) return; try { - await widget.connection.selectDatabase(widget.database); await widget.connection.zrem(_cmdKey, member); await _load(); } catch (e) { @@ -632,8 +617,8 @@ class _RedisKeyEditorState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - final shadcnCs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; + final shadcnCs = context.colors; final palette = context.semanticPalette; if (_loading) { @@ -719,7 +704,7 @@ class _RedisKeyEditorState extends material.State { ); } - Widget _buildHeader(ColorScheme cs, shadcn.ColorScheme scs) { + Widget _buildHeader(QueryaUiColors cs, QueryaUiColors scs) { final typeCol = _typeColor(_effectiveType); return material.Container( padding: @@ -755,7 +740,8 @@ class _RedisKeyEditorState extends material.State { maxLines: 1, style: material.TextStyle( fontSize: 14, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontWeight: material.FontWeight.w500, color: cs.foreground, ), @@ -847,7 +833,7 @@ class _RedisKeyEditorState extends material.State { ); } - Widget _buildContent(ColorScheme cs, shadcn.ColorScheme scs) { + Widget _buildContent(QueryaUiColors cs, QueryaUiColors scs) { switch (_effectiveType) { case 'string': return _buildStringEditor(cs); @@ -884,30 +870,30 @@ class _RedisKeyEditorState extends material.State { // ─── Type editors ─────────────────────────────────────────────────────── - Widget _buildStringEditor(ColorScheme cs) { + Widget _buildStringEditor(QueryaUiColors cs) { return RedisStringEditor( value: _stringValue, controller: _stringController, isReadOnly: widget.isReadOnly, onSave: _saveString, - colorScheme: cs, + colors: cs, ); } - Widget _buildHashEditor(ColorScheme cs, shadcn.ColorScheme scs) { + Widget _buildHashEditor(QueryaUiColors cs, QueryaUiColors scs) { return RedisHashEditor( heading: _collectionHeading('Hash fields'), entries: _hashValue.entries.toList(), isReadOnly: widget.isReadOnly, onSet: _hashSet, onDelete: _hashDel, - colorScheme: cs, + colors: cs, shadcnCs: scs, footer: _hasMore ? _loadMoreTile() : null, ); } - Widget _buildListEditor(ColorScheme cs, shadcn.ColorScheme scs) { + Widget _buildListEditor(QueryaUiColors cs, QueryaUiColors scs) { return RedisListEditor( heading: _collectionHeading('List items'), items: _listValue, @@ -915,33 +901,33 @@ class _RedisKeyEditorState extends material.State { onPush: _listPush, onSet: _listSet, onRemove: _listRemove, - colorScheme: cs, + colors: cs, shadcnCs: scs, footer: _hasMore ? _loadMoreTile() : null, ); } - Widget _buildSetEditor(ColorScheme cs, shadcn.ColorScheme scs) { + Widget _buildSetEditor(QueryaUiColors cs, QueryaUiColors scs) { return RedisSetEditor( heading: _collectionHeading('Set members'), members: _setValue, isReadOnly: widget.isReadOnly, onAdd: _setAdd, onRemove: _setRemove, - colorScheme: cs, + colors: cs, shadcnCs: scs, footer: _hasMore ? _loadMoreTile() : null, ); } - Widget _buildZsetEditor(ColorScheme cs, shadcn.ColorScheme scs) { + Widget _buildZsetEditor(QueryaUiColors cs, QueryaUiColors scs) { return RedisZsetEditor( heading: _collectionHeading('Sorted set'), members: _zsetValue, isReadOnly: widget.isReadOnly, onAdd: _zsetAdd, onRemove: _zsetRemove, - colorScheme: cs, + colors: cs, shadcnCs: scs, footer: _hasMore ? _loadMoreTile() : null, ); @@ -1041,9 +1027,9 @@ class _RedisTtlDialogContentState children: [ const Text('Enter TTL in seconds (0 to remove)').muted().small(), const Gap(8), - TextField( + QueryaTextField( controller: _controller, - placeholder: const Text('Seconds'), + placeholder: 'Seconds', ), ], ), @@ -1107,9 +1093,9 @@ class _RedisRenameDialogContentState children: [ const Text('Enter new key name').muted().small(), const Gap(8), - TextField( + QueryaTextField( controller: _controller, - placeholder: const Text('New key name'), + placeholder: 'New key name', ), ], ), diff --git a/lib/features/redis/redis_key_tree_engine.dart b/lib/features/redis/redis_key_tree_engine.dart index 695dce3a..e39a90de 100644 --- a/lib/features/redis/redis_key_tree_engine.dart +++ b/lib/features/redis/redis_key_tree_engine.dart @@ -50,7 +50,15 @@ class RedisKeyFolderNode { final List leaves; /// Total count of keys in this subtree (both direct leaves and nested leaves). - int get totalKeyCount { + /// + /// Counted on first use and kept: the folder row asks for it on every + /// rebuild, and a walk of the whole subtree each time costs O(keys x depth). + /// The tree is not changed once the engine returns it, so the count stays + /// right; it is lazy so a node whose lists are filled after construction + /// still counts what is in them at first use. + late final int totalKeyCount = _countKeys(); + + int _countKeys() { var count = leaves.length; for (final f in folders) { count += f.totalKeyCount; @@ -128,6 +136,22 @@ class _MutableFolder { final List leaves = []; } +/// Sorts [items] by [nameOf], case-insensitively, lower-casing every name once. +/// +/// `toLowerCase()` inside the comparator ran twice per comparison, so a folder +/// with tens of thousands of keys paid O(n log n) string allocations (#1551). +/// The comparisons give the same results on the same order, so the permutation +/// (ties included) is the one the comparator version produced. +void _sortByLowerName(List items, String Function(T item) nameOf) { + if (items.length < 2) return; + final keyed = [ + for (final item in items) (lower: nameOf(item).toLowerCase(), item: item), + ]..sort((a, b) => a.lower.compareTo(b.lower)); + for (var i = 0; i < keyed.length; i++) { + items[i] = keyed[i].item; + } +} + /// Engine to partition and build a folder hierarchy of Redis keys by a delimiter. class RedisKeyTreeEngine { const RedisKeyTreeEngine._(); @@ -142,7 +166,8 @@ class RedisKeyTreeEngine { if (delimiter.isEmpty) { final leaves = [ for (final k in keys) RedisKeyLeafNode(name: k.name.label, keyInfo: k), - ]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + ]; + _sortByLowerName(leaves, (l) => l.name); return RedisKeyTree( rootFolders: const [], rootLeaves: leaves, @@ -152,51 +177,55 @@ class RedisKeyTreeEngine { final rootFolderMap = {}; final rootLeaves = []; + final delimiterLength = delimiter.length; for (final key in keys) { final label = key.name.label; - if (!label.contains(delimiter)) { + var next = label.indexOf(delimiter); + if (next < 0) { rootLeaves.add(RedisKeyLeafNode(name: label, keyInfo: key)); continue; } - final parts = label.split(delimiter); - // Example: 'user:100:profile' with ':' -> parts: ['user', '100', 'profile'] - // Folder segments: ['user', '100'] - // Leaf segment: 'profile' - final folderSegments = parts.sublist(0, parts.length - 1); - final rawLeaf = parts.last; - final leafSegment = rawLeaf.isEmpty ? '(empty)' : rawLeaf; - + // Example: 'user:100:profile' with ':' -> folders 'user' and '100', leaf + // 'profile'. The segments are cut out by index, one at a time, with no + // split() list and no sublist (#1523); a folder's full prefix is the + // label up to and including its delimiter. var currentMap = rootFolderMap; _MutableFolder? currentFolder; - var currentPrefix = ''; + var start = 0; - for (final seg in folderSegments) { - currentPrefix = '$currentPrefix$seg$delimiter'; + while (next >= 0) { + final seg = label.substring(start, next); + final prefixEnd = next + delimiterLength; currentFolder = currentMap.putIfAbsent( seg, - () => _MutableFolder(name: seg, fullPrefix: currentPrefix), + () => _MutableFolder( + name: seg, + fullPrefix: label.substring(0, prefixEnd), + ), ); currentMap = currentFolder.subfolders; + start = prefixEnd; + next = label.indexOf(delimiter, start); } + final rawLeaf = label.substring(start); + final leafSegment = rawLeaf.isEmpty ? '(empty)' : rawLeaf; currentFolder!.leaves.add( RedisKeyLeafNode(name: leafSegment, keyInfo: key), ); } - rootLeaves.sort( - (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + _sortByLowerName(rootLeaves, (l) => l.name); List convertFolders(Map map) { - final sortedKeys = map.keys.toList() - ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + final sortedKeys = map.keys.toList(); + _sortByLowerName(sortedKeys, (k) => k); final result = []; for (final key in sortedKeys) { final mf = map[key]!; - mf.leaves.sort( - (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + _sortByLowerName(mf.leaves, (l) => l.name); result.add( RedisKeyFolderNode( name: mf.name, diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 640472fa..9d528ad9 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/theme/querya_semantic_palette.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'redis_key_tree_engine.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -76,7 +76,6 @@ class _RedisKeysViewState extends material.State { _hasMore = true; }); try { - await widget.connection.selectDatabase(widget.database); _dbSize = await widget.connection.dbSize(); await _scanBatch(); if (!mounted) return; @@ -188,7 +187,6 @@ class _RedisKeysViewState extends material.State { ); if (!mounted || !confirmed) return; try { - await widget.connection.selectDatabase(widget.database); await widget.connection.del(keyInfo.name); setState(() { _keys.removeWhere((k) => k.name == keyInfo.name); @@ -216,14 +214,17 @@ class _RedisKeysViewState extends material.State { ); if (!mounted || !confirmed) return; try { - await widget.connection.selectDatabase(widget.database); await widget.connection.delMany(keys.map((k) => k.name)); + if (!mounted) return; setState(() { final toRemove = keys.map((k) => k.name).toSet(); _keys.removeWhere((k) => toRemove.contains(k.name)); _dbSize = (_dbSize - count).clamp(0, _dbSize); }); } catch (e) { + if (!mounted) return; + // Some chunks may have been removed: show what is really left. + await _load(); if (mounted) setState(() => _error = 'Delete folder failed: $e'); } } @@ -294,7 +295,7 @@ class _RedisKeysViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading && _keys.isEmpty) { return const material.Center( @@ -335,7 +336,7 @@ class _RedisKeysViewState extends material.State { cacheExtent: 400, itemCount: _keys.length + (_hasMore ? 1 : 0), itemBuilder: (context, i) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; + final shadcnCs = context.colors; if (i >= _keys.length) { return material.Padding( padding: const material.EdgeInsets.only(top: 12), @@ -352,7 +353,7 @@ class _RedisKeysViewState extends material.State { padding: EdgeInsets.only(top: i > 0 ? 4 : 0), child: _KeyTile( keyInfo: _keys[i], - colorScheme: cs, + colors: cs, shadcnCs: shadcnCs, palette: context.semanticPalette, onTap: () => widget.onKeyTap?.call(_keys[i].name, _keys[i].type), @@ -370,7 +371,7 @@ class _RedisKeysViewState extends material.State { cacheExtent: 400, itemCount: visibleItems.length + (_hasMore ? 1 : 0), itemBuilder: (context, i) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; + final shadcnCs = context.colors; if (i >= visibleItems.length) { return material.Padding( padding: const material.EdgeInsets.only(top: 12), @@ -391,7 +392,7 @@ class _RedisKeysViewState extends material.State { folder: item.folder, isExpanded: item.isExpanded, depth: item.depth, - colorScheme: cs, + colors: cs, shadcnCs: shadcnCs, palette: context.semanticPalette, onToggle: () { @@ -415,7 +416,7 @@ class _RedisKeysViewState extends material.State { keyInfo: item.leaf.keyInfo, displayName: item.leaf.name, indent: 16.0 * item.depth, - colorScheme: cs, + colors: cs, shadcnCs: shadcnCs, palette: context.semanticPalette, onTap: () => widget.onKeyTap?.call( @@ -445,8 +446,8 @@ class _RedisKeysViewState extends material.State { ); } - Widget _buildFilterBar(ColorScheme cs) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; + Widget _buildFilterBar(QueryaUiColors cs) { + final shadcnCs = context.colors; return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), @@ -536,26 +537,15 @@ class _RedisKeysViewState extends material.State { material.SizedBox( width: 36, height: 28, - child: material.TextField( + child: QueryaTextField( controller: _delimiterController, + size: QueryaControlSize.sm, textAlign: material.TextAlign.center, + contentPadding: material.EdgeInsets.zero, style: const material.TextStyle( - fontSize: 13, - fontFamily: 'monospace', - ), - decoration: material.InputDecoration( - contentPadding: material.EdgeInsets.zero, - isDense: true, - border: material.OutlineInputBorder( - borderRadius: material.BorderRadius.circular(6), - borderSide: material.BorderSide( - color: cs.border.withValues(alpha: 0.4), - ), - ), - focusedBorder: material.OutlineInputBorder( - borderRadius: material.BorderRadius.circular(6), - borderSide: material.BorderSide(color: cs.primary), - ), + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, ), onChanged: (_) => setState(() {}), ), @@ -589,7 +579,7 @@ class _RedisKeysViewState extends material.State { ); } - Widget _buildErrorBanner(ColorScheme cs) { + Widget _buildErrorBanner(QueryaUiColors cs) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 8), color: cs.destructive.withValues(alpha: 0.1), @@ -614,8 +604,8 @@ class _RedisKeysViewState extends material.State { ); } - Widget _buildStatusBar(ColorScheme cs) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; + Widget _buildStatusBar(QueryaUiColors cs) { + final shadcnCs = context.colors; return material.Container( constraints: const material.BoxConstraints(minHeight: 44), padding: const material.EdgeInsets.symmetric(horizontal: 16), @@ -688,7 +678,7 @@ class _FolderTile extends material.StatelessWidget { required this.folder, required this.isExpanded, required this.depth, - required this.colorScheme, + required this.colors, required this.shadcnCs, required this.palette, required this.onToggle, @@ -698,15 +688,15 @@ class _FolderTile extends material.StatelessWidget { final RedisKeyFolderNode folder; final bool isExpanded; final int depth; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; final QueryaSemanticPalette palette; final material.VoidCallback onToggle; final material.VoidCallback? onDelete; @override material.Widget build(material.BuildContext context) { - final cs = colorScheme; + final cs = colors; final indent = 16.0 * depth; return material.Material( @@ -750,7 +740,8 @@ class _FolderTile extends material.StatelessWidget { style: material.TextStyle( fontSize: 13, fontWeight: material.FontWeight.w600, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, color: cs.foreground, ), ), @@ -795,7 +786,7 @@ onPressed: onDelete, class _KeyTile extends material.StatelessWidget { const _KeyTile({ required this.keyInfo, - required this.colorScheme, + required this.colors, required this.shadcnCs, required this.palette, required this.onTap, @@ -805,8 +796,8 @@ class _KeyTile extends material.StatelessWidget { }); final RedisKeyInfo keyInfo; - final ColorScheme colorScheme; - final shadcn.ColorScheme shadcnCs; + final QueryaUiColors colors; + final QueryaUiColors shadcnCs; final QueryaSemanticPalette palette; final material.VoidCallback onTap; final material.VoidCallback? onDelete; @@ -858,7 +849,7 @@ class _KeyTile extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = colorScheme; + final cs = colors; final ki = keyInfo; final typeCol = _typeColor(ki.type, palette); @@ -908,7 +899,9 @@ class _KeyTile extends material.StatelessWidget { maxLines: 1, style: material.TextStyle( fontSize: 13, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, color: cs.foreground, ), ), diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 40f46a16..c6716937 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -2,15 +2,14 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; const _pollInterval = Duration(seconds: 3); @@ -74,12 +73,12 @@ class RedisView extends material.StatefulWidget { material.State createState() => _RedisViewState(); } -class _RedisViewState extends material.State { +class _RedisViewState extends material.State + with PollingState { RedisConnection? _connection; RedisInfoSections? _info; bool _loading = true; String? _error; - Timer? _timer; @override void initState() { @@ -91,7 +90,7 @@ class _RedisViewState extends material.State { void didUpdateWidget(covariant RedisView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { - _timer?.cancel(); + stopPolling(); _disconnectCurrent(); _load(); } @@ -99,7 +98,6 @@ class _RedisViewState extends material.State { @override void dispose() { - _timer?.cancel(); _disconnectCurrent(); super.dispose(); } @@ -118,7 +116,7 @@ class _RedisViewState extends material.State { } Future _load({bool reconnect = false}) async { - _timer?.cancel(); + stopPolling(); if (!mounted) return; setState(() { _loading = true; @@ -160,13 +158,14 @@ class _RedisViewState extends material.State { } _connection = conn; await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); } catch (e) { if (mounted) { setState(() { _error = e.toString(); _loading = false; }); + syncPolling(); } } } @@ -193,56 +192,53 @@ class _RedisViewState extends material.State { setState(() => _loading = false); } - Future _onPollTick() async { + @override + Duration? get pollInterval => _pollInterval; + + @override + bool get pollShouldRun { final c = _connection; - if (c == null || !c.isConnected) return; - try { - final raw = await c.info(); - final info = parseRedisInfo(raw); - if (!mounted) return; - if (!replaceIfChanged(_info, info, (v) => _info = v)) return; - setState(() => _info = info); - } catch (_) {} + return c != null && c.isConnected && !_loading && _error == null; } - void _startTimer() { - _timer?.cancel(); - _timer = Timer.periodic(_pollInterval, (_) => unawaited(_onPollTick())); + @override + Future pollTick() async { + final c = _connection; + if (c == null || !c.isConnected) return; + final raw = await c.info(); + final info = parseRedisInfo(raw); + if (!mounted) return; + if (!replaceIfChanged(_info, info, (v) => _info = v)) return; + setState(() => _info = info); } @override material.Widget build(material.BuildContext context) { - final c = _connection; - _timer = syncTickerGatedPeriodicTimer( - context: context, - timer: _timer, - shouldRun: c != null && c.isConnected && !_loading, - interval: _pollInterval, - onTick: () => unawaited(_onPollTick()), - ); - - final cs = Theme.of(context).colorScheme; - - return material.Container( - color: cs.background, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 16), - child: _header(context), - ), - const QueryaDivider(height: 1), - material.Expanded( - child: _buildBody(context, cs), - ), - ], - ), + final cs = context.colors; + + return PollStaleOverlay( + status: polling.status, + child: material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 16), + child: _header(context), + ), + const QueryaDivider(height: 1), + material.Expanded( + child: _buildBody(context, cs), + ), + ], + ), + ), ); } material.Widget _buildBody( - material.BuildContext context, shadcn.ColorScheme cs) { + material.BuildContext context, QueryaUiColors cs) { if (_loading) { return const material.Center( child: QueryaSpinner( @@ -408,7 +404,7 @@ class _RedisViewState extends material.State { } material.Widget _header(material.BuildContext context) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ @@ -532,7 +528,7 @@ class _RedisViewState extends material.State { material.Widget _summaryChips( material.BuildContext context, RedisInfoSections info) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; final version = sectionValue(info, 'Server', 'redis_version') ?? '—'; final uptime = _formatUptime(info); final clients = sectionInt(info, 'Clients', 'connected_clients') ?? 0; @@ -611,7 +607,7 @@ class _RedisViewState extends material.State { double? minHeight, bool stretchBody = false, }) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Container( width: double.infinity, constraints: minHeight != null @@ -867,7 +863,7 @@ class _RedisViewState extends material.State { material.Widget _metricRow( material.BuildContext context, String label, String value) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Padding( padding: const material.EdgeInsets.symmetric(vertical: 6), child: material.Row( diff --git a/lib/features/results/charts/chart_data.dart b/lib/features/results/charts/chart_data.dart index c9c8e19e..0ec7916e 100644 --- a/lib/features/results/charts/chart_data.dart +++ b/lib/features/results/charts/chart_data.dart @@ -45,32 +45,103 @@ class ChartData { /// Name of the slice that collects the categories beyond Top N. static const String otherLabel = 'Other'; - static final _isoTime = RegExp( - r'^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?(Z|[+-]\d{2}:?\d{2})?$', - ); - static final _hasZone = RegExp(r'(Z|[+-]\d{2}:?\d{2})$'); + static bool _digitsAt(String s, int at, int count) { + if (at + count > s.length) return false; + for (var i = at; i < at + count; i++) { + final c = s.codeUnitAt(i); + if (c < 0x30 || c > 0x39) return false; + } + return true; + } + + /// The decimal value of the [count] digits at [at], or -1 when they are not + /// all digits. + static int _digitsValue(String s, int at, int count) { + if (!_digitsAt(s, at, count)) return -1; + var v = 0; + for (var i = at; i < at + count; i++) { + v = v * 10 + (s.codeUnitAt(i) - 0x30); + } + return v; + } + + /// Checks [s] against + /// `YYYY-MM-DD([T ]HH:MM(:SS(.f+)?)?)?(Z|[+-]HH:?MM)?` without a regex: + /// -1 when it does not fit, 1 when it ends in a zone, 0 otherwise. + static int _scanIso(String s) { + final n = s.length; + if (n < 10 || + !_digitsAt(s, 0, 4) || + s.codeUnitAt(4) != 0x2D || + !_digitsAt(s, 5, 2) || + s.codeUnitAt(7) != 0x2D || + !_digitsAt(s, 8, 2)) { + return -1; + } + var i = 10; + if (i < n) { + final sep = s.codeUnitAt(i); + if (sep == 0x54 || sep == 0x20) { + // [T ]HH:MM + if (!_digitsAt(s, i + 1, 2) || + i + 3 >= n || + s.codeUnitAt(i + 3) != 0x3A || + !_digitsAt(s, i + 4, 2)) { + return -1; + } + i += 6; + // :SS + if (i < n && s.codeUnitAt(i) == 0x3A && _digitsAt(s, i + 1, 2)) { + i += 3; + // .f+ + if (i < n && s.codeUnitAt(i) == 0x2E && _digitsAt(s, i + 1, 1)) { + i++; + while (i < n && _digitsAt(s, i, 1)) { + i++; + } + } + } + } + } + if (i == n) return 0; + final z = s.codeUnitAt(i); + if (z == 0x5A) return i + 1 == n ? 1 : -1; // Z + if (z != 0x2B && z != 0x2D) return -1; // + or - + i++; + if (!_digitsAt(s, i, 2)) return -1; + i += 2; + if (i < n && s.codeUnitAt(i) == 0x3A) i++; + if (!_digitsAt(s, i, 2)) return -1; + return i + 2 == n ? 1 : -1; + } /// Parses an ISO date (`2026-01-31`) or timestamp (`2026-01-31 10:00`, /// `2026-01-31T10:00:00Z`). Without a zone the wall-clock fields are kept, so /// a day stays a day whatever the machine's time zone is. + /// + /// Called for every cell of a candidate X column (up to 100,000 rows), so the + /// shape check and the field read-back work on code units: no regex, no + /// substring, no `replaceFirst` (#1514). static DateTime? parseTime(String raw) { final text = raw.trim(); - if (!_isoTime.hasMatch(text)) return null; - final parsed = DateTime.tryParse(text.replaceFirst(' ', 'T')); + final zone = _scanIso(text); + if (zone < 0) return null; + // DateTime.tryParse takes a space as well as `T` between date and time. + final parsed = DateTime.tryParse(text); if (parsed == null) return null; // DateTime rolls an impossible date over (2026-13-45 becomes 2027-02-14); // the fields must read back as typed. - if (parsed.year != int.parse(text.substring(0, 4)) || - parsed.month != int.parse(text.substring(5, 7)) || - parsed.day != int.parse(text.substring(8, 10))) { + if (parsed.year != _digitsValue(text, 0, 4) || + parsed.month != _digitsValue(text, 5, 2) || + parsed.day != _digitsValue(text, 8, 2)) { return null; } if (text.length >= 16 && - (parsed.hour != int.parse(text.substring(11, 13)) || - parsed.minute != int.parse(text.substring(14, 16)))) { + (parsed.hour != _digitsValue(text, 11, 2) || + parsed.minute != _digitsValue(text, 14, 2))) { return null; } - if (_hasZone.hasMatch(text)) return parsed.toUtc(); + if (zone == 1) return parsed.toUtc(); return DateTime.utc(parsed.year, parsed.month, parsed.day, parsed.hour, parsed.minute, parsed.second, parsed.millisecond, parsed.microsecond); } diff --git a/lib/features/results/charts/quick_chart_view.dart b/lib/features/results/charts/quick_chart_view.dart index e03afaf2..b20161f2 100644 --- a/lib/features/results/charts/quick_chart_view.dart +++ b/lib/features/results/charts/quick_chart_view.dart @@ -597,7 +597,7 @@ class _QuickChartViewState extends material.State { material.BuildContext context, List points, double width) { final palette = context.semanticPalette; final wb = context.workbench; - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final label = material.TextStyle(color: wb.mutedForeground, fontSize: 10); final step = (points.length / 8).ceil().clamp(1, 1000); // Labels shown: one per step. Rotate them when a slot is too narrow. diff --git a/lib/features/settings/preferences_about_section.dart b/lib/features/settings/preferences_about_section.dart index af02cb6b..45f35fb2 100644 --- a/lib/features/settings/preferences_about_section.dart +++ b/lib/features/settings/preferences_about_section.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/platform/open_directory.dart'; import 'package:querya_desktop/core/storage/app_data_root.dart'; @@ -8,16 +9,30 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; -class PreferencesAboutSection extends StatelessWidget { +class PreferencesAboutSection extends material.StatefulWidget { const PreferencesAboutSection({super.key}); - static const String appVersion = '0.4.17'; static const String githubRepoUrl = 'https://github.com/QueryaHub/Querya-Desktop'; + @override + material.State createState() => + _PreferencesAboutSectionState(); +} + +class _PreferencesAboutSectionState + extends material.State { + // Created once: a future made in build() would restart on every rebuild of + // the dialog (every keystroke in its search) and make these rows flicker. + late final Future _version = PackageInfo.fromPlatform() + .then((i) => i.version) + .catchError((Object _) => ''); + late final Future _portable = AppDataRoot.isPortableMode(); + + static const githubRepoUrl = PreferencesAboutSection.githubRepoUrl; + @override Widget build(BuildContext context) { - final theme = Theme.of(context); return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, @@ -26,10 +41,10 @@ class PreferencesAboutSection extends StatelessWidget { material.Container( padding: const material.EdgeInsets.all(16), decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.18), + color: context.colors.muted.withValues(alpha: 0.18), borderRadius: material.BorderRadius.circular(8), border: material.Border.all( - color: theme.colorScheme.border.withValues(alpha: 0.25), + color: context.colors.border.withValues(alpha: 0.25), ), ), child: material.Row( @@ -38,17 +53,23 @@ class PreferencesAboutSection extends StatelessWidget { width: 48, height: 48, decoration: material.BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.12), + color: context.colors.primary.withValues(alpha: 0.12), borderRadius: material.BorderRadius.circular(8), border: material.Border.all( - color: theme.colorScheme.primary.withValues(alpha: 0.3), + color: context.colors.primary.withValues(alpha: 0.3), ), ), child: material.Center( - child: material.Icon( - QueryaIcons.database, - size: 24, - color: theme.colorScheme.primary, + child: material.Image.asset( + 'assets/images/querya_logo.png', + width: 28, + height: 28, + filterQuality: material.FilterQuality.medium, + errorBuilder: (_, __, ___) => material.Icon( + QueryaIcons.database, + size: 24, + color: context.colors.primary, + ), ), ), ), @@ -62,9 +83,14 @@ class PreferencesAboutSection extends StatelessWidget { .large() .foreground(), const material.SizedBox(height: 2), - const Text('Version $appVersion (Desktop Build)') - .muted() - .small(), + FutureBuilder( + future: _version, + builder: (context, snapshot) => Text( + snapshot.data?.isNotEmpty == true + ? 'Version ${snapshot.data} (Desktop Build)' + : 'Desktop Build', + ).muted().small(), + ), const material.SizedBox(height: 4), const Text('Fast, native multi-database management studio.') .muted() @@ -85,7 +111,7 @@ class PreferencesAboutSection extends StatelessWidget { const material.SizedBox(height: 12), FutureBuilder( - future: AppDataRoot.isPortableMode(), + future: _portable, builder: (context, snapshot) { final isPortable = snapshot.data ?? false; if (!isPortable) return const SizedBox.shrink(); @@ -93,10 +119,10 @@ class PreferencesAboutSection extends StatelessWidget { margin: const material.EdgeInsets.only(bottom: 12), padding: const material.EdgeInsets.all(12), decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.25), + color: context.colors.muted.withValues(alpha: 0.25), borderRadius: material.BorderRadius.circular(6), border: material.Border.all( - color: theme.colorScheme.primary.withValues(alpha: 0.3), + color: context.colors.primary.withValues(alpha: 0.3), ), ), child: material.Row( diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index f4794773..abe2ff34 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -36,7 +36,7 @@ class _PreferencesAppearanceSectionState bool _installingFromUrl = false; bool _openingThemesFolder = false; - Future _setThemeMode(ThemeMode mode) async { + Future _setThemeMode(material.ThemeMode mode) async { await _controller.setThemeMode(mode); } @@ -152,31 +152,29 @@ class _PreferencesAppearanceSectionState final themes = c.availableThemes; final refreshingThemes = c.isLoadingAvailableThemes; - return material.SingleChildScrollView( - primary: false, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Appearance').semiBold().small().foreground(), + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Appearance').semiBold().small().foreground(), const material.SizedBox(height: 8), PreferencesFieldRow( label: 'Theme mode', - control: PreferencesDropdownMenu( + control: PreferencesDropdownMenu( value: c.themeMode, onSelected: (v) { if (v != null) unawaited(_setThemeMode(v)); }, entries: const [ material.DropdownMenuEntry( - value: ThemeMode.system, + value: material.ThemeMode.system, label: 'System (match OS)', ), material.DropdownMenuEntry( - value: ThemeMode.dark, + value: material.ThemeMode.dark, label: 'Dark', ), material.DropdownMenuEntry( - value: ThemeMode.light, + value: material.ThemeMode.light, label: 'Light', ), ], @@ -204,7 +202,7 @@ class _PreferencesAppearanceSectionState c.selectedThemeLoadError!, style: material.TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.destructive, + color: context.colors.destructive, ), ), ), @@ -275,10 +273,10 @@ class _PreferencesAppearanceSectionState material.Container( padding: const material.EdgeInsets.all(14), decoration: material.BoxDecoration( - color: Theme.of(context).colorScheme.muted.withValues(alpha: 0.14), + color: context.colors.muted.withValues(alpha: 0.14), borderRadius: material.BorderRadius.circular(8), border: material.Border.all( - color: Theme.of(context).colorScheme.border.withValues(alpha: 0.25), + color: context.colors.border.withValues(alpha: 0.25), ), ), child: material.Column( @@ -342,7 +340,7 @@ class _PreferencesAppearanceSectionState _importError!, style: material.TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.destructive, + color: context.colors.destructive, ), ), ], @@ -352,7 +350,7 @@ class _PreferencesAppearanceSectionState _folderOpenError!, style: material.TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.destructive, + color: context.colors.destructive, ), ), ], @@ -365,10 +363,9 @@ class _PreferencesAppearanceSectionState 'Install from URL requires HTTPS and optional SHA-256 verification. ' 'VS Code JSON/JSONC (.colors subset) and Querya custom JSON are supported.', ), - ], - ), + ], + ); + }, ); - }, -); } } diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index 264909f5..924e74e3 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -2,12 +2,13 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; import 'package:querya_desktop/shared/widgets/querya_text.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; /// Helper / hint copy in Preferences — readable on imported themes. class PreferencesHint extends StatelessWidget { @@ -17,7 +18,7 @@ class PreferencesHint extends StatelessWidget { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return Text( text, style: TextStyle( @@ -289,7 +290,7 @@ class _InterfaceScaleSliderState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final displayScale = _displayScale; final pct = (displayScale * 100).round(); final fine = _fineControl; @@ -297,34 +298,36 @@ class _InterfaceScaleSliderState extends material.State { return material.Row( children: [ material.Expanded( - child: Slider( - value: SliderValue.single( - _sliderPosition(displayScale, fine: fine), + child: material.SliderTheme( + data: material.SliderTheme.of(context).copyWith( + activeTrackColor: cs.primary, + inactiveTrackColor: cs.muted, + thumbColor: cs.primary, + overlayColor: cs.primary.withValues(alpha: 0.12), + activeTickMarkColor: cs.primaryForeground.withValues(alpha: 0.5), + inactiveTickMarkColor: cs.mutedForeground.withValues(alpha: 0.5), + trackHeight: 4, + ), + child: material.Slider( + value: _sliderPosition(displayScale, fine: fine), + min: fine ? kMinUiScale : 0, + max: fine ? kMaxUiScale : (kUiScalePresets.length - 1).toDouble(), + divisions: fine ? _fineDivisions : kUiScalePresets.length - 1, + onChanged: (value) { + final next = _scaleFromSlider(value, fine: _shiftHeld); + setState(() => _dragScale = next); + }, + onChangeEnd: (value) { + final next = _scaleFromSlider(value, fine: _shiftHeld); + setState(() => _dragScale = null); + unawaited( + UiScaleController.instance.commitScale( + next, + fine: _shiftHeld, + ), + ); + }, ), - min: fine ? kMinUiScale : 0, - max: fine ? kMaxUiScale : (kUiScalePresets.length - 1).toDouble(), - divisions: fine ? _fineDivisions : kUiScalePresets.length - 1, - hintValue: const SliderValue.single(kDefaultUiScale), - onChanged: (value) { - final next = _scaleFromSlider( - value.value, - fine: _shiftHeld, - ); - setState(() => _dragScale = next); - }, - onChangeEnd: (value) { - final next = _scaleFromSlider( - value.value, - fine: _shiftHeld, - ); - setState(() => _dragScale = null); - unawaited( - UiScaleController.instance.commitScale( - next, - fine: _shiftHeld, - ), - ); - }, ), ), const material.SizedBox(width: 8), diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 4ce70256..2e742183 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -1,9 +1,12 @@ -import 'dart:async' show unawaited; +import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/updater/installers/update_install_context.dart'; import 'package:querya_desktop/core/updater/update_manifest.dart'; import 'package:querya_desktop/features/settings/preferences_about_section.dart'; import 'package:querya_desktop/features/settings/preferences_appearance_section.dart'; @@ -50,6 +53,16 @@ class _PreferencesDialogContent extends material.StatefulWidget { class PreferencesDialogContentState extends material.State<_PreferencesDialogContent> { late PreferencesCategory _selectedCategory; + + /// Panes opened at least once. They stay mounted (offstage when not + /// selected), so going back to one does not reload it or flash its empty + /// state, and each keeps its own scroll position. + final _visited = {}; + + /// The last widget built for each visited pane. A pane that is not shown + /// reuses it, so a rebuild of the dialog (a category tap, a keystroke in + /// the search) does not rebuild every hidden pane too. + final _paneWidgets = {}; final _searchController = material.TextEditingController(); final _searchFocusNode = material.FocusNode(); String _searchQuery = ''; @@ -90,19 +103,20 @@ class PreferencesDialogContentState setState(() => _searchQuery = query.trim()); } + // One after another on purpose: sqflite serializes queries on one database + // anyway, and concurrent ones each start a lock-wait timer. Future _load() async { + final s = AppSettings.instance; try { - final startup = await AppSettings.instance.getCheckForUpdatesOnStartup(); - final channel = await AppSettings.instance.getUpdateChannel(); - final destructive = - await AppSettings.instance.getConfirmDestructiveOperations(); - final pg = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); - final my = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); - final sqlite = - await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(); - final rows = await AppSettings.instance.getSqlResultMaxRows(); - final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); - final font = await AppSettings.instance.getSqlEditorFontSize(); + final startup = await s.getCheckForUpdatesOnStartup(); + final channel = await s.getUpdateChannel(); + final destructive = await s.getConfirmDestructiveOperations(); + final pg = await s.getPostgresSqlStmtTimeoutSeconds(); + final my = await s.getMysqlSqlStmtTimeoutSeconds(); + final sqlite = await s.getSqliteSqlStmtTimeoutSeconds(); + final rows = await s.getSqlResultMaxRows(); + final hist = await s.getSqlHistoryMaxEntries(); + final font = await s.getSqlEditorFontSize(); if (!mounted) return; setState(() { _checkUpdatesOnStartup = startup; @@ -115,7 +129,11 @@ class PreferencesDialogContentState _historyMax = hist; _fontSize = font; }); - } catch (_) {} + } catch (error, stackTrace) { + // The dialog stays on its defaults; leave a trace of why (a broken + // settings database, an I/O error) for whoever reads the log (#1637). + debugPrint('PreferencesDialog: could not load the preferences: $error\n$stackTrace'); + } } Future _setCheckUpdatesOnStartup(bool enabled) async { @@ -210,8 +228,7 @@ class PreferencesDialogContentState ); count += matching.length; case PreferencesCategory.about: - if ('about version storage directory cache logs sqlite' - .contains(q)) { + if ('about version storage directory cache logs sqlite'.contains(q)) { count++; } } @@ -220,7 +237,8 @@ class PreferencesDialogContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + _visited.add(_selectedCategory); + final theme = context.colors; final onPopover = theme.popoverForeground; return material.CallbackShortcuts( @@ -290,7 +308,8 @@ class PreferencesDialogContentState ), const Gap(8), material.Expanded( - child: material.TextField( + child: QueryaTextField( + framed: false, controller: _searchController, focusNode: _searchFocusNode, onChanged: (val) { @@ -315,17 +334,7 @@ class PreferencesDialogContentState fontSize: 12, color: theme.foreground, ), - decoration: material.InputDecoration( - hintText: 'Filter preferences... (Ctrl+F)', - hintStyle: material.TextStyle( - fontSize: 12, - color: theme.mutedForeground - .withValues(alpha: 0.6), - ), - border: material.InputBorder.none, - isDense: true, - contentPadding: material.EdgeInsets.zero, - ), + placeholder: 'Filter preferences… (Ctrl+F)', ), ), if (_searchQuery.isNotEmpty) @@ -347,11 +356,11 @@ class PreferencesDialogContentState ), const Gap(8), QueryaIconButton( -icon: const material.Icon(material.Icons.close_rounded), -tooltip: 'Close', -size: QueryaControlSize.sm, -onPressed: () => material.Navigator.of(context).pop(), -), + icon: const material.Icon(material.Icons.close_rounded), + tooltip: 'Close', + size: QueryaControlSize.sm, + onPressed: () => material.Navigator.of(context).pop(), + ), ], ), ), @@ -361,45 +370,44 @@ onPressed: () => material.Navigator.of(context).pop(), child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - // Left Category Rail - material.Container( - width: 184, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide( - color: theme.border.withValues(alpha: 0.28), - ), - ), - ), - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, - vertical: 10, - ), - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.stretch, - children: [ - for (final cat - in PreferencesCategory.values) - _buildCategoryRailTile(cat, theme), - ], - ), - ), + // Left Category Rail + material.Container( + width: context.scaled(184), + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: theme.border.withValues(alpha: 0.28), ), + ), + ), + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 10, + ), + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + children: [ + for (final cat in PreferencesCategory.values) + _buildCategoryRailTile(cat, theme), + ], + ), + ), + ), - // Right Detail Content Area - material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, - vertical: 16, - ), - child: _buildSelectedCategoryPane(theme), - ), - ), + // Right Detail Content Area + material.Expanded( + child: material.Stack( + children: [ + for (final cat in PreferencesCategory.values) + if (_visited.contains(cat)) + _buildPaneHost(cat, theme), ], ), + ), + ], + ), ), // Footer Actions @@ -421,9 +429,30 @@ onPressed: () => material.Navigator.of(context).pop(), ); } + material.Widget _buildPaneHost(PreferencesCategory cat, QueryaUiColors theme) { + final selected = cat == _selectedCategory; + return material.Offstage( + key: material.ValueKey('preferences_pane_${cat.name}'), + offstage: !selected, + child: material.TickerMode( + enabled: selected, + child: material.SingleChildScrollView( + primary: false, + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + child: selected + ? _paneWidgets[cat] = _buildPane(cat, theme) + : _paneWidgets[cat] ?? _buildPane(cat, theme), + ), + ), + ); + } + material.Widget _buildCategoryRailTile( PreferencesCategory cat, - ColorScheme theme, + QueryaUiColors theme, ) { final isSelected = _selectedCategory == cat; final matches = _matchCountForCategory(cat); @@ -498,8 +527,8 @@ onPressed: () => material.Navigator.of(context).pop(), ); } - material.Widget _buildSelectedCategoryPane(ColorScheme theme) { - switch (_selectedCategory) { + material.Widget _buildPane(PreferencesCategory cat, QueryaUiColors theme) { + switch (cat) { case PreferencesCategory.general: return _buildGeneralSection(theme); case PreferencesCategory.appearance: @@ -519,7 +548,7 @@ onPressed: () => material.Navigator.of(context).pop(), } } - material.Widget _buildGeneralSection(ColorScheme theme) { + material.Widget _buildGeneralSection(QueryaUiColors theme) { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ @@ -529,23 +558,30 @@ onPressed: () => material.Navigator.of(context).pop(), 'Application startup behavior and update release channels.', ), const material.SizedBox(height: 14), - PreferencesSwitchRow( value: _checkUpdatesOnStartup, - title: const Text('Check for updates automatically on startup').small(), + title: + const Text('Check for updates automatically on startup').small(), subtitle: const Text( 'Silently checks GitHub Releases when Querya starts to notify you of new features.', ).muted().xSmall(), onChanged: (v) => unawaited(_setCheckUpdatesOnStartup(v)), ), - const material.SizedBox(height: 16), PreferencesFieldRow( + key: const material.ValueKey('release_channel_row'), label: 'Release channel', - hint: - 'Stable receives thoroughly tested monthly updates. Beta receives early releases with experimental features.', + // On a package install the channel does nothing: the package + // manager brings the updates, stable only (#1497). + hint: UpdateInstallContext.currentIsPackageManagerInstall + ? 'Updates come from your package manager (stable releases ' + 'only), so the release channel does not apply to this install.' + : 'Stable receives thoroughly tested monthly updates. Beta receives early releases with experimental features.', control: PreferencesDropdownMenu( - value: _updateChannel, + enabled: !UpdateInstallContext.currentIsPackageManagerInstall, + value: UpdateInstallContext.currentIsPackageManagerInstall + ? UpdateChannel.stable + : _updateChannel, onSelected: (v) { if (v != null) unawaited(_setUpdateChannel(v)); }, @@ -565,7 +601,7 @@ onPressed: () => material.Navigator.of(context).pop(), ); } - material.Widget _buildSqlSection(ColorScheme theme) { + material.Widget _buildSqlSection(QueryaUiColors theme) { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ @@ -575,7 +611,6 @@ onPressed: () => material.Navigator.of(context).pop(), 'Global statement timeouts, editor formatting, and query history.', ), const material.SizedBox(height: 16), - PreferencesFieldRow( label: 'Editor font size', control: PreferencesDropdownMenu( @@ -593,18 +628,17 @@ onPressed: () => material.Navigator.of(context).pop(), ], ), ), - const material.SizedBox(height: 16), PreferencesFieldRow( label: 'PostgreSQL timeout', - hint: 'Statement timeout applied to queries on PostgreSQL connections.', + hint: + 'Statement timeout applied to queries on PostgreSQL connections.', control: SqlStatementTimeoutDropdown( value: _pgTimeout, expandToParent: true, onChanged: (v) => unawaited(_setPg(v)), ), ), - const material.SizedBox(height: 16), PreferencesFieldRow( label: 'MySQL timeout', @@ -616,7 +650,6 @@ onPressed: () => material.Navigator.of(context).pop(), onChanged: (v) => unawaited(_setMysql(v)), ), ), - const material.SizedBox(height: 16), PreferencesFieldRow( label: 'SQLite timeout', @@ -628,11 +661,11 @@ onPressed: () => material.Navigator.of(context).pop(), onChanged: (v) => unawaited(_setSqlite(v)), ), ), - const material.SizedBox(height: 16), PreferencesFieldRow( label: 'Query history limit', - hint: 'Per connection and database. Older queries are trimmed automatically.', + hint: + 'Per connection and database. Older queries are trimmed automatically.', control: PreferencesDropdownMenu( value: _historyMax, onSelected: (v) { @@ -647,7 +680,6 @@ onPressed: () => material.Navigator.of(context).pop(), ], ), ), - const material.SizedBox(height: 16), PreferencesSwitchRow( value: _confirmDestructive, @@ -661,17 +693,16 @@ onPressed: () => material.Navigator.of(context).pop(), ); } - material.Widget _buildDataGridSection(ColorScheme theme) { + material.Widget _buildDataGridSection(QueryaUiColors theme) { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ const Text('Data Grid & Results').semiBold().small().foreground(), const material.SizedBox(height: 6), const PreferencesHint( - 'Configure virtualization limits, column sizing, and result viewing.', + 'How many rows a query result loads into the grid.', ), const material.SizedBox(height: 16), - PreferencesFieldRow( label: 'Max rows in results', hint: @@ -690,16 +721,6 @@ onPressed: () => material.Navigator.of(context).pop(), ], ), ), - - const material.SizedBox(height: 16), - PreferencesSwitchRow( - value: _confirmDestructive, - title: const Text('Confirm DML staging applications').small(), - subtitle: const Text( - 'Requests confirmation before applying staged row inserts, deletes, or cell updates.', - ).muted().xSmall(), - onChanged: (v) => unawaited(_setConfirmDestructive(v)), - ), ], ); } diff --git a/lib/features/settings/preferences_extensions_section.dart b/lib/features/settings/preferences_extensions_section.dart index 48073646..89ce9213 100644 --- a/lib/features/settings/preferences_extensions_section.dart +++ b/lib/features/settings/preferences_extensions_section.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/local_extension_installer.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/platform/macos_sandbox.dart'; import 'package:querya_desktop/core/platform/open_directory.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -96,6 +97,7 @@ class PreferencesExtensionsSectionState @override material.Widget build(material.BuildContext context) { final busy = _installing || _openingFolder; + final wb = context.workbench; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ @@ -105,6 +107,13 @@ class PreferencesExtensionsSectionState 'Install a local .zip or .qext package without the Marketplace. ' 'Useful for development and offline environments.', ), + if (MacOsSandbox.isActive) ...[ + const material.SizedBox(height: 8), + PreferencesHint( + key: const material.ValueKey('extensions_sandbox_notice'), + MacOsSandbox.unavailable('Running extensions'), + ), + ], const material.SizedBox(height: 12), material.Wrap( spacing: 8, @@ -126,11 +135,47 @@ class PreferencesExtensionsSectionState ), if (_error != null) ...[ const material.SizedBox(height: 10), - Text(_error!).small().foreground(), + material.Row( + children: [ + material.Icon( + material.Icons.error_outline_rounded, + size: 14, + color: wb.destructive, + ), + const Gap(6), + material.Expanded( + child: material.Text( + _error!, + style: material.TextStyle( + fontSize: 12, + color: wb.destructive, + ), + ), + ), + ], + ), ], if (_success != null) ...[ const material.SizedBox(height: 10), - Text(_success!).muted().small(), + material.Row( + children: [ + material.Icon( + material.Icons.check_circle_outline_rounded, + size: 14, + color: wb.success, + ), + const Gap(6), + material.Expanded( + child: material.Text( + _success!, + style: material.TextStyle( + fontSize: 12, + color: wb.success, + ), + ), + ), + ], + ), ], ], ); diff --git a/lib/features/settings/preferences_mcp_section.dart b/lib/features/settings/preferences_mcp_section.dart index 65ff81e6..31f25730 100644 --- a/lib/features/settings/preferences_mcp_section.dart +++ b/lib/features/settings/preferences_mcp_section.dart @@ -6,8 +6,10 @@ import 'package:querya_desktop/core/mcp/mcp_access_store.dart'; import 'package:querya_desktop/core/mcp/mcp_client_config.dart'; import 'package:querya_desktop/core/mcp/mcp_query_service.dart'; import 'package:querya_desktop/core/mcp/mcp_server_controller.dart'; +import 'package:querya_desktop/core/platform/macos_sandbox.dart'; import 'package:querya_desktop/core/security/connection_environment.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -39,14 +41,18 @@ class PreferencesMcpSection extends material.StatefulWidget { PreferencesMcpSectionState(); } -class PreferencesMcpSectionState - extends material.State { +class PreferencesMcpSectionState extends material.State { McpServerController get _controller => widget.controller ?? McpServerController.instance; McpAccessSettings get _access => widget.access ?? McpAccessStore.instance; bool _enabled = false; bool _busy = false; + + /// False until the first load ends: nothing that depends on the stored state + /// (the switch, the connection list, the log) is drawn before that, so the + /// pane never shows "off" / "no connections" and then jumps to the truth. + bool _loaded = false; List _connections = const []; Set _shared = {}; List _activity = const []; @@ -77,17 +83,28 @@ class PreferencesMcpSectionState () => LocalDb.instance.getConnections())(); final shared = await _access.readableIds(); if (!mounted) return; + List activity = const []; + try { + activity = await (widget.loadActivity ?? + () => LocalDb.instance.listMcpActivity(limit: 50))(); + } catch (_) {} + if (!mounted) return; setState(() { _enabled = enabled; _connections = [ for (final c in all) - if (c.id != null && McpQueryService.dialectOf(c.type) != null) c, + if (c.id != null && _mcpReadable(c.type)) c, ]; _shared = shared; + _activity = activity; + _loaded = true; }); - await _loadActivity(); } + /// The connection types an MCP client can read: the SQL tools and MongoDB. + static bool _mcpReadable(String type) => + McpQueryService.dialectOf(type) != null || type == 'mongodb'; + Future _loadActivity() async { try { final list = await (widget.loadActivity ?? @@ -129,7 +146,8 @@ class PreferencesMcpSectionState if (!mounted) return; showAppToast( context: context, - message: '${kind.label} config copied. Paste it into ${kind.configLocation}.', + message: + '${kind.label} config copied. Paste it into ${kind.configLocation}.', variant: AppToastVariant.success, ); } @@ -160,29 +178,52 @@ class PreferencesMcpSectionState 'read the connections you share below. Read-only: writes are ' 'refused, and no host, user or password is ever sent to the model.', ), + if (MacOsSandbox.isActive) ...[ + const material.SizedBox(height: 8), + PreferencesHint( + key: const material.ValueKey('mcp_sandbox_notice'), + MacOsSandbox.unavailable('The MCP server'), + ), + ], const material.SizedBox(height: 14), - PreferencesSwitchRow( - key: const material.ValueKey('mcp_enabled'), - value: _enabled, - enabled: !_busy, - title: const Text('Enable MCP server').small(), - subtitle: material.ValueListenableBuilder( - valueListenable: _controller.status, - builder: (context, s, _) => - Text(_statusText(s)).muted().xSmall(), + if (!_loaded) + const material.Padding( + padding: material.EdgeInsets.symmetric(vertical: 24), + child: material.Center( + child: QueryaSpinner(size: QueryaSpinnerSize.md), + ), + ) + else ...[ + PreferencesSwitchRow( + key: const material.ValueKey('mcp_enabled'), + value: _enabled, + enabled: !_busy, + title: const Text('Enable MCP server').small(), + subtitle: material.ValueListenableBuilder( + valueListenable: _controller.status, + builder: (context, s, _) => Text(_statusText(s)).muted().xSmall(), + ), + onChanged: (v) => unawaited(_setEnabled(v)), ), - onChanged: (v) => unawaited(_setEnabled(v)), - ), - const material.SizedBox(height: 18), - _connectionsBlock(), - const material.SizedBox(height: 18), - _clientsBlock(), - const material.SizedBox(height: 18), - _activityBlock(), + const material.SizedBox(height: 18), + _connectionsBlock(), + const material.SizedBox(height: 18), + _clientsBlock(), + const material.SizedBox(height: 18), + _activityBlock(), + ], ], ); } + static String _typeLabel(String type) => switch (type) { + 'postgresql' => 'PostgreSQL', + 'mysql' => 'MySQL', + 'sqlite' => 'SQLite', + 'mongodb' => 'MongoDB', + _ => type, + }; + String _statusText(McpServerStatus s) { if (s.error != null) return 'Not running: ${s.error}'; if (!s.running) return 'Stopped. AI clients cannot connect.'; @@ -198,14 +239,15 @@ class PreferencesMcpSectionState const material.SizedBox(height: 4), const PreferencesHint( 'Only switched-on connections are visible to MCP clients. ' - 'PostgreSQL, MySQL and SQLite.', + 'PostgreSQL, MySQL, SQLite and MongoDB.', ), const material.SizedBox(height: 8), if (_connections.isEmpty) const QueryaEmptyState( compact: true, - title: 'No SQL connections', - description: 'Add a PostgreSQL, MySQL or SQLite connection first.', + title: 'No connections to share', + description: + 'Add a PostgreSQL, MySQL, SQLite or MongoDB connection first.', ) else for (final c in _connections) @@ -216,7 +258,7 @@ class PreferencesMcpSectionState children: [ material.Flexible(child: Text(c.name).small()), const material.SizedBox(width: 8), - QueryaBadge(label: c.type), + QueryaBadge(label: _typeLabel(c.type)), if (c.environment == ConnectionEnvironment.production) ...[ const material.SizedBox(width: 6), const QueryaBadge.status('PROD', @@ -236,18 +278,45 @@ class PreferencesMcpSectionState } material.Widget _clientsBlock() { + final wb = context.workbench; + final shim = _shimPath; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ const Text('Connect a client').semiBold().small().foreground(), const material.SizedBox(height: 4), - PreferencesHint(_shimPath != null - ? 'The client starts querya-mcp, which talks to this running app: ' - '$_shimPath' + PreferencesHint(shim != null + ? 'Copy the config for your client and paste it where it keeps MCP ' + 'servers. The client starts querya-mcp, which talks to this ' + 'running app:' : 'querya-mcp is not bundled with this build. Download it from the ' - 'GitHub release and replace /path/to/querya-mcp in the copied ' - 'config.'), - const material.SizedBox(height: 8), + 'GitHub release, then replace this path in the copied config:'), + const material.SizedBox(height: 6), + // The path on its own line, unbroken, so it can be read whole. + material.Container( + key: const material.ValueKey('mcp_shim_path'), + width: double.infinity, + padding: + const material.EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: material.BoxDecoration( + color: wb.mutedForeground.withValues(alpha: 0.08), + borderRadius: material.BorderRadius.circular(6), + ), + child: material.Text( + shim ?? '/path/to/${McpClientConfig.executableName}', + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + // The editor's font stack: already resolved when a workspace is + // open, while the generic 'monospace' costs a fontconfig lookup. + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, + fontSize: 12, + color: wb.mutedForeground, + ), + ), + ), + const material.SizedBox(height: 10), material.Wrap( spacing: 8, runSpacing: 8, @@ -255,19 +324,46 @@ class PreferencesMcpSectionState for (final kind in McpClientKind.values) QueryaButton.secondary( key: material.ValueKey('mcp_copy_${kind.name}'), - label: 'Copy ${kind.label} config', + label: kind == McpClientKind.generic + ? 'Copy stdio config' + : 'Copy ${kind.label} config', + tooltip: kind == McpClientKind.generic + ? 'For any other client that starts MCP servers over stdio' + : null, icon: material.Icons.copy_rounded, size: QueryaControlSize.md, onPressed: () => unawaited(_copy(kind)), ), + ], + ), + const material.SizedBox(height: 18), + // A different kind of action from copying: it cuts connected clients + // off, so it gets its own row with the consequence spelled out. + material.Row( + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Access token').small(), + const material.SizedBox(height: 2), + const PreferencesHint( + 'Regenerating disconnects the connected clients; they ' + 'reconnect on their next start.', + ), + ], + ), + ), + const material.SizedBox(width: 16), QueryaButton.secondary( key: const material.ValueKey('mcp_regenerate'), - label: 'Regenerate token', + label: 'Regenerate', icon: material.Icons.key_rounded, size: QueryaControlSize.md, - tooltip: 'Disconnects connected clients; they reconnect on ' - 'their next start.', - onPressed: _enabled && !_busy ? () => unawaited(_regenerate()) : null, + tooltip: + _enabled ? null : 'Turn the MCP server on to regenerate.', + onPressed: + _enabled && !_busy ? () => unawaited(_regenerate()) : null, ), ], ), @@ -283,15 +379,16 @@ class PreferencesMcpSectionState children: [ const Text('Recent calls').semiBold().small().foreground(), const material.Spacer(), - QueryaButton.secondary( + QueryaButton.ghost( key: const material.ValueKey('mcp_clear_log'), label: 'Clear', - size: QueryaControlSize.md, - onPressed: _activity.isEmpty ? null : () => unawaited(_clearLog()), + size: QueryaControlSize.sm, + onPressed: + _activity.isEmpty ? null : () => unawaited(_clearLog()), ), ], ), - const material.SizedBox(height: 8), + const material.SizedBox(height: 6), if (_activity.isEmpty) const QueryaEmptyState( compact: true, @@ -299,17 +396,21 @@ class PreferencesMcpSectionState description: 'Tool calls from MCP clients appear here.', ) else - for (final e in _activity) _ActivityRow(entry: e), + for (var i = 0; i < _activity.length; i++) + _ActivityRow(entry: _activity[i], first: i == 0), ], ); } } class _ActivityRow extends material.StatelessWidget { - const _ActivityRow({required this.entry}); + const _ActivityRow({required this.entry, this.first = false}); final McpActivityEntry entry; + /// The first row has no divider above it. + final bool first; + @override material.Widget build(material.BuildContext context) { final wb = context.workbench; @@ -319,42 +420,99 @@ class _ActivityRow extends material.StatelessWidget { : '${at.hour.toString().padLeft(2, '0')}:' '${at.minute.toString().padLeft(2, '0')}:' '${at.second.toString().padLeft(2, '0')}'; - final result = entry.error != null + final refused = entry.refusalRule != null; + final failed = entry.error != null; + final (icon, iconColor) = refused + ? (material.Icons.block_rounded, wb.warning) + : failed + ? (material.Icons.error_outline_rounded, wb.destructive) + : (material.Icons.check_circle_outline_rounded, wb.success); + final result = failed ? entry.error! : '${entry.rowCount != null ? '${entry.rowCount} row(s) · ' : ''}' '${entry.durationMs} ms'; - return material.Padding( - padding: const material.EdgeInsets.symmetric(vertical: 4), - child: material.Column( + + return material.Container( + padding: const material.EdgeInsets.symmetric(vertical: 8), + decoration: first + ? null + : material.BoxDecoration( + border: material.Border( + top: material.BorderSide(color: wb.borderSubtle), + ), + ), + child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - Text([ - time, - entry.client, - entry.tool, - if (entry.connectionName != null) entry.connectionName!, - ].where((s) => s.isNotEmpty).join(' · ')) - .xSmall(), - if (entry.sqlText != null) - Text( - entry.sqlText!, - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontFamily: 'monospace', color: wb.mutedForeground), - ).xSmall(), - Text( - result, - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - color: entry.error != null ? wb.destructive : wb.mutedForeground), - ).xSmall(), - // The guard rule that refused the call, when one did. - if (entry.refusalRule != null) - Text('refused by ${entry.refusalRule}') - .xSmall() - .muted(), + material.Padding( + padding: const material.EdgeInsets.only(top: 1), + child: material.Icon(icon, size: 15, color: iconColor), + ), + const material.SizedBox(width: 10), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + Text(entry.tool).small().semiBold(), + // Takes the free space, so the name is cut only when the + // row really is too narrow, and pushes the time right. + material.Expanded( + child: Text( + entry.connectionName == null + ? '' + : ' · ${entry.connectionName}', + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + ).small().muted(), + ), + const material.SizedBox(width: 8), + Text('${entry.client} $time').xSmall().muted(), + ], + ), + if (entry.sqlText != null) ...[ + const material.SizedBox(height: 3), + Text( + entry.sqlText!, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + // The editor's font stack: already resolved when a workspace is + // open, while the generic 'monospace' costs a fontconfig lookup. + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, + color: wb.mutedForeground, + ), + ).xSmall(), + ], + const material.SizedBox(height: 3), + material.Row( + children: [ + material.Flexible( + child: Text( + result, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + color: failed ? wb.destructive : wb.mutedForeground, + ), + ).xSmall(), + ), + // The guard rule that refused the call. + if (refused) ...[ + const material.SizedBox(width: 8), + QueryaBadge.status( + 'refused: ${entry.refusalRule}', + status: QueryaBadgeStatus.warning, + ), + ], + ], + ), + ], + ), + ), ], ), ); diff --git a/lib/features/settings/preferences_shortcuts_section.dart b/lib/features/settings/preferences_shortcuts_section.dart index 67da3443..59e566b8 100644 --- a/lib/features/settings/preferences_shortcuts_section.dart +++ b/lib/features/settings/preferences_shortcuts_section.dart @@ -208,7 +208,6 @@ class _PreferencesShortcutsSectionState @override Widget build(BuildContext context) { - final theme = Theme.of(context); final q = widget.searchQuery.trim().toLowerCase(); final all = PreferencesShortcutsSection.allShortcuts; @@ -250,13 +249,13 @@ class _PreferencesShortcutsSectionState ), decoration: material.BoxDecoration( color: _selectedCategory == cat - ? theme.colorScheme.primary.withValues(alpha: 0.16) - : theme.colorScheme.muted.withValues(alpha: 0.3), + ? context.colors.primary.withValues(alpha: 0.16) + : context.colors.muted.withValues(alpha: 0.3), borderRadius: material.BorderRadius.circular(16), border: material.Border.all( color: _selectedCategory == cat - ? theme.colorScheme.primary.withValues(alpha: 0.5) - : theme.colorScheme.border.withValues(alpha: 0.25), + ? context.colors.primary.withValues(alpha: 0.5) + : context.colors.border.withValues(alpha: 0.25), ), ), child: material.Row( @@ -270,8 +269,8 @@ class _PreferencesShortcutsSectionState ? material.FontWeight.w600 : material.FontWeight.w400, color: _selectedCategory == cat - ? theme.colorScheme.primary - : theme.colorScheme.foreground, + ? context.colors.primary + : context.colors.foreground, ), ), const material.SizedBox(width: 4), @@ -280,8 +279,8 @@ class _PreferencesShortcutsSectionState style: material.TextStyle( fontSize: 10, color: _selectedCategory == cat - ? theme.colorScheme.primary.withValues(alpha: 0.8) - : theme.colorScheme.mutedForeground, + ? context.colors.primary.withValues(alpha: 0.8) + : context.colors.mutedForeground, ), ), ], @@ -307,7 +306,7 @@ class _PreferencesShortcutsSectionState ), ) else ...[ - _buildShortcutTable(context, filtered, theme), + _buildShortcutTable(context, filtered), ], ], ); @@ -316,7 +315,6 @@ class _PreferencesShortcutsSectionState material.Widget _buildShortcutTable( BuildContext context, List items, - ThemeData theme, ) { final categories = >{}; for (final item in items) { @@ -333,10 +331,10 @@ class _PreferencesShortcutsSectionState ), material.Container( decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.16), + color: context.colors.muted.withValues(alpha: 0.16), borderRadius: material.BorderRadius.circular(8), border: material.Border.all( - color: theme.colorScheme.border.withValues(alpha: 0.25), + color: context.colors.border.withValues(alpha: 0.25), ), ), child: material.Column( @@ -345,9 +343,9 @@ class _PreferencesShortcutsSectionState if (i > 0) QueryaDivider( height: 1, - color: theme.colorScheme.border.withValues(alpha: 0.18), + color: context.colors.border.withValues(alpha: 0.18), ), - _buildShortcutRow(context, entry.value[i], theme), + _buildShortcutRow(context, entry.value[i]), ], ], ), @@ -361,7 +359,6 @@ class _PreferencesShortcutsSectionState material.Widget _buildShortcutRow( BuildContext context, ShortcutItem item, - ThemeData theme, ) { return material.Padding( padding: const material.EdgeInsets.symmetric(horizontal: 14, vertical: 8), @@ -381,30 +378,39 @@ class _PreferencesShortcutsSectionState ), ), const material.SizedBox(width: 12), - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - for (var c = 0; c < item.allKeys.length; c++) ...[ - if (c > 0) - material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 6), - child: const Text('or').muted().xSmall(), - ), - ..._keycaps(item.allKeys[c], theme), + material.Flexible( + child: material.Wrap( + alignment: material.WrapAlignment.end, + crossAxisAlignment: material.WrapCrossAlignment.center, + spacing: 4, + runSpacing: 4, + children: [ + for (var c = 0; c < item.allKeys.length; c++) ...[ + if (c > 0) + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4), + child: const Text('or').muted().xSmall(), + ), + ..._keycaps(context, item.allKeys[c]), + ], ], - ], + ), ), ], ), ); } - List _keycaps(List keys, ThemeData theme) { + List _keycaps( + BuildContext context, + List keys, + ) { + final wb = context.workbenchOrDefault; return [ for (var j = 0; j < keys.length; j++) ...[ if (j > 0) material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 3), + padding: const material.EdgeInsets.symmetric(horizontal: 2), child: const Text('+').muted().xSmall(), ), material.Container( @@ -413,14 +419,14 @@ class _PreferencesShortcutsSectionState vertical: 3, ), decoration: material.BoxDecoration( - color: theme.colorScheme.background, + color: wb.surface, borderRadius: material.BorderRadius.circular(4), border: material.Border.all( - color: theme.colorScheme.border.withValues(alpha: 0.4), + color: context.colors.border.withValues(alpha: 0.4), ), boxShadow: [ material.BoxShadow( - color: material.Colors.black.withValues(alpha: 0.08), + color: wb.shadow.withValues(alpha: 0.12), offset: const material.Offset(0, 1), blurRadius: 1, ), @@ -430,9 +436,10 @@ class _PreferencesShortcutsSectionState keys[j], style: material.TextStyle( fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 11, fontWeight: material.FontWeight.w600, - color: theme.colorScheme.foreground, + color: context.colors.foreground, ), ), ), diff --git a/lib/features/settings/theme_color_picker_dialog.dart b/lib/features/settings/theme_color_picker_dialog.dart index 553c9d43..af3b958b 100644 --- a/lib/features/settings/theme_color_picker_dialog.dart +++ b/lib/features/settings/theme_color_picker_dialog.dart @@ -7,7 +7,7 @@ Future showThemeColorPickerDialog({ required material.BuildContext context, required Color initial, }) async { - var picked = ColorDerivative.fromColor(initial); + var picked = initial; return showAppDialog( context: context, @@ -16,8 +16,8 @@ Future showThemeColorPickerDialog({ title: const material.Text('Pick color'), content: material.SizedBox( width: 320, - height: 360, - child: ColorPicker( + height: 300, + child: QueryaColorPicker( value: picked, onChanged: (value) => picked = value, ), @@ -31,7 +31,7 @@ Future showThemeColorPickerDialog({ QueryaButton.primary( label: 'Apply', onPressed: () => - material.Navigator.pop(dialogContext, picked.toColor()), + material.Navigator.pop(dialogContext, picked), size: QueryaControlSize.lg, ), ], diff --git a/lib/features/settings/theme_editor_dialog.dart b/lib/features/settings/theme_editor_dialog.dart index 7dbee598..167479d9 100644 --- a/lib/features/settings/theme_editor_dialog.dart +++ b/lib/features/settings/theme_editor_dialog.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/theme/parser/color_parser.dart'; import 'package:querya_desktop/core/theme/parser/querya_theme_manifest.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; @@ -92,9 +94,9 @@ class _ThemeEditorDialogState extends material.State { try { initial = currentHex != null ? parseQueryaThemeColor(currentHex) - : Theme.of(context).colorScheme.primary; + : context.colors.primary; } on FormatException { - initial = Theme.of(context).colorScheme.primary; + initial = context.colors.primary; } final picked = await showThemeColorPickerDialog( @@ -145,23 +147,25 @@ class _ThemeEditorDialogState extends material.State { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; + final cs = context.colors; return QueryaModalDialog( - constraints: const material.BoxConstraints(maxWidth: 580), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 580, + maxHeight: 600, + ), title: const Text('Theme Studio'), description: const Text('Customize color tokens with real-time UI preview'), icon: const material.Icon(material.Icons.palette_outlined), showCloseButton: true, content: material.SizedBox( - width: 540, - height: 480, + width: context.scaled(540), child: _loading - ? const material.Center( - child: QueryaSpinner(size: QueryaSpinnerSize.lg), - ) - : material.SingleChildScrollView( + ? const material.Center( + child: QueryaSpinner(size: QueryaSpinnerSize.lg), + ) + : material.SingleChildScrollView( child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ @@ -253,7 +257,7 @@ class _ThemeEditorDialogState extends material.State { ], ), ), - ), + ), actions: [ QueryaButton.secondary( label: 'Reset from current', @@ -289,7 +293,7 @@ class _ThemeEditorColorRow extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; Color? swatchColor; if (hex != null) { try { @@ -344,6 +348,7 @@ class _ThemeEditorColorRow extends material.StatelessWidget { fontSize: 12, color: cs.mutedForeground, fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, ), ), ), diff --git a/lib/features/settings/theme_editor_section.dart b/lib/features/settings/theme_editor_section.dart index f8cc93d1..f1d306c4 100644 --- a/lib/features/settings/theme_editor_section.dart +++ b/lib/features/settings/theme_editor_section.dart @@ -14,8 +14,7 @@ class ThemeEditorSection extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; + final cs = context.colors; return material.Container( margin: const material.EdgeInsets.only(top: 12), diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index 94c0315b..1d63fe9f 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; @@ -8,7 +11,6 @@ import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/theme_definition.dart'; import 'package:querya_desktop/features/settings/theme_preview_card.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; /// Debounce delay before requesting a hover preview load. const Duration themePreviewDebounce = Duration(milliseconds: 120); @@ -216,7 +218,7 @@ class _ThemePickerButtonState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final fieldWidth = widget.expandToParent ? null : (widget.width != null ? context.scaled(widget.width!) : null); @@ -297,9 +299,8 @@ class _ThemePickerButtonState extends material.State { } material.Widget _buildMenuPanel( - material.BuildContext context, ColorScheme cs) { + material.BuildContext context, QueryaUiColors cs) { final filteredThemes = _filteredThemes; - final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); return material.Column( children: [ @@ -310,44 +311,10 @@ class _ThemePickerButtonState extends material.State { context.scaled(8), context.scaled(4), ), - child: material.TextField( + child: QueryaTextField( controller: _searchController, - style: material.TextStyle( - fontSize: context.scaled(QueryaDropdownTokens.fontSize), - color: cs.popoverForeground, - ), - decoration: material.InputDecoration( - isDense: true, - hintText: 'Search themes…', - hintStyle: material.TextStyle(color: cs.mutedForeground), - prefixIcon: material.Icon( - material.Icons.search, - size: context.scaled(18), - color: cs.mutedForeground, - ), - prefixIconConstraints: material.BoxConstraints( - minWidth: context.scaled(36), - minHeight: context.scaled(32), - ), - contentPadding: material.EdgeInsets.symmetric( - horizontal: context.scaled(8), - vertical: context.scaled(8), - ), - filled: true, - fillColor: cs.muted.withValues(alpha: 0.18), - border: material.OutlineInputBorder( - borderRadius: material.BorderRadius.circular(radius), - borderSide: material.BorderSide(color: cs.border), - ), - enabledBorder: material.OutlineInputBorder( - borderRadius: material.BorderRadius.circular(radius), - borderSide: material.BorderSide(color: cs.border), - ), - focusedBorder: material.OutlineInputBorder( - borderRadius: material.BorderRadius.circular(radius), - borderSide: material.BorderSide(color: cs.ring), - ), - ), + placeholder: 'Search themes…', + leading: const material.Icon(material.Icons.search), ), ), if (widget.onPreviewTheme != null) @@ -393,7 +360,7 @@ class _ThemePickerButtonState extends material.State { return _ThemePickerRow( definition: theme, selected: theme.id == widget.selectedThemeId, - colorScheme: cs, + colors: cs, onHover: widget.onPreviewTheme == null ? null : () => _schedulePreview(theme), @@ -415,7 +382,7 @@ class _ThemePickerButtonState extends material.State { material.Widget _buildTrigger({ required material.BuildContext context, required material.MenuController controller, - required ColorScheme cs, + required QueryaUiColors cs, required double? fieldWidth, }) { final borderColor = _enabled @@ -492,7 +459,7 @@ class _ThemePickerButtonState extends material.State { material.Widget _triggerLabelText({ required material.BuildContext context, - required ColorScheme cs, + required QueryaUiColors cs, required bool expand, }) { final text = material.Text( @@ -574,14 +541,14 @@ class _ThemePickerRow extends material.StatefulWidget { const _ThemePickerRow({ required this.definition, required this.selected, - required this.colorScheme, + required this.colors, required this.onSelected, this.onHover, }); final ThemeDefinition definition; final bool selected; - final ColorScheme colorScheme; + final QueryaUiColors colors; final material.VoidCallback onSelected; final material.VoidCallback? onHover; @@ -594,7 +561,7 @@ class _ThemePickerRowState extends material.State<_ThemePickerRow> { @override material.Widget build(material.BuildContext context) { - final cs = widget.colorScheme; + final cs = widget.colors; final bg = _hovered ? cs.accent.withValues(alpha: 0.14) : widget.selected @@ -648,18 +615,18 @@ class _ThemePickerRowState extends material.State<_ThemePickerRow> { material.Expanded( child: _ThemePickerRowTitle( definition: widget.definition, - colorScheme: cs, + colors: cs, selected: widget.selected, ), ), material.SizedBox(width: context.scaled(6)), _SourceBadge( label: _sourceBadgeLabel(widget.definition.source), - colorScheme: cs, + colors: cs, ), material.SizedBox(width: context.scaled(6)), _BrightnessLabel( - isDark: widget.definition.isDark, colorScheme: cs), + isDark: widget.definition.isDark, colors: cs), ], ), ), @@ -672,17 +639,17 @@ class _ThemePickerRowState extends material.State<_ThemePickerRow> { class _ThemePickerRowTitle extends material.StatelessWidget { const _ThemePickerRowTitle({ required this.definition, - required this.colorScheme, + required this.colors, required this.selected, }); final ThemeDefinition definition; - final ColorScheme colorScheme; + final QueryaUiColors colors; final bool selected; @override material.Widget build(material.BuildContext context) { - final cs = colorScheme; + final cs = colors; final subtitle = definition.metadata?.pickerSubtitle; if (subtitle == null) { @@ -730,15 +697,15 @@ class _ThemePickerRowTitle extends material.StatelessWidget { class _SourceBadge extends material.StatelessWidget { const _SourceBadge({ required this.label, - required this.colorScheme, + required this.colors, }); final String label; - final ColorScheme colorScheme; + final QueryaUiColors colors; @override material.Widget build(material.BuildContext context) { - final cs = colorScheme; + final cs = colors; return material.Container( padding: material.EdgeInsets.symmetric( horizontal: context.scaled(6), @@ -767,15 +734,15 @@ class _SourceBadge extends material.StatelessWidget { class _BrightnessLabel extends material.StatelessWidget { const _BrightnessLabel({ required this.isDark, - required this.colorScheme, + required this.colors, }); final bool isDark; - final ColorScheme colorScheme; + final QueryaUiColors colors; @override material.Widget build(material.BuildContext context) { - final cs = colorScheme; + final cs = colors; return material.Row( mainAxisSize: material.MainAxisSize.min, children: [ diff --git a/lib/features/settings/theme_preview_card.dart b/lib/features/settings/theme_preview_card.dart index b61d0136..b20f55d0 100644 --- a/lib/features/settings/theme_preview_card.dart +++ b/lib/features/settings/theme_preview_card.dart @@ -44,7 +44,7 @@ class ThemePreviewCard extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final appScheme = Theme.of(context).colorScheme; + final appScheme = context.colors; final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); if (isLoading) { @@ -105,7 +105,7 @@ class ThemePreviewCard extends material.StatelessWidget { ); } - final scheme = previewTheme.colorScheme; + final scheme = previewTheme.colors; final workbench = previewTheme.workbench; final editor = previewTheme.editor; @@ -193,7 +193,7 @@ class ThemePreviewCard extends material.StatelessWidget { width: double.infinity, padding: material.EdgeInsets.all(context.scaled(8)), decoration: material.BoxDecoration( - color: Theme.of(context).colorScheme.muted.withValues(alpha: 0.12), + color: context.colors.muted.withValues(alpha: 0.12), borderRadius: material.BorderRadius.circular(radius), border: material.Border.all(color: borderColor), ), @@ -223,7 +223,7 @@ class _Swatch extends material.StatelessWidget { color: color, borderRadius: material.BorderRadius.circular(radius), border: material.Border.all( - color: material.Colors.black.withValues(alpha: 0.12), + color: context.colors.border.withValues(alpha: 0.6), ), ), ), @@ -232,7 +232,7 @@ class _Swatch extends material.StatelessWidget { label, style: material.TextStyle( fontSize: context.scaled(9), - color: Theme.of(context).colorScheme.mutedForeground, + color: context.colors.mutedForeground, ), ), ], diff --git a/lib/features/settings/theme_remote_install_dialog.dart b/lib/features/settings/theme_remote_install_dialog.dart index c8f7ea68..3cf927d8 100644 --- a/lib/features/settings/theme_remote_install_dialog.dart +++ b/lib/features/settings/theme_remote_install_dialog.dart @@ -72,7 +72,7 @@ class _ThemeRemoteInstallDialogState @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final host = Uri.tryParse(_urlController.text.trim())?.host; return QueryaModalDialog( @@ -97,23 +97,21 @@ class _ThemeRemoteInstallDialogState ), ], const material.SizedBox(height: 12), - material.TextField( + const Text('Theme URL').small().semiBold(), + const Gap(6), + QueryaTextField( controller: _urlController, - decoration: const material.InputDecoration( - labelText: 'Theme URL', - hintText: 'https://example.com/themes/my-theme.json', - ), + placeholder: 'https://example.com/themes/my-theme.json', keyboardType: material.TextInputType.url, autocorrect: false, onChanged: (_) => setState(() => _validationError = null), ), const material.SizedBox(height: 12), - material.TextField( + const Text('SHA-256 checksum (optional)').small().semiBold(), + const Gap(6), + QueryaTextField( controller: _checksumController, - decoration: const material.InputDecoration( - labelText: 'SHA-256 checksum (optional)', - hintText: 'hex digest or ?sha256= on URL', - ), + placeholder: 'hex digest or ?sha256= on URL', autocorrect: false, ), if (_validationError != null) ...[ diff --git a/lib/features/sqlite/sqlite_overview_tab.dart b/lib/features/sqlite/sqlite_overview_tab.dart index 637321ca..904a283f 100644 --- a/lib/features/sqlite/sqlite_overview_tab.dart +++ b/lib/features/sqlite/sqlite_overview_tab.dart @@ -134,7 +134,7 @@ class _SqliteOverviewTabState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; if (_loading && _overview == null) { return const material.Center( @@ -217,7 +217,7 @@ class _SqliteOverviewTabState extends material.State { } material.Widget _header(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final name = widget.connectionRow.name; final path = widget.connectionRow.host ?? 'in-memory'; return material.Row( @@ -280,7 +280,7 @@ class _SqliteOverviewTabState extends material.State { int pageCount, int pageSize, ) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; material.Widget chip(String label, String value, material.IconData icon) { return material.Expanded( @@ -345,7 +345,7 @@ class _SqliteOverviewTabState extends material.State { } material.Widget _tablesCard(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return material.Container( decoration: material.BoxDecoration( color: cs.card, diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 38969b5c..945fb55b 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/database/compact_result_dataset.dart'; +import 'package:querya_desktop/core/database/result_columns_builder.dart'; import 'package:querya_desktop/core/database/sql_limit.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; @@ -105,19 +105,20 @@ class SqliteSqlExecutionDelegate extends BaseSqlExecutionDelegate { final limitCount = truncated ? cap : results.length; final injectedLimit = effectiveSql != sql; - final rawRows = >[]; + final builder = ResultColumnsBuilder( + cols, + cellFormatter: (val, _) => sqliteResultCellToDisplayString(val), + ); var count = 0; for (final row in results) { if (count >= limitCount) break; - rawRows.add([for (final col in cols) row[col]]); + for (var i = 0; i < cols.length; i++) { + builder.addCell(i, row[cols[i]]); + } + builder.endRow(); count++; } - - final dataset = CompactResultDataset.fromRawRows( - cols, - rawRows, - cellFormatter: (val, _) => sqliteResultCellToDisplayString(val), - ); + final dataset = builder.build(); final isTruncated = truncated || (injectedLimit && results.length >= cap); final statusMsg = formatStatusMessage( diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index debbae35..73034080 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -379,7 +379,7 @@ class _SqliteTableViewState extends material.State { onNavigateHome: widget.onNavigateHome, showExportToolbar: true, customToolbarBuilder: (ctx, state) { - final cs = Theme.of(ctx).colorScheme; + final cs = ctx.colors; final title = '${widget.tableName}${widget.isView ? ' (view)' : ''}'; return material.Container( diff --git a/lib/features/sqlite/sqlite_workspace_home.dart b/lib/features/sqlite/sqlite_workspace_home.dart index fc7090db..9077a929 100644 --- a/lib/features/sqlite/sqlite_workspace_home.dart +++ b/lib/features/sqlite/sqlite_workspace_home.dart @@ -78,7 +78,7 @@ class _SqliteWorkspaceHomeState extends material.State { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); + final colors = context.colors; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -86,7 +86,7 @@ class _SqliteWorkspaceHomeState extends material.State { constraints: const material.BoxConstraints(minHeight: 44), padding: const material.EdgeInsets.symmetric(horizontal: 12), decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), + color: colors.muted.withValues(alpha: 0.6), ), child: material.Row( children: [ @@ -96,7 +96,7 @@ class _SqliteWorkspaceHomeState extends material.State { material.Icon( material.Icons.lock_outline_rounded, size: 14, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ], if (widget.lastSelectedSqliteObject != null && diff --git a/lib/features/updater/update_available_badge.dart b/lib/features/updater/update_available_badge.dart index 0126aafd..43a5c97b 100644 --- a/lib/features/updater/update_available_badge.dart +++ b/lib/features/updater/update_available_badge.dart @@ -1,3 +1,4 @@ +import 'package:flutter/widgets.dart'; import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; @@ -8,7 +9,6 @@ import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/updater/update_controller.dart'; import 'package:querya_desktop/features/updater/update_dialog.dart'; import 'package:querya_desktop/shared/widgets/querya_text.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; /// Soft pulse period for the update chip at Full motion (see F9 / #482). /// @@ -16,7 +16,14 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; /// [QueryaMotion.effectiveDuration]; Off / OS `disableAnimations` stop the pulse. const Duration kUpdateBadgePulsePeriod = Duration(milliseconds: 1400); -/// Pulsing title-bar chip when a background update check finds a newer release. +/// How many [kUpdateBadgePulsePeriod] legs the chip pulses for when it appears +/// (forward and back count as one leg each, so 6 is three full breaths), after +/// which it settles to the static chip colors. An endless pulse keeps the +/// engine producing a frame at the display rate and costs an idle app ~30% of +/// a CPU core (#1554). +const int kUpdateBadgePulseLegs = 6; + +/// Title-bar chip that pulses a few times when a background update check finds a newer release. class UpdateAvailableBadge extends material.StatefulWidget { const UpdateAvailableBadge({super.key, required this.controller}); @@ -30,6 +37,10 @@ class UpdateAvailableBadgeState extends material.State with material.SingleTickerProviderStateMixin { late final material.AnimationController _pulse; + /// The finite pulse already ran for the update that is pending now; it is + /// cleared when the badge goes away so the next update pulses again. + bool _pulsed = false; + @visibleForTesting bool get isPulseAnimating => _pulse.isAnimating; @@ -77,6 +88,7 @@ class UpdateAvailableBadgeState extends material.State _pulse.stop(); } _pulse.value = 0; + if (!show) _pulsed = false; return; } @@ -86,8 +98,18 @@ class UpdateAvailableBadgeState extends material.State if (periodChanged) { _pulse.duration = period; } - if (!_pulse.isAnimating || periodChanged) { - _pulse.repeat(reverse: true); + final running = _pulse.isAnimating; + if (_pulsed && !running) return; // settled: stay static, stay idle + + if (!running || periodChanged) { + _pulsed = true; + unawaited( + _pulse + .repeat(reverse: true, count: kUpdateBadgePulseLegs) + .whenComplete(() { + if (mounted) _pulse.value = 0; + }), + ); } } diff --git a/lib/features/updater/update_changelog_view.dart b/lib/features/updater/update_changelog_view.dart index a8113ba0..7661f25a 100644 --- a/lib/features/updater/update_changelog_view.dart +++ b/lib/features/updater/update_changelog_view.dart @@ -1,7 +1,8 @@ import 'dart:convert'; import 'package:flutter/material.dart' as material; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:flutter/widgets.dart' show StatelessWidget, Text; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/querya_text.dart'; /// Lightweight Markdown-ish renderer for GitHub release notes. @@ -86,7 +87,7 @@ class UpdateChangelogView extends StatelessWidget { return material.SelectionArea( child: material.DefaultTextStyle( style: material.TextStyle( - color: Theme.of(context).colorScheme.foreground, + color: context.colors.foreground, height: 1.45, ), child: material.Column( diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index 7d912159..5a8405c5 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -2,8 +2,10 @@ import 'dart:async' show unawaited; import 'dart:io'; import 'package:flutter/material.dart' as material; +import 'package:url_launcher/url_launcher_string.dart'; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/core/updater/app_updater_service.dart'; import 'package:querya_desktop/core/updater/sha256_checksums.dart'; @@ -86,6 +88,59 @@ Future showUnsavedUpdateRestartDialog(material.BuildContext context) { ); } +/// The local database could not be copied before the update (#1497): install +/// anyway, or cancel. +Future showUpdateBackupFailedDialog( + material.BuildContext context, + String error, +) { + return showAppDialog( + context: context, + builder: (ctx) { + return QueryaDialogCard( + constraints: const material.BoxConstraints(maxWidth: 440), + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Could not back up local data').semiBold().large(), + const Gap(8), + Text( + 'Querya Desktop copies its local data (connections, history, ' + 'settings) before an update, so you can go back if the new ' + 'version changes it. The copy failed: $error', + ).muted().small(), + const Gap(20), + material.Align( + alignment: material.Alignment.centerRight, + child: material.Wrap( + alignment: material.WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + QueryaButton.ghost( + label: 'Cancel', + onPressed: () => material.Navigator.of(ctx).pop(false), + size: QueryaControlSize.lg, + ), + QueryaButton.destructive( + label: 'Install without a copy', + onPressed: () => material.Navigator.of(ctx).pop(true), + size: QueryaControlSize.lg, + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); +} + /// Update check / download / install pane. Test hooks are optional overrides. class UpdateDialogContent extends material.StatefulWidget { const UpdateDialogContent({ @@ -99,6 +154,7 @@ class UpdateDialogContent extends material.StatefulWidget { this.selectAsset, this.download, this.install, + this.backupLocalData, this.confirmRestartIfUnsaved, this.showCopyToast = true, }); @@ -113,6 +169,11 @@ class UpdateDialogContent extends material.StatefulWidget { final Future Function(UpdateAsset asset, UpdateManifest manifest)? download; final Future Function(File file)? install; + + /// Copies the local database before installing; defaults to + /// [LocalDb.backupBeforeUpdate]. + final Future Function(String fromVersion, String toVersion)? + backupLocalData; final Future Function(material.BuildContext context)? confirmRestartIfUnsaved; final bool showCopyToast; @@ -270,6 +331,24 @@ class UpdateDialogContentState extends material.State { if (!mounted || confirmed != true) return; } + // A copy of the local database first: the new version may change its + // schema, and an older one cannot read it back (#1497). + final from = _currentVersion.isEmpty ? 'unknown' : _currentVersion; + final to = _manifest?.version ?? 'unknown'; + // A test that replaces [install] and not this hook skips the copy. + final backup = widget.backupLocalData ?? + (widget.install == null + ? (String f, String t) => + LocalDb.instance.backupBeforeUpdate(fromVersion: f, toVersion: t) + : null); + try { + if (backup != null) await backup(from, to); + } catch (e) { + if (!mounted) return; + final proceed = await showUpdateBackupFailedDialog(context, '$e'); + if (!mounted || proceed != true) return; + } + try { if (widget.install != null) { await widget.install!(file); @@ -351,7 +430,7 @@ class UpdateDialogContentState extends material.State { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final colors = context.colors; final wb = context.workbench; return QueryaDialogCard( @@ -361,7 +440,7 @@ class UpdateDialogContentState extends material.State { minWidth: 360, maxHeight: 640, ), - borderColor: theme.muted, + borderColor: colors.muted, child: material.Column( mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, @@ -420,7 +499,7 @@ class UpdateDialogContentState extends material.State { decoration: material.BoxDecoration( border: material.Border( top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3), + color: colors.border.withValues(alpha: 0.3), ), ), ), @@ -437,7 +516,8 @@ class UpdateDialogContentState extends material.State { UpdateDialogPhase.upToDate => 'You are running the latest version of Querya Desktop (v$_currentVersion).', UpdateDialogPhase.available => _installBlocked - ? 'This build is managed by ${_packageCommand ?? 'the package manager'}.' + ? 'Querya Desktop v${_manifest?.version ?? ''} is available; ' + 'this install is updated outside the app.' : 'Querya Desktop v${_manifest?.version ?? ''} is available!', UpdateDialogPhase.downloading => 'Downloading update…', UpdateDialogPhase.readyToInstall => 'Update ready to install.', @@ -480,8 +560,10 @@ class UpdateDialogContentState extends material.State { children: [ if (_installBlocked && _phase == UpdateDialogPhase.available) ...[ Text( - 'Install updates with ${_packageCommand ?? 'the system package manager'} ' - 'instead of downloading a GitHub package.', + _packageCommand != null + ? 'Install updates with $_packageCommand ' + 'instead of downloading a GitHub package.' + : _updater.packageManagerUpdateHint, ).muted().small(), const material.SizedBox(height: 12), ], @@ -549,12 +631,22 @@ class UpdateDialogContentState extends material.State { size: QueryaControlSize.lg, ), const material.SizedBox(width: 8), - if (_installBlocked) + if (_installBlocked && _packageCommand != null) QueryaButton.primary( label: 'Copy update command', onPressed: () => unawaited(_copyPackageCommand()), size: QueryaControlSize.lg, ) + else if (_installBlocked) + QueryaButton.primary( + label: 'Open release page', + onPressed: () => unawaited(launchUrlString( + 'https://github.com/QueryaHub/Querya-Desktop/releases/tag/' + '${_manifest?.version ?? ''}', + mode: LaunchMode.externalApplication, + )), + size: QueryaControlSize.lg, + ) else QueryaButton.primary( label: 'Download', diff --git a/lib/features/workspace/data_grid_calc_bar.dart b/lib/features/workspace/data_grid_calc_bar.dart index a85ba3e0..329e0343 100644 --- a/lib/features/workspace/data_grid_calc_bar.dart +++ b/lib/features/workspace/data_grid_calc_bar.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; -import 'package:querya_desktop/core/motion/querya_motion.dart'; -import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:gap/gap.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; /// Status bar footer for Data Grid displaying live selection statistics (Count, Distinct, Sum, Avg, Median, Min, Max). class DataGridCalcBar extends StatelessWidget { @@ -17,26 +19,24 @@ class DataGridCalcBar extends StatelessWidget { @override Widget build(BuildContext context) { final visible = stats.totalCount > 1 || stats.hasNumericStats; - final cs = Theme.of(context).colorScheme; - final duration = context.motionDuration(QueryaMotion.fast); - final curve = context.motionCurve(QueryaMotion.enter); + final cs = context.colors; - return AnimatedContainer( - duration: duration, - curve: curve, - height: visible ? 26 : 0, - clipBehavior: Clip.hardEdge, - padding: const material.EdgeInsets.symmetric(horizontal: 10), - decoration: material.BoxDecoration( - color: cs.card, - border: material.Border( - top: material.BorderSide( - color: cs.border.withValues(alpha: 0.35), - width: 1, + return QueryaAnimatedExpand( + expanded: visible, + alignment: Alignment.topCenter, + child: material.Container( + height: 26, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), ), ), - ), - child: material.Row( + child: material.Row( children: [ material.Expanded( child: material.SingleChildScrollView( @@ -120,6 +120,7 @@ class DataGridCalcBar extends StatelessWidget { ), ], ), + ), ); } } @@ -135,7 +136,7 @@ class _StatBadge extends StatelessWidget { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return material.Tooltip( message: 'Click to copy $label: $value', @@ -162,7 +163,8 @@ class _StatBadge extends StatelessWidget { fontSize: 11, fontWeight: FontWeight.w600, color: cs.foreground, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, ), ), ], diff --git a/lib/features/workspace/data_grid_filter_bar.dart b/lib/features/workspace/data_grid_filter_bar.dart index 74cd2cc6..efbf135a 100644 --- a/lib/features/workspace/data_grid_filter_bar.dart +++ b/lib/features/workspace/data_grid_filter_bar.dart @@ -1,10 +1,14 @@ import 'dart:async' show Timer; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/shared/widgets/querya_icon_button.dart'; import 'package:querya_desktop/shared/widgets/querya_text.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:gap/gap.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; /// Kind of filter autocomplete suggestion. enum FilterSuggestionKind { @@ -215,7 +219,7 @@ class _DataGridFilterBarState extends material.State { _overlayEntry = material.OverlayEntry( builder: (context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return material.Positioned( width: 320, @@ -279,7 +283,7 @@ class _DataGridFilterBarState extends material.State { overlay.insert(_overlayEntry!); } - material.Widget _buildSuggestionIcon(FilterSuggestionKind kind, ColorScheme cs) { + material.Widget _buildSuggestionIcon(FilterSuggestionKind kind, QueryaUiColors cs) { switch (kind) { case FilterSuggestionKind.column: return material.Icon( @@ -333,7 +337,7 @@ class _DataGridFilterBarState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final isFiltered = widget.filterText.trim().isNotEmpty; return material.CompositedTransformTarget( @@ -382,26 +386,16 @@ class _DataGridFilterBarState extends material.State { } return material.KeyEventResult.ignored; }, - child: material.TextField( + child: QueryaTextField( + framed: false, controller: _controller, focusNode: _focusNode, autofocus: true, onChanged: _onChanged, onSubmitted: _onSubmitted, - style: TextStyle( - fontSize: 12, - color: cs.foreground, - ), - decoration: material.InputDecoration( - hintText: 'Filter results... (e.g. "active", "status = ACTIVE", "amount > 100")', - hintStyle: TextStyle( - fontSize: 12, - color: cs.mutedForeground.withValues(alpha: 0.7), - ), - border: material.InputBorder.none, - isDense: true, - contentPadding: material.EdgeInsets.zero, - ), + style: const TextStyle(fontSize: 12), + placeholder: + 'Filter results… (e.g. "active", "status = ACTIVE", "amount > 100")', ), ), ), diff --git a/lib/features/workspace/data_grid_groupings_view.dart b/lib/features/workspace/data_grid_groupings_view.dart index fa0722d7..aac300d9 100644 --- a/lib/features/workspace/data_grid_groupings_view.dart +++ b/lib/features/workspace/data_grid_groupings_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/features/workspace/grid_groupings_engine.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; @@ -109,7 +110,7 @@ class _DataGridGroupingsViewState ); } - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final groups = _cachedGroups; return material.Column( @@ -320,6 +321,8 @@ class _DataGridGroupingsViewState fontSize: 13, fontWeight: FontWeight.w600, fontFamily: 'monospace', + fontFamilyFallback: + QueryaTypography.cjkFontFamilyFallback, ), ), ), diff --git a/lib/features/workspace/data_grid_staging_toolbar.dart b/lib/features/workspace/data_grid_staging_toolbar.dart index 9d514843..b14e45c1 100644 --- a/lib/features/workspace/data_grid_staging_toolbar.dart +++ b/lib/features/workspace/data_grid_staging_toolbar.dart @@ -24,7 +24,7 @@ class DataGridStagingToolbar extends StatelessWidget { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return ListenableBuilder( listenable: stagingBuffer, @@ -165,7 +165,7 @@ class _PendingBadge extends StatelessWidget { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final label = compact ? '$count' : '$count pending ${count == 1 ? 'change' : 'changes'}'; diff --git a/lib/features/workspace/data_grid_value_panel.dart b/lib/features/workspace/data_grid_value_panel.dart index f642b5c9..08185c91 100644 --- a/lib/features/workspace/data_grid_value_panel.dart +++ b/lib/features/workspace/data_grid_value_panel.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -9,7 +10,8 @@ import 'package:querya_desktop/shared/widgets/querya_button.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; import 'package:querya_desktop/shared/widgets/querya_icon_button.dart'; import 'package:querya_desktop/shared/widgets/querya_text.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:gap/gap.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'xml_html_formatter.dart'; @@ -274,7 +276,7 @@ class _DataGridValuePanelState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final activeLang = _effectiveLanguage; return material.Container( diff --git a/lib/features/workspace/destructive_query_dialog.dart b/lib/features/workspace/destructive_query_dialog.dart index 24d7471d..212397e4 100644 --- a/lib/features/workspace/destructive_query_dialog.dart +++ b/lib/features/workspace/destructive_query_dialog.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -99,246 +102,264 @@ class _DestructiveQueryDialogState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; - final isDark = theme.brightness == Brightness.dark; + final cs = context.colors; + final isDark = context.colors.brightness == Brightness.dark; final isCritical = widget.result.maxRiskLevel == 'CRITICAL'; - return QueryaDialogCard( + // 680 x 540 at 1x, scaled with the UI scale, and never past the window + // minus the usual dialog margins (#1642): on a narrow window or at 1.25x+ + // the fixed size ran off the screen. The body keeps its designed height + // and scrolls when the window is shorter than that. + final insets = WindowLayout.dialogSymmetricInsets(context); + final screen = material.MediaQuery.sizeOf(context); + final width = WindowLayout.scaledDialogExtent( + context, + screenExtent: screen.width, + insetTotal: insets.horizontal, + baseMax: 680, + baseMin: 540, + ); + final height = WindowLayout.scaledDialogExtent( + context, + screenExtent: screen.height, + insetTotal: insets.vertical, + baseMax: 540, + baseMin: 440, + ); + + final card = QueryaDialogCard( borderColor: cs.destructive.withValues(alpha: isDark ? 0.6 : 0.4), - constraints: const material.BoxConstraints( - minWidth: 540, - maxWidth: 680, - minHeight: 440, - maxHeight: 580, - ), + constraints: material.BoxConstraints(maxWidth: width, maxHeight: height), child: material.FocusTraversalGroup( - child: material.SizedBox( - height: 540, - child: material.Padding( - padding: const material.EdgeInsets.all(20), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Header - material.Row( - children: [ - material.Container( - padding: const material.EdgeInsets.all(8), - decoration: material.BoxDecoration( - color: cs.destructive.withValues(alpha: 0.15), - borderRadius: material.BorderRadius.circular(8), - ), - child: material.Icon( - material.Icons.warning_amber_rounded, - size: 24, - color: cs.destructive, + child: material.SingleChildScrollView( + child: material.SizedBox( + height: context.scaled(540), + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + color: cs.destructive.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(8), + ), + child: material.Icon( + material.Icons.warning_amber_rounded, + size: 24, + color: cs.destructive, + ), ), - ), - const Gap(12), - material.Expanded( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - Text( - isCritical - ? 'Critical Destructive Operation' - : 'Destructive Operation Detected', - ).semiBold().large(), - const Gap(2), - if (widget.connectionName != null) + const Gap(12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ Text( - 'Target connection: ${widget.connectionName}', - ).muted().small() - else - const Text( - 'This statement will permanently alter or delete database objects.', - ).muted().small(), - ], + isCritical + ? 'Critical Destructive Operation' + : 'Destructive Operation Detected', + ).semiBold().large(), + const Gap(2), + if (widget.connectionName != null) + Text( + 'Target connection: ${widget.connectionName}', + ).muted().small() + else + const Text( + 'This statement will permanently alter or delete database objects.', + ).muted().small(), + ], + ), ), - ), - ], - ), - const Gap(16), + ], + ), + const Gap(16), - // Detected operations list - material.Container( - padding: const material.EdgeInsets.all(12), - decoration: material.BoxDecoration( - color: cs.destructive.withValues( - alpha: isDark ? 0.12 : 0.06, - ), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( + // Detected operations list + material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( color: cs.destructive.withValues( - alpha: isDark ? 0.35 : 0.25, + alpha: isDark ? 0.12 : 0.06, + ), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.destructive.withValues( + alpha: isDark ? 0.35 : 0.25, + ), ), ), - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - for (final op in widget.result.operations) ...[ - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: material.BoxDecoration( - color: cs.destructive, - borderRadius: material.BorderRadius.circular(4), - ), - child: Text( - op.type.label, - style: const TextStyle( - color: material.Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + for (final op in widget.result.operations) ...[ + material.Row( + crossAxisAlignment: + material.CrossAxisAlignment.start, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.destructive, + borderRadius: + material.BorderRadius.circular(4), + ), + child: Text( + op.type.label, + style: const TextStyle( + color: material.Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), ), ), - ), - const Gap(8), - material.Expanded( - child: Text( - op.description, - style: material.TextStyle( - fontSize: 12, - color: cs.foreground, - fontWeight: material.FontWeight.w500, + const Gap(8), + material.Expanded( + child: Text( + op.description, + style: material.TextStyle( + fontSize: 12, + color: cs.foreground, + fontWeight: material.FontWeight.w500, + ), ), ), - ), - ], - ), - if (op != widget.result.operations.last) const Gap(8), + ], + ), + if (op != widget.result.operations.last) const Gap(8), + ], ], - ], + ), ), - ), - const Gap(14), + const Gap(14), - // SQL Script Preview Header - material.Row( - mainAxisAlignment: material.MainAxisAlignment.spaceBetween, - children: [ - const Text('QUERY PREVIEW').semiBold().xSmall().muted(), - material.InkWell( - onTap: _copySql, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon( - _copied - ? material.Icons.check_rounded - : material.Icons.copy_rounded, - size: 13, - color: _copied - ? material.Colors.green - : cs.mutedForeground, - ), - const Gap(4), - Text(_copied ? 'Copied' : 'Copy SQL').xSmall(), - ], + // SQL Script Preview Header + material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + children: [ + const Text('QUERY PREVIEW').semiBold().xSmall().muted(), + material.InkWell( + onTap: _copySql, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + _copied + ? material.Icons.check_rounded + : material.Icons.copy_rounded, + size: 13, + color: _copied + ? material.Colors.green + : cs.mutedForeground, + ), + const Gap(4), + Text(_copied ? 'Copied' : 'Copy SQL').xSmall(), + ], + ), ), ), - ), - ], - ), - const Gap(6), + ], + ), + const Gap(6), - // SQL Code block container - material.Expanded( - child: material.Container( - padding: const material.EdgeInsets.all(12), - decoration: material.BoxDecoration( - color: isDark - ? const material.Color(0xFF141416) - : const material.Color(0xFFF4F4F6), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.6), + // SQL Code block container + material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: context.workbench.editorBackground, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.6), + ), ), - ), - child: material.SingleChildScrollView( - child: material.SelectableText( - widget.sql, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12.5, - height: 1.45, - color: isDark - ? const material.Color(0xFFE2E8F0) - : const material.Color(0xFF1E293B), + child: material.SingleChildScrollView( + child: material.SelectableText( + widget.sql, + style: material.TextStyle( + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, + fontSize: 12.5, + height: 1.45, + color: cs.foreground, + ), ), ), ), ), - ), - const Gap(14), - - // Confirmation Checkbox - material.Row( - children: [ - material.Checkbox( - value: _acknowledged, - onChanged: (v) => - setState(() => _acknowledged = v ?? false), - ), - const Gap(8), - material.Expanded( - child: material.GestureDetector( - onTap: () => - setState(() => _acknowledged = !_acknowledged), - child: const Text( - 'I understand that this query cannot be undone and may result in permanent data loss.', - ).small(), - ), - ), - ], - ), - const Gap(16), + const Gap(14), - // Action buttons - material.FocusTraversalGroup( - policy: material.WidgetOrderTraversalPolicy(), - child: material.Wrap( - spacing: 12, - runSpacing: 8, - alignment: material.WrapAlignment.end, - crossAxisAlignment: material.WrapCrossAlignment.center, + // Confirmation Checkbox + material.Row( children: [ - QueryaButton.ghost( - label: 'Cancel', - onPressed: () => - material.Navigator.of(context).pop(false), - size: QueryaControlSize.lg, + material.Checkbox( + value: _acknowledged, + onChanged: (v) => + setState(() => _acknowledged = v ?? false), ), - QueryaButton.destructive( - label: 'Execute Destructive Statement', - icon: material.Icons.delete_forever_rounded, - onPressed: _acknowledged - ? () => material.Navigator.of(context).pop(true) - : null, - size: QueryaControlSize.lg, + const Gap(8), + material.Expanded( + child: material.GestureDetector( + onTap: () => + setState(() => _acknowledged = !_acknowledged), + child: const Text( + 'I understand that this query cannot be undone and may result in permanent data loss.', + ).small(), + ), ), ], ), - ), - ], + const Gap(16), + + // Action buttons + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.end, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + QueryaButton.ghost( + label: 'Cancel', + onPressed: () => + material.Navigator.of(context).pop(false), + size: QueryaControlSize.lg, + ), + QueryaButton.destructive( + label: 'Execute Destructive Statement', + icon: material.Icons.delete_forever_rounded, + onPressed: _acknowledged + ? () => material.Navigator.of(context).pop(true) + : null, + size: QueryaControlSize.lg, + ), + ], + ), + ), + ], + ), ), ), ), ), ); + return material.Padding(padding: insets, child: card); } } diff --git a/lib/features/workspace/dml_preview_dialog.dart b/lib/features/workspace/dml_preview_dialog.dart index d99fc430..c84fcc48 100644 --- a/lib/features/workspace/dml_preview_dialog.dart +++ b/lib/features/workspace/dml_preview_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -60,9 +61,9 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; - final isDark = theme.brightness == Brightness.dark; + final cs = context.colors; + final wb = context.workbench; + final isDark = context.colors.brightness == Brightness.dark; final sql = widget.plan.toTransactionSql(); return QueryaDialogCard( @@ -122,12 +123,17 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { color: cs.foreground, ), const Gap(6), - Text( - widget.plan.schema != null && - widget.plan.schema!.isNotEmpty - ? '${widget.plan.schema}.${widget.plan.tableName}' - : widget.plan.tableName, - ).semiBold().small(), + // A long schema.table name is cut, not overflowed (#1642). + material.Flexible( + child: Text( + widget.plan.schema != null && + widget.plan.schema!.isNotEmpty + ? '${widget.plan.schema}.${widget.plan.tableName}' + : widget.plan.tableName, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + ).semiBold().small(), + ), ], ), ), @@ -159,19 +165,19 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { if (_updateCount > 0) _buildCountPill( label: '$_updateCount UPDATE', - color: material.Colors.amber.shade700, + color: wb.warning, isDark: isDark, ), if (_insertCount > 0) _buildCountPill( label: '$_insertCount INSERT', - color: material.Colors.green.shade600, + color: wb.success, isDark: isDark, ), if (_deleteCount > 0) _buildCountPill( label: '$_deleteCount DELETE', - color: material.Colors.red.shade600, + color: wb.destructive, isDark: isDark, ), ], @@ -191,12 +197,12 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { vertical: 6, ), decoration: material.BoxDecoration( - color: material.Colors.amber.withValues( + color: wb.warning.withValues( alpha: isDark ? 0.15 : 0.08, ), borderRadius: material.BorderRadius.circular(6), border: material.Border.all( - color: material.Colors.amber.withValues(alpha: 0.4), + color: wb.warning.withValues(alpha: 0.4), ), ), child: material.Row( @@ -204,7 +210,7 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { material.Icon( material.Icons.warning_amber_rounded, size: 15, - color: material.Colors.amber.shade700, + color: wb.warning, ), const Gap(8), material.Expanded( @@ -240,7 +246,7 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { : material.Icons.copy_rounded, size: 13, color: _copied - ? material.Colors.green + ? wb.success : cs.mutedForeground, ), const Gap(4), @@ -258,9 +264,7 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { child: material.Container( padding: const material.EdgeInsets.all(12), decoration: material.BoxDecoration( - color: isDark - ? const material.Color(0xFF141416) - : const material.Color(0xFFF4F4F6), + color: context.workbench.editorBackground, borderRadius: material.BorderRadius.circular(8), border: material.Border.all( color: cs.border.withValues(alpha: 0.6), @@ -270,12 +274,12 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { child: material.SelectableText( sql, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 12.5, height: 1.45, - color: isDark - ? const material.Color(0xFFE2E8F0) - : const material.Color(0xFF1E293B), + color: cs.foreground, ), ), ), diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index aab422a1..76cecff3 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -1,4 +1,5 @@ import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; import 'package:querya_desktop/core/database/statement_queue.dart'; import 'package:querya_desktop/core/database/sql_statement_splitter.dart'; import 'dart:async'; @@ -37,6 +38,8 @@ import 'package:querya_desktop/features/workspace/sql_query_tab_session.dart'; import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; import 'package:querya_desktop/features/workspace/table_view_staging.dart'; import 'package:querya_desktop/features/workspace/sql_workspace_toolbar.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Reusable base SQL workspace supporting multi-tab editing, statement execution, @@ -149,6 +152,7 @@ class GenericSqlWorkspaceState extends material.State { id: '${widget.sessionPrefix}_tab_1', title: widget.initialTabTitle ?? 'Query 1', initialSql: widget.initialSql, + dialect: widget.dialect, ), ); _appSettingsListener = () { @@ -169,6 +173,18 @@ class GenericSqlWorkspaceState extends material.State { } } + QueryaTheme? _lastTheme; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final theme = QueryaThemeScope.maybeOf(context); + if (_lastTheme != null && _lastTheme != theme) { + invalidateAllPanes(); + } + _lastTheme = theme; + } + SqlExecutionDelegate? _catalogDelegate; SqlExecutionDelegate get _diagramDelegate { @@ -259,6 +275,7 @@ class GenericSqlWorkspaceState extends material.State { filePath: filePath, initialFraction: _sessions.isNotEmpty ? _activeSession.topFraction.value : 0.65, + dialect: widget.dialect, ); _sessions.add(session); _activeSessionIndex = _sessions.length - 1; @@ -578,6 +595,7 @@ class GenericSqlWorkspaceState extends material.State { statementAtCursor: statementAtCursor, selected: selected, caret: selection.isValid ? selection.baseOffset : scope.length, + backslashEscapes: widget.dialect.backslashEscapesQuotes, ); if (spans.isEmpty) { // Say so: a silent Run on an empty editor reads as a broken button. @@ -607,7 +625,10 @@ class GenericSqlWorkspaceState extends material.State { final confirmDestructive = await AppSettings.instance.getConfirmDestructiveOperations(); if (confirmDestructive) { - final inspection = DestructiveSqlDetector.inspect(runSql); + final inspection = DestructiveSqlDetector.inspect( + runSql, + backslashEscapes: widget.dialect.backslashEscapesQuotes, + ); if (inspection.isDestructive) { if (!mounted) return; final confirmed = await showDestructiveQueryDialog( @@ -790,6 +811,20 @@ class GenericSqlWorkspaceState extends material.State { } catch (e) { if (mounted) { invalidatePane(session); + // One statement: the caret goes where the driver says it failed. + if (sent.length == 1) { + final offset = QueryErrorText.offsetIn(e, sent.single); + if (offset != null) { + final runStart = statementAtCursor + ? scopeStart + spans.single.start + : scopeStart + (scope.length - scope.trimLeft().length); + // The text may have been edited while the query ran. + final at = (runStart + offset) + .clamp(0, session.controller.text.length); + session.controller.selection = + material.TextSelection.collapsed(offset: at); + } + } setState(() { session.error = e.toString(); session.running = false; @@ -827,6 +862,7 @@ class GenericSqlWorkspaceState extends material.State { required bool statementAtCursor, required bool selected, required int caret, + required bool backslashEscapes, }) async { if (statementAtCursor && selected) { final start = scope.length - scope.trimLeft().length; @@ -835,7 +871,10 @@ class GenericSqlWorkspaceState extends material.State { ? [SqlStatementSpan(start: start, end: end, line: 1)] : const []; } - final all = await SqlStatementSplitter.spansAsync(scope); + final all = await SqlStatementSplitter.spansAsync( + scope, + backslashEscapes: backslashEscapes, + ); if (!statementAtCursor) return all; final at = SqlStatementSplitter.at(all, caret); return at == null ? const [] : [at]; @@ -1269,9 +1308,7 @@ class GenericSqlWorkspaceState extends material.State { top: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: SqlEditorChrome.sqlToolbarDecoration(context), + _SqlWorkspaceToolbarContainer( child: SqlWorkspaceToolbar( session: session, delegate: widget.delegate, @@ -1308,51 +1345,74 @@ class GenericSqlWorkspaceState extends material.State { ), ], ), - bottom: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (session.planRoot != null) - material.Padding( - padding: const material.EdgeInsets.fromLTRB(8, 6, 8, 6), - child: material.Align( - alignment: material.Alignment.centerLeft, - child: material.SizedBox( - width: 200, - child: QueryaTabStrip( - labels: const ['Plan', 'Text'], - selectedIndex: session.planAsTree ? 0 : 1, - onSelected: (i) => setState(() => session.planAsTree = i == 0), + bottom: material.Padding( + // The results sit in a card like the editor above them. + padding: const material.EdgeInsets.fromLTRB(12, 0, 12, 12), + child: SqlEditorChrome( + background: context.workbench.surface, + showFocus: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (session.planRoot != null) + material.Padding( + padding: const material.EdgeInsets.fromLTRB(8, 6, 8, 6), + child: material.Align( + alignment: material.Alignment.centerLeft, + child: material.SizedBox( + width: 200, + child: QueryaTabStrip( + labels: const ['Plan', 'Text'], + selectedIndex: session.planAsTree ? 0 : 1, + onSelected: (i) => setState(() => session.planAsTree = i == 0), + ), + ), ), ), + Expanded( + child: session.planRoot != null && session.planAsTree + ? PlanTreeView(root: session.planRoot!) + : ResultsTab( + columns: session.columns, + rows: session.rows, + errorMessage: session.error, + isLoading: session.running, + affectedRows: session.affectedRows, + statusLine: session.statusLine, + elapsed: session.elapsed, + stagingBuffer: session.stagingBuffer, + columnDataTypes: session.resultGridColumnDataTypes, + onApplyChanges: widget.isReadOnly || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + ) + ? null + : () => applyStagedChanges(session), + isSaving: session.savingChanges, + errorAction: widget.errorActionBuilder?.call(context, session), + ), ), - ), - Expanded( - child: session.planRoot != null && session.planAsTree - ? PlanTreeView(root: session.planRoot!) - : ResultsTab( - columns: session.columns, - rows: session.rows, - errorMessage: session.error, - isLoading: session.running, - affectedRows: session.affectedRows, - statusLine: session.statusLine, - elapsed: session.elapsed, - stagingBuffer: session.stagingBuffer, - columnDataTypes: session.resultGridColumnDataTypes, - onApplyChanges: widget.isReadOnly || - !sqlResultGridSaveEnabled( - sql: session.lastExecutedSql, - resultColumns: session.columns, - primaryKeys: session.resultGridPrimaryKeys, - ) - ? null - : () => applyStagedChanges(session), - isSaving: session.savingChanges, - errorAction: widget.errorActionBuilder?.call(context, session), - ), + ], ), - ], + ), ), ); } } + +class _SqlWorkspaceToolbarContainer extends StatelessWidget { + const _SqlWorkspaceToolbarContainer({required this.child}); + + final material.Widget child; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: SqlEditorChrome.sqlToolbarDecoration(context), + child: child, + ); + } +} diff --git a/lib/features/workspace/generic_table_view.dart b/lib/features/workspace/generic_table_view.dart index 5c2d987b..b0562241 100644 --- a/lib/features/workspace/generic_table_view.dart +++ b/lib/features/workspace/generic_table_view.dart @@ -677,7 +677,7 @@ class GenericTableViewState extends material.State { } material.Widget _buildDefaultToolbar() { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final title = '$effectiveTableTitle${widget.isMaterializedView ? ' (materialized view)' : widget.isView ? ' (view)' : ''}'; return material.Container( @@ -807,7 +807,7 @@ class GenericTableViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final buffer = _stagingBuffer; final toolbar = widget.customToolbarBuilder != null diff --git a/lib/features/workspace/grid/grid_cell_actions.dart b/lib/features/workspace/grid/grid_cell_actions.dart index 6dae9a29..f98b164b 100644 --- a/lib/features/workspace/grid/grid_cell_actions.dart +++ b/lib/features/workspace/grid/grid_cell_actions.dart @@ -94,14 +94,14 @@ extension _GridCellActions on _VirtualResultGridState { } void _openCellContextMenu(int row, int col, material.Offset globalPosition) { - showContextMenuBuilder( + showQueryaContextMenuBuilder( context: context, position: globalPosition, itemsBuilder: (ctx) => _buildCellContextMenuItems(ctx, row, col), ); } - List _buildCellContextMenuItems( + List _buildCellContextMenuItems( material.BuildContext context, int row, int column, @@ -123,10 +123,10 @@ extension _GridCellActions on _VirtualResultGridState { StagedRowStatus.unchanged; final cellStatus = widget.stagingBuffer?.getCellStatus(modelRow, column) ?? StagedCellStatus.clean; - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return [ - MenuButton( + QueryaMenuItem( leading: const material.Icon(material.Icons.content_copy_rounded, size: 16), trailing: const material.Text('Ctrl+C', @@ -134,7 +134,7 @@ extension _GridCellActions on _VirtualResultGridState { onPressed: (_) => _handleCopyCell(row, column), child: const material.Text('Copy Value'), ), - MenuButton( + QueryaMenuItem( leading: const material.Icon(material.Icons.table_chart_outlined, size: 16), trailing: const material.Text('Ctrl+Shift+C', @@ -142,20 +142,20 @@ extension _GridCellActions on _VirtualResultGridState { onPressed: (_) => _handleCopyCell(row, column, withHeaders: true), child: const material.Text('Copy with Headers'), ), - MenuButton( + QueryaMenuItem( leading: const material.Icon(material.Icons.data_object_rounded, size: 16), onPressed: (_) => _handleCopyCell(row, column, asJson: true), child: const material.Text('Copy as JSON'), ), - MenuButton( + QueryaMenuItem( leading: const material.Icon(material.Icons.grid_on_rounded, size: 16), onPressed: (_) => _handleCopyCell(row, column, asCsv: true), child: const material.Text('Copy as CSV'), ), if (canFilter && columnName.isNotEmpty) ...[ - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( leading: const material.Icon(material.Icons.filter_alt_outlined, size: 16), onPressed: (_) => _handleFilterByValue(row, column, invert: false), @@ -165,7 +165,7 @@ extension _GridCellActions on _VirtualResultGridState { : 'Filter by Value ($columnName = $preview)', ), ), - MenuButton( + QueryaMenuItem( leading: const material.Icon(material.Icons.filter_alt_off_outlined, size: 16), onPressed: (_) => _handleFilterByValue(row, column, invert: true), @@ -176,13 +176,13 @@ extension _GridCellActions on _VirtualResultGridState { ), ), if (numVal != null) ...[ - MenuButton( + QueryaMenuItem( leading: const material.Icon(material.Icons.chevron_right_rounded, size: 16), onPressed: (_) => _handleFilterComparison(row, column, '>'), child: material.Text('Filter > $text ($columnName > $text)'), ), - MenuButton( + QueryaMenuItem( leading: const material.Icon(material.Icons.chevron_left_rounded, size: 16), onPressed: (_) => _handleFilterComparison(row, column, '<'), @@ -190,8 +190,8 @@ extension _GridCellActions on _VirtualResultGridState { ), ], ], - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( leading: const material.Icon(material.Icons.visibility_outlined, size: 16), trailing: const material.Text('Ctrl+I', @@ -200,7 +200,7 @@ extension _GridCellActions on _VirtualResultGridState { child: const material.Text('Inspect Cell Value…'), ), if (hasStagingBuffer) ...[ - MenuButton( + QueryaMenuItem( leading: const material.Icon( material.Icons.remove_circle_outline_rounded, size: 16), @@ -209,20 +209,20 @@ extension _GridCellActions on _VirtualResultGridState { onPressed: (_) => _handleSetNull(row, column), child: const material.Text('Set NULL'), ), - MenuButton( + QueryaMenuItem( leading: const material.Icon(material.Icons.format_clear_rounded, size: 16), onPressed: (_) => _handleSetEmpty(row, column), child: const material.Text('Set to Empty String'), ), if (cellStatus == StagedCellStatus.modified) - MenuButton( + QueryaMenuItem( leading: const material.Icon(material.Icons.undo_rounded, size: 16), onPressed: (_) => _handleRevertCell(row, column), child: const material.Text('Revert Cell Changes'), ), - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( leading: const material.Icon(material.Icons.content_paste_go_rounded, size: 16), trailing: const material.Text('Ctrl+D', @@ -230,7 +230,7 @@ extension _GridCellActions on _VirtualResultGridState { onPressed: (_) => _handleDuplicateRow(row), child: const material.Text('Duplicate Row'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon( rowStatus == StagedRowStatus.deleted ? material.Icons.restore_from_trash_rounded @@ -254,7 +254,7 @@ extension _GridCellActions on _VirtualResultGridState { ), if (rowStatus == StagedRowStatus.modified || rowStatus == StagedRowStatus.deleted) - MenuButton( + QueryaMenuItem( leading: const material.Icon(material.Icons.restore_rounded, size: 16), onPressed: (_) => _handleRevertRow(row), diff --git a/lib/features/workspace/grid/grid_cell_renderer.dart b/lib/features/workspace/grid/grid_cell_renderer.dart index 1fc339a0..562f7ccc 100644 --- a/lib/features/workspace/grid/grid_cell_renderer.dart +++ b/lib/features/workspace/grid/grid_cell_renderer.dart @@ -8,7 +8,7 @@ class _GridCell extends material.StatelessWidget { this.columnName = '', required this.text, required this.width, - required this.colorScheme, + required this.colors, required this.striped, this.rowStatus = StagedRowStatus.unchanged, this.cellStatus = StagedCellStatus.clean, @@ -29,7 +29,7 @@ class _GridCell extends material.StatelessWidget { final String columnName; final String text; final double width; - final ColorScheme colorScheme; + final QueryaUiColors colors; final bool striped; final StagedRowStatus rowStatus; final StagedCellStatus cellStatus; @@ -61,7 +61,7 @@ class _GridCell extends material.StatelessWidget { columnName == other.columnName && text == other.text && width == other.width && - colorScheme == other.colorScheme && + colors == other.colors && striped == other.striped && rowStatus == other.rowStatus && cellStatus == other.cellStatus && @@ -109,30 +109,29 @@ class _GridCell extends material.StatelessWidget { final isInserted = rowStatus == StagedRowStatus.inserted; final isModified = cellStatus == StagedCellStatus.modified; - final style = material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.normal, - fontFamily: 'monospace', - color: isDeleted - ? colorScheme.destructive.withValues(alpha: 0.7) - : (isNull - ? colorScheme.mutedForeground.withValues(alpha: 0.5) - : colorScheme.foreground), - decoration: isDeleted ? material.TextDecoration.lineThrough : null, - fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, + final style = _CellLook.style( + colors, + isNull: isNull, + isDeleted: isDeleted, + ); + final decoration = _CellLook.decoration( + colors, + background: isSelected + ? _CellBackground.selected + : (isDeleted + ? _CellBackground.deleted + : (isModified + ? _CellBackground.modified + : (isInserted + ? _CellBackground.inserted + : (striped + ? _CellBackground.striped + : _CellBackground.none)))), + right: isSelectionRight, + left: isSelectionLeft, + top: isSelectionTop, + bottom: isSelectionBottom, ); - - final bg = isSelected - ? colorScheme.primary.withValues(alpha: 0.18) - : (isDeleted - ? colorScheme.destructive.withValues(alpha: 0.08) - : (isModified - ? colorScheme.primary.withValues(alpha: 0.14) - : (isInserted - ? colorScheme.primary.withValues(alpha: 0.08) - : (striped - ? colorScheme.muted.withValues(alpha: 0.12) - : material.Colors.transparent)))); // Primary/secondary gestures and hover tooltips are hoisted to the grid // level (#983, #1346). _GridCell is a lightweight leaf widget. @@ -144,28 +143,7 @@ class _GridCell extends material.StatelessWidget { height: double.infinity, padding: const material.EdgeInsets.symmetric(horizontal: 10), alignment: material.Alignment.centerLeft, - decoration: material.BoxDecoration( - color: bg, - border: material.Border( - right: material.BorderSide( - color: isSelectionRight - ? colorScheme.primary - : colorScheme.border.withValues(alpha: 0.3), - width: isSelectionRight ? 1.5 : 1.0, - ), - left: isSelectionLeft - ? material.BorderSide(color: colorScheme.primary, width: 1.5) - : material.BorderSide.none, - top: isSelectionTop - ? material.BorderSide(color: colorScheme.primary, width: 1.5) - : material.BorderSide.none, - bottom: isSelectionBottom - ? material.BorderSide(color: colorScheme.primary, width: 1.5) - : material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.15), - ), - ), - ), + decoration: decoration, child: material.Stack( clipBehavior: material.Clip.none, alignment: material.Alignment.centerLeft, @@ -182,7 +160,7 @@ class _GridCell extends material.StatelessWidget { right: -8, child: material.CustomPaint( size: const material.Size(6, 6), - painter: _TriangleCornerPainter(color: colorScheme.primary), + painter: _TriangleCornerPainter(color: colors.primary), ), ), ], @@ -192,6 +170,94 @@ class _GridCell extends material.StatelessWidget { } } +enum _CellBackground { selected, deleted, modified, inserted, striped, none } + +/// The looks a cell can have are few, so each is built once per colour scheme +/// and shared (#1519): a scroll that builds a new row reuses them instead of +/// allocating a style, a decoration, a border and four sides per cell. +abstract final class _CellLook { + static QueryaUiColors? _scheme; + static final _styles = List.filled(4, null); + static final _decorations = {}; + + /// A new colour scheme (theme switch) drops what was built for the old one. + static void _forget(QueryaUiColors scheme) { + if (identical(_scheme, scheme)) return; + _scheme = scheme; + _styles.fillRange(0, _styles.length, null); + _decorations.clear(); + } + + static material.TextStyle style( + QueryaUiColors scheme, { + required bool isNull, + required bool isDeleted, + }) { + _forget(scheme); + final key = (isNull ? 1 : 0) | (isDeleted ? 2 : 0); + return _styles[key] ??= material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.normal, + fontFamily: 'monospace', + // Without a CJK family the engine searches for a fallback per paragraph + // and is ~40x slower on CJK text (#1553). + fontFamilyFallback: QueryaTypography.cjkFontFamilyFallback, + color: isDeleted + ? scheme.destructive.withValues(alpha: 0.7) + : (isNull + ? scheme.mutedForeground.withValues(alpha: 0.5) + : scheme.foreground), + decoration: isDeleted ? material.TextDecoration.lineThrough : null, + fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, + ); + } + + static material.BoxDecoration decoration( + QueryaUiColors scheme, { + required _CellBackground background, + required bool right, + required bool left, + required bool top, + required bool bottom, + }) { + _forget(scheme); + final key = background.index | + (right ? 1 << 3 : 0) | + (left ? 1 << 4 : 0) | + (top ? 1 << 5 : 0) | + (bottom ? 1 << 6 : 0); + return _decorations[key] ??= material.BoxDecoration( + color: switch (background) { + _CellBackground.selected => scheme.primary.withValues(alpha: 0.18), + _CellBackground.deleted => scheme.destructive.withValues(alpha: 0.08), + _CellBackground.modified => scheme.primary.withValues(alpha: 0.14), + _CellBackground.inserted => scheme.primary.withValues(alpha: 0.08), + _CellBackground.striped => scheme.muted.withValues(alpha: 0.12), + _CellBackground.none => material.Colors.transparent, + }, + border: material.Border( + right: material.BorderSide( + color: right + ? scheme.primary + : scheme.border.withValues(alpha: 0.3), + width: right ? 1.5 : 1.0, + ), + left: left + ? material.BorderSide(color: scheme.primary, width: 1.5) + : material.BorderSide.none, + top: top + ? material.BorderSide(color: scheme.primary, width: 1.5) + : material.BorderSide.none, + bottom: bottom + ? material.BorderSide(color: scheme.primary, width: 1.5) + : material.BorderSide( + color: scheme.border.withValues(alpha: 0.15), + ), + ), + ); + } +} + class _TriangleCornerPainter extends material.CustomPainter { const _TriangleCornerPainter({required this.color}); final material.Color color; diff --git a/lib/features/workspace/grid/grid_column_sizer.dart b/lib/features/workspace/grid/grid_column_sizer.dart index b319a9c1..c4a19ab1 100644 --- a/lib/features/workspace/grid/grid_column_sizer.dart +++ b/lib/features/workspace/grid/grid_column_sizer.dart @@ -1,6 +1,6 @@ +import 'package:flutter/widgets.dart'; import 'dart:math' as math; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; /// Layout metrics for [VirtualResultGrid]. abstract final class ResultGridMetrics { diff --git a/lib/features/workspace/grid/grid_header_renderer.dart b/lib/features/workspace/grid/grid_header_renderer.dart index ebb92cb4..83d94db4 100644 --- a/lib/features/workspace/grid/grid_header_renderer.dart +++ b/lib/features/workspace/grid/grid_header_renderer.dart @@ -6,7 +6,7 @@ class _HeaderRow extends material.StatelessWidget { required this.columnWidths, required this.window, required this.height, - required this.colorScheme, + required this.colors, this.sortColumnIndex, this.sortOrder, this.onSortColumn, @@ -18,7 +18,7 @@ class _HeaderRow extends material.StatelessWidget { final List columnWidths; final ResultGridColumnWindow window; final double height; - final ColorScheme colorScheme; + final QueryaUiColors colors; final int? sortColumnIndex; final ResultGridSortOrder? sortOrder; final material.ValueChanged? onSortColumn; @@ -30,10 +30,10 @@ class _HeaderRow extends material.StatelessWidget { return material.Container( height: height, decoration: material.BoxDecoration( - color: colorScheme.muted.withValues(alpha: 0.35), + color: colors.muted.withValues(alpha: 0.35), border: material.Border( bottom: material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.5), + color: colors.border.withValues(alpha: 0.5), ), ), ), @@ -45,7 +45,7 @@ class _HeaderRow extends material.StatelessWidget { _HeaderCell( text: columns[i], width: columnWidths[i], - colorScheme: colorScheme, + colors: colors, sortOrder: sortColumnIndex == i ? sortOrder : null, onSort: onSortColumn != null ? () => onSortColumn!(i) : null, onResize: onResizeColumn != null @@ -66,7 +66,7 @@ class _HeaderCell extends material.StatelessWidget { const _HeaderCell({ required this.text, required this.width, - required this.colorScheme, + required this.colors, this.sortOrder, this.onSort, this.onResize, @@ -75,7 +75,7 @@ class _HeaderCell extends material.StatelessWidget { final String text; final double width; - final ColorScheme colorScheme; + final QueryaUiColors colors; final ResultGridSortOrder? sortOrder; final material.VoidCallback? onSort; final material.ValueChanged? onResize; @@ -87,7 +87,7 @@ class _HeaderCell extends material.StatelessWidget { final style = material.TextStyle( fontSize: 12, fontWeight: material.FontWeight.w600, - color: isSorted ? colorScheme.primary : colorScheme.foreground, + color: isSorted ? colors.primary : colors.foreground, ); return material.Container( @@ -96,7 +96,7 @@ class _HeaderCell extends material.StatelessWidget { decoration: material.BoxDecoration( border: material.Border( right: material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.3), + color: colors.border.withValues(alpha: 0.3), ), ), ), @@ -138,7 +138,7 @@ class _HeaderCell extends material.StatelessWidget { child: material.Icon( material.Icons.arrow_upward_rounded, size: 14, - color: colorScheme.primary, + color: colors.primary, ), ), ], diff --git a/lib/features/workspace/grid/grid_row_renderer.dart b/lib/features/workspace/grid/grid_row_renderer.dart index 9a711d69..9c610bb2 100644 --- a/lib/features/workspace/grid/grid_row_renderer.dart +++ b/lib/features/workspace/grid/grid_row_renderer.dart @@ -10,7 +10,7 @@ class _DataRow extends material.StatefulWidget { required this.columnWidths, required this.window, required this.height, - required this.colorScheme, + required this.colors, required this.striped, this.selection, this.stagingBuffer, @@ -30,7 +30,7 @@ class _DataRow extends material.StatefulWidget { final List columnWidths; final ResultGridColumnWindow window; final double height; - final ColorScheme colorScheme; + final QueryaUiColors colors; final bool striped; final ResultGridSelection? selection; final DataGridStagingBuffer? stagingBuffer; @@ -69,7 +69,7 @@ class _DataRow extends material.StatefulWidget { foundation.listEquals(columnWidths, other.columnWidths)) && window == other.window && height == other.height && - colorScheme == other.colorScheme && + colors == other.colors && striped == other.striped && selection == other.selection && identical(stagingBuffer, other.stagingBuffer) && @@ -107,6 +107,38 @@ class _DataRow extends material.StatefulWidget { material.Widget _buildRow(Map cache) { final rowStatus = stagingBuffer?.getRowStatus(modelRowIndex) ?? StagedRowStatus.unchanged; + final sel = selection; + // Rows outside the selection skip the column tests for every cell. + final rowSelected = + sel != null && rowIndex >= sel.startRow && rowIndex <= sel.endRow; + + _GridCell cellFor(int c) { + final inSelection = rowSelected && c >= sel.startColumn && c <= sel.endColumn; + final columnName = c < columns.length ? columns[c] : ''; + return _GridCell( + key: material.ValueKey(c), + row: rowIndex, + column: c, + columnName: columnName, + text: c < row.length ? _maskCellText(piiKinds, c, row[c]) : '', + width: columnWidths[c], + colors: colors, + striped: striped, + rowStatus: rowStatus, + cellStatus: stagingBuffer?.getCellStatus(modelRowIndex, c) ?? + StagedCellStatus.clean, + isSelected: inSelection, + isEditing: editingCell?.row == rowIndex && editingCell?.column == c, + isSelectionTop: inSelection && rowIndex == sel.startRow, + isSelectionBottom: inSelection && rowIndex == sel.endRow, + isSelectionLeft: inSelection && c == sel.startColumn, + isSelectionRight: inSelection && c == sel.endColumn, + dataTypeName: columnDataTypes?[columnName], + onCommitEdit: onCommitEdit, + onCancelEdit: onCancelEdit, + onOpenInspector: onOpenInspector, + ); + } return material.Semantics( container: true, @@ -122,46 +154,7 @@ class _DataRow extends material.StatefulWidget { width: window.leadingWidth, ), for (var c = window.first; c <= window.last; c++) - _reuseCell( - cache, - c, - _GridCell( - key: material.ValueKey(c), - row: rowIndex, - column: c, - columnName: c < columns.length ? columns[c] : '', - text: c < row.length - ? _maskCellText(piiKinds, c, row[c]) - : '', - width: columnWidths[c], - colorScheme: colorScheme, - striped: striped, - rowStatus: rowStatus, - cellStatus: - stagingBuffer?.getCellStatus(modelRowIndex, c) ?? - StagedCellStatus.clean, - isSelected: selection?.contains(rowIndex, c) ?? false, - isEditing: editingCell?.row == rowIndex && - editingCell?.column == c, - isSelectionTop: selection != null && - selection!.contains(rowIndex, c) && - rowIndex == selection!.startRow, - isSelectionBottom: selection != null && - selection!.contains(rowIndex, c) && - rowIndex == selection!.endRow, - isSelectionLeft: selection != null && - selection!.contains(rowIndex, c) && - c == selection!.startColumn, - isSelectionRight: selection != null && - selection!.contains(rowIndex, c) && - c == selection!.endColumn, - dataTypeName: - columnDataTypes?[c < columns.length ? columns[c] : ''], - onCommitEdit: onCommitEdit, - onCancelEdit: onCancelEdit, - onOpenInspector: onOpenInspector, - ), - ), + _reuseCell(cache, c, cellFor(c)), if (window.trailingWidth > 0) material.SizedBox( key: const material.ValueKey('trail'), diff --git a/lib/features/workspace/grid/grid_selection_manager.dart b/lib/features/workspace/grid/grid_selection_manager.dart index 3766828c..d1f41d49 100644 --- a/lib/features/workspace/grid/grid_selection_manager.dart +++ b/lib/features/workspace/grid/grid_selection_manager.dart @@ -1,6 +1,6 @@ +import 'package:flutter/widgets.dart'; import 'dart:convert'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; /// Coordinate of a cell in [VirtualResultGrid]. @immutable @@ -66,23 +66,22 @@ class ResultGridSelection { String toTsv(List> rows, {List? columns}) { if (rows.isEmpty) return ''; final buffer = StringBuffer(); + // Cells go straight into the buffer: no per-row list and join (#1524). if (columns != null && columns.isNotEmpty) { - final headerCells = []; for (var c = startColumn; c <= endColumn; c++) { - final col = c < columns.length ? columns[c] : ''; - headerCells.add(_escapeTsv(col)); + if (c > startColumn) buffer.write('\t'); + buffer.write(_escapeTsv(c < columns.length ? columns[c] : '')); } - buffer.writeln(headerCells.join('\t')); + buffer.writeln(); } for (var r = startRow; r <= endRow; r++) { if (r < 0 || r >= rows.length) continue; final rowData = rows[r]; - final cells = []; for (var c = startColumn; c <= endColumn; c++) { - final val = c < rowData.length ? rowData[c] : ''; - cells.add(_escapeTsv(val)); + if (c > startColumn) buffer.write('\t'); + buffer.write(_escapeTsv(c < rowData.length ? rowData[c] : '')); } - buffer.writeln(cells.join('\t')); + buffer.writeln(); } return buffer.toString().trimRight(); } @@ -93,22 +92,20 @@ class ResultGridSelection { if (rows.isEmpty) return ''; final buffer = StringBuffer(); if (columns != null && columns.isNotEmpty) { - final headerCells = []; for (var c = startColumn; c <= endColumn; c++) { - final col = c < columns.length ? columns[c] : ''; - headerCells.add(_escapeCsv(col)); + if (c > startColumn) buffer.write(','); + buffer.write(_escapeCsv(c < columns.length ? columns[c] : '')); } - buffer.writeln(headerCells.join(',')); + buffer.writeln(); } for (var r = startRow; r <= endRow; r++) { if (r < 0 || r >= rows.length) continue; final rowData = rows[r]; - final cells = []; for (var c = startColumn; c <= endColumn; c++) { - final val = c < rowData.length ? rowData[c] : ''; - cells.add(_escapeCsv(val)); + if (c > startColumn) buffer.write(','); + buffer.write(_escapeCsv(c < rowData.length ? rowData[c] : '')); } - buffer.writeln(cells.join(',')); + buffer.writeln(); } return buffer.toString().trimRight(); } @@ -127,16 +124,18 @@ class ResultGridSelection { final val = c < rowData.length ? rowData[c] : ''; if (val == 'NULL') { map[colName] = null; - } else if (RegExp(r'^0\d+$').hasMatch(val.trim())) { + } else if (_hasLeadingZeroDigits(val.trim())) { // Preserve numeric strings with leading zeros (e.g. '01234', '007') map[colName] = val; - } else if (int.tryParse(val) != null) { - map[colName] = int.parse(val); - } else if (double.tryParse(val) != null) { - map[colName] = double.parse(val); - } else if (val.toLowerCase() == 'true') { + } else if ((_mayBeNumber(val) ? int.tryParse(val) : null) + case final asInt?) { + map[colName] = asInt; + } else if ((_mayBeNumber(val) ? double.tryParse(val) : null) + case final asDouble?) { + map[colName] = asDouble; + } else if (_equalsAsciiIgnoreCase(val, 'true')) { map[colName] = true; - } else if (val.toLowerCase() == 'false') { + } else if (_equalsAsciiIgnoreCase(val, 'false')) { map[colName] = false; } else { map[colName] = val; @@ -150,22 +149,56 @@ class ResultGridSelection { return const JsonEncoder.withIndent(' ').convert(result); } - static String _escapeTsv(String val) { - if (val.contains('\t') || - val.contains('\n') || - val.contains('\r') || - val.contains('"')) { - return '"${val.replaceAll('"', '""')}"'; + /// Cheap pre-check so text cells do not pay for two failed number parses: + /// after any leading blanks (the parsers skip them) a number starts with a + /// digit, a sign or a dot. A non-ASCII start is left to the parsers. + static bool _mayBeNumber(String s) { + for (var i = 0; i < s.length; i++) { + final c = s.codeUnitAt(i); + if (c == 0x20 || (c >= 0x09 && c <= 0x0D)) continue; + return (c >= 0x30 && c <= 0x39) || + c == 0x2D || + c == 0x2B || + c == 0x2E || + c > 0x7F; } - return val; + return false; + } + + /// [lower] must be lower-case ASCII. Same answer as + /// `s.toLowerCase() == lower`, without the copy. + static bool _equalsAsciiIgnoreCase(String s, String lower) { + if (s.length != lower.length) return false; + for (var i = 0; i < s.length; i++) { + var c = s.codeUnitAt(i); + if (c >= 0x41 && c <= 0x5A) c += 0x20; + if (c != lower.codeUnitAt(i)) return false; + } + return true; } - static String _escapeCsv(String val) { - if (val.contains(',') || - val.contains('"') || - val.contains('\n') || - val.contains('\r')) { - return '"${val.replaceAll('"', '""')}"'; + /// `^0\d+$`: a zero followed by one or more ASCII digits and nothing else. + static bool _hasLeadingZeroDigits(String s) { + if (s.length < 2 || s.codeUnitAt(0) != 0x30) return false; + for (var i = 1; i < s.length; i++) { + final c = s.codeUnitAt(i); + if (c < 0x30 || c > 0x39) return false; + } + return true; + } + + static String _escapeTsv(String val) => _quoteIfAny(val, 0x09); + + static String _escapeCsv(String val) => _quoteIfAny(val, 0x2C); + + /// Quotes [val] when it holds a quote, a line break or [separator]: one pass + /// over the code units instead of one `contains` scan per character. + static String _quoteIfAny(String val, int separator) { + for (var i = 0; i < val.length; i++) { + final c = val.codeUnitAt(i); + if (c == 0x22 || c == 0x0A || c == 0x0D || c == separator) { + return '"${val.replaceAll('"', '""')}"'; + } } return val; } diff --git a/lib/features/workspace/grid/grid_sorting.dart b/lib/features/workspace/grid/grid_sorting.dart index e2ea3ea8..d8f46235 100644 --- a/lib/features/workspace/grid/grid_sorting.dart +++ b/lib/features/workspace/grid/grid_sorting.dart @@ -13,19 +13,21 @@ enum _SortKeyType { nullOrEmpty, numeric, dateTime, string } class _SortKey implements Comparable<_SortKey> { final _SortKeyType type; final num? numVal; - final DateTime? dtVal; + + /// The instant of a date / timestamp, in microseconds since the epoch. + final int dtMicros; final String strLower; final String strRaw; _SortKey._({ required this.type, this.numVal, - this.dtVal, + this.dtMicros = 0, this.strLower = '', this.strRaw = '', }); - factory _SortKey.parse(String val) { + factory _SortKey.parse(String val, Map localOffsets) { if (val == 'NULL' || val.isEmpty) { return _SortKey._(type: _SortKeyType.nullOrEmpty); } @@ -33,9 +35,14 @@ class _SortKey implements Comparable<_SortKey> { if (n != null) { return _SortKey._(type: _SortKeyType.numeric, numVal: n, strRaw: val); } - final dt = DateTime.tryParse(val); - if (dt != null) { - return _SortKey._(type: _SortKeyType.dateTime, dtVal: dt, strRaw: val); + final micros = isoMicrosSinceEpoch(val, localOffsets: localOffsets) ?? + DateTime.tryParse(val)?.microsecondsSinceEpoch; + if (micros != null) { + return _SortKey._( + type: _SortKeyType.dateTime, + dtMicros: micros, + strRaw: val, + ); } return _SortKey._( type: _SortKeyType.string, @@ -57,7 +64,7 @@ class _SortKey implements Comparable<_SortKey> { return numVal!.compareTo(other.numVal!); } if (type == _SortKeyType.dateTime && other.type == _SortKeyType.dateTime) { - return dtVal!.compareTo(other.dtVal!); + return dtMicros.compareTo(other.dtMicros); } final aLower = @@ -72,6 +79,123 @@ class _SortKey implements Comparable<_SortKey> { } } +/// [s] as `DateTime.parse(s).microsecondsSinceEpoch` for the shapes database +/// drivers print, read from the code units: `YYYY-MM-DD`, then optionally +/// `[T ]HH:MM`, `:SS`, `.fraction` and `Z` / `±HH[[:]MM]`. Null for any other +/// shape, which the caller hands to [DateTime.tryParse]: this is only a +/// shortcut past its regex, with the same result (#1533). +/// +/// Without a zone the time is local, as in [DateTime.parse]; fields out of +/// range roll over the same way, because they go through the same +/// constructors. The fraction keeps its first six digits, as there. +/// +/// The local constructor asks the system for the zone offset on every call +/// (~4 µs, nearly all of a timestamp sort). With [localOffsets], one map per +/// sort, a day whose offset is the same at its start and its end is looked +/// up once and the time is computed from the UTC fields; a day with a +/// daylight-saving change still goes through the local constructor. +@foundation.visibleForTesting +int? isoMicrosSinceEpoch(String s, {Map? localOffsets}) { + final n = s.length; + int digits(int at, int count) { + if (at + count > n) return -1; + var v = 0; + for (var i = at; i < at + count; i++) { + final c = s.codeUnitAt(i) - 0x30; + if (c < 0 || c > 9) return -1; + v = v * 10 + c; + } + return v; + } + + if (n < 10 || s.codeUnitAt(4) != 0x2D || s.codeUnitAt(7) != 0x2D) { + return null; + } + final year = digits(0, 4); + final month = digits(5, 2); + final day = digits(8, 2); + if (year < 0 || month < 0 || day < 0) return null; + var hour = 0, minute = 0, second = 0, milli = 0, micro = 0; + var i = 10; + if (i < n) { + final sep = s.codeUnitAt(i); + // `T` or a space, as DateTime.parse takes; a lower-case `t` it does not. + if (sep != 0x54 && sep != 0x20) return null; + hour = digits(i + 1, 2); + if (hour < 0 || i + 3 >= n || s.codeUnitAt(i + 3) != 0x3A) return null; + minute = digits(i + 4, 2); + if (minute < 0) return null; + i += 6; + if (i < n && s.codeUnitAt(i) == 0x3A) { + second = digits(i + 1, 2); + if (second < 0) return null; + i += 3; + if (i < n && s.codeUnitAt(i) == 0x2E) { + i++; + final start = i; + var fraction = 0; + while (i < n) { + final c = s.codeUnitAt(i) - 0x30; + if (c < 0 || c > 9) break; + if (i - start < 6) fraction = fraction * 10 + c; + i++; + } + if (i == start) return null; + for (var k = i - start; k < 6; k++) { + fraction *= 10; + } + milli = fraction ~/ 1000; + micro = fraction % 1000; + } + } + } + if (i == n) { + if (localOffsets != null) { + final wall = DateTime.utc( + year, month, day, hour, minute, second, milli, micro); + final key = wall.year * 10000 + wall.month * 100 + wall.day; + final offset = localOffsets[key] ??= + _dayOffsetMicros(wall.year, wall.month, wall.day); + if (offset != _offsetChangesThatDay) { + return wall.microsecondsSinceEpoch - offset; + } + } + return DateTime(year, month, day, hour, minute, second, milli, micro) + .microsecondsSinceEpoch; + } + var offsetMinutes = 0; + final z = s.codeUnitAt(i); + if (z == 0x5A || z == 0x7A) { + if (i + 1 != n) return null; + } else if (z == 0x2B || z == 0x2D) { + final hh = digits(i + 1, 2); + if (hh < 0) return null; + i += 3; + var mm = 0; + if (i < n) { + if (s.codeUnitAt(i) == 0x3A) i++; + mm = digits(i, 2); + if (mm < 0 || i + 2 != n) return null; + } + offsetMinutes = (hh * 60 + mm) * (z == 0x2D ? -1 : 1); + } else { + return null; + } + return DateTime.utc(year, month, day, hour, minute, second, milli, micro) + .microsecondsSinceEpoch - + offsetMinutes * Duration.microsecondsPerMinute; +} + +const int _offsetChangesThatDay = 1 << 62; + +/// The local zone's offset (µs) on that day, or [_offsetChangesThatDay] when +/// it is not the same at the first and the last second of the day. +int _dayOffsetMicros(int year, int month, int day) { + final start = DateTime(year, month, day).timeZoneOffset; + final end = DateTime(year, month, day, 23, 59, 59).timeZoneOffset; + return start == end ? start.inMicroseconds : _offsetChangesThatDay; +} + /// Result of sorting rows in [VirtualResultGrid], preserving model indices. class SortedResultGridData { final List> rows; @@ -99,11 +223,12 @@ SortedResultGridData sortResultGridRowsWithIndices({ ); } final n = rows.length; + final localOffsets = {}; final keys = List<_SortKey>.generate(n, (i) { final row = rows[i]; final val = columnIndex < row.length ? row[columnIndex] : ''; - return _SortKey.parse(val); + return _SortKey.parse(val, localOffsets); }, growable: false); final indices = List.generate(n, (i) => i, growable: false); diff --git a/lib/features/workspace/grid/grid_view_builder.dart b/lib/features/workspace/grid/grid_view_builder.dart index e720a3b9..cfc8e852 100644 --- a/lib/features/workspace/grid/grid_view_builder.dart +++ b/lib/features/workspace/grid/grid_view_builder.dart @@ -10,7 +10,7 @@ extension _GridViewBuilder on _VirtualResultGridState { _columnOffsets = computeResultGridColumnOffsets(_columnWidths); _widthsNeedUpdate = false; } - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final piiKinds = _activePiiKinds; return material.CallbackShortcuts( @@ -354,7 +354,7 @@ extension _GridViewBuilder on _VirtualResultGridState { columnWidths: displayWidths, window: window, height: headerHeight, - colorScheme: cs, + colors: cs, sortColumnIndex: _sortColumnIndex, sortOrder: _sortOrder, onSortColumn: _toggleSort, @@ -403,7 +403,7 @@ extension _GridViewBuilder on _VirtualResultGridState { columnWidths: displayWidths, window: window, height: rowHeight, - colorScheme: cs, + colors: cs, striped: !isEven, selection: _selectionForRow(rowIndex), stagingBuffer: widget.stagingBuffer, diff --git a/lib/features/workspace/grid_cell_editor.dart b/lib/features/workspace/grid_cell_editor.dart index dce589de..70f5163f 100644 --- a/lib/features/workspace/grid_cell_editor.dart +++ b/lib/features/workspace/grid_cell_editor.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/workspace/grid_data_type_validator.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; /// Active inline editor widget for a data grid cell. class GridCellEditor extends material.StatefulWidget { @@ -157,7 +160,7 @@ class _GridCellEditorState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final hasError = _validationError != null; return material.AnimatedContainer( @@ -168,7 +171,7 @@ class _GridCellEditorState extends material.State { decoration: material.BoxDecoration( color: cs.card, border: material.Border.all( - color: hasError ? material.Colors.red.shade500 : cs.primary, + color: hasError ? cs.destructive : cs.primary, width: 1.5, ), ), @@ -181,18 +184,15 @@ class _GridCellEditorState extends material.State { focusNode: _focusNode, onKeyEvent: _handleKeyEvent, autofocus: true, - child: material.TextField( + child: QueryaTextField( + framed: false, controller: _controller, autofocus: true, maxLines: 1, style: const material.TextStyle( fontSize: 12, fontFamily: 'monospace', - ), - decoration: const material.InputDecoration( - border: material.InputBorder.none, - isDense: true, - contentPadding: material.EdgeInsets.zero, + fontFamilyFallback: QueryaTypography.cjkFontFamilyFallback, ), onSubmitted: (value) { _commit(value, moveNextRow: true); @@ -208,7 +208,7 @@ class _GridCellEditorState extends material.State { child: material.Icon( material.Icons.error_outline_rounded, size: 14, - color: material.Colors.red.shade500, + color: cs.destructive, ), ), ), diff --git a/lib/features/workspace/grid_cell_popover_inspector.dart b/lib/features/workspace/grid_cell_popover_inspector.dart index 6ff292b9..bf2981ae 100644 --- a/lib/features/workspace/grid_cell_popover_inspector.dart +++ b/lib/features/workspace/grid_cell_popover_inspector.dart @@ -1,5 +1,6 @@ import 'dart:async' show unawaited; import 'dart:convert'; +import 'package:flutter/foundation.dart' show compute, visibleForTesting; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; @@ -8,6 +9,42 @@ import 'package:querya_desktop/features/workspace/xml_html_formatter.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +/// Text of at least this many UTF-16 units (about 256 KB) is parsed, formatted +/// and minified on a worker isolate: `jsonDecode` and the XML formatter on a +/// multi-megabyte cell would hold the UI thread for whole frames. +@visibleForTesting +int inspectorIsolateThreshold = 256 * 1024; + +String _prettyJson(String text) => + const JsonEncoder.withIndent(' ').convert(jsonDecode(text)); + +String _compactJson(String text) => jsonEncode(jsonDecode(text)); + +String _prettyXml(String text) => XmlHtmlFormatter.format(text); + +bool _looksLikeJson(String trimmed) => + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')); + +bool _looksLikeXml(String trimmed) => + trimmed.startsWith('<') && trimmed.endsWith('>'); + +/// Whether [trimmed] is valid JSON or valid XML (top-level so [compute] can run it). +({bool json, bool xml}) _detectKind(String trimmed) { + if (_looksLikeJson(trimmed)) { + try { + jsonDecode(trimmed); + return (json: true, xml: false); + } catch (_) { + return (json: false, xml: false); + } + } + if (_looksLikeXml(trimmed)) { + return (json: false, xml: XmlHtmlFormatter.validate(trimmed) == null); + } + return (json: false, xml: false); +} + /// Opens a rich modal inspector for viewing and editing large text, JSON, XML, or BLOB values. /// /// **Apply** and **Ctrl/Cmd+Enter** return the edited string for local staging @@ -100,41 +137,48 @@ class _GridCellInspectorDialogState super.dispose(); } - void _formatJson() { - try { - final parsed = jsonDecode(_controller.text); - final pretty = const JsonEncoder.withIndent(' ').convert(parsed); - setState(() { - _isNull = false; - _controller.text = pretty; - }); - } catch (_) { - // Not valid JSON, keep as is - } - } + bool _busy = false; - void _minifyJson() { - try { - final parsed = jsonDecode(_controller.text); - final compact = jsonEncode(parsed); - setState(() { - _isNull = false; - _controller.text = compact; - }); - } catch (_) { - // Not valid JSON, keep as is + /// Replaces the text with `transform(text)`; a text that does not parse is + /// left as it is. Past [inspectorIsolateThreshold] the work runs on a worker + /// isolate, with the buttons held back meanwhile; an edit made while it ran + /// wins over the result. + Future _rewrite(String Function(String) transform) async { + if (_busy) return; + final text = _controller.text; + String result; + if (text.length < inspectorIsolateThreshold) { + try { + result = transform(text); + } catch (_) { + return; + } + } else { + setState(() => _busy = true); + try { + result = await compute(transform, text); + } catch (_) { + if (mounted) setState(() => _busy = false); + return; + } + if (!mounted) return; + _busy = false; + if (!identical(text, _controller.text) && text != _controller.text) { + setState(() {}); + return; + } } + setState(() { + _isNull = false; + _controller.text = result; + }); } - void _formatXml() { - try { - final pretty = XmlHtmlFormatter.format(_controller.text); - setState(() { - _isNull = false; - _controller.text = pretty; - }); - } catch (_) {} - } + void _formatJson() => unawaited(_rewrite(_prettyJson)); + + void _minifyJson() => unawaited(_rewrite(_compactJson)); + + void _formatXml() => unawaited(_rewrite(_prettyXml)); void _formatHex() { var raw = _controller.text.trim(); @@ -169,26 +213,65 @@ class _GridCellInspectorDialogState }); } - bool _isJson() { - final text = _controller.text.trim(); - if ((text.startsWith('{') && text.endsWith('}')) || - (text.startsWith('[') && text.endsWith(']'))) { - try { - jsonDecode(text); - return true; - } catch (_) { - return false; - } + // build() asks for both on every rebuild. Parsing a large cell each time + // janks, so the answer is kept for the text it was computed for (the + // controller hands out the same String until the text changes). Text past + // [inspectorIsolateThreshold] is checked on a worker isolate: the buttons + // appear when it answers. + String? _kindText; + var _kindIsJson = false; + var _kindIsXml = false; + var _detecting = false; + + void _refreshKind() { + final raw = _controller.text; + if (identical(raw, _kindText)) return; + _kindText = raw; + _kindIsJson = false; + _kindIsXml = false; + final text = raw.trim(); + if (!_looksLikeJson(text) && !_looksLikeXml(text)) return; + if (raw.length >= inspectorIsolateThreshold) { + _detectInBackground(); + return; } - return false; + final kind = _detectKind(text); + _kindIsJson = kind.json; + _kindIsXml = kind.xml; + } + + void _detectInBackground() { + if (_detecting) return; // the running check re-reads the latest text + final raw = _kindText; + if (raw == null) return; + _detecting = true; + unawaited(compute(_detectKind, raw.trim()).then((kind) { + _detecting = false; + if (!mounted) return; + if (identical(raw, _kindText)) { + if (kind.json != _kindIsJson || kind.xml != _kindIsXml) { + setState(() { + _kindIsJson = kind.json; + _kindIsXml = kind.xml; + }); + } + } else if (_kindText != null && + _kindText!.length >= inspectorIsolateThreshold) { + _detectInBackground(); // edited meanwhile: check the new text + } + }, onError: (Object _) { + _detecting = false; + })); + } + + bool _isJson() { + _refreshKind(); + return _kindIsJson; } bool _isXml() { - final text = _controller.text.trim(); - if (text.startsWith('<') && text.endsWith('>')) { - return XmlHtmlFormatter.validate(text) == null; - } - return false; + _refreshKind(); + return _kindIsXml; } bool _isHex() { @@ -238,7 +321,7 @@ class _GridCellInspectorDialogState @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final rowLabel = widget.rowIndex != null ? ' (Row ${widget.rowIndex! + 1})' : ''; final text = _isNull ? '' : _controller.text; @@ -299,14 +382,15 @@ class _GridCellInspectorDialogState QueryaButton.ghost( label: 'Format JSON', icon: material.Icons.format_align_left_rounded, - onPressed: _formatJson, + loading: _busy, + onPressed: _busy ? null : _formatJson, size: QueryaControlSize.sm, ), const Gap(6), QueryaButton.ghost( label: 'Minify', icon: material.Icons.compress_rounded, - onPressed: _minifyJson, + onPressed: _busy ? null : _minifyJson, size: QueryaControlSize.sm, ), const Gap(6), @@ -315,7 +399,8 @@ class _GridCellInspectorDialogState QueryaButton.ghost( label: 'Format XML', icon: material.Icons.code_rounded, - onPressed: _formatXml, + loading: _busy, + onPressed: _busy ? null : _formatXml, size: QueryaControlSize.sm, ), const Gap(6), diff --git a/lib/features/workspace/grid_filter_engine.dart b/lib/features/workspace/grid_filter_engine.dart index 7f2794e7..68ef7a00 100644 --- a/lib/features/workspace/grid_filter_engine.dart +++ b/lib/features/workspace/grid_filter_engine.dart @@ -109,14 +109,22 @@ abstract final class GridFilterEngine { return List.generate(rows.length, (i) => i); } + // Each cell is lower-cased at most once per row, not once per term. + var widest = 0; + for (final row in rows) { + if (row.length > widest) widest = row.length; + } + final lowered = List.filled(widest, null); + final result = []; for (var r = 0; r < rows.length; r++) { final row = rows[r]; + lowered.fillRange(0, row.length, null); var matchAll = true; for (final term in terms) { var termMatch = false; for (var c = 0; c < row.length; c++) { - if (row[c].toLowerCase().contains(term)) { + if ((lowered[c] ??= row[c].toLowerCase()).contains(term)) { termMatch = true; break; } @@ -175,8 +183,27 @@ class _NotNode extends _FilterAstNode { } } +/// What a predicate does, decided once from its operator text. +enum _PredicateKind { + isNull, + isNotNull, + inList, + notInList, + between, + like, + notLike, + ilike, + notIlike, + compare, +} + +enum _Comparison { eq, ne, gt, ge, lt, le, contains } + class _PredicateNode extends _FilterAstNode { - const _PredicateNode({ + /// Everything that depends only on the query text is worked out here, once: + /// the operator, the lower-cased target, its number, the normalised `IN` set + /// and the `BETWEEN` bounds. [evaluate] runs once per row (#1550). + _PredicateNode({ required this.colIndex, required this.op, required this.targetValue, @@ -184,7 +211,13 @@ class _PredicateNode extends _FilterAstNode { this.betweenMin, this.betweenMax, this.likeRegex, - }); + }) : _kind = _kindOf(op.toUpperCase().trim()), + _cmp = _cmpOf(op), + _lowerTarget = targetValue.toLowerCase(), + _numTarget = double.tryParse(targetValue.trim()), + _inSet = {for (final v in inValues) v.toLowerCase().trim()}, + _numMin = betweenMin == null ? null : double.tryParse(betweenMin.trim()), + _numMax = betweenMax == null ? null : double.tryParse(betweenMax.trim()); final int colIndex; final String op; @@ -197,116 +230,132 @@ class _PredicateNode extends _FilterAstNode { /// per-row scan does not rebuild the same RegExp for every row. final RegExp? likeRegex; + final _PredicateKind _kind; + final _Comparison _cmp; + final String _lowerTarget; + final double? _numTarget; + final Set _inSet; + final double? _numMin; + final double? _numMax; + @override bool evaluate(List row, List lowerColumns) { if (colIndex < 0 || colIndex >= row.length) return false; final cellValue = row[colIndex]; - final isNull = cellValue == 'NULL' || cellValue == 'null' || cellValue.isEmpty; - final upperOp = op.toUpperCase().trim(); - - // IS NULL / IS NOT NULL - if (upperOp == 'IS NULL') { - return isNull; - } - if (upperOp == 'IS NOT NULL') { - return !isNull; - } - - // IN / NOT IN - if (upperOp == 'IN') { - final lowerCell = cellValue.toLowerCase().trim(); - return inValues.any((v) => v.toLowerCase().trim() == lowerCell); - } - if (upperOp == 'NOT IN') { - final lowerCell = cellValue.toLowerCase().trim(); - return !inValues.any((v) => v.toLowerCase().trim() == lowerCell); - } - // BETWEEN x AND y - if (upperOp == 'BETWEEN' && betweenMin != null && betweenMax != null) { + switch (_kind) { + case _PredicateKind.isNull: + return _isNull(cellValue); + case _PredicateKind.isNotNull: + return !_isNull(cellValue); + case _PredicateKind.inList: + return _inSet.contains(cellValue.toLowerCase().trim()); + case _PredicateKind.notInList: + return !_inSet.contains(cellValue.toLowerCase().trim()); + case _PredicateKind.between: + final min = betweenMin; + final max = betweenMax; + if (min != null && max != null) { + final numCell = double.tryParse(cellValue.trim()); + final numMin = _numMin; + final numMax = _numMax; + if (numCell != null && numMin != null && numMax != null) { + return numCell >= numMin && numCell <= numMax; + } + return cellValue.compareTo(min) >= 0 && cellValue.compareTo(max) <= 0; + } + case _PredicateKind.like: + return (likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true)) + .hasMatch(cellValue); + case _PredicateKind.notLike: + return !(likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true)) + .hasMatch(cellValue); + case _PredicateKind.ilike: + return (likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false)) + .hasMatch(cellValue); + case _PredicateKind.notIlike: + return !(likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false)) + .hasMatch(cellValue); + case _PredicateKind.compare: + break; + } + + // Numeric comparison if both values can be parsed as numbers. The cell is + // only parsed when the target is a number: a text target never needs it. + final numTarget = _numTarget; + if (numTarget != null) { final numCell = double.tryParse(cellValue.trim()); - final numMin = double.tryParse(betweenMin!.trim()); - final numMax = double.tryParse(betweenMax!.trim()); - if (numCell != null && numMin != null && numMax != null) { - return numCell >= numMin && numCell <= numMax; - } - return cellValue.compareTo(betweenMin!) >= 0 && cellValue.compareTo(betweenMax!) <= 0; - } - - // LIKE / NOT LIKE - if (upperOp == 'LIKE') { - final regex = - likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true); - return regex.hasMatch(cellValue); - } - if (upperOp == 'NOT LIKE') { - final regex = - likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true); - return !regex.hasMatch(cellValue); - } - - // ILIKE / NOT ILIKE - if (upperOp == 'ILIKE') { - final regex = - likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false); - return regex.hasMatch(cellValue); - } - if (upperOp == 'NOT ILIKE') { - final regex = - likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false); - return !regex.hasMatch(cellValue); - } - - final lowerCell = cellValue.toLowerCase(); - final lowerTarget = targetValue.toLowerCase(); - - // Numeric comparison if both values can be parsed as numbers - final numCell = double.tryParse(cellValue.trim()); - final numTarget = double.tryParse(targetValue.trim()); - - if (numCell != null && numTarget != null) { - switch (op) { - case '=': - case '==': - case ':': - return (numCell - numTarget).abs() < 1e-9; - case '!=': - case '<>': - return (numCell - numTarget).abs() >= 1e-9; - case '>': - return numCell > numTarget; - case '>=': - return numCell >= numTarget; - case '<': - return numCell < numTarget; - case '<=': - return numCell <= numTarget; + if (numCell != null) { + switch (_cmp) { + case _Comparison.eq: + case _Comparison.contains: + // `:` is equality between two numbers. + if (_cmp == _Comparison.eq || op == ':') { + return (numCell - numTarget).abs() < 1e-9; + } + case _Comparison.ne: + return (numCell - numTarget).abs() >= 1e-9; + case _Comparison.gt: + return numCell > numTarget; + case _Comparison.ge: + return numCell >= numTarget; + case _Comparison.lt: + return numCell < numTarget; + case _Comparison.le: + return numCell <= numTarget; + } } } // String / Lexicographic comparison - switch (op) { - case '=': - case '==': + final lowerCell = cellValue.toLowerCase(); + final lowerTarget = _lowerTarget; + switch (_cmp) { + case _Comparison.eq: return lowerCell == lowerTarget; - case ':': + case _Comparison.contains: return lowerCell.contains(lowerTarget); - case '!=': - case '<>': + case _Comparison.ne: return lowerCell != lowerTarget; - case '>': + case _Comparison.gt: return lowerCell.compareTo(lowerTarget) > 0; - case '>=': + case _Comparison.ge: return lowerCell.compareTo(lowerTarget) >= 0; - case '<': + case _Comparison.lt: return lowerCell.compareTo(lowerTarget) < 0; - case '<=': + case _Comparison.le: return lowerCell.compareTo(lowerTarget) <= 0; - default: - return lowerCell.contains(lowerTarget); } } + static _PredicateKind _kindOf(String upperOp) => switch (upperOp) { + 'IS NULL' => _PredicateKind.isNull, + 'IS NOT NULL' => _PredicateKind.isNotNull, + 'IN' => _PredicateKind.inList, + 'NOT IN' => _PredicateKind.notInList, + 'BETWEEN' => _PredicateKind.between, + 'LIKE' => _PredicateKind.like, + 'NOT LIKE' => _PredicateKind.notLike, + 'ILIKE' => _PredicateKind.ilike, + 'NOT ILIKE' => _PredicateKind.notIlike, + _ => _PredicateKind.compare, + }; + + /// `:` and any operator this engine does not know compare as "contains" on + /// text; `:` alone is also numeric equality. + static _Comparison _cmpOf(String op) => switch (op) { + '=' || '==' => _Comparison.eq, + '!=' || '<>' => _Comparison.ne, + '>' => _Comparison.gt, + '>=' => _Comparison.ge, + '<' => _Comparison.lt, + '<=' => _Comparison.le, + _ => _Comparison.contains, + }; + + static bool _isNull(String cellValue) => + cellValue == 'NULL' || cellValue == 'null' || cellValue.isEmpty; + static RegExp _likeToRegExp(String pattern, {required bool caseSensitive}) { final buffer = StringBuffer('^'); for (var i = 0; i < pattern.length; i++) { @@ -325,12 +374,13 @@ class _PredicateNode extends _FilterAstNode { } class _FreeTextNode extends _FilterAstNode { - const _FreeTextNode(this.term); + _FreeTextNode(this.term) : _lowerTerm = term.toLowerCase(); final String term; + final String _lowerTerm; @override bool evaluate(List row, List lowerColumns) { - final lowerTerm = term.toLowerCase(); + final lowerTerm = _lowerTerm; for (var c = 0; c < row.length; c++) { if (row[c].toLowerCase().contains(lowerTerm)) { return true; @@ -362,6 +412,21 @@ class _FilterToken { } abstract final class _FilterLexer { + // Compiled once. They have no `^`: they are tried with `matchAsPrefix` at an + // offset, so the rest of the input is never copied (#1527, #1512). + static final _andRe = RegExp(r'AND\b|&&', caseSensitive: false); + static final _orRe = RegExp(r'OR\b|\|\|', caseSensitive: false); + static final _notRe = RegExp(r'NOT\b|!', caseSensitive: false); + static final _isNullRe = + RegExp(r'IS\s+(NOT\s+)?NULL\b', caseSensitive: false); + static final _inRe = RegExp(r'(NOT\s+)?IN\s*\(', caseSensitive: false); + static final _betweenRe = RegExp(r'BETWEEN\s+', caseSensitive: false); + static final _andKeywordRe = RegExp(r'AND\s+', caseSensitive: false); + static final _likeRe = + RegExp(r'(NOT\s+)?(ILIKE|LIKE)\s+', caseSensitive: false); + static final _comparisonRe = RegExp(r'>=|<=|!=|<>|==|=|>|<|:'); + static final _identifierRe = RegExp(r'[a-zA-Z_]\w*'); + static List<_FilterToken> tokenize(String input, List lowerColumns) { final tokens = <_FilterToken>[]; var i = 0; @@ -386,25 +451,21 @@ abstract final class _FilterLexer { } // Check logical operators: AND, &&, OR, ||, NOT, ! - final rem = input.substring(i); - final andMatch = - RegExp(r'^(AND\b|&&)', caseSensitive: false).firstMatch(rem); + final andMatch = _andRe.matchAsPrefix(input, i); if (andMatch != null) { tokens.add(const _FilterToken(_TokenType.and, value: 'AND')); i += andMatch.group(0)!.length; continue; } - final orMatch = - RegExp(r'^(OR\b|\|\|)', caseSensitive: false).firstMatch(rem); + final orMatch = _orRe.matchAsPrefix(input, i); if (orMatch != null) { tokens.add(const _FilterToken(_TokenType.or, value: 'OR')); i += orMatch.group(0)!.length; continue; } - final notMatch = - RegExp(r'^(NOT\b|!)', caseSensitive: false).firstMatch(rem); + final notMatch = _notRe.matchAsPrefix(input, i); if (notMatch != null) { tokens.add(const _FilterToken(_TokenType.not, value: 'NOT')); i += notMatch.group(0)!.length; @@ -455,12 +516,10 @@ abstract final class _FilterLexer { final hadSpace = p > spaceStart; final canMatchKeyword = hadSpace || isQuotedCol; - final rem = input.substring(p); // 1. IS NULL / IS NOT NULL if (canMatchKeyword) { - final isNullMatch = - RegExp(r'^IS\s+(NOT\s+)?NULL\b', caseSensitive: false).firstMatch(rem); + final isNullMatch = _isNullRe.matchAsPrefix(input, p); if (isNullMatch != null) { final isNot = isNullMatch.group(1) != null; return ( @@ -476,8 +535,7 @@ abstract final class _FilterLexer { // 2. IN / NOT IN if (canMatchKeyword) { - final inMatch = - RegExp(r'^(NOT\s+)?IN\s*\(', caseSensitive: false).firstMatch(rem); + final inMatch = _inRe.matchAsPrefix(input, p); if (inMatch != null) { final isNot = inMatch.group(1) != null; var inPos = p + inMatch.group(0)!.length; @@ -512,8 +570,7 @@ abstract final class _FilterLexer { // 3. BETWEEN x AND y if (canMatchKeyword) { - final betweenMatch = - RegExp(r'^BETWEEN\s+', caseSensitive: false).firstMatch(rem); + final betweenMatch = _betweenRe.matchAsPrefix(input, p); if (betweenMatch != null) { var bPos = p + betweenMatch.group(0)!.length; final minVal = _parseValue(input, bPos); @@ -523,9 +580,7 @@ abstract final class _FilterLexer { while (andPos < input.length && input[andPos].trim().isEmpty) { andPos++; } - final andRem = input.substring(andPos); - final andMatch = - RegExp(r'^AND\s+', caseSensitive: false).firstMatch(andRem); + final andMatch = _andKeywordRe.matchAsPrefix(input, andPos); if (andMatch != null) { var maxPos = andPos + andMatch.group(0)!.length; final maxVal = _parseValue(input, maxPos); @@ -549,9 +604,7 @@ abstract final class _FilterLexer { // 4. LIKE / ILIKE / NOT LIKE / NOT ILIKE if (canMatchKeyword) { - final likeMatch = - RegExp(r'^(NOT\s+)?(ILIKE|LIKE)\s+', caseSensitive: false) - .firstMatch(rem); + final likeMatch = _likeRe.matchAsPrefix(input, p); if (likeMatch != null) { final isNot = likeMatch.group(1) != null; final likeType = likeMatch.group(2)!.toUpperCase(); @@ -576,10 +629,9 @@ abstract final class _FilterLexer { } // 5. Comparison operators: >=, <=, !=, <>, ==, =, >, <, : - final opMatch = - RegExp(r'^(>=|<=|!=|<>|==|=|>|<|:)').firstMatch(rem); + final opMatch = _comparisonRe.matchAsPrefix(input, p); if (opMatch != null) { - final op = opMatch.group(1)!; + final op = opMatch.group(0)!; final oPos = p + opMatch.group(0)!.length; final valParsed = _parseValue(input, oPos); if (valParsed != null) { @@ -638,8 +690,7 @@ abstract final class _FilterLexer { } // Unquoted column: [a-zA-Z_]\w* - final rem = input.substring(i); - final match = RegExp(r'^[a-zA-Z_]\w*').firstMatch(rem); + final match = _identifierRe.matchAsPrefix(input, i); if (match != null) { final name = match.group(0)!; final colIdx = lowerColumns.indexOf(name.toLowerCase()); @@ -773,7 +824,7 @@ class _FilterParser { return _FreeTextNode(token.value); } - return const _FreeTextNode(''); + return _FreeTextNode(''); } bool _match(_TokenType type) { diff --git a/lib/features/workspace/grid_groupings_engine.dart b/lib/features/workspace/grid_groupings_engine.dart index 215689ea..a3364366 100644 --- a/lib/features/workspace/grid_groupings_engine.dart +++ b/lib/features/workspace/grid_groupings_engine.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart'; /// Aggregation operation to perform on groups. enum GroupingAggType { @@ -194,8 +195,15 @@ abstract final class GridGroupingsEngine { for (final row in rows) { if (targetCol < row.length) { - final val = row[targetCol].replaceAll(',', '').trim(); - final parsed = double.tryParse(val); + // `trim()` hands back the cell itself when there is nothing to trim, + // and the comma-aware parse does not copy it either. Anything it + // cannot read takes the old copy, so the result is the same as + // `double.tryParse(cell.replaceAll(',', '').trim())`. + final cell = row[targetCol].trim(); + final parsed = cell.contains(',') + ? GridSelectionCalcEngine.parseThousandsSeparated(cell) ?? + double.tryParse(cell.replaceAll(',', '').trim()) + : double.tryParse(cell); if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { numbers.add(parsed); } diff --git a/lib/features/workspace/grid_selection_calc_engine.dart b/lib/features/workspace/grid_selection_calc_engine.dart index b56d1291..6552a432 100644 --- a/lib/features/workspace/grid_selection_calc_engine.dart +++ b/lib/features/workspace/grid_selection_calc_engine.dart @@ -131,9 +131,12 @@ abstract final class GridSelectionCalcEngine { distinctSet.add(trimmed); - // Try parsing numeric values (stripping commas if present) - final normalized = trimmed.replaceAll(',', ''); - final parsed = double.tryParse(normalized); + // `trim()` returns the cell itself when there is nothing to trim, and + // neither parse below copies it: a drag over thousands of cells + // allocates no strings for the numbers (#1520). + final parsed = trimmed.contains(',') + ? parseThousandsSeparated(trimmed) + : double.tryParse(trimmed); if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { numericList.add(parsed); sum += parsed; @@ -252,6 +255,90 @@ abstract final class GridSelectionCalcEngine { list[j] = temp; } + static const List _pow10 = [ + 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, // + 1e12, 1e13, 1e14, 1e15, 1e16, 1e17, 1e18, 1e19, 1e20, 1e21, 1e22, + ]; + + /// [s] read as a number whose commas are dropped (`1,234.5`), as + /// `double.tryParse(s.replaceAll(',', ''))` reads it, without the copy. + /// + /// Up to 15 significant digits and a power of ten up to 22 the value is + /// exact: an integer below 2^53 times or divided by an exactly represented + /// power of ten is one correctly rounded operation. Longer numbers, which + /// do not fit that, take the copy and `double.tryParse`. + static double? parseThousandsSeparated(String s) { + final n = s.length; + var i = 0; + // Commas count for nothing anywhere, as in the copy they are dropped from. + void skipCommas() { + while (i < n && s.codeUnitAt(i) == 0x2C) { + i++; + } + } + + skipCommas(); + var negative = false; + if (i < n && (s.codeUnitAt(i) == 0x2B || s.codeUnitAt(i) == 0x2D)) { + negative = s.codeUnitAt(i) == 0x2D; + i++; + } + var mantissa = 0; + var significant = 0; + var scale = 0; // decimal exponent of the mantissa's last digit + var digits = 0; + var inFraction = false; + for (; i < n; i++) { + final c = s.codeUnitAt(i); + if (c == 0x2C) continue; // , + if (c == 0x2E) { + if (inFraction) return null; + inFraction = true; + continue; + } + if (c < 0x30 || c > 0x39) break; + digits++; + if (significant == 0 && c == 0x30) { + if (inFraction) scale--; + continue; // a leading zero adds no precision + } + significant++; + if (significant > 15) return _parseCopy(s); + mantissa = mantissa * 10 + (c - 0x30); + if (inFraction) scale--; + } + if (digits == 0) return null; + if (i < n) { + final e = s.codeUnitAt(i); + if (e != 0x45 && e != 0x65) return null; // e / E + i++; + skipCommas(); + var expNegative = false; + if (i < n && (s.codeUnitAt(i) == 0x2B || s.codeUnitAt(i) == 0x2D)) { + expNegative = s.codeUnitAt(i) == 0x2D; + i++; + } + var expDigits = 0; + var exp = 0; + for (; i < n; i++) { + final c = s.codeUnitAt(i); + if (c == 0x2C) continue; + if (c < 0x30 || c > 0x39) return null; + expDigits++; + exp = exp * 10 + (c - 0x30); + if (exp > 400) return _parseCopy(s); + } + if (expDigits == 0) return null; + scale += expNegative ? -exp : exp; + } + if (scale.abs() > 22) return _parseCopy(s); + final m = mantissa.toDouble(); + final value = scale >= 0 ? m * _pow10[scale] : m / _pow10[-scale]; + return negative ? -value : value; + } + + static double? _parseCopy(String s) => double.tryParse(s.replaceAll(',', '')); + /// Formats a numeric stat cleanly for UI display. static String formatNum(double? val) { if (val == null) return '-'; @@ -260,6 +347,13 @@ abstract final class GridSelectionCalcEngine { } // Limit decimal precision to 4 decimal places final formatted = val.toStringAsFixed(4); - return formatted.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), ''); + // Drop the trailing zeros, then one trailing dot (`3.1400` -> `3.14`, + // `10.0000` -> `10`): an index walk, no regex (#1531). + var end = formatted.length; + while (end > 0 && formatted.codeUnitAt(end - 1) == 0x30) { + end--; + } + if (end > 0 && formatted.codeUnitAt(end - 1) == 0x2E) end--; + return end == formatted.length ? formatted : formatted.substring(0, end); } } diff --git a/lib/features/workspace/plan_tree_view.dart b/lib/features/workspace/plan_tree_view.dart index 0a35525b..b61434bf 100644 --- a/lib/features/workspace/plan_tree_view.dart +++ b/lib/features/workspace/plan_tree_view.dart @@ -97,7 +97,7 @@ class _PlanRow extends material.StatelessWidget { color: hottest ? wb.accent.withValues(alpha: 0.12) : null, border: material.Border.all( color: selected - ? Theme.of(context).colorScheme.foreground + ? context.colors.foreground : hottest ? wb.accent : wb.borderSubtle, diff --git a/lib/features/workspace/query_editor_tab.dart b/lib/features/workspace/query_editor_tab.dart index 83f1674b..60716ef9 100644 --- a/lib/features/workspace/query_editor_tab.dart +++ b/lib/features/workspace/query_editor_tab.dart @@ -1,9 +1,9 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart' as material show EdgeInsets, Padding, TextEditingController; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; class QueryEditorTab extends StatelessWidget { const QueryEditorTab({ diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 9d385f71..6cd3a283 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -11,11 +11,13 @@ import 'package:flutter/gestures.dart' import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show Clipboard, ClipboardData, HardwareKeyboard, LogicalKeyboardKey; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/security/pii_masker.dart'; import 'package:querya_desktop/core/security/pii_masking_controller.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/core/ui/querya_tooltip.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/grid_cell_editor.dart'; @@ -23,7 +25,10 @@ import 'package:querya_desktop/features/workspace/grid_cell_popover_inspector.da import 'package:querya_desktop/features/workspace/grid/grid_column_sizer.dart'; import 'package:querya_desktop/features/workspace/grid/grid_selection_manager.dart'; import 'package:querya_desktop/features/workspace/grid/grid_sorting.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:gap/gap.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; +import 'package:querya_desktop/shared/widgets/querya_context_menu.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; export 'package:querya_desktop/features/workspace/grid/grid_column_sizer.dart'; export 'package:querya_desktop/features/workspace/grid/grid_selection_manager.dart'; @@ -159,12 +164,20 @@ class _VirtualResultGridState extends material.State { ResultGridColumnWindow? _currentWindow; double _currentRowsViewportHeight = 0.0; + /// Handlers that live in `extension _Grid* on _VirtualResultGridState`. Tearing + /// off an extension method makes a new closure each time and two of them are + /// never equal, so `removeListener(_onPiiMaskingChanged)` would not find what + /// `addListener(_onPiiMaskingChanged)` stored (#1555). Keep one closure and + /// pass that to both. + late final void Function() _piiMaskingListener = _onPiiMaskingChanged; + late final void Function() _horizontalScrollListener = _onHorizontalScroll; + @override void initState() { super.initState(); - _horizontalController.addListener(_onHorizontalScroll); + _horizontalController.addListener(_horizontalScrollListener); _verticalController.addListener(_onVerticalScroll); - PiiMaskingController.instance.addListener(_onPiiMaskingChanged); + PiiMaskingController.instance.addListener(_piiMaskingListener); widget.stagingBuffer?.addListener(_onStagingBufferChanged); _lastKnownStagedRowCount = _baseRows.length; _updateSortedRows(); @@ -319,8 +332,7 @@ class _VirtualResultGridState extends material.State { final overlay = material.Overlay.maybeOf(context); if (overlay == null || !mounted) return; - final theme = Theme.of(context); - final cs = theme.colorScheme; + final cs = context.colors; final media = material.MediaQuery.maybeOf(context); final screenW = media?.size.width ?? 1920.0; final screenH = media?.size.height ?? 1080.0; @@ -343,7 +355,7 @@ class _VirtualResultGridState extends material.State { ), decoration: material.BoxDecoration( color: cs.popover, - borderRadius: BorderRadius.circular(theme.radiusSm), + borderRadius: BorderRadius.circular(5.0), border: material.Border.all( color: cs.border, width: 0.8, @@ -389,11 +401,11 @@ class _VirtualResultGridState extends material.State { void dispose() { _hideCellTooltip(); _selectionValuesDebounce?.cancel(); - PiiMaskingController.instance.removeListener(_onPiiMaskingChanged); + PiiMaskingController.instance.removeListener(_piiMaskingListener); _stopAutoScroll(); _rowWidgets.clear(); widget.stagingBuffer?.removeListener(_onStagingBufferChanged); - _horizontalController.removeListener(_onHorizontalScroll); + _horizontalController.removeListener(_horizontalScrollListener); _horizontalController.dispose(); _verticalController.removeListener(_onVerticalScroll); _verticalController.dispose(); diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 57be122c..f1af2c61 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/security/pii_masking_controller.dart'; import 'package:querya_desktop/core/actions/data_grid_command_bridge.dart'; -import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; import 'package:querya_desktop/features/results/charts/quick_chart_view.dart'; import 'package:querya_desktop/features/workspace/data_grid_calc_bar.dart'; import 'package:querya_desktop/features/workspace/data_grid_filter_bar.dart'; @@ -16,6 +16,7 @@ import 'package:querya_desktop/features/workspace/grid_filter_engine.dart'; import 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; +import 'package:querya_desktop/shared/widgets/query_error_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -84,6 +85,8 @@ class ResultsTab extends material.StatefulWidget { } class _ResultsTabState extends material.State { + /// The driver text of a query error is folded away until asked for (#1557). + /// Selection / focus state lives in notifiers so a navigation step rebuilds /// only the widgets that show it (toolbar, value panel, calc bar), not the /// whole tab and its grid. @@ -379,6 +382,27 @@ class _ResultsTabState extends material.State { super.dispose(); } + /// What the mode bar says about the rows, or null when there is nothing to + /// add. A plain row count is the status bar's (#1558); here stay the notes + /// it does not have: a truncated result, affected rows, a script's outcome, + /// and the count of a filtered grid (`3 of 12 rows`). + String? _barSummary(int shown, int total) { + final line = widget.statusLine; + if (_filterText.isNotEmpty) { + return line != null && !_plainRowCount.hasMatch(line) + ? '$line · $shown of $total rows' + : '$shown of $total rows'; + } + if (line != null) return _plainRowCount.hasMatch(line) ? null : line; + if (widget.affectedRows != null) { + return 'Rows affected: ${widget.affectedRows}'; + } + return '$shown rows'; + } + + /// `12 row(s).`: the driver workspaces' plain count. + static final _plainRowCount = RegExp(r'^\d+ row\(s\)\.$'); + @override material.Widget build(material.BuildContext context) { if (widget.isLoading) { @@ -396,34 +420,9 @@ class _ResultsTabState extends material.State { key: const material.ValueKey('results_mode_error'), child: material.Padding( padding: const material.EdgeInsets.all(32), - child: material.Column( - mainAxisAlignment: material.MainAxisAlignment.center, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon( - material.Icons.error_outline_rounded, - size: 48, - color: Theme.of(context).colorScheme.destructive, - ), - const Gap(16), - const Text('Query Error').large().semiBold(), - const Gap(8), - // A long driver message scrolls inside the space left, instead - // of pushing the column past the pane. - material.Flexible( - child: VirtualSelectableTextView( - text: widget.errorMessage!, - style: material.TextStyle( - color: Theme.of(context).colorScheme.mutedForeground, - fontSize: 13, - ), - ), - ), - if (widget.errorAction != null) ...[ - const Gap(16), - widget.errorAction!, - ], - ], + child: QueryErrorView( + error: QueryErrorText.parse(widget.errorMessage!), + action: widget.errorAction, ), ), ); @@ -513,12 +512,10 @@ class _ResultsTabState extends material.State { vertical: 4, ), decoration: material.BoxDecoration( - color: Theme.of(context).colorScheme.card, + color: context.colors.card, border: material.Border( bottom: material.BorderSide( - color: Theme.of(context) - .colorScheme - .border + color: context.colors.border .withValues(alpha: 0.35), ), ), @@ -541,12 +538,9 @@ class _ResultsTabState extends material.State { setState(() => _viewMode = ResultViewMode.values[i]), ), const Gap(10), - Text( - widget.statusLine ?? - (widget.affectedRows != null - ? 'Rows affected: ${widget.affectedRows}' - : '${filteredRows.length}${_filterText.isNotEmpty ? ' of ${effectiveRows.length}' : ''} rows'), - ).small().semiBold(), + if (_barSummary(filteredRows.length, effectiveRows.length) + case final summary?) + Text(summary).small().semiBold(), if (widget.elapsed != null) ...[ const Gap(8), QueryaBadge( diff --git a/lib/features/workspace/sql_editor_chrome.dart b/lib/features/workspace/sql_editor_chrome.dart index 4ca45a36..f7d56e24 100644 --- a/lib/features/workspace/sql_editor_chrome.dart +++ b/lib/features/workspace/sql_editor_chrome.dart @@ -2,21 +2,30 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; -/// Outer chrome for SQL editors: border, surface, brand accent glow. -class SqlEditorChrome extends StatelessWidget { - const SqlEditorChrome({super.key, required this.child}); +/// Outer chrome for SQL editors and the results under them: one rounded card +/// with a border. +class SqlEditorChrome extends StatefulWidget { + const SqlEditorChrome({ + super.key, + required this.child, + this.background, + this.showFocus = true, + }); final Widget child; + /// The card's surface; the editor background by default. + final material.Color? background; + + /// Whether the border takes the accent while something inside has the + /// keyboard. Off for a card with nothing to type in (the results). + final bool showFocus; + static const double outerRadius = 14; static const double innerRadius = 10; - /// Accent glow strength; slightly softer on light themes. - static double chromeGlowAlpha(Brightness brightness) => - brightness == Brightness.light ? 0.08 : 0.1; - static double inlineGlowAlpha(Brightness brightness) => brightness == Brightness.light ? 0.05 : 0.07; @@ -26,7 +35,7 @@ class SqlEditorChrome extends StatelessWidget { ) { final workbench = context.workbench; return material.BoxDecoration( - color: workbench.surface.withValues(alpha: 0.85), + color: workbench.surface, border: material.Border( bottom: material.BorderSide( color: workbench.borderSubtle.withValues(alpha: 0.35), @@ -66,42 +75,44 @@ class SqlEditorChrome extends StatelessWidget { return inlineFieldDecoration( context.editorTheme, context.workbench, - brightness: Theme.of(context).brightness, + brightness: context.colors.brightness, ); } + @override + State createState() => _SqlEditorChromeState(); +} + +class _SqlEditorChromeState extends State { + bool _focused = false; + @override Widget build(BuildContext context) { final editor = context.editorTheme; final workbench = context.workbench; - final brightness = Theme.of(context).brightness; final border = editor.widgetBorder ?? workbench.borderSubtle; - final glow = workbench.accent.withValues( - alpha: chromeGlowAlpha(brightness), - ); + final surface = widget.background ?? editor.background; - return material.Container( - decoration: material.BoxDecoration( - borderRadius: material.BorderRadius.circular(outerRadius), - boxShadow: [ - material.BoxShadow( - color: glow, - blurRadius: 28, - spreadRadius: 0, - offset: const material.Offset(0, 10), - ), - ], - ), + // One framed card, the same for the editor and the results. No glow: the + // only accent is the border while the editor has the keyboard (#1558). + return Focus( + canRequestFocus: false, + skipTraversal: true, + onFocusChange: (v) { + if (widget.showFocus) setState(() => _focused = v); + }, child: material.Container( decoration: material.BoxDecoration( - color: editor.background, - borderRadius: material.BorderRadius.circular(outerRadius), + color: surface, + borderRadius: material.BorderRadius.circular(SqlEditorChrome.outerRadius), border: material.Border.all( - color: border.withValues(alpha: 0.5), + color: widget.showFocus && _focused + ? workbench.accent.withValues(alpha: 0.6) + : border.withValues(alpha: 0.5), ), ), clipBehavior: material.Clip.antiAlias, - child: child, + child: widget.child, ), ); } diff --git a/lib/features/workspace/sql_query_history_dialog.dart b/lib/features/workspace/sql_query_history_dialog.dart index 69ec52b3..460a0a00 100644 --- a/lib/features/workspace/sql_query_history_dialog.dart +++ b/lib/features/workspace/sql_query_history_dialog.dart @@ -85,13 +85,7 @@ class _SqlQueryHistoryDialogContentState }); } - static final _whitespacePattern = RegExp(r'\s+'); - - static String _previewOneLine(String sql) { - final collapsed = sql.replaceAll(_whitespacePattern, ' ').trim(); - if (collapsed.length <= 96) return collapsed; - return '${collapsed.substring(0, 93)}…'; - } + static String _previewOneLine(String sql) => previewSqlOneLine(sql); static String? _formatWhen(String iso) { final t = DateTime.tryParse(iso)?.toLocal(); @@ -256,7 +250,7 @@ class _SqlQueryHistoryDialogContentState @override material.Widget build(material.BuildContext context) { - final scheme = Theme.of(context).colorScheme; + final scheme = context.colors; return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, @@ -405,3 +399,52 @@ class _SqlQueryHistoryDialogContentState ); } } + +/// Longest one-line preview kept whole; longer text is cut to +/// [_previewKeep] characters plus an ellipsis. +const int _previewMax = 96; +const int _previewKeep = 93; + +/// Same set as the regex `\s` (what the old `replaceAll(RegExp(r'\s+'), ' ')` +/// collapsed). +bool _isPreviewWhitespace(int c) => + (c >= 0x09 && c <= 0x0D) || + c == 0x20 || + c == 0xA0 || + c == 0x1680 || + (c >= 0x2000 && c <= 0x200A) || + c == 0x2028 || + c == 0x2029 || + c == 0x202F || + c == 0x205F || + c == 0x3000 || + c == 0xFEFF; + +/// One-line, whitespace-collapsed, trimmed preview of [sql] for a list row. +/// +/// Reads only as far as it needs: a 100 KB migration is cut after about a +/// hundred characters instead of being copied whole by a regex replace (#1545). +@visibleForTesting +String previewSqlOneLine(String sql) { + final out = StringBuffer(); + var length = 0; + var pendingSpace = false; + for (var i = 0; i < sql.length; i++) { + if (_isPreviewWhitespace(sql.codeUnitAt(i))) { + pendingSpace = length > 0; + continue; + } + if (pendingSpace) { + out.writeCharCode(0x20); + length++; + pendingSpace = false; + if (length > _previewMax) break; + } + out.writeCharCode(sql.codeUnitAt(i)); + length++; + if (length > _previewMax) break; + } + final text = out.toString(); + if (length <= _previewMax) return text; + return '${text.substring(0, _previewKeep)}…'; +} diff --git a/lib/features/workspace/sql_query_tab_bar.dart b/lib/features/workspace/sql_query_tab_bar.dart index a77e5dd7..f40479cd 100644 --- a/lib/features/workspace/sql_query_tab_bar.dart +++ b/lib/features/workspace/sql_query_tab_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; import 'package:querya_desktop/features/workspace/sql_query_tab_session.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -26,18 +27,11 @@ class SqlQueryTabBar extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; - + // The same strip as the toolbar under it: workbench surface, hairline. return material.Container( height: 40, padding: const material.EdgeInsets.symmetric(horizontal: 8), - decoration: material.BoxDecoration( - color: cs.card, - border: material.Border( - bottom: material.BorderSide(color: cs.border), - ), - ), + decoration: SqlEditorChrome.sqlToolbarDecoration(context), child: material.Row( children: [ material.Expanded( diff --git a/lib/features/workspace/sql_query_tab_session.dart b/lib/features/workspace/sql_query_tab_session.dart index 62f94278..08c9f9b8 100644 --- a/lib/features/workspace/sql_query_tab_session.dart +++ b/lib/features/workspace/sql_query_tab_session.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/actions/sql_script_format.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart' + show SqlDialect; import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; @@ -19,6 +21,7 @@ class SqlQueryTabSession { this.filePath, double initialFraction = 0.65, this.isDiagram = false, + this.dialect, material.TextEditingController? customController, }) : controller = customController ?? SyntaxHighlightService.createHighlightController( @@ -38,6 +41,9 @@ class SqlQueryTabSession { /// True for the ER diagram tab (no editor / result grid). final bool isDiagram; + + /// The connection's SQL dialect, for Format SQL's lexical rules. + final SqlDialect? dialect; String title; String? filePath; final material.TextEditingController controller; @@ -99,11 +105,13 @@ class SqlQueryTabSession { /// the database fills in, same as Table Browser. Map? resultGridColumnMeta; + /// Formats the script in place. The text keeps its length, so the caret + /// and selection stay where they were (#1566). void formatSql() { - final next = formatSqlScript(controller.text); - controller.value = material.TextEditingValue( - text: next, - selection: material.TextSelection.collapsed(offset: next.length), + final value = controller.value; + controller.value = value.copyWith( + text: formatSqlScript(value.text, dialect: dialect), + composing: material.TextRange.empty, ); } diff --git a/lib/features/workspace/sql_workspace_toolbar.dart b/lib/features/workspace/sql_workspace_toolbar.dart index 8de9d253..444c99fe 100644 --- a/lib/features/workspace/sql_workspace_toolbar.dart +++ b/lib/features/workspace/sql_workspace_toolbar.dart @@ -82,7 +82,10 @@ class SqlWorkspaceToolbar extends StatelessWidget { final VoidCallback onToggleAutocommit; final void Function(int? seconds) onTimeoutChanged; - static const double compactWidthBreakpoint = 900; + /// Below this width only the run button keeps its label; the rest are + /// icons. Below [iconOnlyWidth] the run button is an icon too. + static const double labelsWidth = 900; + static const double iconOnlyWidth = 620; static String get _modLabel => Platform.isMacOS ? '⌘' : 'Ctrl+'; List> _sessionMenuItems() => [ @@ -125,27 +128,33 @@ class SqlWorkspaceToolbar extends StatelessWidget { } } - material.Widget _txBadge() { - final open = txOpen; - // An unknown state (before the first run) reads as the commit mode, not - // as a dash. - if (open == true) { + /// What is not the default: an open transaction or manual commit. Auto-commit + /// is the default and is named by the Session menu, so it has no badge of its + /// own next to it (#1558). + material.Widget? _txBadge() { + if (txOpen == true) { return const QueryaBadge.status( 'Transaction open', status: QueryaBadgeStatus.warning, ); } - return QueryaBadge.status( - autocommit ? 'Auto-commit' : 'Manual commit', - status: autocommit ? QueryaBadgeStatus.neutral : QueryaBadgeStatus.info, - ); + if (!autocommit) { + return const QueryaBadge.status( + 'Manual commit', + status: QueryaBadgeStatus.info, + ); + } + return null; } @override material.Widget build(material.BuildContext context) { return material.LayoutBuilder( builder: (context, constraints) { - final compact = constraints.maxWidth < compactWidthBreakpoint; + // Controls give up their labels as the row narrows, the run button + // last, so the row stays on one line without hiding what it does. + final compact = constraints.maxWidth < labelsWidth; + final runCompact = constraints.maxWidth < iconOnlyWidth; // One size for the whole row: the run split button, the buttons, the // icon buttons and the session menu are all `md` (#1439). return QueryaControlScope( @@ -163,7 +172,7 @@ class SqlWorkspaceToolbar extends StatelessWidget { menuKey: const material.ValueKey('run_menu'), label: 'Execute (F5)', icon: material.Icons.play_arrow_rounded, - compact: compact, + compact: runCompact, loading: session.running, onPressed: () => onExecute(false), menuTooltip: 'Run options', @@ -187,10 +196,11 @@ class SqlWorkspaceToolbar extends StatelessWidget { ), if (delegate.supportsExplain) ...[ const material.SizedBox(width: 8), - QueryaButton.secondary( + // A magnifier over lines: the plan. The diagram keeps the tree. + QueryaButton.ghost( key: const material.ValueKey('explain_query'), label: 'Explain', - icon: material.Icons.account_tree_outlined, + icon: material.Icons.manage_search_rounded, compact: compact, tooltip: 'Show the query plan', onPressed: session.running ? null : onExplain, @@ -242,7 +252,7 @@ class SqlWorkspaceToolbar extends StatelessWidget { const material.SizedBox(width: 8), ], if (delegate.supportsTransactions) ...[ - _txBadge(), + if (_txBadge() case final badge?) badge, if (txOpen == true) ...[ const material.SizedBox(width: 8), QueryaButton.secondary( @@ -262,10 +272,14 @@ class SqlWorkspaceToolbar extends StatelessWidget { ], QueryaMenuButton( key: const material.ValueKey('session_menu'), + variant: QueryaButtonVariant.ghost, items: _sessionMenuItems(), onSelected: (choice) => _onSessionChoice(context, choice), enabled: !session.running, icon: material.Icons.tune_rounded, + tooltip: autocommit + ? 'Session: auto-commit' + : 'Session: manual commit', label: compact ? null : (autocommit ? 'Session · auto-commit' : 'Session · manual'), diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart index 1b7abf1e..4fcc7f9f 100644 --- a/lib/features/workspace/table_view_staging.dart +++ b/lib/features/workspace/table_view_staging.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/core/ui/querya_tooltip.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/dml_preview_dialog.dart'; @@ -286,7 +287,7 @@ class _SaveFailedDialogState extends material.State<_SaveFailedDialog> { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final info = widget.info; return QueryaDialogCard( constraints: const material.BoxConstraints(maxWidth: 480), @@ -353,7 +354,9 @@ class _SaveFailedDialogState extends material.State<_SaveFailedDialog> { child: material.SelectableText( info.details, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 11, color: cs.mutedForeground, ), diff --git a/lib/main.dart b/lib/main.dart index c7f06b6e..3268a7d7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,10 +10,12 @@ import 'core/actions/querya_command_registry.dart'; import 'core/editor/syntax_highlight_service.dart'; import 'core/extensions/local_extension_registry.dart'; import 'core/layout/ui_scale_controller.dart'; +import 'core/market/marketplace_selection.dart'; import 'core/mcp/mcp_server_controller.dart'; import 'core/motion/display_refresh_service.dart'; import 'core/motion/querya_motion_controller.dart'; import 'core/platform/file_launch_service.dart'; +import 'core/platform/macos_file_open_channel.dart'; import 'core/security/ssl_certificate_support.dart'; import 'core/storage/local_db.dart'; import 'core/theme/theme_controller.dart'; @@ -25,6 +27,7 @@ void main([List args = const []]) async { WidgetsFlutterBinding.ensureInitialized(); installAppWiring(); FileLaunchService.instance.processLaunchArguments(args); + await MacOsFileOpenChannel.install(); FlutterError.onError = (details) { FlutterError.presentError(details); @@ -42,6 +45,7 @@ void main([List args = const []]) async { await QueryaMotionController.instance.load(); QueryaCommandRegistry.instance.ensureCoreDefaults(); unawaited(LocalExtensionRegistry.instance.load()); + await installMarketplaceRepository(); unawaited(UpdateController.instance.initialize()); unawaited(cleanupStaleMongoTlsTempFiles()); unawaited(McpServerController.instance.startIfEnabled()); diff --git a/lib/shared/services/data_export_service.dart b/lib/shared/services/data_export_service.dart index ac282c80..66f341f6 100644 --- a/lib/shared/services/data_export_service.dart +++ b/lib/shared/services/data_export_service.dart @@ -1,8 +1,10 @@ import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; +import 'dart:math' as math; import 'package:file_selector/file_selector.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/csv/result_grid_csv.dart'; @@ -74,27 +76,37 @@ class DataExportService { ) { if (columns.isEmpty) return ''; final buf = StringBuffer(); + _writeMarkdownHeader(buf, columns); + _writeMarkdownRows(buf, columns, rows); + return buf.toString(); + } - String escapeMd(String s) { - if (s == 'NULL') return '`NULL`'; - return s - .replaceAll('|', '\\|') - .replaceAll('\r\n', ' ') - .replaceAll('\n', ' '); - } + static String _escapeMd(String s) { + if (s == 'NULL') return '`NULL`'; + return s + .replaceAll('|', '\\|') + .replaceAll('\r\n', ' ') + .replaceAll('\n', ' '); + } - buf.write('| ${columns.map(escapeMd).join(' | ')} |\n'); + static void _writeMarkdownHeader(StringBuffer buf, List columns) { + buf.write('| ${columns.map(_escapeMd).join(' | ')} |\n'); buf.write('| ${columns.map((_) => '---').join(' | ')} |\n'); + } + static void _writeMarkdownRows( + StringBuffer buf, + List columns, + List> rows, + ) { for (final row in rows) { final padded = List.generate( columns.length, - (i) => i < row.length ? escapeMd(row[i]) : '`NULL`', + (i) => i < row.length ? _escapeMd(row[i]) : '`NULL`', growable: false, ); buf.write('| ${padded.join(' | ')} |\n'); } - return buf.toString(); } /// Formats rows as SQL INSERT statements (`INSERT INTO table (...) VALUES (...);`). @@ -147,13 +159,52 @@ class DataExportService { await Clipboard.setData(ClipboardData(text: text)); } - /// Formats data on a background isolate to prevent UI stutter on large datasets. + /// About how many cells one isolate hand-off in [formatAsync] carries. + /// + /// The rows a closure captures are copied on the calling isolate before the + /// worker starts: 50,000 x 20 cells in one closure stopped the UI for + /// ~20-30 ms; 20,000 cells take a few ms, and the event loop runs between + /// chunks (#1537). + @visibleForTesting + static const int handOffCells = 20000; + + /// Formats data on background isolates to prevent UI stutter on large + /// datasets. Large inputs go over in chunks of about [handOffCells] cells; + /// the result is the same text as the `format*` functions produce. static Future formatAsync( DataExportFormat format, { required List columns, required List> rows, String tableName = 'export_table', - }) { + }) async { + final width = math.max(1, columns.length); + if (columns.isEmpty || rows.length * width <= handOffCells) { + return _formatInIsolate(format, columns, rows, tableName); + } + final chunkRows = math.max(1, handOffCells ~/ width); + final parts = []; + for (var start = 0; start < rows.length; start += chunkRows) { + final end = math.min(start + chunkRows, rows.length); + parts.add(await _formatChunkInIsolate( + format, + columns, + rows.sublist(start, end), + tableName, + first: start == 0, + last: end == rows.length, + )); + } + return parts.join(); + } + + // Separate functions so each closure captures only its own arguments: a + // closure created inside [formatAsync] would carry the whole `rows` along. + static Future _formatInIsolate( + DataExportFormat format, + List columns, + List> rows, + String tableName, + ) { return Isolate.run(() { switch (format) { case DataExportFormat.csv: @@ -168,6 +219,61 @@ class DataExportService { }); } + static Future _formatChunkInIsolate( + DataExportFormat format, + List columns, + List> rows, + String tableName, { + required bool first, + required bool last, + }) { + return Isolate.run(() => formatChunk( + format, + columns, + rows, + tableName, + first: first, + last: last, + )); + } + + /// The part of the whole document that the non-empty [rows] make, when + /// they are a chunk of a larger table: joined in order, the chunks give + /// exactly what the `format*` function gives for all the rows. [first] + /// adds the header (and JSON's `[`), [last] JSON's `]`. + @visibleForTesting + static String formatChunk( + DataExportFormat format, + List columns, + List> rows, + String tableName, { + required bool first, + required bool last, + }) { + switch (format) { + case DataExportFormat.csv: + if (first) return resultGridAsCsv(columns, rows); + final buf = StringBuffer(); + for (final row in rows) { + buf.write('\n'); + buf.write(formatCsvDataRow(row, columns.length)); + } + return buf.toString(); + case DataExportFormat.json: + // An indented array is `[\n`, the items joined by `,\n`, `\n]`. + final whole = formatJson(columns, rows); + final items = whole.substring(2, whole.length - 2); + return '${first ? '[\n' : ',\n'}$items${last ? '\n]' : ''}'; + case DataExportFormat.markdown: + final buf = StringBuffer(); + if (first) _writeMarkdownHeader(buf, columns); + _writeMarkdownRows(buf, columns, rows); + return buf.toString(); + case DataExportFormat.sqlDump: + return formatSqlInsertDump(tableName, columns, rows); + } + } + /// Streams formatted output to [sink] without assembling one giant [String]. static Future writeToSink( IOSink sink, diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart index 0dffda0d..6d40229d 100644 --- a/lib/shared/widgets/app_dialog.dart +++ b/lib/shared/widgets/app_dialog.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; /// Shows a modal dialog with a frosted, dimmed backdrop over the app. /// @@ -153,7 +154,8 @@ class _DialogBackdrop extends StatelessWidget { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), child: ColoredBox( - color: const Color(0xFF000000).withValues(alpha: tintAlpha * t), + color: context.workbenchOrDefault.shadow + .withValues(alpha: tintAlpha * t), ), ), ); diff --git a/lib/shared/widgets/app_toast.dart b/lib/shared/widgets/app_toast.dart index 3a92e16a..ac3c7184 100644 --- a/lib/shared/widgets/app_toast.dart +++ b/lib/shared/widgets/app_toast.dart @@ -18,7 +18,8 @@ enum AppToastVariant { /// Toasts share one entry in the root overlay and stack upwards. Each one /// fades and slides in over [QueryaMotion.standard] (instantly under reduced /// motion), stays for [showDuration] and closes itself, or earlier through its -/// close button. +/// close button. Closing plays the entry backwards and gives its height back +/// gradually, so the toasts above it slide down instead of jumping. void showAppToast({ required material.BuildContext context, required String message, @@ -129,8 +130,8 @@ class _ToastViewState extends material.State<_ToastView> void initState() { super.initState(); final d = widget.data; - // The controller only plays the entry; a timer ends the toast, so a test - // that settles frames does not run its whole lifetime. + // The controller plays the entry and the exit; a timer starts the exit, + // so a test that settles frames does not run the toast's whole lifetime. _controller = material.AnimationController( vsync: this, duration: d.enter == Duration.zero ? const Duration(milliseconds: 1) : d.enter, @@ -145,7 +146,15 @@ class _ToastViewState extends material.State<_ToastView> void _close() { if (_closed) return; _closed = true; - widget.onClosed(); + _timer.cancel(); + if (widget.data.enter == Duration.zero) { + widget.onClosed(); // reduced motion: gone at once + return; + } + // Out the way it came in (#1645); the list drops it when that is done. + _controller.reverse().whenCompleteOrCancel(() { + if (mounted) widget.onClosed(); + }); } @override @@ -170,11 +179,17 @@ class _ToastViewState extends material.State<_ToastView> return material.AnimatedBuilder( animation: _in, - builder: (context, child) => material.Opacity( - opacity: _in.value, - child: material.Transform.translate( - offset: material.Offset(0, (1 - _in.value) * 12), - child: child, + // The height follows the same animation, so neighbours move smoothly + // when a toast arrives or leaves. + builder: (context, child) => material.SizeTransition( + sizeFactor: _in, + axisAlignment: -1, + child: material.Opacity( + opacity: _in.value, + child: material.Transform.translate( + offset: material.Offset(0, (1 - _in.value) * 12), + child: child, + ), ), ), child: material.Padding( diff --git a/lib/shared/widgets/connection_environment_badge.dart b/lib/shared/widgets/connection_environment_badge.dart index 11ac03b9..45d9761a 100644 --- a/lib/shared/widgets/connection_environment_badge.dart +++ b/lib/shared/widgets/connection_environment_badge.dart @@ -15,20 +15,13 @@ material.Color environmentAccentColor( ConnectionEnvironment.production => wb.destructive, }; -/// Like [environmentAccentColor], but falls back to fixed colors when no -/// [QueryaThemeScope] is mounted (isolated form / dialog tests). +/// Like [environmentAccentColor], but resolves from ambient theme or defaults +/// when no [QueryaThemeScope] is mounted (isolated form / dialog tests). material.Color environmentAccentColorOf( material.BuildContext context, ConnectionEnvironment environment, -) { - final wb = QueryaThemeScope.maybeOf(context)?.workbench; - if (wb != null) return environmentAccentColor(wb, environment); - return switch (environment) { - ConnectionEnvironment.development => const material.Color(0xFF4CAF50), - ConnectionEnvironment.staging => const material.Color(0xFFF59E0B), - ConnectionEnvironment.production => const material.Color(0xFFEF4444), - }; -} +) => + environmentAccentColor(context.workbenchOrDefault, environment); /// Background for window chrome (title / status bar) of a connection in /// [environment]: [base] untouched for Development, tinted for the others. diff --git a/lib/shared/widgets/connection_tree_loading_row.dart b/lib/shared/widgets/connection_tree_loading_row.dart index b11651b7..fea12d6f 100644 --- a/lib/shared/widgets/connection_tree_loading_row.dart +++ b/lib/shared/widgets/connection_tree_loading_row.dart @@ -43,10 +43,7 @@ class ConnectionTreeLoadingRow extends material.StatelessWidget { label, style: material.DefaultTextStyle.of(context).style.copyWith( fontSize: 12, - color: QueryaThemeScope.maybeOf(context) - ?.workbench - .mutedForeground ?? - material.Theme.of(context).colorScheme.onSurfaceVariant, + color: context.workbench.mutedForeground, ), ), ], diff --git a/lib/shared/widgets/poll_stale_overlay.dart b/lib/shared/widgets/poll_stale_overlay.dart new file mode 100644 index 00000000..625d74dd --- /dev/null +++ b/lib/shared/widgets/poll_stale_overlay.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; + +/// Shows [child] with a small "out of date" note in the top-right corner while +/// the latest poll failed, so a dashboard that kept its last good numbers does +/// not look live. +class PollStaleOverlay extends StatelessWidget { + const PollStaleOverlay({super.key, required this.status, required this.child}); + + final ValueListenable status; + final Widget child; + + static String message(PollStatus s) { + final at = s.lastSuccess; + if (at == null) return 'Updates failed'; + String two(int n) => n.toString().padLeft(2, '0'); + return 'Updates failed · showing ${two(at.hour)}:${two(at.minute)}:${two(at.second)}'; + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill(child: child), + Positioned( + top: 8, + right: 16, + child: ValueListenableBuilder( + valueListenable: status, + builder: (context, s, _) { + if (!s.isStale) return const SizedBox.shrink(); + final colors = context.colors; + return Tooltip( + message: '${s.lastError}', + child: DecoratedBox( + key: const ValueKey('poll_stale_hint'), + decoration: BoxDecoration( + color: colors.destructive.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + message(s), + style: TextStyle( + fontSize: 12, + color: colors.destructive, + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/query_error_view.dart b/lib/shared/widgets/query_error_view.dart new file mode 100644 index 00000000..cb073540 --- /dev/null +++ b/lib/shared/widgets/query_error_view.dart @@ -0,0 +1,130 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// A failed query: the driver's message as the headline, the rest (without the +/// statement the app wrapped around the user's SQL) behind "Show details", and +/// a Copy button (#1557). Shared by the SQL results pane and the Mongo query +/// workspace. +/// +/// [inline] is for a panel that has its own title row (a box above results): +/// left aligned and without the large icon and title. +class QueryErrorView extends material.StatefulWidget { + const QueryErrorView({ + super.key, + required this.error, + this.action, + this.inline = false, + }); + + final QueryErrorText error; + + /// Shown under the error, e.g. a way to fix the connection. + final material.Widget? action; + final bool inline; + + @override + material.State createState() => _QueryErrorViewState(); +} + +class _QueryErrorViewState extends material.State { + bool _showDetails = false; + bool _copied = false; + + @override + void didUpdateWidget(covariant QueryErrorView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.error.detail != widget.error.detail) { + _showDetails = false; + _copied = false; + } + } + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: widget.error.detail)); + if (!mounted) return; + setState(() => _copied = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _copied = false); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = context.colors; + final error = widget.error; + final inline = widget.inline; + final align = inline + ? material.CrossAxisAlignment.start + : material.CrossAxisAlignment.center; + return material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: align, + children: [ + if (!inline) ...[ + material.Icon( + material.Icons.error_outline_rounded, + size: 48, + color: cs.destructive, + ), + const Gap(16), + const Text('Query Error').large().semiBold(), + const Gap(8), + ], + material.SelectableText( + error.summary, + textAlign: inline ? material.TextAlign.start : material.TextAlign.center, + style: material.TextStyle(color: cs.foreground, fontSize: 14), + ), + if (error.hasDetail) ...[ + const Gap(12), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + QueryaButton.ghost( + label: _showDetails ? 'Hide details' : 'Show details', + icon: _showDetails + ? material.Icons.expand_less_rounded + : material.Icons.expand_more_rounded, + size: QueryaControlSize.sm, + onPressed: () => setState(() => _showDetails = !_showDetails), + ), + const Gap(6), + QueryaButton.ghost( + label: _copied ? 'Copied' : 'Copy error', + icon: _copied + ? material.Icons.check_rounded + : material.Icons.copy_rounded, + size: QueryaControlSize.sm, + onPressed: () => unawaited(_copy()), + ), + ], + ), + ], + if (error.hasDetail && _showDetails) ...[ + const Gap(8), + // A long driver message scrolls inside the space left, instead of + // pushing the column past the pane. + material.Flexible( + child: VirtualSelectableTextView( + text: error.detail, + style: material.TextStyle( + color: cs.mutedForeground, + fontSize: 13, + ), + ), + ), + ], + if (widget.action != null) ...[ + const Gap(16), + widget.action!, + ], + ], + ); + } +} diff --git a/lib/shared/widgets/querya_badge.dart b/lib/shared/widgets/querya_badge.dart index 1d06c163..a40b7e90 100644 --- a/lib/shared/widgets/querya_badge.dart +++ b/lib/shared/widgets/querya_badge.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; /// Semantic status variant for [QueryaBadge.status]. enum QueryaBadgeStatus { @@ -105,7 +106,7 @@ class QueryaBadge extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = context.queryaTheme.colorScheme; + final cs = context.colors; final workbench = QueryaThemeScope.maybeOf(context)?.workbench; final palette = context.semanticPalette; @@ -203,7 +204,9 @@ class QueryaBadge extends material.StatelessWidget { style: material.TextStyle( fontSize: 10, fontWeight: material.FontWeight.w600, - fontFamily: isMonospace ? 'monospace' : null, + fontFamily: isMonospace ? QueryaTypography.mono : null, + fontFamilyFallback: + isMonospace ? QueryaTypography.monoFontFamilyFallback : null, color: fg, letterSpacing: 0.3, ), diff --git a/lib/shared/widgets/querya_button.dart b/lib/shared/widgets/querya_button.dart index fc1b7c53..48d12106 100644 --- a/lib/shared/widgets/querya_button.dart +++ b/lib/shared/widgets/querya_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:flutter/rendering.dart' show RenderObject, RenderProxyBox; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; @@ -292,11 +293,40 @@ class _QueryaButtonState extends material.State { if (_pressed != value) setState(() => _pressed = value); } - /// Black or white, whichever reads on [background]. - static material.Color _onColor(material.Color background) => - background.computeLuminance() > 0.5 - ? const material.Color(0xFF000000) - : const material.Color(0xFFFFFFFF); + + /// Whether the label was cut short at the last layout (#1689). + bool _labelCut = false; + + void _onLabelCut(bool cut) { + if (mounted && cut != _labelCut) setState(() => _labelCut = cut); + } + + /// The label on one line with an ellipsis. With [showWhenCut] it reports + /// after each layout whether it fit, and a label that did not gets a tooltip + /// with its full text, so a button that fits shows none. No + /// [material.LayoutBuilder]: buttons sit in intrinsic-size layouts. + material.Widget _label( + String label, + material.TextStyle style, { + required bool showWhenCut, + }) { + final text = material.Text( + label, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + softWrap: false, + style: style, + ); + if (!showWhenCut) return text; + final reported = _CutReport(onChanged: _onLabelCut, child: text); + if (!_labelCut) return reported; + return material.Tooltip( + message: label, + waitDuration: kQueryaTooltipWait, + excludeFromSemantics: true, + child: reported, + ); + } @override material.Widget build(material.BuildContext context) { @@ -313,7 +343,7 @@ class _QueryaButtonState extends material.State { // ---- colours ------------------------------------------------------- final material.Color foreground; material.Color background; - var outline = const material.Color(0x00000000); + var outline = material.Colors.transparent; if (filled) { final base = w.variant == QueryaButtonVariant.primary ? wb.accent @@ -326,7 +356,7 @@ class _QueryaButtonState extends material.State { } else { foreground = w.variant == QueryaButtonVariant.primary ? wb.onAccent - : _onColor(base); + : scheme.destructiveForeground; final tint = _pressed && enabled ? 0.22 : (_hovered && enabled ? 0.12 : 0.0); @@ -375,17 +405,17 @@ class _QueryaButtonState extends material.State { if (!iconOnly) ...[ if (leading != null) material.SizedBox(width: gap), material.Flexible( - child: material.Text( + child: _label( w.label!, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - softWrap: false, - style: material.TextStyle( + material.TextStyle( fontSize: fontSize, fontWeight: material.FontWeight.w500, height: 1.2, color: foreground, ), + // An explicit tooltip wins; otherwise a label cut short by a + // narrow toolbar shows in full on hover (#1689). + showWhenCut: w.tooltip == null || w.tooltip!.isEmpty, ), ), if (w.shortcutHint != null) ...[ @@ -540,3 +570,43 @@ class _QueryaButtonState extends material.State { ); } } + +/// Lays its text child out as usual and reports, after the frame, whether the +/// text was cut: its full one-line width is more than the width it got. +class _CutReport extends material.SingleChildRenderObjectWidget { + const _CutReport({required this.onChanged, required super.child}); + + final void Function(bool cut) onChanged; + + @override + RenderObject createRenderObject(material.BuildContext context) => + _RenderCutReport(onChanged); + + @override + void updateRenderObject( + material.BuildContext context, + _RenderCutReport renderObject, + ) { + renderObject.onChanged = onChanged; + } +} + +class _RenderCutReport extends RenderProxyBox { + _RenderCutReport(this.onChanged); + + void Function(bool cut) onChanged; + bool? _reported; + + @override + void performLayout() { + super.performLayout(); + final text = child; + if (text == null) return; + final cut = text.getMaxIntrinsicWidth(double.infinity) > size.width + 0.5; + if (cut == _reported) return; + _reported = cut; + // Not during layout: the owner rebuilds with or without a tooltip. + final report = onChanged; + material.WidgetsBinding.instance.addPostFrameCallback((_) => report(cut)); + } +} diff --git a/lib/shared/widgets/querya_card.dart b/lib/shared/widgets/querya_card.dart new file mode 100644 index 00000000..2d38b410 --- /dev/null +++ b/lib/shared/widgets/querya_card.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; + +/// A raised surface: the card colour, a hairline border, rounded corners +/// (#1734, epic #1465). Replaces shadcn's `Card`. +class QueryaCard extends material.StatelessWidget { + const QueryaCard({ + super.key, + required this.child, + this.padding, + this.radius = 10, + }); + + final material.Widget child; + + /// Inside the border; none by default (the child pads itself). + final material.EdgeInsetsGeometry? padding; + final double radius; + + @override + material.Widget build(material.BuildContext context) { + final cs = context.colors; + return material.Container( + padding: padding, + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: cs.border.withValues(alpha: 0.7)), + ), + child: child, + ); + } +} diff --git a/lib/shared/widgets/querya_color_picker.dart b/lib/shared/widgets/querya_color_picker.dart new file mode 100644 index 00000000..50456a07 --- /dev/null +++ b/lib/shared/widgets/querya_color_picker.dart @@ -0,0 +1,298 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; + +/// A colour picker (#1734, epic #1465): a saturation / brightness area, hue +/// and alpha bars, and a hex field (`#RRGGBB` or `#AARRGGBB`). +class QueryaColorPicker extends material.StatefulWidget { + const QueryaColorPicker({ + super.key, + required this.value, + required this.onChanged, + this.alpha = true, + }); + + final material.Color value; + final material.ValueChanged onChanged; + + /// Whether the alpha bar and the `AA` of the hex are offered. + final bool alpha; + + /// `#RRGGBB`, or `#AARRGGBB` with [alpha]. + static String toHex(material.Color c, {bool alpha = true}) { + String h(double v) => + (v * 255).round().clamp(0, 255).toRadixString(16).padLeft(2, '0'); + return '#${alpha ? h(c.a) : ''}${h(c.r)}${h(c.g)}${h(c.b)}'.toUpperCase(); + } + + /// The colour of `RRGGBB`, `#RRGGBB`, `AARRGGBB` or `#AARRGGBB`; null when + /// it is not one of them. + static material.Color? parseHex(String text) { + final t = text.trim().replaceFirst('#', ''); + if (!RegExp(r'^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$').hasMatch(t)) return null; + final v = int.parse(t, radix: 16); + return material.Color(t.length == 6 ? 0xFF000000 | v : v); + } + + @override + material.State createState() => _QueryaColorPickerState(); +} + +class _QueryaColorPickerState extends material.State { + late material.HSVColor _hsv; + late final material.TextEditingController _hex; + + @override + void initState() { + super.initState(); + _hsv = material.HSVColor.fromColor(widget.value); + _hex = material.TextEditingController( + text: QueryaColorPicker.toHex(widget.value, alpha: widget.alpha)); + } + + @override + void dispose() { + _hex.dispose(); + super.dispose(); + } + + void _set(material.HSVColor next) { + setState(() => _hsv = next); + final c = next.toColor(); + _hex.text = QueryaColorPicker.toHex(c, alpha: widget.alpha); + widget.onChanged(c); + } + + void _typed(String text) { + final c = QueryaColorPicker.parseHex(text); + if (c == null) return; + setState(() => _hsv = material.HSVColor.fromColor(c)); + widget.onChanged(c); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = context.colors; + final hueColor = material.HSVColor.fromAHSV(1, _hsv.hue, 1, 1).toColor(); + final opaque = _hsv.withAlpha(1).toColor(); + return material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.SizedBox( + height: 160, + child: _Drag( + onPosition: (p) => _set(_hsv + .withSaturation(p.dx.clamp(0, 1)) + .withValue(1 - p.dy.clamp(0, 1))), + child: material.CustomPaint( + painter: _SvPainter(hueColor, _hsv.saturation, _hsv.value, + cs.foreground), + ), + ), + ), + const material.SizedBox(height: 12), + _Bar( + height: 14, + position: _hsv.hue / 360, + onPosition: (v) => _set(_hsv.withHue((v * 360).clamp(0, 359.999))), + gradient: material.LinearGradient(colors: [ + for (var i = 0; i <= 6; i++) + material.HSVColor.fromAHSV(1, i * 60, 1, 1).toColor(), + ]), + thumb: hueColor, + ), + if (widget.alpha) ...[ + const material.SizedBox(height: 10), + _Bar( + height: 14, + position: _hsv.alpha, + onPosition: (v) => _set(_hsv.withAlpha(v.clamp(0, 1))), + gradient: material.LinearGradient( + colors: [opaque.withValues(alpha: 0), opaque]), + thumb: _hsv.toColor(), + checkered: true, + ), + ], + const material.SizedBox(height: 12), + material.Row( + children: [ + material.Container( + width: 32, + height: 32, + decoration: material.BoxDecoration( + color: _hsv.toColor(), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all(color: cs.border), + ), + ), + const material.SizedBox(width: 10), + material.Expanded( + child: QueryaTextField( + controller: _hex, + onChanged: _typed, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[#0-9a-fA-F]')), + LengthLimitingTextInputFormatter(9), + ], + ), + ), + ], + ), + ], + ); + } +} + +/// Reports the pointer as a 0–1 position in its box while pressed or dragged. +class _Drag extends material.StatelessWidget { + const _Drag({required this.child, required this.onPosition}); + + final material.Widget child; + final material.ValueChanged onPosition; + + @override + material.Widget build(material.BuildContext context) { + return material.LayoutBuilder(builder: (context, box) { + void at(material.Offset local) => onPosition(material.Offset( + (local.dx / box.maxWidth).clamp(0, 1), + (local.dy / box.maxHeight).clamp(0, 1), + )); + return material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onPanDown: (d) => at(d.localPosition), + onPanUpdate: (d) => at(d.localPosition), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(6), + child: child, + ), + ); + }); + } +} + +class _Bar extends material.StatelessWidget { + const _Bar({ + required this.height, + required this.position, + required this.onPosition, + required this.gradient, + required this.thumb, + this.checkered = false, + }); + + final double height; + final double position; + final material.ValueChanged onPosition; + final material.Gradient gradient; + final material.Color thumb; + final bool checkered; + + @override + material.Widget build(material.BuildContext context) { + final cs = context.colors; + return material.SizedBox( + height: height + 8, + child: material.LayoutBuilder(builder: (context, box) { + final x = position.clamp(0, 1) * box.maxWidth; + return material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onPanDown: (d) => onPosition(d.localPosition.dx / box.maxWidth), + onPanUpdate: (d) => onPosition(d.localPosition.dx / box.maxWidth), + child: material.Stack( + clipBehavior: material.Clip.none, + alignment: material.Alignment.centerLeft, + children: [ + material.Positioned.fill( + top: 4, + bottom: 4, + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(height / 2), + child: material.Stack(fit: material.StackFit.expand, children: [ + if (checkered) + material.CustomPaint(painter: _Checkers(cs.border)), + material.DecoratedBox( + decoration: material.BoxDecoration(gradient: gradient)), + ]), + ), + ), + material.Positioned( + left: x - 9, + child: material.Container( + width: 18, + height: 18, + decoration: material.BoxDecoration( + color: thumb, + shape: material.BoxShape.circle, + border: material.Border.all(color: cs.foreground, width: 2), + ), + ), + ), + ], + ), + ); + }), + ); + } +} + +class _SvPainter extends material.CustomPainter { + _SvPainter(this.hue, this.s, this.v, this.ring); + + final material.Color hue; + final double s; + final double v; + final material.Color ring; + + @override + void paint(material.Canvas canvas, material.Size size) { + final rect = material.Offset.zero & size; + canvas.drawRect( + rect, + material.Paint() + ..shader = material.LinearGradient( + colors: [material.Colors.white, hue], + ).createShader(rect)); + canvas.drawRect( + rect, + material.Paint() + ..shader = const material.LinearGradient( + begin: material.Alignment.topCenter, + end: material.Alignment.bottomCenter, + colors: [material.Color(0x00000000), material.Color(0xFF000000)], + ).createShader(rect)); + final p = material.Offset(s * size.width, (1 - v) * size.height); + canvas.drawCircle( + p, + 8, + material.Paint() + ..style = material.PaintingStyle.stroke + ..strokeWidth = 2 + ..color = ring); + } + + @override + bool shouldRepaint(_SvPainter old) => + old.hue != hue || old.s != s || old.v != v || old.ring != ring; +} + +class _Checkers extends material.CustomPainter { + _Checkers(this.color); + + final material.Color color; + + @override + void paint(material.Canvas canvas, material.Size size) { + final paint = material.Paint()..color = color.withValues(alpha: 0.5); + const cell = 7.0; + for (var y = 0.0, row = 0; y < size.height; y += cell, row++) { + for (var x = row.isEven ? 0.0 : cell; x < size.width; x += cell * 2) { + canvas.drawRect(material.Rect.fromLTWH(x, y, cell, cell), paint); + } + } + } + + @override + bool shouldRepaint(_Checkers old) => old.color != color; +} diff --git a/lib/shared/widgets/querya_context_menu.dart b/lib/shared/widgets/querya_context_menu.dart new file mode 100644 index 00000000..fc462b13 --- /dev/null +++ b/lib/shared/widgets/querya_context_menu.dart @@ -0,0 +1,362 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/shared/widgets/querya_divider.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; +import 'package:querya_desktop/shared/widgets/querya_menu_surface.dart'; + +/// One command or submenu row on the kit's menu surface (#1732). +/// +/// Replaces shadcn's `MenuButton` / `MenuItem`. When [subMenu] is non-empty, +/// renders a [material.SubmenuButton]; otherwise a [material.MenuItemButton]. +class QueryaMenuItem extends material.StatelessWidget { + const QueryaMenuItem({ + super.key, + required this.child, + this.leading, + this.trailing, + this.onPressed, + this.onTap, + this.subMenu, + this.enabled = true, + }); + + /// The label or widget for this menu entry. + final material.Widget child; + + /// Optional leading icon or decoration. + final material.Widget? leading; + + /// Optional trailing widget, such as a keyboard shortcut hint or tag. + final material.Widget? trailing; + + /// Callback called with [BuildContext] when activated. + final void Function(material.BuildContext context)? onPressed; + + /// Parameterless callback called when activated. + final material.VoidCallback? onTap; + + /// Submenu items shown when this item expands. + final List? subMenu; + + /// Whether this item is interactive. Defaults to true. + final bool enabled; + + @override + material.Widget build(material.BuildContext context) { + if (subMenu != null && subMenu!.isNotEmpty) { + return material.SubmenuButton( + menuStyle: queryaMenuStyle(context), + style: queryaMenuItemButtonStyle(context, enabled: enabled), + leadingIcon: leading, + trailingIcon: trailing, + menuChildren: subMenu!, + child: child, + ); + } + return material.MenuItemButton( + style: queryaMenuItemButtonStyle(context, enabled: enabled), + leadingIcon: leading, + trailingIcon: trailing, + onPressed: (enabled && (onPressed != null || onTap != null)) + ? () { + onTap?.call(); + onPressed?.call(context); + } + : null, + child: child, + ); + } +} + +/// Thin separator between sections in a menu (#1732). +/// +/// Replaces shadcn's `MenuDivider`. +class QueryaMenuDivider extends material.StatelessWidget { + const QueryaMenuDivider({super.key}); + + @override + material.Widget build(material.BuildContext context) { + return const material.Padding( + padding: material.EdgeInsets.symmetric(vertical: 4), + child: QueryaDivider(), + ); + } +} + +/// Style for menu row buttons in popovers and context menus. +material.ButtonStyle queryaMenuItemButtonStyle( + material.BuildContext context, { + bool enabled = true, +}) { + final cs = context.colors; + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + return material.MenuItemButton.styleFrom( + minimumSize: material.Size( + 0, + QueryaDropdownTokens.scaledMenuItemHeight(context), + ), + padding: material.EdgeInsets.symmetric( + horizontal: context.scaled(8), + ), + foregroundColor: enabled ? cs.popoverForeground : cs.mutedForeground, + disabledForegroundColor: cs.mutedForeground, + iconColor: cs.mutedForeground, + disabledIconColor: cs.mutedForeground, + overlayColor: cs.accent.withValues(alpha: 0.14), + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(radius), + ), + textStyle: QueryaDropdownTokens.menuItemTextStyle( + context, + enabled ? cs.popoverForeground : cs.mutedForeground, + selected: false, + ), + ); +} + +/// Wraps [child] to open a context menu on secondary click (right click), +/// long-press, or context-menu keyboard shortcut (#1732). +/// +/// Replaces shadcn's `ContextMenu`. Supports eager [items] or lazy [itemsBuilder]. +class QueryaContextMenu extends material.StatefulWidget { + const QueryaContextMenu({ + super.key, + required this.child, + this.items, + this.itemsBuilder, + this.enabled = true, + this.behavior = material.HitTestBehavior.translucent, + }) : assert( + items != null || itemsBuilder != null, + 'QueryaContextMenu requires either items or itemsBuilder', + ); + + final material.Widget child; + final List? items; + final List Function(material.BuildContext context)? + itemsBuilder; + final bool enabled; + final material.HitTestBehavior behavior; + + @override + material.State createState() => _QueryaContextMenuState(); +} + +class _QueryaContextMenuState extends material.State { + final material.MenuController _controller = material.MenuController(); + bool _isOpen = false; + + void _open(material.Offset localPosition) { + if (!widget.enabled) return; + if (widget.itemsBuilder != null) { + setState(() { + _isOpen = true; + }); + } + _controller.open(position: localPosition); + } + + @override + material.Widget build(material.BuildContext context) { + if (!widget.enabled) return widget.child; + + final children = widget.items != null + ? widget.items! + : (_isOpen ? widget.itemsBuilder!(context) : const []); + + return material.MenuAnchor( + controller: _controller, + consumeOutsideTap: true, + style: queryaMenuStyle(context), + onClose: () { + if (_isOpen) { + setState(() { + _isOpen = false; + }); + } + }, + menuChildren: children, + builder: (context, controller, child) { + return material.Shortcuts( + shortcuts: const { + material.SingleActivator(LogicalKeyboardKey.contextMenu): + _OpenContextMenuIntent(), + material.SingleActivator(LogicalKeyboardKey.f10, shift: true): + _OpenContextMenuIntent(), + }, + child: material.Actions( + actions: >{ + _OpenContextMenuIntent: + material.CallbackAction<_OpenContextMenuIntent>( + onInvoke: (_) { + final box = context.findRenderObject() as material.RenderBox?; + final pos = box != null + ? box.size.center(material.Offset.zero) + : material.Offset.zero; + _open(pos); + return null; + }, + ), + }, + child: material.GestureDetector( + behavior: widget.behavior, + onSecondaryTapDown: (details) => _open(details.localPosition), + onLongPressStart: (details) => _open(details.localPosition), + child: child, + ), + ), + ); + }, + child: widget.child, + ); + } +} + +class _OpenContextMenuIntent extends material.Intent { + const _OpenContextMenuIntent(); +} + +/// Shows a context menu at [position] with the given [items]. +Future showQueryaContextMenu({ + required material.BuildContext context, + required material.Offset position, + required List items, +}) async { + final overlay = material.Overlay.of(context, rootOverlay: true); + late final material.OverlayEntry entry; + var removed = false; + + void safeRemove() { + if (!removed && entry.mounted) { + removed = true; + entry.remove(); + } + } + + entry = material.OverlayEntry( + builder: (overlayContext) { + return _StandaloneContextMenuOverlay( + position: position, + items: items, + onClose: safeRemove, + ); + }, + ); + + overlay.insert(entry); +} + +/// Shows a context menu at [position] with lazily evaluated [itemsBuilder]. +Future showQueryaContextMenuBuilder({ + required material.BuildContext context, + required material.Offset position, + required List Function(material.BuildContext context) + itemsBuilder, +}) { + return showQueryaContextMenu( + context: context, + position: position, + items: itemsBuilder(context), + ); +} + +class _StandaloneContextMenuOverlay extends material.StatefulWidget { + const _StandaloneContextMenuOverlay({ + required this.position, + required this.items, + required this.onClose, + }); + + final material.Offset position; + final List items; + final material.VoidCallback onClose; + + @override + material.State<_StandaloneContextMenuOverlay> createState() => + _StandaloneContextMenuOverlayState(); +} + +class _StandaloneContextMenuOverlayState + extends material.State<_StandaloneContextMenuOverlay> { + final material.MenuController _controller = material.MenuController(); + final material.FocusNode _overlayFocus = + material.FocusNode(debugLabel: 'StandaloneContextMenu'); + + @override + void initState() { + super.initState(); + material.WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _controller.open(position: widget.position); + _overlayFocus.requestFocus(); + } + }); + } + + @override + void dispose() { + _overlayFocus.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + return material.Shortcuts( + shortcuts: const { + material.SingleActivator(LogicalKeyboardKey.escape): + material.DismissIntent(), + }, + child: material.Actions( + actions: >{ + material.DismissIntent: + material.CallbackAction( + onInvoke: (_) { + _controller.close(); + widget.onClose(); + return null; + }, + ), + }, + child: material.Focus( + focusNode: _overlayFocus, + autofocus: true, + child: material.Stack( + children: [ + material.Positioned.fill( + child: material.GestureDetector( + behavior: material.HitTestBehavior.translucent, + onTapDown: (_) { + _controller.close(); + widget.onClose(); + }, + onSecondaryTapDown: (_) { + _controller.close(); + widget.onClose(); + }, + child: const material.SizedBox.expand(), + ), + ), + material.Positioned( + left: 0, + top: 0, + child: material.FocusScope( + autofocus: true, + child: material.MenuAnchor( + controller: _controller, + consumeOutsideTap: true, + style: queryaMenuStyle(context), + onClose: widget.onClose, + menuChildren: widget.items, + child: const material.SizedBox.shrink(), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index b917d6d2..557e321d 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -6,8 +6,8 @@ import 'package:flutter/services.dart'; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; -import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; @@ -30,7 +30,7 @@ class QueryaDropdownItem { /// Stable dropdown built on [material.MenuAnchor] (no Overlay portal). /// -/// Visual metrics: [QueryaDropdownTokens]. Colors from shadcn [QueryaThemeColorScheme]. +/// Visual metrics: [QueryaDropdownTokens]. Colors from [QueryaUiColors]. /// /// **Exit motion:** item pick and trigger-toggle delay [MenuController.close] /// so fade-slide can run. Outside-tap / focus-loss closes via [MenuAnchor] @@ -153,7 +153,7 @@ class _QueryaDropdownState extends material.State> { return QueryaControlScope.maybeOf(context) ?? QueryaControlSize.lg; } - List _menuChildren(QueryaThemeColorScheme cs, QueryaControlSize size) { + List _menuChildren(QueryaUiColors cs, QueryaControlSize size) { if (_cachedMenuChildren != null && listEquals(_cachedMenuItems, widget.items) && _cachedMenuValue == widget.value && @@ -172,7 +172,7 @@ class _QueryaDropdownState extends material.State> { material.Widget _triggerLabelText({ required material.BuildContext context, required String label, - required QueryaThemeColorScheme cs, + required QueryaUiColors cs, required bool expand, required QueryaControlSize size, }) { @@ -204,14 +204,14 @@ class _QueryaDropdownState extends material.State> { material.Widget _menuItem( QueryaDropdownItem item, - QueryaThemeColorScheme cs, + QueryaUiColors cs, QueryaControlSize size, ) { return _QueryaDropdownMenuItem( item: item, selected: item.value == widget.value, enabled: widget.enabled && item.enabled, - colorScheme: cs, + colors: cs, compact: widget.compact, size: size, onPick: () { @@ -224,7 +224,7 @@ class _QueryaDropdownState extends material.State> { material.Widget _buildTrigger({ required material.BuildContext context, required material.MenuController controller, - required QueryaThemeColorScheme cs, + required QueryaUiColors cs, required String label, required double? fieldWidth, }) { @@ -503,7 +503,7 @@ class _QueryaDropdownMenuItem extends material.StatefulWidget { required this.item, required this.selected, required this.enabled, - required this.colorScheme, + required this.colors, required this.compact, required this.size, required this.onPick, @@ -512,7 +512,7 @@ class _QueryaDropdownMenuItem extends material.StatefulWidget { final QueryaDropdownItem item; final bool selected; final bool enabled; - final QueryaThemeColorScheme colorScheme; + final QueryaUiColors colors; final bool compact; final QueryaControlSize size; final material.VoidCallback onPick; @@ -527,7 +527,7 @@ class _QueryaDropdownMenuItemState bool _hovered = false; bool _focused = false; - material.Widget _leading(material.BuildContext context, QueryaThemeColorScheme cs) { + material.Widget _leading(material.BuildContext context, QueryaUiColors cs) { final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); final checkSize = context.scaled(QueryaDropdownTokens.selectedCheckSize); if (widget.selected) { @@ -545,7 +545,7 @@ class _QueryaDropdownMenuItemState @override material.Widget build(material.BuildContext context) { - final cs = widget.colorScheme; + final cs = widget.colors; final bg = (_hovered || _focused) ? cs.accent.withValues(alpha: 0.14) : widget.selected diff --git a/lib/shared/widgets/querya_menu_bar.dart b/lib/shared/widgets/querya_menu_bar.dart new file mode 100644 index 00000000..072afc4e --- /dev/null +++ b/lib/shared/widgets/querya_menu_bar.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/shared/widgets/querya_context_menu.dart'; +import 'package:querya_desktop/shared/widgets/querya_menu_surface.dart'; + +/// Top-level application menu bar for the window title bar (#1732). +/// +/// Replaces shadcn's `Menubar`. Supports keyboard navigation: Alt / F10 focuses +/// the menu bar, arrow keys navigate between and inside menus, Esc dismisses. +class QueryaMenuBar extends material.StatefulWidget { + const QueryaMenuBar({ + super.key, + required this.children, + this.controller, + }); + + /// The top-level menu triggers (typically [QueryaMenuItem] with [subMenu]). + final List children; + + /// Optional menu controller. + final material.MenuController? controller; + + @override + material.State createState() => _QueryaMenuBarState(); +} + +class _QueryaMenuBarState extends material.State { + final material.FocusNode _focusNode = + material.FocusNode(debugLabel: 'QueryaMenuBar'); + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + return material.Focus( + focusNode: _focusNode, + child: material.MenuBar( + controller: widget.controller, + style: const material.MenuStyle( + backgroundColor: + material.WidgetStatePropertyAll(material.Colors.transparent), + surfaceTintColor: + material.WidgetStatePropertyAll(material.Colors.transparent), + shadowColor: + material.WidgetStatePropertyAll(material.Colors.transparent), + elevation: material.WidgetStatePropertyAll(0), + padding: material.WidgetStatePropertyAll(material.EdgeInsets.zero), + ), + children: [ + for (final child in widget.children) + _wrapTopLevelItem(context, child), + ], + ), + ); + } + + material.Widget _wrapTopLevelItem( + material.BuildContext context, + material.Widget item, + ) { + if (item is QueryaMenuItem && item.subMenu != null) { + final cs = context.colors; + return material.SubmenuButton( + key: item.key, + menuStyle: queryaMenuStyle(context), + style: material.SubmenuButton.styleFrom( + padding: + const material.EdgeInsets.symmetric(horizontal: 8, vertical: 0), + minimumSize: const material.Size(0, 26), + foregroundColor: cs.foreground, + overlayColor: cs.accent.withValues(alpha: 0.14), + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(4), + ), + textStyle: const material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w500, + ), + ), + leadingIcon: item.leading, + trailingIcon: item.trailing, + menuChildren: item.subMenu!, + child: item.child, + ); + } + return item; + } +} diff --git a/lib/shared/widgets/querya_modal_dialog.dart b/lib/shared/widgets/querya_modal_dialog.dart index a551cdea..8432b600 100644 --- a/lib/shared/widgets/querya_modal_dialog.dart +++ b/lib/shared/widgets/querya_modal_dialog.dart @@ -139,7 +139,9 @@ class QueryaModalDialog extends material.StatelessWidget { // Content if (content != null) ...[ const material.SizedBox(height: 16), - content!, + constraints.hasBoundedHeight + ? material.Flexible(child: content!) + : content!, ], // Actions diff --git a/lib/shared/widgets/querya_spinner.dart b/lib/shared/widgets/querya_spinner.dart index 5d6e70e5..3c680131 100644 --- a/lib/shared/widgets/querya_spinner.dart +++ b/lib/shared/widgets/querya_spinner.dart @@ -48,11 +48,11 @@ class QueryaSpinner extends material.StatelessWidget { material.Widget build(material.BuildContext context) { final theme = QueryaThemeScope.maybeOf(context); final workbench = theme?.workbench; - final fallback = material.Theme.of(context).colorScheme; + final colors = context.colors; final dimension = customDimension ?? size.dimension; final stroke = strokeWidth ?? size.strokeWidth; - final effectiveColor = color ?? workbench?.accent ?? fallback.primary; + final effectiveColor = color ?? workbench?.accent ?? colors.primary; final spinner = material.SizedBox( width: dimension, @@ -77,7 +77,7 @@ class QueryaSpinner extends material.StatelessWidget { label!, style: material.TextStyle( fontSize: size == QueryaSpinnerSize.sm ? 11 : 12, - color: workbench?.mutedForeground ?? fallback.onSurfaceVariant, + color: workbench?.mutedForeground ?? colors.mutedForeground, ), ), ], diff --git a/lib/shared/widgets/querya_text_field.dart b/lib/shared/widgets/querya_text_field.dart new file mode 100644 index 00000000..bf0a3515 --- /dev/null +++ b/lib/shared/widgets/querya_text_field.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/shared/widgets/querya_button.dart'; + +/// The text input of the kit (#1733, epic #1465): one field for every form. +/// +/// A single-line field has the height of a [QueryaControlSize] (the nearest +/// [QueryaControlScope], else `md`), so a field and a button in one row line +/// up; a field with `maxLines` above 1 (or `minLines`, or `expands`) grows +/// with its text. The border takes the accent while the field has the +/// keyboard and the destructive colour with an [errorText]. +/// +/// [leading] and [trailing] hold an icon or a small button; with +/// [obscureText] a show / hide button is the trailing slot unless [trailing] +/// is given. +class QueryaTextField extends material.StatefulWidget { + const QueryaTextField({ + super.key, + this.controller, + this.focusNode, + this.placeholder, + this.onChanged, + this.onSubmitted, + this.autofocus = false, + this.enabled = true, + this.readOnly = false, + this.obscureText = false, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.keyboardType, + this.inputFormatters, + this.textInputAction, + this.size, + this.leading, + this.trailing, + this.errorText, + this.style, + this.textAlignVertical, + this.framed = true, + this.textAlign = material.TextAlign.start, + this.autocorrect = true, + this.contentPadding, + }); + + final material.TextEditingController? controller; + final material.FocusNode? focusNode; + + /// Hint text shown while the field is empty. + final String? placeholder; + final material.ValueChanged? onChanged; + final material.ValueChanged? onSubmitted; + final bool autofocus; + final bool enabled; + final bool readOnly; + + /// Hides the text; a trailing button reveals it. + final bool obscureText; + + /// One line by default; null or more for a multi-line field. + final int? maxLines; + final int? minLines; + final bool expands; + final material.TextInputType? keyboardType; + final List? inputFormatters; + final material.TextInputAction? textInputAction; + + /// Control size; without one the nearest [QueryaControlScope], else `md`. + final QueryaControlSize? size; + final material.Widget? leading; + final material.Widget? trailing; + + /// Shown under the field in the destructive colour; also turns the border. + final String? errorText; + + /// Text style; the size and colour of the field by default. + final material.TextStyle? style; + final material.TextAlignVertical? textAlignVertical; + + /// False for a field inside a frame of its own (an editor card): no fill, + /// border or padding. + final bool framed; + final material.TextAlign textAlign; + final bool autocorrect; + + /// Replaces the padding between the border and the text. + final material.EdgeInsetsGeometry? contentPadding; + + @override + material.State createState() => _QueryaTextFieldState(); +} + +class _QueryaTextFieldState extends material.State { + late material.FocusNode _focusNode; + bool _ownsFocusNode = false; + bool _focused = false; + bool _revealed = false; + + @override + void initState() { + super.initState(); + _attachFocusNode(); + } + + void _attachFocusNode() { + _ownsFocusNode = widget.focusNode == null; + _focusNode = widget.focusNode ?? material.FocusNode(); + _focused = _focusNode.hasFocus; + _focusNode.addListener(_onFocus); + } + + @override + void didUpdateWidget(covariant QueryaTextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + _focusNode.removeListener(_onFocus); + if (_ownsFocusNode) _focusNode.dispose(); + _attachFocusNode(); + } + } + + @override + void dispose() { + _focusNode.removeListener(_onFocus); + if (_ownsFocusNode) _focusNode.dispose(); + super.dispose(); + } + + void _onFocus() { + if (_focused != _focusNode.hasFocus) { + setState(() => _focused = _focusNode.hasFocus); + } + } + + bool get _multiline => + widget.expands || + widget.maxLines == null || + widget.maxLines! > 1 || + (widget.minLines ?? 1) > 1; + + @override + material.Widget build(material.BuildContext context) { + final cs = context.colors; + final size = widget.size ?? QueryaControlSize.of(context); + final fontSize = size.scaledFontSize(context); + final hasError = widget.errorText != null; + final muted = cs.mutedForeground; + + material.Widget? trailing = widget.trailing; + if (trailing == null && widget.obscureText) { + trailing = QueryaButton.icon( + icon: _revealed + ? material.Icons.visibility_outlined + : material.Icons.visibility_off_outlined, + tooltip: _revealed ? 'Hide' : 'Show', + size: QueryaControlSize.sm, + onPressed: widget.enabled + ? () => setState(() => _revealed = !_revealed) + : null, + ); + } + + final field = material.TextField( + controller: widget.controller, + focusNode: _focusNode, + autofocus: widget.autofocus, + enabled: widget.enabled, + readOnly: widget.readOnly, + obscureText: widget.obscureText && !_revealed, + autocorrect: widget.autocorrect, + textAlign: widget.textAlign, + maxLines: widget.expands ? null : (widget.obscureText ? 1 : widget.maxLines), + minLines: widget.expands ? null : widget.minLines, + expands: widget.expands, + keyboardType: widget.keyboardType, + inputFormatters: widget.inputFormatters, + textInputAction: widget.textInputAction, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + textAlignVertical: widget.textAlignVertical ?? + (_multiline ? material.TextAlignVertical.top : null), + style: material.TextStyle( + fontSize: fontSize, + color: widget.enabled ? cs.foreground : muted, + ).merge(widget.style), + cursorColor: cs.primary, + decoration: material.InputDecoration( + hintText: widget.placeholder, + hintStyle: material.TextStyle( + fontSize: widget.style?.fontSize ?? fontSize, color: muted), + isDense: true, + border: material.InputBorder.none, + enabledBorder: material.InputBorder.none, + focusedBorder: material.InputBorder.none, + disabledBorder: material.InputBorder.none, + errorBorder: material.InputBorder.none, + contentPadding: widget.contentPadding ?? + (widget.framed + ? material.EdgeInsets.symmetric( + vertical: _multiline ? 8 : 0, + horizontal: widget.leading == null ? 10 : 0, + ) + : material.EdgeInsets.zero), + ), + ); + + material.Widget content = widget.framed + ? material.Container( + height: _multiline ? null : size.scaledHeight(context), + constraints: _multiline + ? material.BoxConstraints(minHeight: size.scaledHeight(context)) + : null, + padding: material.EdgeInsets.only( + left: widget.leading == null ? 0 : 10, + right: trailing == null ? 0 : 4, + ), + decoration: material.BoxDecoration( + color: widget.enabled + ? cs.card + : cs.muted.withValues(alpha: 0.4), + borderRadius: + material.BorderRadius.circular(size.scaledRadius(context)), + border: material.Border.all( + color: hasError + ? cs.destructive + : _focused + ? cs.ring + : cs.border.withValues(alpha: 0.7), + ), + ), + child: material.Row( + crossAxisAlignment: _multiline + ? material.CrossAxisAlignment.start + : material.CrossAxisAlignment.center, + children: [ + if (widget.leading != null) ...[ + material.IconTheme( + data: material.IconThemeData( + size: size.scaledIconSize(context), + color: muted, + ), + child: widget.leading!, + ), + const material.SizedBox(width: 8), + ], + material.Expanded(child: field), + if (trailing != null) trailing, + ], + ), + ) + : field; + + if (hasError) { + content = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + content, + const material.SizedBox(height: 4), + material.Text( + widget.errorText!, + style: material.TextStyle(fontSize: 12, color: cs.destructive), + ), + ], + ); + } + return content; + } +} diff --git a/lib/shared/widgets/ssl_certificate_fields.dart b/lib/shared/widgets/ssl_certificate_fields.dart index d4d81873..eb55573b 100644 --- a/lib/shared/widgets/ssl_certificate_fields.dart +++ b/lib/shared/widgets/ssl_certificate_fields.dart @@ -78,10 +78,10 @@ class _SslFileField extends material.StatelessWidget { material.Row( children: [ material.Expanded( - child: TextField( + child: QueryaTextField( key: Key(label), controller: controller, - placeholder: const Text('/path/to/file.pem'), + placeholder: '/path/to/file.pem', onChanged: onChanged == null ? null : (_) => onChanged!(), ), ), diff --git a/lib/shared/widgets/tree_load_error.dart b/lib/shared/widgets/tree_load_error.dart index 3a481bfa..440c7b11 100644 --- a/lib/shared/widgets/tree_load_error.dart +++ b/lib/shared/widgets/tree_load_error.dart @@ -31,9 +31,9 @@ class TreeLoadError extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final destructive = theme.colorScheme.destructive; - final muted = theme.colorScheme.mutedForeground; + final colors = context.colors; + final destructive = colors.destructive; + final muted = colors.mutedForeground; return material.Padding( padding: padding, diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index caee584b..6d4a609c 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -10,6 +10,7 @@ library; export 'app_dialog.dart'; export 'app_toast.dart'; +export 'poll_stale_overlay.dart'; export 'export_menu_button.dart'; export 'querya_dialog_card.dart'; export 'querya_tab_strip.dart'; @@ -24,8 +25,10 @@ export 'querya_action_menu.dart' show QueryaActionMenuItem; export 'querya_badge.dart'; export 'querya_button.dart'; export 'querya_button_group.dart'; +export 'querya_context_menu.dart'; export 'querya_dialog_actions.dart'; export 'querya_divider.dart'; +export 'querya_menu_bar.dart'; export 'querya_menu_button.dart'; export 'querya_split_button.dart'; export 'querya_empty_state.dart'; @@ -34,6 +37,9 @@ export 'querya_modal_dialog.dart'; export 'querya_search_field.dart'; export 'querya_spinner.dart'; export 'querya_text.dart'; +export 'querya_text_field.dart'; +export 'querya_card.dart'; +export 'querya_color_picker.dart'; export 'tree_load_error.dart'; export 'package:querya_desktop/core/motion/querya_motion.dart'; export 'package:querya_desktop/core/motion/querya_motion_context.dart'; @@ -41,4 +47,18 @@ export 'package:querya_desktop/core/motion/querya_animated_expand.dart'; export 'package:querya_desktop/core/motion/querya_fade_slide.dart'; export 'package:querya_desktop/core/theme/querya_theme_scope.dart' show QueryaThemeContext; -export 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +export 'package:gap/gap.dart'; +export 'package:shadcn_flutter/shadcn_flutter.dart' + hide + Gap, + MaxGap, + SliverGap, + TextExtension, + ContextMenu, + MenuButton, + MenuDivider, + MenuItem, + Menubar, + InputFeature, + TextField; + diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 32253666..70bca4ed 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -74,7 +74,7 @@ 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* ReleaseUnsigned.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = ReleaseUnsigned.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; @@ -165,7 +165,7 @@ 33CC10F02044A3C60003C045 /* AppDelegate.swift */, 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, + 33E51914231749380026EE4D /* ReleaseUnsigned.entitlements */, 33CC11242044D66E0003C045 /* Resources */, 33BA886A226E78AF003329D5 /* Configs */, ); @@ -627,7 +627,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_ENTITLEMENTS = Runner/ReleaseUnsigned.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = Runner/Info.plist; diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index b3c17614..0a671e1e 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -3,6 +3,26 @@ import FlutterMacOS @main class AppDelegate: FlutterAppDelegate { + /// Files Finder handed over ("Open With", double-click) before Dart asked + /// for them, and the channel to Dart once the window has an engine (#1589). + private var pendingPaths: [String] = [] + private var fileOpenChannel: FlutterMethodChannel? + private var dartTookPending = false + + override init() { + super.init() + // macOS sends files as an Open Documents Apple Event, not in argv. The + // handler is installed when launching starts, which replaces AppKit's + // default one, so it does not depend on the delegate methods of + // FlutterAppDelegate. + NotificationCenter.default.addObserver( + self, + selector: #selector(installOpenDocumentsHandler(_:)), + name: NSApplication.willFinishLaunchingNotification, + object: nil + ) + } + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } @@ -10,4 +30,61 @@ class AppDelegate: FlutterAppDelegate { override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } + + @objc private func installOpenDocumentsHandler(_ notification: Notification) { + NSAppleEventManager.shared().setEventHandler( + self, + andSelector: #selector(handleOpenDocuments(_:withReplyEvent:)), + forEventClass: AEEventClass(kCoreEventClass), + andEventID: AEEventID(kAEOpenDocuments) + ) + } + + @objc private func handleOpenDocuments( + _ event: NSAppleEventDescriptor, + withReplyEvent reply: NSAppleEventDescriptor + ) { + guard let list = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject)) else { + return + } + var paths: [String] = [] + if list.numberOfItems == 0 { + if let url = list.fileURLValue { paths.append(url.path) } + } else { + for index in 1...list.numberOfItems { + if let url = list.atIndex(index)?.fileURLValue { paths.append(url.path) } + } + } + deliver(paths) + } + + private func deliver(_ paths: [String]) { + guard !paths.isEmpty else { return } + if dartTookPending, let channel = fileOpenChannel { + channel.invokeMethod("openFiles", arguments: paths) + } else { + pendingPaths.append(contentsOf: paths) + } + } + + /// Called by the main window once its Flutter engine exists. + func attachFileOpenChannel(messenger: FlutterBinaryMessenger) { + let channel = FlutterMethodChannel(name: "querya/file_open", binaryMessenger: messenger) + channel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { + result(nil) + return + } + if call.method == "takePendingFiles" { + // From now on files go straight to Dart. + let paths = self.pendingPaths + self.pendingPaths.removeAll() + self.dartTookPending = true + result(paths) + } else { + result(FlutterMethodNotImplemented) + } + } + fileOpenChannel = channel + } } diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 82b6f9d9..8c54ac28 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 13b35eba..6242f6ed 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 0a3f5fa4..be655ee8 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index bdb57226..9a74f411 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index f083318e..345df27c 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 326c0e72..8cabeae9 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 2f1632cf..74a50968 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index b8e380c0..3a66dfd1 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -14,6 +14,9 @@ class MainFlutterWindow: BitsdojoWindow { self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) + (NSApp.delegate as? AppDelegate)?.attachFileOpenChannel( + messenger: flutterViewController.engine.binaryMessenger + ) super.awakeFromNib() } diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements deleted file mode 100644 index c1a22b8a..00000000 --- a/macos/Runner/Release.entitlements +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.network.client - - com.apple.security.network.server - - com.apple.security.files.user-selected.read-write - - - diff --git a/macos/Runner/ReleaseUnsigned.entitlements b/macos/Runner/ReleaseUnsigned.entitlements new file mode 100644 index 00000000..4ef59103 --- /dev/null +++ b/macos/Runner/ReleaseUnsigned.entitlements @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packaging/linux/aur/querya_desktop.png b/packaging/linux/aur/querya_desktop.png index 326c0e72..8cabeae9 100644 Binary files a/packaging/linux/aur/querya_desktop.png and b/packaging/linux/aur/querya_desktop.png differ diff --git a/packaging/linux/flatpak/com.queryahub.querya_desktop.png b/packaging/linux/flatpak/com.queryahub.querya_desktop.png index 326c0e72..8cabeae9 100644 Binary files a/packaging/linux/flatpak/com.queryahub.querya_desktop.png and b/packaging/linux/flatpak/com.queryahub.querya_desktop.png differ diff --git a/packaging/windows/querya.iss b/packaging/windows/querya.iss index 32c42d3b..37c86ec0 100644 --- a/packaging/windows/querya.iss +++ b/packaging/windows/querya.iss @@ -42,6 +42,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "openwith"; Description: "Show Querya in the ""Open with"" menu for .sql and SQLite files"; GroupDescription: "File associations:"; Flags: unchecked Name: "assoc_sql"; Description: "Register Querya as an editor for .sql files"; GroupDescription: "File associations:"; Flags: unchecked Name: "assoc_sqlite"; Description: "Associate Querya with SQLite database files (.sqlite, .db)"; GroupDescription: "File associations:"; Flags: unchecked @@ -76,12 +77,12 @@ Root: HKCR; Subkey: "Querya.SQLite"; ValueType: string; ValueName: ""; ValueData Root: HKCR; Subkey: "Querya.SQLite\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Tasks: assoc_sqlite Root: HKCR; Subkey: "Querya.SQLite\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Tasks: assoc_sqlite -; Windows Applications registry (ensures presence in modern Open With menu) -Root: HKCR; Subkey: "Applications\{#MyAppExeName}"; ValueType: string; ValueName: "FriendlyAppName"; ValueData: "{#MyAppName}"; Flags: uninsdeletekey -Root: HKCR; Subkey: "Applications\{#MyAppExeName}\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0" -Root: HKCR; Subkey: "Applications\{#MyAppExeName}\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1""" -Root: HKCR; Subkey: "Applications\{#MyAppExeName}\SupportedTypes"; ValueType: string; ValueName: ".sql"; ValueData: "" -Root: HKCR; Subkey: "Applications\{#MyAppExeName}\SupportedTypes"; ValueType: string; ValueName: ".sqlite"; ValueData: "" -Root: HKCR; Subkey: "Applications\{#MyAppExeName}\SupportedTypes"; ValueType: string; ValueName: ".sqlite3"; ValueData: "" -Root: HKCR; Subkey: "Applications\{#MyAppExeName}\SupportedTypes"; ValueType: string; ValueName: ".db"; ValueData: "" -Root: HKCR; Subkey: "Applications\{#MyAppExeName}\SupportedTypes"; ValueType: string; ValueName: ".db3"; ValueData: "" +; Windows Applications registry (optional via task: presence in the Open With menu) +Root: HKCR; Subkey: "Applications\{#MyAppExeName}"; ValueType: string; ValueName: "FriendlyAppName"; ValueData: "{#MyAppName}"; Flags: uninsdeletekey; Tasks: openwith +Root: HKCR; Subkey: "Applications\{#MyAppExeName}\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Tasks: openwith +Root: HKCR; Subkey: "Applications\{#MyAppExeName}\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Tasks: openwith +Root: HKCR; Subkey: "Applications\{#MyAppExeName}\SupportedTypes"; ValueType: string; ValueName: ".sql"; ValueData: ""; Tasks: openwith +Root: HKCR; Subkey: "Applications\{#MyAppExeName}\SupportedTypes"; ValueType: string; ValueName: ".sqlite"; ValueData: ""; Tasks: openwith +Root: HKCR; Subkey: "Applications\{#MyAppExeName}\SupportedTypes"; ValueType: string; ValueName: ".sqlite3"; ValueData: ""; Tasks: openwith +Root: HKCR; Subkey: "Applications\{#MyAppExeName}\SupportedTypes"; ValueType: string; ValueName: ".db"; ValueData: ""; Tasks: openwith +Root: HKCR; Subkey: "Applications\{#MyAppExeName}\SupportedTypes"; ValueType: string; ValueName: ".db3"; ValueData: ""; Tasks: openwith diff --git a/pubspec.yaml b/pubspec.yaml index 1a80a8f5..422cf781 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). publish_to: 'none' # app, never published to pub.dev (allows path dependencies) -version: 0.4.22+5 +version: 0.5.0-beta.1+6 @@ -14,8 +14,10 @@ dependencies: sdk: flutter http: ^1.2.2 crypto: ^3.0.6 - refresh_rate: ^1.0.2 + refresh_rate: ^2.0.1 shadcn_flutter: ^0.0.52 + # Spacing widget; shadcn_flutter re-exported it, now taken directly (#1729). + gap: ^3.0.1 bitsdojo_window: ^0.1.6 path: ^1.9.0 path_provider: ^2.1.5 @@ -30,7 +32,9 @@ dependencies: flutter_secure_storage: ^10.3.1 file_selector: ^1.1.0 syntax_highlight: ^0.5.0 - archive: ^4.2.0 + # 4.5.0+: a zip entry is not decoded past the size its header declares, + # which SafeZipExtractor's limits rely on (#1561). + archive: ^4.5.2 url_launcher: ^6.3.1 package_info_plus: ^9.0.1 flutter_svg: ^2.3.0 @@ -54,16 +58,14 @@ dependency_overrides: # Patched ToastLayer (fixes InheritedNotifier crash on resize / hot reload). shadcn_flutter: path: third_party/shadcn_flutter - # Linux -Werror: fix uninitialized rates[0] in refresh_rate 1.0.2. - refresh_rate: - path: third_party/refresh_rate flutter: uses-material-design: true config: - # The vendored refresh_rate plugin's Package.swift points its target at - # ../Classes, outside the package root, so Swift Package Manager (on by - # default since Flutter 3.47) cannot resolve it; macOS builds use CocoaPods. + # Swift Package Manager (on by default since Flutter 3.47) is off for every + # plugin: macOS builds use CocoaPods. The vendored refresh_rate plugin that + # needed this is gone (refresh_rate 2.x has a valid Package.swift); whether + # to turn it back on is its own change, to be tried on a Mac. enable-swift-package-manager: false assets: - assets/images/ diff --git a/scripts/ci/__pycache__/app_bench_report.cpython-314.pyc b/scripts/ci/__pycache__/app_bench_report.cpython-314.pyc new file mode 100644 index 00000000..bbf27aa4 Binary files /dev/null and b/scripts/ci/__pycache__/app_bench_report.cpython-314.pyc differ diff --git a/scripts/ci/app_bench_report.py b/scripts/ci/app_bench_report.py index 2debe55f..2f3b5486 100644 --- a/scripts/ci/app_bench_report.py +++ b/scripts/ci/app_bench_report.py @@ -6,8 +6,9 @@ Reads the `BENCH frames=.. build p50/p90/max=.. raster ..` lines and checks each scenario of LIMITS: build p90 against its limit (ms), and for multi_tab_run the number of query panes rebuilt by one run. The first output -line is `OVER=`, the number of scenarios over a limit or missing. -Informational: always exits 0. +line is `OVER=`, the number of scenarios over a limit or missing. A run that +aborted prints `BENCH FAILED: ` (`flutter run` exits 0 whatever the +app does), which the report names. Informational: always exits 0. """ import re import sys @@ -32,6 +33,13 @@ r"build p50/p90/max=([\d.]+)/([\d.]+)/([\d.]+)\s+" r"raster p50/p90/max=([\d.]+)/([\d.]+)/([\d.]+).*?stutters=(\d+)") _PANES = re.compile(r"BENCH multi_tab_run panes rebuilt=(\d+)") +_FAILED = re.compile(r"BENCH FAILED: (.*)$", re.M) + + +def failure(text): + """The reason the bench aborted, or None.""" + m = _FAILED.search(text) + return m[1].strip() if m else None def parse(text): @@ -75,6 +83,9 @@ def report(text): over += bad lines += ["", f"Query panes rebuilt by one run in one of five tabs: " f"**{panes}** (limit {MAX_PANES_REBUILT}){' ⚠️' if bad else ''}."] + failed = failure(text) + if failed: + lines += ["", f"⚠️ The run aborted: `{failed}`. Scenarios after it did not run."] lines += ["", "Times in ms, profile build under xvfb with software " "rendering. Informational, never blocks a merge."] return over, "\n".join(lines) diff --git a/scripts/ci/test_app_bench_report.py b/scripts/ci/test_app_bench_report.py index 5485b84a..93fb4b3a 100644 --- a/scripts/ci/test_app_bench_report.py +++ b/scripts/ci/test_app_bench_report.py @@ -64,6 +64,16 @@ def test_rebuilding_every_pane_is_flagged(self): self.assertEqual(over, 1) self.assertIn("**5**", body) + def test_an_aborted_run_is_named(self): + text = line("charts_5000") + "\nBENCH FAILED: Bad state: timeout waiting for the Diagram tab\nstack\n" + self.assertEqual(abr.failure(text), "Bad state: timeout waiting for the Diagram tab") + _, body = abr.report(text) + self.assertIn("The run aborted: `Bad state: timeout waiting for the Diagram tab`", body) + + def test_a_clean_run_has_no_failure(self): + self.assertIsNone(abr.failure(all_ok())) + self.assertNotIn("aborted", abr.report(all_ok())[1]) + def test_main_prints_the_count_first(self): with tempfile.TemporaryDirectory() as d: p = os.path.join(d, "app.log") diff --git a/scripts/dev/__pycache__/mcp_stdio_check.cpython-314.pyc b/scripts/dev/__pycache__/mcp_stdio_check.cpython-314.pyc new file mode 100644 index 00000000..18663043 Binary files /dev/null and b/scripts/dev/__pycache__/mcp_stdio_check.cpython-314.pyc differ diff --git a/scripts/dev/devcontainer_start.sh b/scripts/dev/devcontainer_start.sh new file mode 100755 index 00000000..3de37616 --- /dev/null +++ b/scripts/dev/devcontainer_start.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Runs every time the dev container starts (devcontainer.json: postStartCommand). +# +# 1. starts the database stack on the host's Docker daemon and waits until the +# long-running services are healthy, with a clear status instead of hiding a +# failure; +# 2. joins this container to the stack's network, so the service names resolve; +# 3. makes `localhost:` work inside the container by forwarding the usual +# ports to the services (the databases run next to the dev container, not in +# it, so nothing listens on its loopback otherwise). +# +# It never fails the container start: a dev container without the databases is +# still useful for builds and tests. +set -u + +compose_file="docker/docker-compose.yml" +project="querya-dev" +timeout="${QUERYA_STACK_TIMEOUT:-180}" + +say() { printf '[querya-dev] %s\n' "$*"; } + +if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + say "Docker is not reachable from the container: the database stack is not started." + say "Check that the host daemon runs, then: cd docker && docker compose up -d" + exit 0 +fi + +say "starting the database stack ..." +if ! docker compose -f "$compose_file" up -d; then + say "docker compose up failed (see the output above)." + exit 0 +fi + +# Long-running services with a healthcheck; the seed jobs exit on their own. +services=(querya-postgres querya-mysql querya-redis querya-mongo querya-clickhouse) +deadline=$((SECONDS + timeout)) +pending=("${services[@]}") +while [ "${#pending[@]}" -gt 0 ] && [ "$SECONDS" -lt "$deadline" ]; do + next=() + for name in "${pending[@]}"; do + state="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$name" 2>/dev/null || echo missing)" + case "$state" in + healthy | running) ;; + *) next+=("$name") ;; + esac + done + pending=("${next[@]}") + [ "${#pending[@]}" -gt 0 ] && sleep 3 +done + +if [ "${#pending[@]}" -gt 0 ]; then + say "not healthy after ${timeout}s: ${pending[*]} (docker compose -f $compose_file ps)" +else + say "all databases are healthy." +fi + +# Join the stack's network (a no-op when it is already joined, and not possible +# with host networking, where localhost already reaches the published ports). +docker network connect "${project}_default" "$(hostname)" >/dev/null 2>&1 || true + +# localhost: -> service:. Skipped for a port something already +# listens on, and for a service name that does not resolve. +forward() { + local port="$1" host="$2" + if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then + return 0 + fi + if ! getent hosts "$host" >/dev/null 2>&1; then + return 0 + fi + command -v socat >/dev/null 2>&1 || return 0 + nohup socat "TCP-LISTEN:${port},bind=127.0.0.1,fork,reuseaddr" "TCP:${host}:${port}" \ + >/dev/null 2>&1 & +} + +forward 5432 postgres +forward 3306 mysql +forward 6379 redis +forward 27017 mongo +forward 8123 clickhouse +forward 9000 clickhouse +# Profile `live-tests`: MySQL 8.0 with mysql_native_password, its own host port. +forward "${MYSQL_LIVE_PORT:-3307}" mysql-live + +say "postgres :5432 mysql :3306 redis :6379 mongo :27017 clickhouse :8123/:9000 on localhost" +docker compose -f "$compose_file" ps --format 'table {{.Name}}\t{{.Status}}' 2>/dev/null || true +exit 0 diff --git a/scripts/dev/headless_app.sh b/scripts/dev/headless_app.sh new file mode 100755 index 00000000..790fdfc7 --- /dev/null +++ b/scripts/dev/headless_app.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# L2 headless run: the real Querya app under a virtual display, in portable +# mode, on a throw-away data directory. No window server or GPU is needed. +# +# scripts/dev/headless_app.sh [options] +# +# --bundle DIR built bundle (default: build/linux/x64/release/bundle, +# then .../debug/bundle); must contain `querya_desktop` +# --screenshot FILE save a screenshot of the virtual screen (PNG) +# --wait SECONDS how long to wait for the window (default 60) +# --keep SECONDS how long it has to stay alive after the window shows up +# (default 3); catches crashes right after start +# --log FILE copy the app's stdout / stderr there +# --keep-data do not delete the temporary data directory +# --seed-sql FILE SQL applied to the profile database (querya.db) before +# the app under test starts: the app runs once to create +# and migrate the database, is stopped, FILE is applied +# with sqlite3, and the app starts again +# --run COMMAND shell command to run once the window is up (the app +# stays up meanwhile); the exit code is its exit code. +# Gets QUERYA_MCP_ENDPOINT (this run's own endpoint file), +# QUERYA_HEADLESS_DB and QUERYA_HEADLESS_DATA +# -h, --help this text +# +# Exit codes: 0 the app started, showed a window and stayed up (and --run +# succeeded); 1 it died, no window appeared in time, or --run failed; 2 usage +# error, missing bundle or missing tool. +# +# Needs: Xvfb; xdotool or xwininfo to see the window (without them the check is +# "the process is still alive after --wait"); ImageMagick `import` for +# --screenshot. Environment: QUERYA_HEADLESS_DISPLAY to force a display number. +set -u + +bundle="" +screenshot="" +wait_s=60 +keep_s=3 +log_out="" +keep_data=0 +seed_sql="" +run_cmd="" + +usage() { sed -n '2,/^set -u/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; } +die() { printf 'headless_app: %s\n' "$*" >&2; exit 2; } +say() { printf 'headless_app: %s\n' "$*"; } + +while [ $# -gt 0 ]; do + case "$1" in + --bundle) bundle="${2:-}"; shift 2 ;; + --screenshot) screenshot="${2:-}"; shift 2 ;; + --wait) wait_s="${2:-}"; shift 2 ;; + --keep) keep_s="${2:-}"; shift 2 ;; + --log) log_out="${2:-}"; shift 2 ;; + --keep-data) keep_data=1; shift ;; + --seed-sql) seed_sql="${2:-}"; shift 2 ;; + --run) run_cmd="${2:-}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + *) die "unknown option: $1 (see --help)" ;; + esac +done + +if [ -z "$bundle" ]; then + for candidate in build/linux/x64/release/bundle build/linux/x64/debug/bundle; do + if [ -x "$candidate/querya_desktop" ]; then bundle="$candidate"; break; fi + done +fi +[ -n "$bundle" ] || die "no bundle found; build one (flutter build linux) or pass --bundle DIR" +[ -x "$bundle/querya_desktop" ] || die "$bundle/querya_desktop is missing or not executable" +command -v Xvfb >/dev/null 2>&1 || die "Xvfb is not installed (apt-get install xvfb)" +if [ -n "$seed_sql" ]; then + [ -f "$seed_sql" ] || die "--seed-sql: $seed_sql is not a file" + command -v sqlite3 >/dev/null 2>&1 || die "--seed-sql needs the sqlite3 command (apt-get install sqlite3)" +fi +if [ -n "$screenshot" ] && ! command -v import >/dev/null 2>&1; then + die "--screenshot needs ImageMagick's 'import' (apt-get install imagemagick)" +fi + +work="$(mktemp -d "${TMPDIR:-/tmp}/querya-headless.XXXXXX")" +app_pid="" +xvfb_pid="" + +cleanup() { + local status=$? + if [ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null; then + kill -TERM "$app_pid" 2>/dev/null + for _ in $(seq 1 20); do + kill -0 "$app_pid" 2>/dev/null || break + sleep 0.5 + done + kill -KILL "$app_pid" 2>/dev/null + fi + if [ -n "$xvfb_pid" ]; then + kill -TERM "$xvfb_pid" 2>/dev/null + wait "$xvfb_pid" 2>/dev/null + fi + if [ -n "$log_out" ] && [ -f "$work/app.log" ]; then + cp "$work/app.log" "$log_out" 2>/dev/null + fi + if [ "$keep_data" -eq 1 ]; then + say "data and log kept in $work" + else + rm -rf "$work" + fi + exit "$status" +} +trap cleanup EXIT + +fail_with_log() { + say "$1" + if [ -f "$work/app.log" ]; then + say "--- last lines of the app output ---" + tail -n 40 "$work/app.log" >&2 + fi + exit 1 +} + +# A free display number. +if [ -n "${QUERYA_HEADLESS_DISPLAY:-}" ]; then + num="${QUERYA_HEADLESS_DISPLAY#:}" +else + num=99 + while [ -e "/tmp/.X${num}-lock" ] || [ -S "/tmp/.X11-unix/X${num}" ]; do + num=$((num + 1)) + [ "$num" -lt 200 ] || die "no free X display between :99 and :199" + done +fi +export DISPLAY=":${num}" + +Xvfb "$DISPLAY" -screen 0 1280x800x24 -nolisten tcp >"$work/xvfb.log" 2>&1 & +xvfb_pid=$! +for _ in $(seq 1 40); do + [ -S "/tmp/.X11-unix/X${num}" ] && break + kill -0 "$xvfb_pid" 2>/dev/null || { cat "$work/xvfb.log" >&2; die "Xvfb did not start"; } + sleep 0.25 +done +[ -S "/tmp/.X11-unix/X${num}" ] || die "Xvfb on $DISPLAY did not come up" +say "virtual display $DISPLAY" + +# Portable mode keeps its data in {bundle}/QueryaData, so run a copy: the +# bundle that was built stays untouched and every run starts on an empty profile. +cp -a "$bundle" "$work/bundle" || die "could not copy $bundle" +export QUERYA_PORTABLE=1 +export NO_AT_BRIDGE=1 + +# Own endpoint file, so that a second app on the machine (or a leftover one) +# can never be the one querya-mcp talks to. +export QUERYA_MCP_ENDPOINT="$work/mcp-endpoint.json" + +runner=() +if command -v dbus-run-session >/dev/null 2>&1; then + runner=(dbus-run-session --) +fi + +start_app() { + ${runner[@]+"${runner[@]}"} "$work/bundle/querya_desktop" >>"$work/app.log" 2>&1 & + app_pid=$! + say "started querya_desktop (pid $app_pid)" +} + +stop_app() { + [ -n "$app_pid" ] || return 0 + kill -TERM "$app_pid" 2>/dev/null + for _ in $(seq 1 20); do + kill -0 "$app_pid" 2>/dev/null || break + sleep 0.5 + done + kill -KILL "$app_pid" 2>/dev/null + wait "$app_pid" 2>/dev/null + app_pid="" +} + +have_window_tool=0 +if command -v xdotool >/dev/null 2>&1 || command -v xwininfo >/dev/null 2>&1; then + have_window_tool=1 +fi + +window_up() { + if command -v xdotool >/dev/null 2>&1; then + xdotool search --onlyvisible --name '.' >/dev/null 2>&1 + else + # A top-level window with a real size (nothing else maps one on a bare Xvfb). + xwininfo -root -tree 2>/dev/null | grep -Eq '[0-9]{3,4}x[0-9]{3,4}\+' + fi +} + +wait_for_window() { + local deadline=$((SECONDS + wait_s)) shown=0 + while [ "$SECONDS" -lt "$deadline" ]; do + kill -0 "$app_pid" 2>/dev/null || fail_with_log "the app exited before showing a window" + if [ "$have_window_tool" -eq 1 ]; then + if window_up; then shown=1; break; fi + fi + sleep 1 + done + if [ "$have_window_tool" -eq 1 ]; then + [ "$shown" -eq 1 ] || fail_with_log "no window after ${wait_s}s" + say "window is up" + else + say "no xdotool / xwininfo: only checking that the process stays alive" + fi +} + +start_app +wait_for_window + +if [ -n "$seed_sql" ]; then + # The first start created and migrated the profile database. Let it settle, + # stop the app (it holds the file open), seed, and start again. + sleep 2 + stop_app + db="$(find "$work/bundle" -name querya.db -print -quit 2>/dev/null)" + [ -n "$db" ] || fail_with_log "the profile database querya.db was not created" + sqlite3 "$db" <"$seed_sql" || fail_with_log "could not apply $seed_sql to $db" + say "seeded $(basename "$seed_sql") into $db" + start_app + wait_for_window +fi + +end=$((SECONDS + keep_s)) +while [ "$SECONDS" -lt "$end" ]; do + kill -0 "$app_pid" 2>/dev/null || fail_with_log "the app exited after showing its window" + sleep 0.5 +done + +if [ -n "$screenshot" ]; then + mkdir -p "$(dirname "$screenshot")" + if import -display "$DISPLAY" -window root "$screenshot" 2>"$work/import.log"; then + say "screenshot: $screenshot" + else + cat "$work/import.log" >&2 + fail_with_log "could not take the screenshot" + fi +fi + +if [ -n "$run_cmd" ]; then + export QUERYA_HEADLESS_DB + QUERYA_HEADLESS_DB="$(find "$work/bundle" -name querya.db -print -quit 2>/dev/null)" + export QUERYA_HEADLESS_DATA="$work/bundle/QueryaData" + say "running: $run_cmd" + if ! bash -c "$run_cmd"; then + fail_with_log "--run command failed" + fi + kill -0 "$app_pid" 2>/dev/null || fail_with_log "the app died while the command ran" +fi + +say "ok" +exit 0 diff --git a/scripts/dev/headless_mcp_check.sh b/scripts/dev/headless_mcp_check.sh new file mode 100755 index 00000000..1167e047 --- /dev/null +++ b/scripts/dev/headless_mcp_check.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# L2 headless MCP end to end: the real app under Xvfb with its MCP server on, +# one fixture SQLite connection shared, and a client talking MCP to it through +# the real `querya-mcp` bridge. +# +# scripts/dev/headless_mcp_check.sh --mcp PATH [--bundle DIR] [--log FILE] +# +# --mcp PATH the querya-mcp executable (dart compile exe +# packages/querya_mcp_bridge/bin/querya_mcp.dart -o querya-mcp) +# --bundle DIR built bundle, as for headless_app.sh +# --log FILE the app's output, as for headless_app.sh +# +# Needs what headless_app.sh needs, plus sqlite3 and python3. Exit codes are +# headless_app.sh's: 0 all checks passed, 1 something failed, 2 usage / tools. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +mcp="" +pass=() +while [ $# -gt 0 ]; do + case "$1" in + --mcp) mcp="${2:-}"; shift 2 ;; + --bundle | --log) pass+=("$1" "${2:-}"); shift 2 ;; + -h | --help) sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "headless_mcp_check: unknown option: $1" >&2; exit 2 ;; + esac +done +[ -x "$mcp" ] || { echo "headless_mcp_check: --mcp must point at the querya-mcp executable" >&2; exit 2; } +command -v python3 >/dev/null 2>&1 || { echo "headless_mcp_check: python3 is missing" >&2; exit 2; } +command -v sqlite3 >/dev/null 2>&1 || { echo "headless_mcp_check: sqlite3 is missing" >&2; exit 2; } + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/querya-mcp-check.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +# The database the model will query. +sqlite3 "$tmp/fixture.db" <<'SQL' +CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL NOT NULL); +INSERT INTO items (name, price) VALUES ('alpha', 10), ('beta', 20), ('gamma', 30); +SQL + +# What the settings page would store: the connection, the server on, the +# connection shared. A second connection stays unshared, so the check can see +# that sharing is opt-in. +cat >"$tmp/seed.sql" </dev/null 2>&1; then fi VERSION="$(grep '^version:' "$ROOT/pubspec.yaml" | sed 's/^version: //; s/+.*//')" +# The file keeps the tag form; the package version sorts a beta before its +# final release (0.5.0~beta.1, #1494). +DEB_VERSION="$("$ROOT/scripts/linux/package_version.sh" "$VERSION")" OUT="${2:-$ROOT/Querya-Desktop-${VERSION}-linux.deb}" ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)" @@ -61,7 +64,7 @@ INSTALLED_SIZE="$(du -sk "$PKG" | awk '{print $1}')" cat > "$PKG/DEBIAN/control" </dev/null 2>&1; then fi VERSION="$(grep '^version:' "$ROOT/pubspec.yaml" | sed 's/^version: //; s/+.*//')" -# RPM Version must not contain '-' (it separates Version from Release). -# Keep artifact filename as the pubspec/marketing version (e.g. 0.4.11-b). -RPM_VERSION="${VERSION//-/.}" +# RPM Version must not contain '-' (it separates Version from Release), and a +# beta must sort before its final release: 0.5.0-beta.1 -> 0.5.0~beta.1 (#1494). +# The artifact file name keeps the pubspec / tag form. +RPM_VERSION="$("$ROOT/scripts/linux/package_version.sh" "$VERSION")" OUT="${2:-$ROOT/Querya-Desktop-${VERSION}-linux.rpm}" WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/querya-rpm.XXXXXX")" diff --git a/scripts/linux/package_version.sh b/scripts/linux/package_version.sh new file mode 100755 index 00000000..009d09e9 --- /dev/null +++ b/scripts/linux/package_version.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# The version string a .deb / .rpm carries for a pubspec version (#1494). +# +# ./scripts/linux/package_version.sh 0.5.0-beta.1 # -> 0.5.0~beta.1 +# +# or `source` it and call `package_version`. +# +# A SemVer pre-release must sort *before* its final release. As is, dpkg reads +# `0.5.0-beta.1` as upstream 0.5.0 with revision `beta.1`, newer than `0.5.0`, +# and rpm's `0.5.0.beta.1` is newer too, so beta users would never be upgraded +# to the final package. Both sort `~` before anything, even the end of the +# string: `0.5.0~beta.1 < 0.5.0`. Any further `-` becomes `.`, since `-` +# separates the Debian revision and the RPM release. + +package_version() { + printf '%s\n' "$1" | sed 's/-/~/; s/-/./g' +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + set -euo pipefail + package_version "${1:?usage: package_version.sh }" +fi diff --git a/scripts/release/check_release.sh b/scripts/release/check_release.sh new file mode 100755 index 00000000..c2ba34ab --- /dev/null +++ b/scripts/release/check_release.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Checks that a release tag can be pushed: the things the Release workflow +# needs, checked before the tag instead of failing in "Publish GitHub Release" +# afterwards (#1498). +# +# scripts/release/check_release.sh 0.5.0-beta.1 +# scripts/release/check_release.sh v0.5.0 --no-tag-check # tag already exists (CI) +# +# Checks: the tag is SemVer (X.Y.Z or X.Y.Z-pre); pubspec's version without +# `+build` equals the tag without `v`; a pre-release has a build number in +# pubspec; CHANGELOG.md has `## []`; the tag is not used yet. +# Then prints what the workflow will do with it. Exits 1 on any problem. +# +# QUERYA_RELEASE_ROOT points at another checkout (tests). +set -euo pipefail + +usage() { echo "usage: $0 [--no-tag-check]" >&2; exit 2; } +[[ $# -ge 1 ]] || usage +TAG="$1" +TAG_CHECK=1 +[[ "${2:-}" == "--no-tag-check" ]] && TAG_CHECK=0 + +SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT="${QUERYA_RELEASE_ROOT:-$(cd "$SCRIPTS/.." && pwd)}" +VERSION="${TAG#v}" +problems=0 +fail() { echo "✗ $*"; problems=$((problems + 1)); } +ok() { echo "✓ $*"; } + +if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + ok "tag $TAG is a release version" +else + fail "tag $TAG is not X.Y.Z or X.Y.Z- (letters, digits and dots)" +fi +PRERELEASE=0 +[[ "$VERSION" == *-* ]] && PRERELEASE=1 + +LINE="$(grep '^version:' "$ROOT/pubspec.yaml" | sed 's/^version:[[:space:]]*//')" +SEMVER="${LINE%%+*}" +BUILD="" +[[ "$LINE" == *+* ]] && BUILD="${LINE##*+}" +if [[ "$SEMVER" == "$VERSION" ]]; then + ok "pubspec version $LINE matches the tag" +else + fail "pubspec says $LINE; set 'version: $VERSION+' (the artifacts are named after pubspec)" +fi +if (( PRERELEASE )) && ! [[ "$BUILD" =~ ^[0-9]+$ ]]; then + fail "a pre-release needs a build number in pubspec: 'version: $VERSION+'" +fi + +if "$SCRIPTS/extract-changelog-section.sh" "$VERSION" "$ROOT/CHANGELOG.md" >/dev/null 2>&1; then + ok "CHANGELOG.md has ## [$VERSION]" +else + fail "CHANGELOG.md has no '## [$VERSION]' section (Publish GitHub Release reads it)" +fi + +if (( TAG_CHECK )); then + if git -C "$ROOT" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + fail "tag $TAG already exists" + else + ok "tag $TAG is not used yet" + fi +fi + +echo +if (( PRERELEASE )); then + echo "The Release workflow will publish a GitHub pre-release (not 'latest'):" + echo " offered in the app only on the Beta / Early Access channel;" + echo " AUR and the APT / DNF repositories are skipped." +else + echo "The Release workflow will publish a stable release and make it 'latest':" + echo " offered on both channels; AUR and APT / DNF are published (when their secrets are set)." +fi + +if (( problems )); then + echo + echo "$problems problem(s): fix them before pushing the tag." + exit 1 +fi diff --git a/scripts/release/prepare_beta.sh b/scripts/release/prepare_beta.sh new file mode 100755 index 00000000..8a3daa61 --- /dev/null +++ b/scripts/release/prepare_beta.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Prepares beta N of version X.Y.Z on a release branch (#1498): +# - pubspec: version: X.Y.Z-beta.N+ +# - CHANGELOG: a "## [X.Y.Z-beta.N] - " heading under "## [Unreleased]", +# so what was unreleased becomes the beta's notes +# and prints the commands to check, commit and tag. Commits and pushes nothing. +# +# scripts/release/prepare_beta.sh 0.5.0 1 +# +# QUERYA_RELEASE_ROOT points at another checkout (tests). +set -euo pipefail + +[[ $# -eq 2 ]] || { echo "usage: $0 " >&2; exit 2; } +BASE="$1" +N="$2" +[[ "$BASE" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "error: '$BASE' is not X.Y.Z" >&2; exit 2; } +[[ "$N" =~ ^[1-9][0-9]*$ ]] || { echo "error: '$N' is not a beta number (1, 2, ...)" >&2; exit 2; } + +ROOT="${QUERYA_RELEASE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +VERSION="$BASE-beta.$N" +PUBSPEC="$ROOT/pubspec.yaml" +CHANGELOG="$ROOT/CHANGELOG.md" + +LINE="$(grep '^version:' "$PUBSPEC" | sed 's/^version:[[:space:]]*//')" +BUILD=0 +[[ "$LINE" == *+* ]] && BUILD="${LINE##*+}" +[[ "$BUILD" =~ ^[0-9]+$ ]] || BUILD=0 +NEW="$VERSION+$((BUILD + 1))" + +if grep -q "^## \[$VERSION\]" "$CHANGELOG"; then + echo "error: CHANGELOG.md already has ## [$VERSION]" >&2 + exit 1 +fi +if ! grep -q '^## \[Unreleased\]' "$CHANGELOG"; then + echo "error: CHANGELOG.md has no '## [Unreleased]' heading to put the beta under" >&2 + exit 1 +fi + +sed -i.bak "s/^version: .*/version: $NEW/" "$PUBSPEC" && rm -f "$PUBSPEC.bak" +TODAY="$(date +%Y-%m-%d)" +awk -v heading="## [$VERSION] - $TODAY" ' + { print } + /^## \[Unreleased\]/ && !done { print ""; print heading; done = 1 } +' "$CHANGELOG" > "$CHANGELOG.tmp" && mv "$CHANGELOG.tmp" "$CHANGELOG" + +cat < formatted = 0); + + test('the formatter decides from samples; the rest is type-tested', () { + final rows = [ + for (var i = 0; i < 5000; i++) [i, i * 1.5, 'b$i', 's$i'], + ]; + final ds = CompactResultDataset.fromRawRows( + ['i', 'd', 'bytes', 's'], + rows, + cellFormatter: fmt, + ); + expect(ds.columns.map((c) => c.kind), [ + CompactColumnKind.int64, + CompactColumnKind.float64, + CompactColumnKind.generic, // printed differently from its value + CompactColumnKind.string, + ]); + // Detection formats at most 60 cells per column (the bytea column + // stops at its first); packing the generic column formats its cells. + expect(formatted, lessThan(4 * 60 + 5000 + 1)); + }); + + test('a late value of another type still makes the column generic', () { + final rows = [ + for (var i = 0; i < 1000; i++) [i == 900 ? 'n/a' : i], + ]; + final ds = CompactResultDataset.fromRawRows( + ['v'], + rows, + cellFormatter: fmt, + ); + expect(ds.columns.single.kind, CompactColumnKind.generic); + expect(ds.cellAt(900, 0), 'n/a'); + expect(ds.cellAt(899, 0), '899'); + }); + }); } diff --git a/test/core/database/destructive_sql_detector_test.dart b/test/core/database/destructive_sql_detector_test.dart index 6b76b264..aaf2cb49 100644 --- a/test/core/database/destructive_sql_detector_test.dart +++ b/test/core/database/destructive_sql_detector_test.dart @@ -219,4 +219,29 @@ void main() { expect(DestructiveSqlDetector.inspect('UPDATE users SET age = 20 WHERE id = 1;').isDestructive, isFalse); }); }); + + group('DestructiveSqlDetector backslash escapes (#1565 review)', () { + const sql = r"SELECT 'a\'; DROP TABLE users; --'"; + + test('MySQL (the default) reads \\\' as an escaped quote: nothing destructive', + () { + expect(DestructiveSqlDetector.inspect(sql).isDestructive, isFalse); + }); + + test('PostgreSQL and SQLite end the literal at the second quote', () { + final res = DestructiveSqlDetector.inspect(sql, backslashEscapes: false); + expect(res.isDestructive, isTrue); + expect(res.operations.single.type, DestructiveSqlType.dropTable); + }); + + test('an E string keeps its escape in every dialect', () { + expect( + DestructiveSqlDetector.inspect( + r"SELECT E'a\'; DROP TABLE users'", + backslashEscapes: false, + ).isDestructive, + isFalse, + ); + }); + }); } diff --git a/test/core/database/destructive_sql_strip_test.dart b/test/core/database/destructive_sql_strip_test.dart new file mode 100644 index 00000000..16ba3df2 --- /dev/null +++ b/test/core/database/destructive_sql_strip_test.dart @@ -0,0 +1,143 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; + +/// `stripCommentsAndStrings` as it was before #1511 (regex + substring at each +/// `$`, one-character strings), kept as the reference the scanner must match. +String _reference(String sql) { + final buffer = StringBuffer(); + final len = sql.length; + var i = 0; + + while (i < len) { + if (i + 1 < len && sql[i] == '-' && sql[i + 1] == '-') { + i += 2; + while (i < len && sql[i] != '\n' && sql[i] != '\r') { + i++; + } + buffer.write(' '); + continue; + } + if (i + 1 < len && sql[i] == '/' && sql[i + 1] == '*') { + i += 2; + while (i + 1 < len && !(sql[i] == '*' && sql[i + 1] == '/')) { + i++; + } + if (i + 1 < len) { + i += 2; + } else { + i = len; + } + buffer.write(' '); + continue; + } + if (sql[i] == '\$') { + final match = + RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); + if (match != null) { + final tag = match.group(0)!; + i += tag.length; + final closeIdx = sql.indexOf(tag, i); + if (closeIdx != -1) { + i = closeIdx + tag.length; + } else { + i = len; + } + buffer.write("''"); + continue; + } + } + if (sql[i] == "'") { + i++; + while (i < len) { + if (sql[i] == "'") { + if (i + 1 < len && sql[i + 1] == "'") { + i += 2; + } else { + i++; + break; + } + } else if (sql[i] == '\\' && i + 1 < len) { + i += 2; + } else { + i++; + } + } + buffer.write("''"); + continue; + } + buffer.write(sql[i]); + i++; + } + return buffer.toString(); +} + +void main() { + group('stripCommentsAndStrings scanner (#1511)', () { + test('matches the previous implementation on hand-written scripts', () { + const cases = [ + '', + 'SELECT 1', + '-- only a comment', + 'SELECT 1 -- tail\nFROM t', + 'SELECT 1 -- tail\r\nFROM t', + '/* block */ SELECT 1', + 'SELECT /* unterminated', + 'SELECT /* a */ 1 /* b */', + '/**/SELECT/***/1', + "SELECT 'it''s' FROM t", + "SELECT 'a\\'b' FROM t", + "SELECT 'unterminated", + "SELECT 'x\\", + r'SELECT $$ DROP TABLE x; $$ FROM t', + r'SELECT $fn$ body $fn$ , $1, $2', + r'SELECT $a$ never closed', + r'SELECT $ alone, $1, $_x$ one $_x$', + r'SELECT $$$$', + r"DO $$ BEGIN RAISE NOTICE 'x'; END $$;", + "DELETE FROM t WHERE a = 'WHERE' -- WHERE\n", + '-', + '/', + '\$', + "'", + 'aé\u{1F600}b -- \u{1F600}\n\'\u{1F600}\'', + ]; + for (final sql in cases) { + expect( + DestructiveSqlDetector.stripCommentsAndStrings(sql), + _reference(sql), + reason: sql, + ); + } + }); + + test('matches the previous implementation on random SQL-like text', () { + const pieces = [ + "'", "''", '"', '--', '/*', '*/', '\n', '\r\n', ' ', ';', r'$', r'$$', + r'$a$', r'$tag$', r'$1', r'\', 'SELECT', 'DROP', 'WHERE', 'x', '1', + '(', ')', 'é', '\u{1F600}', '-', '/', '*', + ]; + final random = Random(1511); + for (var n = 0; n < 3000; n++) { + final sql = [ + for (var k = random.nextInt(30); k > 0; k--) + pieces[random.nextInt(pieces.length)], + ].join(); + expect( + DestructiveSqlDetector.stripCommentsAndStrings(sql), + _reference(sql), + reason: sql, + ); + } + }); + + test('a large script with many parameters is handled', () { + final sql = List.generate(20000, (i) => 'SELECT \$1, \$2 -- row $i') + .join('\n'); + final out = DestructiveSqlDetector.stripCommentsAndStrings(sql); + expect(out, _reference(sql)); + expect(out, isNot(contains('row'))); + }); + }); +} diff --git a/test/core/database/mongo_service_test.dart b/test/core/database/mongo_service_test.dart index 84feb5ca..a913a076 100644 --- a/test/core/database/mongo_service_test.dart +++ b/test/core/database/mongo_service_test.dart @@ -176,4 +176,18 @@ void main() { expect(MongoService.instance.getConnection(302), isNull); }); }); + + group('effectiveMongoFindLimit (#1357)', () { + test('no limit, or 0 (MongoDB: unlimited), becomes the default cap', () { + expect(effectiveMongoFindLimit(null), kMongoDefaultFindLimit); + expect(effectiveMongoFindLimit(0), kMongoDefaultFindLimit); + expect(effectiveMongoFindLimit(-5), kMongoDefaultFindLimit); + }); + + test('an explicit positive limit is kept', () { + expect(effectiveMongoFindLimit(1), 1); + expect(effectiveMongoFindLimit(100), 100); + expect(effectiveMongoFindLimit(50000), 50000); + }); + }); } diff --git a/test/core/database/postgres_result_cells_scanner_test.dart b/test/core/database/postgres_result_cells_scanner_test.dart new file mode 100644 index 00000000..cf9cbdcf --- /dev/null +++ b/test/core/database/postgres_result_cells_scanner_test.dart @@ -0,0 +1,73 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:postgres/postgres.dart'; +import 'package:querya_desktop/core/database/postgres_result_cells.dart'; + +void main() { + group('postgresByteaHexDisplay (#1529, #1521)', () { + test('writes \\x and two lower-case hex digits per byte', () { + expect(postgresByteaHexDisplay(Uint8List(0)), r'\x'); + expect( + postgresByteaHexDisplay(Uint8List.fromList([0, 1, 15, 16, 171, 255])), + r'\x00010f10abff', + ); + }); + + test('every byte value maps to its two digits', () { + final all = Uint8List.fromList(List.generate(256, (i) => i)); + final text = postgresByteaHexDisplay(all); + expect(text.length, 2 + 512); + for (var i = 0; i < 256; i++) { + expect(text.substring(2 + i * 2, 4 + i * 2), + i.toRadixString(16).padLeft(2, '0')); + } + }); + + test('cell display goes through the same encoder', () { + expect( + postgresResultCellToDisplayString(Uint8List.fromList([222, 173])), + r'\xdead', + ); + }); + }); + + group('date-only cells (#1529, #1521)', () { + test('a date column shows only the date', () { + expect( + postgresResultCellToDisplayString( + DateTime.utc(2026, 1, 5), + typeOid: Type.date.oid, + ), + '2026-01-05', + ); + expect( + postgresResultCellToDisplayString( + DateTime(2026, 1, 5, 13, 30), + dataTypeName: 'date', + ), + '2026-01-05', + ); + }); + + test('years outside 0..9999 keep their sign and digits', () { + expect( + postgresResultCellToDisplayString( + DateTime.utc(12000, 3, 4), + typeOid: Type.date.oid, + ), + '+012000-03-04', + ); + }); + + test('timestamps keep the full ISO text', () { + expect( + postgresResultCellToDisplayString( + DateTime.utc(2026, 1, 5, 7, 8, 9), + typeOid: Type.timestamp.oid, + ), + '2026-01-05T07:08:09.000Z', + ); + }); + }); +} diff --git a/test/core/database/query_error_text_test.dart b/test/core/database/query_error_text_test.dart new file mode 100644 index 00000000..9d732404 --- /dev/null +++ b/test/core/database/query_error_text_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; + +void main() { + _positionTests(); + group('QueryErrorText (#1557)', () { + // The example from the issue, as sqflite_common_ffi prints it. + const sqlite = + 'SqliteFfiException(sqlite_error: 1, , SqliteException(1): while preparing ' + 'statement, no such column: u.name, SQL logic error (code 1)\n' + ' Causing statement (at position 29): SELECT * FROM (\n' + 'SELECT o.id, u.name FROM orders o JOIN users u ON u.id = o.user_id ' + 'ORDER BY o.id LIMIT 200\n' + ')\n' + 'LIMIT 5000}) DatabaseException(SqliteException(1): while preparing ' + 'statement, no such column: u.name, SQL logic error (code 1)\n' + ' Causing statement (at position 29): SELECT * FROM (\n' + 'SELECT o.id, u.name FROM orders o JOIN users u ON u.id = o.user_id ' + 'ORDER BY o.id LIMIT 200\n' + ')'; + + test('SQLite: the message alone, without the wrapper or its position', () { + final e = QueryErrorText.parse(sqlite); + expect(e.summary, 'no such column: u.name'); + expect(e.hasDetail, isTrue); + expect(e.detail, isNot(contains('SELECT * FROM ('))); + expect(e.detail, isNot(contains('LIMIT 5000'))); + expect(e.detail, isNot(contains('at position'))); + expect(e.detail, contains('ORDER BY o.id LIMIT 200')); + expect(e.detail, contains('no such column: u.name')); + }); + + test('PostgreSQL: the server message, without detail and hint', () { + final e = QueryErrorText.parse( + 'Severity.error 42703: column "nme" does not exist hint: Perhaps you meant "name".', + ); + expect(e.summary, 'column "nme" does not exist'); + expect(e.detail, contains('hint: Perhaps')); + }); + + test('MySQL: the server message after the error code', () { + final e = QueryErrorText.parse( + "MySQLServerException [1054]: Unknown column 'nme' in 'field list'", + ); + expect(e.summary, "Unknown column 'nme' in 'field list'"); + }); + + test('anything else: its first line; one line has no extra detail', () { + final one = QueryErrorText.parse('connection refused'); + expect(one.summary, 'connection refused'); + expect(one.hasDetail, isFalse); + + final two = QueryErrorText.parse('Exception: socket closed\nat line 3'); + expect(two.summary, 'socket closed'); + expect(two.hasDetail, isTrue); + }); + + test('text without the wrapper is left as it is', () { + const raw = 'SqliteException(1): while preparing statement, near "SELEC": ' + 'syntax error, SQL logic error (code 1)\n' + ' Causing statement (at position 0): SELEC 1'; + final e = QueryErrorText.parse(raw); + expect(e.summary, 'near "SELEC": syntax error'); + expect(e.detail, raw); + }); + + test('a very long message is cut in the summary only', () { + final long = 'x' * 1000; + final e = QueryErrorText.parse(long); + expect(e.summary.length, lessThan(320)); + expect(e.detail, long); + }); + }); +} + +void _positionTests() { + group('where the error is in the user\'s SQL (#1557)', () { + String sqliteError(String wrappedSql, int at) => + 'SqliteFfiException(sqlite_error: 1, , SqliteException(1): while ' + 'preparing statement, no such column: x, SQL logic error (code 1)\n' + ' Causing statement (at position $at): $wrappedSql})'; + + test('SQLite: the wrapper prefix is taken off', () { + const sql = 'SELECT a, nope FROM t'; + final error = sqliteError('SELECT * FROM (\n$sql\n)\nLIMIT 5000', 16 + 10); + expect(QueryErrorText.offsetIn(error, sql), 10); + expect(sql.substring(10, 14), 'nope'); + }); + + test('SQLite: without the wrapper the offset is the position', () { + const sql = 'UPDATE t SET nope = 1'; + final error = sqliteError(sql, 13); + expect(QueryErrorText.offsetIn(error, sql), 13); + }); + + test('SQLite counts bytes, the editor counts characters', () { + const sql = "SELECT 'héllo', nope FROM t"; + // `é` is two bytes: `nope` starts at character 16, byte 17. + final error = sqliteError('SELECT * FROM (\n$sql\n)', 16 + 17); + expect(QueryErrorText.offsetIn(error, sql), 16); + expect(sql.substring(16, 20), 'nope'); + }); + + test('a position outside the text, or none, gives no offset', () { + expect(QueryErrorText.offsetIn(sqliteError('SELECT 1', 500), 'SELECT 1'), + isNull); + expect( + QueryErrorText.offsetIn( + Exception('MySQLServerException [1064]: syntax error'), 'SELECT'), + isNull); + }); + + test('PostgreSQL positions are 1-based characters', () { + const sql = 'SELECT nope FROM t'; + expect(QueryErrorText.offsetOfPgPosition(8, sql), 7); + expect(sql.substring(7, 11), 'nope'); + expect(QueryErrorText.offsetOfPgPosition(null, sql), isNull); + expect(QueryErrorText.offsetOfPgPosition(0, sql), isNull); + expect(QueryErrorText.offsetOfPgPosition(99, sql), isNull); + }); + }); +} diff --git a/test/core/database/redis_connection_test.dart b/test/core/database/redis_connection_test.dart index 8362a7fc..d7114535 100644 --- a/test/core/database/redis_connection_test.dart +++ b/test/core/database/redis_connection_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import '../../support/redis_connection_test_fake.dart'; void main() { group('RedisConnection initial state', () { diff --git a/test/core/database/redis_database_socket_test.dart b/test/core/database/redis_database_socket_test.dart new file mode 100644 index 00000000..e728c81c --- /dev/null +++ b/test/core/database/redis_database_socket_test.dart @@ -0,0 +1,274 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/database/redis_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import '../../support/redis_connection_test_fake.dart'; + +/// A RESP server that answers `PING` and `SELECT` and records every command. +class _RecordingRedis { + _RecordingRedis._(this._server) { + _server.listen(_onClient); + } + + static Future<_RecordingRedis> start() async => _RecordingRedis._( + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0)); + + final ServerSocket _server; + final commands = >[]; + final _sockets = []; + + int get port => _server.port; + + void _onClient(Socket socket) { + _sockets.add(socket); + var buffer = ''; + socket.listen( + (data) { + buffer += utf8.decode(data, allowMalformed: true); + while (true) { + final parsed = _parseArray(buffer); + if (parsed == null) break; + buffer = buffer.substring(parsed.$2); + final args = parsed.$1; + commands.add(args); + socket.write(args.first.toUpperCase() == 'PING' ? '+PONG\r\n' : '+OK\r\n'); + } + }, + onDone: socket.destroy, + onError: (_) {}, + ); + } + + /// `*\r\n` then n `$\r\n\r\n`; null until all of it is there. + static (List, int)? _parseArray(String s) { + if (!s.startsWith('*')) return null; + var at = s.indexOf('\r\n'); + if (at < 0) return null; + final count = int.parse(s.substring(1, at)); + at += 2; + final args = []; + for (var i = 0; i < count; i++) { + final end = s.indexOf('\r\n', at); + if (end < 0) return null; + final len = int.parse(s.substring(at + 1, end)); + final start = end + 2; + if (s.length < start + len + 2) return null; + args.add(s.substring(start, start + len)); + at = start + len + 2; + } + return (args, at); + } + + /// Drops every client, as a server restart or a network blip would. + void dropClients() { + for (final s in _sockets) { + s.destroy(); + } + _sockets.clear(); + } + + Future close() async { + dropClients(); + await _server.close(); + } +} + +Future _until(bool Function() condition) async { + for (var i = 0; i < 100 && !condition(); i++) { + await Future.delayed(const Duration(milliseconds: 20)); + } +} + +ConnectionRow _row(int id) => ConnectionRow( + id: id, + type: 'redis', + name: 'r', + createdAt: '2026-01-01T00:00:00Z', + ); + +void main() { + group('one socket per database (#1569)', () { + test('explorers of different databases do not share a socket', () { + const id = 8150; + final db0 = RedisService.instance.acquire( + _row(id), + role: RedisSessionRole.explorer, + ); + final db3 = RedisService.instance.acquire( + _row(id), + role: RedisSessionRole.explorer, + database: 3, + ); + expect(db3, isNot(same(db0))); + expect(db0.database, 0); + expect(db3.database, 3); + expect( + RedisService.instance.acquire( + _row(id), + role: RedisSessionRole.explorer, + database: 3, + ), + same(db3), + ); + }); + + test('the CLI has its own socket next to the explorer', () { + const id = 8151; + final explorer = RedisService.instance.acquire( + _row(id), + role: RedisSessionRole.explorer, + database: 2, + ); + final cli = RedisService.instance.acquire( + _row(id), + role: RedisSessionRole.cli, + database: 2, + ); + expect(cli, isNot(same(explorer))); + expect(cli.database, 2); + }); + + test('disconnectByConnectionId drops every role and database', () async { + const id = 8152; + for (final db in [0, 1, 5]) { + RedisService.instance.acquire( + _row(id), + role: RedisSessionRole.explorer, + database: db, + ); + } + RedisService.instance.acquire(_row(id), role: RedisSessionRole.cli); + await RedisService.instance.disconnectByConnectionId(id); + for (final db in [0, 1, 5]) { + expect( + RedisService.instance.getConnection( + id, + role: RedisSessionRole.explorer, + database: db, + ), + isNull, + ); + } + expect( + RedisService.instance.getConnection(id, role: RedisSessionRole.cli), + isNull, + ); + }); + + test('selectDatabase moves the socket it is sent on, nothing else', + () async { + final cli = RedisConnectionTestFake(); + final explorer = RedisConnectionTestFake(); + await cli.connect(); + await explorer.connect(); + + await cli.selectDatabase(3); + + expect(cli.database, 3); + expect(explorer.database, 0); + expect(explorer.sentCommands, isNot(contains('SELECT'))); + }); + }); + + group('SELECT on connect (#1569)', () { + late _RecordingRedis server; + + setUp(() async => server = await _RecordingRedis.start()); + tearDown(() async => server.close()); + + RedisConnection connection({required int database}) => RedisConnection( + id: 0, + name: 'r', + host: '127.0.0.1', + port: server.port, + )..database = database; + + test('db 0 sends no SELECT', () async { + final conn = connection(database: 0); + await conn.connect(); + await conn.disconnect(); + expect(server.commands.map((c) => c.first.toUpperCase()), + isNot(contains('SELECT'))); + }); + + test('a socket for db 3 selects it after the handshake', () async { + final conn = connection(database: 3); + await conn.connect(); + await conn.disconnect(); + expect(server.commands, contains(equals(['SELECT', '3']))); + }); + + test('a reconnect comes back to the database, not db 0', () async { + final conn = connection(database: 0); + await conn.connect(); + await conn.selectDatabase(4); + + server.dropClients(); + await _until(() => !conn.isConnected); + server.commands.clear(); + + await conn.connect(); + await conn.disconnect(); + expect(server.commands, contains(equals(['SELECT', '4']))); + }); + }); + + group('delMany (#1569)', () { + List keys(int n) => [for (var i = 0; i < n; i++) 'k$i']; + + test('uses UNLINK in chunks of 200', () async { + final fake = RedisConnectionTestFake(); + await fake.connect(); + await fake.delMany(keys(450)); + expect(fake.sentCommands, ['UNLINK', 'UNLINK', 'UNLINK']); + }); + + test('falls back to DEL on a server without UNLINK, and remembers', + () async { + final fake = RedisConnectionTestFake(unlinkUnsupported: true); + await fake.connect(); + await fake.delMany(keys(450)); + expect(fake.sentCommands, ['UNLINK', 'DEL', 'DEL', 'DEL']); + + fake.sentCommands.clear(); + await fake.delMany(keys(10)); + expect(fake.sentCommands, ['DEL']); + }); + + test('returns how many keys were removed', () async { + final fake = RedisConnectionTestFake( + firstScanKeys: const ['a', 'b'], + secondScanKeys: const ['c'], + ); + await fake.connect(); + expect(await fake.delMany(['a', 'b', 'c', 'missing']), 3); + }); + + test('a failed chunk is reported after the others have run', () async { + final fake = _FailingChunkFake(failOnCall: 2); + await fake.connect(); + await expectLater(fake.delMany(keys(600)), throwsA(isA())); + expect(fake.calls, 3); + }); + }); +} + +class _FailingChunkFake extends RedisConnectionTestFake { + _FailingChunkFake({required this.failOnCall}); + + final int failOnCall; + int calls = 0; + + @override + Future sendCommand(List args) async { + final op = args.first.toString().toUpperCase(); + if (op == 'UNLINK') { + calls++; + if (calls == failOnCall) throw StateError('chunk $calls failed'); + } + return super.sendCommand(args); + } +} diff --git a/test/core/database/redis_reply_test.dart b/test/core/database/redis_reply_test.dart new file mode 100644 index 00000000..77f7c24c --- /dev/null +++ b/test/core/database/redis_reply_test.dart @@ -0,0 +1,88 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:querya_desktop/core/database/redis_reply.dart'; + +import '../../support/redis_connection_test_fake.dart'; + +void main() { + group('RedisReply.from', () { + test('sorts what the redis package returns into the sealed types', () { + expect(RedisReply.from(null), isA()); + expect(RedisReply.from(7), isA()); + expect(RedisReply.from('OK'), isA()); + expect(RedisReply.from(Uint8List.fromList([104, 105])), isA()); + expect(RedisReply.from([104, 105]), isA()); + expect(RedisReply.from(['a', 1, null]), isA()); + }); + + test('an array holds replies, nested arrays included', () { + final a = RedisReply.from([ + '5', + ['x', 'y'], + ]).asArray(); + expect(a[0].asInt(), 5); + expect(a[1].asArray().map((e) => e.asText()), ['x', 'y']); + }); + }); + + group('accessors', () { + test('asInt reads an integer reply and a string that holds one', () { + expect(RedisReply.from(42).asInt(), 42); + expect(RedisReply.from('42').asInt(), 42); + expect(RedisReply.from(Uint8List.fromList([52, 50])).asInt(), 42); + expect(RedisReply.from(-2).asInt(), -2); + }); + + test('a wrong shape is a RedisProtocolException, not a fallback', () { + expect(() => RedisReply.from('abc').asInt(), + throwsA(isA())); + expect(() => RedisReply.from(null).asInt(), + throwsA(isA())); + expect(() => RedisReply.from(['a']).asInt(), + throwsA(isA())); + expect(() => RedisReply.from(5).asArray(), + throwsA(isA())); + expect(() => RedisReply.from(['a']).asBulk(), + throwsA(isA())); + }); + + test('nil is no value for GET and an empty bulk elsewhere', () { + expect(RedisReply.from(null).asBulkOrNull(), isNull); + expect(RedisReply.from('v').asBulkOrNull()?.text, 'v'); + expect(RedisReply.from(null).asBulk().isEmpty, isTrue); + }); + + test('bytes that are not UTF-8 keep their bytes and have no text', () { + final r = RedisReply.from(Uint8List.fromList([0xff, 0xfe])); + expect(r.asText(), isNull); + expect(r.asBulk().bytes, [0xff, 0xfe]); + }); + + test('asDouble reads scores', () { + expect(RedisReply.from('1.5').asDouble(), 1.5); + expect(RedisReply.from(2).asDouble(), 2.0); + expect(() => RedisReply.from('x').asDouble(), + throwsA(isA())); + }); + }); + + group('RedisConnection.command', () { + test('arguments are converted in one place', () async { + final fake = RedisConnectionTestFake(); + await fake.connect(); + await fake.command(['SELECT', 0]); + await fake.command(['TYPE', RedisBulkValue.utf8('k')]); + await fake.command(['TYPE', Uint8List.fromList([1, 2])]); + expect(fake.sentCommands, containsAll(['SELECT', 'TYPE'])); + }); + + test('an argument that is not a command argument is refused', () async { + final fake = RedisConnectionTestFake(); + await fake.connect(); + expect(() => fake.command(['GET', true]), throwsArgumentError); + expect(() => fake.command(['GET', ['a']]), throwsArgumentError); + }); + }); +} diff --git a/test/core/database/redis_set_keepttl_test.dart b/test/core/database/redis_set_keepttl_test.dart index a5cce05f..ec9cb0d8 100644 --- a/test/core/database/redis_set_keepttl_test.dart +++ b/test/core/database/redis_set_keepttl_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; +import '../../support/redis_connection_test_fake.dart'; class _SetCommandFake extends RedisConnectionTestFake { final List> commands = []; diff --git a/test/core/database/redis_types_and_ttls_test.dart b/test/core/database/redis_types_and_ttls_test.dart index b472e31e..2c8e51a5 100644 --- a/test/core/database/redis_types_and_ttls_test.dart +++ b/test/core/database/redis_types_and_ttls_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; -import 'package:querya_desktop/core/database/redis_connection.dart'; +import '../../support/redis_connection_test_fake.dart'; /// Counts outbound commands to prove [typesAndTtls] fires TYPE+TTL without /// awaiting between keys (true pipeline enqueue). diff --git a/test/core/database/result_columns_builder_test.dart b/test/core/database/result_columns_builder_test.dart new file mode 100644 index 00000000..718e7237 --- /dev/null +++ b/test/core/database/result_columns_builder_test.dart @@ -0,0 +1,167 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/compact_result_dataset.dart'; +import 'package:querya_desktop/core/database/result_columns_builder.dart'; + +CompactColumnKind _expectedKind(List col) { + final cells = col.whereType().toList(); + if (cells.isEmpty) return CompactColumnKind.string; + if (cells.every((v) => v is int)) return CompactColumnKind.int64; + if (cells.every((v) => v is num)) return CompactColumnKind.float64; + if (cells.every((v) => v is String)) return CompactColumnKind.string; + return CompactColumnKind.generic; +} + +Object? _expectedRaw(CompactColumnKind kind, Object? v) { + if (v == null) return null; + return kind == CompactColumnKind.float64 ? (v as num).toDouble() : v; +} + +void main() { + CompactResultDataset build(List names, List> rows, + {String Function(Object?, int)? formatter}) { + final b = ResultColumnsBuilder(names, cellFormatter: formatter); + for (final r in rows) { + b.addRow(r); + } + return b.build(); + } + + test('each column gets the kind its values call for, with its raw values', () { + final rows = [ + [1, 1.5, 'a', 1, null, 'x'], + [2, 2, 'b', 'two', null, 'y'], + [null, null, null, 3.5, null, 7], + [4, 0.25, 'd', null, null, null], + ]; + final d = build(['i', 'f', 's', 'mix', 'nulls', 'm2'], rows); + expect([for (final c in d.columns) c.kind], [ + CompactColumnKind.int64, + CompactColumnKind.float64, + CompactColumnKind.string, + CompactColumnKind.generic, + CompactColumnKind.string, + CompactColumnKind.generic, + ]); + expect(d.rowCount, 4); + // A float column holds its ints as doubles. + expect(d.rawCellAt(1, 1), 2.0); + // A generic column keeps its ints as ints and its doubles as doubles. + expect(d.rawCellAt(0, 3), 1); + expect(d.rawCellAt(0, 3), isA()); + expect(d.rawCellAt(2, 3), 3.5); + expect(d.rawCellAt(1, 3), 'two'); + expect(d.rawCellAt(2, 0), isNull); + expect(d.cellAt(2, 0), 'NULL'); + }); + + test('a late value that does not fit demotes the whole column', () { + final rows = [ + for (var i = 0; i < 700; i++) [i], + ['late'], + ]; + final d = build(['c'], rows); + expect(d.columns.single.kind, CompactColumnKind.generic); + expect(d.rowCount, 701); + expect(d.rawCellAt(0, 0), 0); + expect(d.rawCellAt(699, 0), 699); + expect(d.rawCellAt(700, 0), 'late'); + }); + + test('ints that meet a double late become a float64 column', () { + final rows = [ + for (var i = 0; i < 300; i++) [i], + [0.5], + ]; + final d = build(['c'], rows); + expect(d.columns.single.kind, CompactColumnKind.float64); + expect(d.rawCellAt(299, 0), 299.0); + expect(d.rawCellAt(300, 0), 0.5); + }); + + test('a short row is padded with NULLs', () { + final d = build(['a', 'b'], [ + [1, 'x'], + [2], + ]); + expect(d.rawCellAt(1, 1), isNull); + expect(d.columns[1].kind, CompactColumnKind.string); + }); + + test('cell by cell gives the same dataset as whole rows', () { + final rows = [ + [1, 'a', null], + [2, null, 3.5], + ]; + final whole = build(['x', 'y', 'z'], rows); + final b = ResultColumnsBuilder(['x', 'y', 'z']); + for (final r in rows) { + for (var i = 0; i < r.length; i++) { + b.addCell(i, r[i]); + } + b.endRow(); + } + final cells = b.build(); + for (var r = 0; r < 2; r++) { + expect(cells.rawRow(r), whole.rawRow(r)); + } + }); + + test('the driver formatter decides: values it prints differently stay generic', + () { + // A "boolean" column: ints that print as true/false. + String fmt(Object? v, int col) => + col == 0 ? (v == 1 ? 'true' : 'false') : '$v'; + final d = build(['flag', 'n'], [ + [1, 5], + [0, 6], + ], formatter: fmt); + expect(d.columns[0].kind, CompactColumnKind.generic); + expect(d.columns[1].kind, CompactColumnKind.int64); + expect(d.cellAt(0, 0), 'true'); + expect(d.cellAt(1, 0), 'false'); + }); + + test('nothing but NULLs is a string column; no rows is an empty dataset', () { + expect(build(['a'], [[null], [null]]).columns.single.kind, + CompactColumnKind.string); + final empty = build(['a'], []); + expect(empty.rowCount, 0); + expect(empty.columns, isEmpty); + }); + + test('random columns agree with the kind and values the rules give', () { + final rnd = Random(1552); + Object? pick(int family) { + if (rnd.nextInt(6) == 0) return null; + switch (family) { + case 0: + return rnd.nextInt(1000) - 500; + case 1: + return rnd.nextBool() ? rnd.nextInt(100) : rnd.nextDouble() * 10; + case 2: + return 's${rnd.nextInt(50)}'; + default: + return switch (rnd.nextInt(3)) { + 0 => rnd.nextInt(100), + 1 => 's${rnd.nextInt(9)}', + _ => rnd.nextDouble(), + }; + } + } + + for (var trial = 0; trial < 150; trial++) { + final family = rnd.nextInt(4); + final n = 1 + rnd.nextInt(700); + final col = [for (var i = 0; i < n; i++) pick(family)]; + final d = build(['c'], [for (final v in col) [v]]); + final kind = _expectedKind(col); + expect(d.columns.single.kind, kind, reason: 'trial $trial'); + for (var i = 0; i < n; i++) { + expect(d.rawCellAt(i, 0), _expectedRaw(kind, col[i]), + reason: 'trial $trial row $i'); + } + } + }); +} diff --git a/test/core/database/sql_lexical_scanner_test.dart b/test/core/database/sql_lexical_scanner_test.dart new file mode 100644 index 00000000..91e6277e --- /dev/null +++ b/test/core/database/sql_lexical_scanner_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sql_lexical_scanner.dart'; + +List<(SqlSegmentKind, String)> _scan(String sql) => [ + for (final s in SqlLexicalScanner.segments(sql)) + (s.kind, sql.substring(s.start, s.end)), + ]; + +void main() { + group('SqlLexicalScanner', () { + test('empty script has no segments', () { + expect(SqlLexicalScanner.segments(''), isEmpty); + }); + + test('plain code is one segment', () { + expect(_scan('select 1 from t'), [(SqlSegmentKind.code, 'select 1 from t')]); + }); + + test('splits literals, identifiers, comments and dollar quotes', () { + expect( + _scan("select 'a''b', \"c\", `d` -- e\nfrom t /* f */ where \$\$ g \$\$"), + [ + (SqlSegmentKind.code, 'select '), + (SqlSegmentKind.string, "'a''b'"), + (SqlSegmentKind.code, ', '), + (SqlSegmentKind.quotedIdentifier, '"c"'), + (SqlSegmentKind.code, ', '), + (SqlSegmentKind.quotedIdentifier, '`d`'), + (SqlSegmentKind.code, ' '), + (SqlSegmentKind.comment, '-- e'), + (SqlSegmentKind.code, '\nfrom t '), + (SqlSegmentKind.comment, '/* f */'), + (SqlSegmentKind.code, ' where '), + (SqlSegmentKind.dollarQuoted, r'$$ g $$'), + ], + ); + }); + + test('a tagged dollar quote ends only at the same tag', () { + expect( + _scan(r'$fn$ a $$ b $fn$;'), + [ + (SqlSegmentKind.dollarQuoted, r'$fn$ a $$ b $fn$'), + (SqlSegmentKind.code, ';'), + ], + ); + }); + + test(r'$1 placeholders stay code', () { + expect(_scan(r'select $1, $2'), [(SqlSegmentKind.code, r'select $1, $2')]); + }); + + test('unterminated segments run to the end', () { + expect(_scan("a 'b"), [ + (SqlSegmentKind.code, 'a '), + (SqlSegmentKind.string, "'b"), + ]); + expect(_scan('a /* b'), [ + (SqlSegmentKind.code, 'a '), + (SqlSegmentKind.comment, '/* b'), + ]); + expect(_scan('a "b'), [ + (SqlSegmentKind.code, 'a '), + (SqlSegmentKind.quotedIdentifier, '"b'), + ]); + }); + + test('segments cover the script without gaps', () { + const sql = "x 'y' \"z\" -- w\n/* v */ \$t\$ u \$t\$ end"; + var at = 0; + for (final s in SqlLexicalScanner.segments(sql)) { + expect(s.start, at); + expect(s.end, greaterThan(s.start)); + at = s.end; + } + expect(at, sql.length); + }); + }); + + group('backslashes (PostgreSQL / SQLite)', () { + List<(SqlSegmentKind, String)> scanStd(String sql) => [ + for (final s + in SqlLexicalScanner.segments(sql, backslashEscapes: false)) + (s.kind, sql.substring(s.start, s.end)), + ]; + + test(r"a backslash before the quote does not escape it: 'C:\'", () { + expect(scanStd(r"select 'C:\', 'not null'"), [ + (SqlSegmentKind.code, 'select '), + (SqlSegmentKind.string, r"'C:\'"), + (SqlSegmentKind.code, ', '), + (SqlSegmentKind.string, "'not null'"), + ]); + }); + + test(r"E'...' strings still escape with a backslash", () { + expect(scanStd(r"select E'it\'s', e'a\'b'"), [ + (SqlSegmentKind.code, 'select E'), + (SqlSegmentKind.string, r"'it\'s'"), + (SqlSegmentKind.code, ', e'), + (SqlSegmentKind.string, r"'a\'b'"), + ]); + }); + + test('an E that ends a longer word is not a prefix', () { + expect(scanStd(r"select type'x\', 'y'"), [ + (SqlSegmentKind.code, 'select type'), + (SqlSegmentKind.string, r"'x\'"), + (SqlSegmentKind.code, ', '), + (SqlSegmentKind.string, "'y'"), + ]); + }); + + test('MySQL rules (the default) still escape', () { + expect(_scan(r"select 'it\'s'"), [ + (SqlSegmentKind.code, 'select '), + (SqlSegmentKind.string, r"'it\'s'"), + ]); + }); + }); +} diff --git a/test/core/database/sql_limit_strip_test.dart b/test/core/database/sql_limit_strip_test.dart new file mode 100644 index 00000000..c1465370 --- /dev/null +++ b/test/core/database/sql_limit_strip_test.dart @@ -0,0 +1,108 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; + +/// `stripLeadingWhitespaceAndLineComments` before #1515: one substring + trimLeft +/// per comment, the reference the offset scanner has to match. +String _reference(String sql) { + int blockEnd(String s, int start) { + var depth = 0; + var i = start; + while (i < s.length) { + if (s.startsWith('/*', i)) { + depth++; + i += 2; + } else if (s.startsWith('*/', i)) { + depth--; + i += 2; + if (depth == 0) return i; + } else { + i++; + } + } + return s.length; + } + + var s = sql.trimLeft(); + while (true) { + if (s.isEmpty) return s; + if (s.startsWith('--')) { + final nl = s.indexOf('\n'); + if (nl == -1) return ''; + s = s.substring(nl + 1).trimLeft(); + continue; + } + if (s.startsWith('/*')) { + s = s.substring(blockEnd(s, 0)).trimLeft(); + continue; + } + return s; + } +} + +void main() { + group('stripLeadingWhitespaceAndLineComments offset scanner (#1515)', () { + test('agrees with the previous implementation on listed scripts', () { + const cases = [ + '', + ' ', + '\n\t \r\n', + 'SELECT 1', + ' SELECT 1', + '-- c\nSELECT 1', + '-- c\r\nSELECT 1', + '-- c\n-- d\n\n -- e\nSELECT 1', + '-- only a comment', + '-- c\n', + '/* c */SELECT 1', + '/* a */ /* b */\n SELECT 1', + '/* a /* nested */ still */ SELECT 1', + '/* unterminated', + '/* a */ -- b\n/* c */ SELECT 1', + '--\nSELECT 1', + '-', + '/', + 'SELECT 1 -- trailing\nFROM t', + '  -- c\nSELECT 1', + '\u0085SELECT 1', + '᠎SELECT 1', + '/*/ x */ SELECT 1', + '/**/SELECT 1', + ]; + for (final sql in cases) { + expect( + stripLeadingWhitespaceAndLineComments(sql), + _reference(sql), + reason: sql, + ); + } + }); + + test('agrees with the previous implementation on random scripts', () { + const pieces = [ + '--', '/*', '*/', '\n', '\r\n', ' ', '\t', ' ', '', + 'SELECT', '1', 'x', '-', '/', '*', + ]; + final random = Random(1515); + for (var n = 0; n < 4000; n++) { + final sql = [ + for (var k = random.nextInt(24); k > 0; k--) + pieces[random.nextInt(pieces.length)], + ].join(); + expect( + stripLeadingWhitespaceAndLineComments(sql), + _reference(sql), + reason: sql, + ); + } + }); + + test('a long comment header in front of a large script is one pass', () { + final header = List.generate(500, (i) => '-- header line $i').join('\n'); + final body = 'SELECT ${List.filled(20000, 'col').join(', ')} FROM t'; + final sql = '$header\n$body'; + expect(stripLeadingWhitespaceAndLineComments(sql), body); + }); + }); +} diff --git a/test/core/database/sql_limit_test.dart b/test/core/database/sql_limit_test.dart index 76e3519d..682e4fde 100644 --- a/test/core/database/sql_limit_test.dart +++ b/test/core/database/sql_limit_test.dart @@ -239,4 +239,23 @@ void main() { }); }); }); + + group('statement kind after leading comments (#1515)', () { + test('a long comment header still finds the SELECT, any case', () { + final header = List.filled(200, '-- generated\n').join(); + expect( + injectSqlLimit('$header/* x */\n sElEcT * FROM t', 10), + '$header/* x */\n sElEcT * FROM t\nLIMIT 10', + ); + }); + + test('WITH and VALUES count, other statements are left alone', () { + expect(injectSqlLimit('with a as (select 1) select * from a', 5), + endsWith('LIMIT 5')); + expect(injectSqlLimit('values (1), (2)', 5), endsWith('LIMIT 5')); + expect(injectSqlLimit('-- c\nupdate t set a = 1', 5), + '-- c\nupdate t set a = 1'); + expect(injectSqlLimit('-- only a comment', 5), '-- only a comment'); + }); + }); } diff --git a/test/core/database/sql_mutation_classifier_test.dart b/test/core/database/sql_mutation_classifier_test.dart index ebb8b426..717be9b3 100644 --- a/test/core/database/sql_mutation_classifier_test.dart +++ b/test/core/database/sql_mutation_classifier_test.dart @@ -69,4 +69,14 @@ void main() { expect(containsMutatingSql("SELECT 'a;DELETE FROM t'"), isFalse); }); }); + + group('containsMutatingSql backslash escapes', () { + const sql = r"SELECT 'a\'; DELETE FROM t; --'"; + + test('the dialect decides whether the DELETE is code or part of a literal', + () { + expect(containsMutatingSql(sql), isFalse); // MySQL reading, the default + expect(containsMutatingSql(sql, backslashEscapes: false), isTrue); + }); + }); } diff --git a/test/core/database/sql_statement_splitter_test.dart b/test/core/database/sql_statement_splitter_test.dart index 60243d93..8f20444f 100644 --- a/test/core/database/sql_statement_splitter_test.dart +++ b/test/core/database/sql_statement_splitter_test.dart @@ -165,4 +165,54 @@ void main() { } }); }); + + // A backslash before the closing quote: MySQL reads it as an escaped quote, + // PostgreSQL and SQLite as an ordinary character. + group('SqlStatementSplitter backslash escapes', () { + const sql = r"SELECT 'a\'; DELETE FROM t; --'"; + + List run(String text, {required bool backslashEscapes}) => [ + for (final s in SqlStatementSplitter.spans( + text, + backslashEscapes: backslashEscapes, + )) + s.textIn(text), + ]; + + test('the default keeps the MySQL reading: one statement', () { + expect(texts(sql), [sql]); + expect(run(sql, backslashEscapes: true), [sql]); + }); + + test('without backslash escapes the ; after the literal ends the statement', + () { + expect(run(sql, backslashEscapes: false), [ + r"SELECT 'a\'", + 'DELETE FROM t', + "--'", + ]); + }); + + test('an E string escapes with a backslash whatever the dialect', () { + const e = r"SELECT E'a\'; DELETE FROM t'; SELECT 2"; + expect(run(e, backslashEscapes: false), [ + r"SELECT E'a\'; DELETE FROM t'", + 'SELECT 2', + ]); + }); + + test('a script past the isolate threshold splits the same way', () async { + final script = List.filled(4000, r"SELECT 'a\'; SELECT 2;").join('\n'); + expect(script.length, greaterThan(SqlStatementSplitter.defaultIsolateThreshold)); + for (final escapes in [true, false]) { + final inline = SqlStatementSplitter.spans(script, backslashEscapes: escapes); + final spread = await SqlStatementSplitter.spansAsync( + script, + backslashEscapes: escapes, + ); + expect(spread.length, inline.length, reason: 'backslashEscapes: $escapes'); + expect(spread.last.end, inline.last.end); + } + }); + }); } diff --git a/test/core/database/ssh_tunnel_host_test.dart b/test/core/database/ssh_tunnel_host_test.dart new file mode 100644 index 00000000..a70f42a1 --- /dev/null +++ b/test/core/database/ssh_tunnel_host_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/ssh_tunnel_host.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; + +class _Host with SshTunnelHost { + _Host(this.sshConfig); + + @override + final SshTunnelConfig? sshConfig; + @override + final SshTunnelSecrets? sshSecrets = null; + + Future open() => openSshTunnel(id: 0, host: 'db', port: 5432); + Future release() => releaseSshTunnel(); +} + +void main() { + test('no tunnel is opened without a config or when it is disabled', () async { + expect(await _Host(null).open(), isNull); + expect(await _Host(const SshTunnelConfig(enabled: false)).open(), isNull); + }); + + test('releasing with no tunnel open is harmless and leaves no handle', () async { + final host = _Host(null); + await host.release(); + expect(host.sshTunnelHandle, isNull); + }); +} diff --git a/test/core/editor/syntax_highlight_isolate_test.dart b/test/core/editor/syntax_highlight_isolate_test.dart index 601e6407..32974c59 100644 --- a/test/core/editor/syntax_highlight_isolate_test.dart +++ b/test/core/editor/syntax_highlight_isolate_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/painting.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/editor/syntax_highlight_isolate.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; @@ -56,4 +57,70 @@ void main() { expect(segments, isNotEmpty); expect(segments.map((s) => s.text).join(), code); }); + + group('segmentsToTextSpan style sharing (#1543)', () { + const keyword = HighlightSegment( + text: 'SELECT', + colorArgb: 0xFF2196F3, + fontWeightValue: 700, + ); + const keywordAgain = HighlightSegment( + text: 'FROM', + colorArgb: 0xFF2196F3, + fontWeightValue: 700, + ); + const comment = HighlightSegment( + text: '-- x', + colorArgb: 0xFF808080, + fontStyleIndex: 1, + ); + const plain = HighlightSegment(text: ' '); + + TextStyle? styleOf(TextSpan span, int i) => + (span.children![i] as TextSpan).style; + + test('segments that look the same share one TextStyle', () { + const base = TextStyle(fontSize: 14); + final span = segmentsToTextSpan( + [keyword, plain, keywordAgain, comment], + baseStyle: base, + ); + expect(identical(styleOf(span, 0), styleOf(span, 2)), isTrue); + expect(identical(styleOf(span, 0), styleOf(span, 3)), isFalse); + expect(styleOf(span, 1), isNull); // no colour, weight or style + }); + + test('styles keep the base style and the segment attributes', () { + const base = TextStyle(fontSize: 14); + final span = segmentsToTextSpan([keyword, comment], baseStyle: base); + final kw = styleOf(span, 0)!; + expect(kw.fontSize, 14); + expect(kw.color, const Color(0xFF2196F3)); + expect(kw.fontWeight, FontWeight.w700); + expect(kw.fontStyle, isNull); + final cm = styleOf(span, 1)!; + expect(cm.color, const Color(0xFF808080)); + expect(cm.fontStyle, FontStyle.italic); + expect(cm.fontWeight, isNull); + }); + + test('a different base style is not served the old styles', () { + final a = segmentsToTextSpan([keyword], baseStyle: const TextStyle(fontSize: 12)); + final b = segmentsToTextSpan([keyword], baseStyle: const TextStyle(fontSize: 18)); + expect(styleOf(a, 0)!.fontSize, 12); + expect(styleOf(b, 0)!.fontSize, 18); + }); + + test('a null base style works and more looks than the cache holds do not break', + () { + final segments = [ + for (var i = 0; i < 600; i++) + HighlightSegment(text: 't', colorArgb: 0xFF000000 + i), + ]; + final span = segmentsToTextSpan(segments); + for (var i = 0; i < segments.length; i++) { + expect(styleOf(span, i)!.color, Color(0xFF000000 + i)); + } + }); + }); } diff --git a/test/core/extensions/extension_support_test.dart b/test/core/extensions/extension_support_test.dart index c0681dbb..3bbcc35f 100644 --- a/test/core/extensions/extension_support_test.dart +++ b/test/core/extensions/extension_support_test.dart @@ -9,6 +9,52 @@ import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart' import 'package:querya_desktop/core/market/marketplace_repository.dart'; void main() { + group('manifest format', () { + ExtensionManifest withFormat(int format) => ExtensionManifest( + manifestVersion: format, + id: 'test.fmt', + name: 'Fmt', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.theme, + engines: const {'querya_desktop': '*'}, + ); + + test('this build reads its own format and older ones', () { + expect(ExtensionSupport.manifestFormatProblem(withFormat(1)), isNull); + expect( + ExtensionSupport.manifestFormatProblem( + withFormat(ExtensionManifest.currentManifestVersion)), + isNull, + ); + }); + + test('a newer format is refused with a message that says what to do', () { + final problem = ExtensionSupport.manifestFormatProblem( + withFormat(ExtensionManifest.currentManifestVersion + 1)); + expect(problem, contains('test.fmt')); + expect(problem, contains('Update Querya Desktop')); + }); + + test('fromJson: absent or invalid reads as 1; toJson keeps only a real one', + () { + Map json(Object? v) => { + 'id': 'a.b', + 'name': 'A', + 'type': 'theme', + if (v != null) 'manifestVersion': v, + }; + expect(ExtensionManifest.fromJson(json(null)).manifestVersion, 1); + expect(ExtensionManifest.fromJson(json('2')).manifestVersion, 1); + expect(ExtensionManifest.fromJson(json(0)).manifestVersion, 1); + final v3 = ExtensionManifest.fromJson(json(3)); + expect(v3.manifestVersion, 3); + expect(v3.toJson()['manifestVersion'], 3); + expect(ExtensionManifest.fromJson(json(1)).toJson(), + isNot(contains('manifestVersion'))); + }); + }); + group('ExtensionSupport', () { test('marks bare database drivers as preview-only', () { expect( diff --git a/test/core/extensions/harness/extension_harness_report_test.dart b/test/core/extensions/harness/extension_harness_report_test.dart index c7f2b534..236032d9 100644 --- a/test/core/extensions/harness/extension_harness_report_test.dart +++ b/test/core/extensions/harness/extension_harness_report_test.dart @@ -41,6 +41,29 @@ void main() { expect(r.isSuccess, isFalse); }); + test('strict turns warnings into failures, never the other way', () { + HarnessReport with_(List statuses) => HarnessReport( + extensionId: 'acme.db', + targetVersion: const HostVersion(0, 4, 18), + totalDuration: Duration.zero, + checks: [ + for (var i = 0; i < statuses.length; i++) + HarnessCheck(name: 'c$i', status: statuses[i]), + ], + ); + + final clean = with_([HarnessCheckStatus.passed, HarnessCheckStatus.skipped]); + final warned = with_([HarnessCheckStatus.passed, HarnessCheckStatus.warning]); + final failed = with_([HarnessCheckStatus.failed]); + + expect(clean.passes(), isTrue); + expect(clean.passes(strict: true), isTrue); + expect(warned.passes(), isTrue); + expect(warned.passes(strict: true), isFalse); + expect(failed.passes(), isFalse); + expect(failed.passes(strict: true), isFalse); + }); + test('console output lists every check and a summary', () { final text = _report().toConsole(); expect(text, contains('PASS system.handshake (12 ms)')); diff --git a/test/core/extensions/harness/extension_harness_test.dart b/test/core/extensions/harness/extension_harness_test.dart index 58bc7f96..c8b5fd95 100644 --- a/test/core/extensions/harness/extension_harness_test.dart +++ b/test/core/extensions/harness/extension_harness_test.dart @@ -227,6 +227,61 @@ void main() { expect(tableParams, containsPair('tableName', 'orders')); }); + group('credentials', () { + test('a password is injected before connect and is not in its payload', + () async { + writeExtension(manifest()); + Map? injected; + Map? connectParams; + final handlers = _healthy() + ..['system.injectCredentials'] = (p) { + injected = p; + return {'ok': true}; + } + ..['db.connect'] = (p) { + connectParams = p; + return {'serverVersion': '1'}; + }; + final (report, plugin) = await run( + handlers, + connection: const HarnessConnectionOptions( + params: {'host': 'db', 'username': 'u', 'password': 's3cret'}, + ), + ); + expect(report.isSuccess, isTrue, reason: report.toConsole()); + expect(injected, {'connectionId': 1, 'password': 's3cret'}); + expect(connectParams, containsPair('username', 'u')); + expect(connectParams, isNot(contains('password'))); + expect(plugin!.calls.indexOf('system.injectCredentials'), + lessThan(plugin.calls.indexOf('db.connect'))); + expect(check(report, 'system.injectCredentials').status, + HarnessCheckStatus.passed); + }); + + test('without a password nothing is injected', () async { + writeExtension(manifest()); + final (report, plugin) = await run( + _healthy(), + connection: const HarnessConnectionOptions(params: {'host': 'db'}), + ); + expect(report.isSuccess, isTrue, reason: report.toConsole()); + expect(plugin!.calls, isNot(contains('system.injectCredentials'))); + expect(report.checks.any((c) => c.name == 'system.injectCredentials'), + isFalse); + }); + + test('a plugin without injectCredentials only warns', () async { + writeExtension(manifest()); + final (report, _) = await run( + _healthy(), + connection: const HarnessConnectionOptions(params: {'password': 'p'}), + ); + expect(check(report, 'system.injectCredentials').status, + HarnessCheckStatus.warning); + expect(check(report, 'db.connect').status, HarnessCheckStatus.passed); + }); + }); + test('methods newer than the target host are skipped', () async { writeExtension(manifest()); final (report, plugin) = await run( @@ -240,6 +295,45 @@ void main() { expect(plugin!.calls, isNot(contains('db.getTableSchema'))); }); + group('manifest only', () { + Future manifestOnly(String target) { + var launched = false; + final harness = QueryaExtensionHarness( + launcher: (request) async { + launched = true; + throw StateError('must not start the plugin'); + }, + ); + return harness + .run( + extensionRoot: root.path, + targetVersion: target, + manifestOnly: true, + ) + .whenComplete(() => expect(launched, isFalse)); + } + + test('checks the manifest and starts nothing', () async { + writeExtension(manifest(), entry: false); // no executable at all + final report = await manifestOnly('0.5.0'); + expect(report.checks.map((c) => c.name), ['manifest']); + expect(report.isSuccess, isTrue, reason: report.toConsole()); + }); + + test('still fails on a manifest the emulated host cannot read', () async { + writeExtension(manifest()..['manifestVersion'] = 2, entry: false); + final report = await manifestOnly('0.5.0'); + expect(report.isSuccess, isFalse); + expect(check(report, 'manifest').message, contains('manifestVersion')); + }); + + test('a missing manifest.json is a failure', () async { + final report = await manifestOnly('0.5.0'); + expect(report.isSuccess, isFalse); + expect(check(report, 'manifest').status, HarnessCheckStatus.failed); + }); + }); + test('a missing recommended method is only a warning', () async { writeExtension(manifest()); final (report, _) = await run(_healthy()..remove('db.getCapabilities')); @@ -341,4 +435,69 @@ void main() { throwsArgumentError, ); }); + + group('RPC trace', () { + test('records every call with timing, sizes and a bounded view of the data', + () async { + writeExtension(manifest()); + final handlers = _healthy() + ..['db.query'] = (_) => { + 'columns': ['id', 'note'], + 'rows': [ + for (var i = 0; i < 12; i++) [i, 'x' * 300], + ], + }; + + final (report, _) = await run(handlers); + + final methods = [for (final c in report.rpc) c.method]; + expect(methods, contains('system.handshake')); + expect(methods, contains('db.query')); + expect(report.rpc.every((c) => c.ok), isTrue); + + final query = report.rpc.firstWhere((c) => c.method == 'db.query'); + final rows = (query.response! as Map)['rows'] as List; + // 5 rows and a marker for the other 7; long strings are cut. + expect(rows, hasLength(6)); + expect(rows.last, '… (+7 more)'); + expect(((rows.first as List)[1] as String).length, lessThan(200)); + expect(query.responseBytes, greaterThan(3000)); + }); + + test('redacts secrets in the request params', () async { + writeExtension(manifest()); + + final (report, _) = await run( + _healthy(), + connection: const HarnessConnectionOptions( + params: {'host': 'db', 'password': 's3cret', 'ssh': {'passphrase': 'p'}}, + ), + ); + + // The password travels in system.injectCredentials, not in db.connect. + final inject = + report.rpc.firstWhere((c) => c.method == 'system.injectCredentials'); + expect((inject.request! as Map)['password'], '***'); + + final connect = report.rpc.firstWhere((c) => c.method == 'db.connect'); + final request = connect.request! as Map; + expect(request, isNot(contains('password'))); + expect((request['ssh'] as Map)['passphrase'], '***'); + expect(request['host'], 'db'); + expect(report.toTraceJson(), isNot(contains('s3cret'))); + }); + + test('a failing call is recorded with its error', () async { + writeExtension(manifest()); + final handlers = _healthy() + ..['db.query'] = (_) => throw _RpcError(-32000, 'table missing'); + + final (report, _) = await run(handlers); + + final query = report.rpc.firstWhere((c) => c.method == 'db.query'); + expect(query.ok, isFalse); + expect(query.error, contains('table missing')); + expect(report.toTraceJson(), contains('"success": false')); + }); + }); } diff --git a/test/core/extensions/harness/extension_manifest_validator_test.dart b/test/core/extensions/harness/extension_manifest_validator_test.dart index d7a49b84..6e06b078 100644 --- a/test/core/extensions/harness/extension_manifest_validator_test.dart +++ b/test/core/extensions/harness/extension_manifest_validator_test.dart @@ -92,6 +92,78 @@ void main() { isFalse); }); + group('engine ranges', () { + bool? allows(String host, String range) => + engineRangeAllows(HostVersion.parse(host), range); + + test('comparators', () { + expect(allows('0.5.0', '>=0.4.0'), isTrue); + expect(allows('0.3.9', '>=0.4.0'), isFalse); + expect(allows('0.4.0', '>0.4.0'), isFalse); + expect(allows('0.4.1', '>0.4.0'), isTrue); + expect(allows('0.4.0', '<=0.4.0'), isTrue); + expect(allows('0.4.1', '<=0.4.0'), isFalse); + expect(allows('0.4.19', '<0.5.0'), isTrue); + expect(allows('0.5.0', '<0.5.0'), isFalse); + expect(allows('0.4.19', '=0.4.19'), isTrue); + expect(allows('0.4.20', '=0.4.19'), isFalse); + }); + + test('caret keeps its meaning', () { + expect(allows('0.4.19', '^0.4.11'), isTrue); + expect(allows('0.5.0', '^0.4.11'), isFalse); + expect(allows('1.9.0', '^1.2.0'), isTrue); + expect(allows('2.0.0', '^1.2.0'), isFalse); + }); + + test('several comparators must all hold (the ClickHouse driver)', () { + const range = '>=0.4.0 <3.0.0'; + expect(allows('0.4.19', range), isTrue); + expect(allows('2.9.9', range), isTrue); + expect(allows('3.0.0', range), isFalse); + expect(allows('0.3.0', range), isFalse); + expect(allows('0.5.0', ' >=0.4.0 <3.0.0 '), isTrue); + }); + + test('spaces between an operator and its version are tolerated (#1635)', + () { + const range = '>= 0.4.0 < 3.0.0'; + expect(allows('0.4.19', range), isTrue); + expect(allows('3.0.0', range), isFalse); + expect(allows('0.3.0', range), isFalse); + expect(allows('0.4.1', '> 0.4.0'), isTrue); + expect(allows('0.4.0', '<= 0.4.0'), isTrue); + expect(allows('0.4.19', '= 0.4.19'), isTrue); + expect(allows('0.4.19', '^ 0.4.11'), isTrue); + expect(allows('0.5.0', '>='), isNull); + }); + + test('HostVersion has a > operator (#1636)', () { + expect(HostVersion.parse('0.4.1') > HostVersion.parse('0.4.0'), isTrue); + expect(HostVersion.parse('0.4.0') > HostVersion.parse('0.4.0'), isFalse); + expect(HostVersion.parse('0.3.9') > HostVersion.parse('0.4.0'), isFalse); + }); + + test('* means any version; a range it cannot read is null, not a guess', () { + expect(allows('0.5.0', '*'), isTrue); + for (final bad in ['~0.4', '>=0.4.0 || ^1.0.0', '>=x', '', '0.4.19', '* <3.0.0']) { + expect(allows('0.5.0', bad), isNull, reason: bad); + } + }); + + test('an unreadable range in a manifest is a warning, a readable one that ' + 'excludes the host is a warning with a different message', () { + final unreadable = _check( + _valid()..['engines'] = {'querya_desktop': '>=0.4.0 || ^1.0.0'}).single; + expect(unreadable.isError, isFalse); + expect(unreadable.message, contains('unsupported range')); + final excluded = _check( + _valid()..['engines'] = {'querya_desktop': '>=0.4.0 <0.5.0'}, '0.5.0') + .single; + expect(excluded.message, contains('does not include')); + }); + }); + test('commands on a host older than 0.4.16 warn', () { final m = _valid(); (m['contributions']! as Map)['commands'] = [ @@ -101,4 +173,48 @@ void main() { expect(d.single.path, 'contributions.commands'); expect(_check(m, '0.4.16'), isEmpty); }); + + group('manifestVersion', () { + List errors(Object? manifest, [String target = '0.5.0']) => + _check(manifest, target).where((d) => d.isError).toList(); + + test('absent means format 1 and is fine', () { + expect(_check(_valid()), isEmpty); + }); + + test('format 1 is fine on every host that has the protocol', () { + final m = _valid()..['manifestVersion'] = 1; + for (final host in ['0.4.11', '0.4.18', '0.5.0']) { + // Other diagnostics are fine here (the engines range of _valid() is + // ^0.4.11, which warns on 0.5.0); only manifestVersion must be clean. + expect( + _check(m, host).where((d) => d.path == 'manifestVersion'), + isEmpty, + reason: host, + ); + } + }); + + test('a format newer than the host reads is an error naming the path', () { + final d = errors(_valid()..['manifestVersion'] = 2); + expect(d, hasLength(1)); + expect(d.single.path, 'manifestVersion'); + expect(d.single.message, contains('newer than Querya Desktop 0.5.0')); + }); + + test('a value that is not a whole number >= 1 is an error', () { + for (final bad in [0, -1, 1.5, '1', true, []]) { + final d = errors(_valid()..['manifestVersion'] = bad); + expect(d.map((e) => e.path), ['manifestVersion'], reason: '$bad'); + } + }); + }); + + group('protocol profile manifest formats', () { + test('every host with the protocol reads format 1', () { + for (final host in ['0.4.11', '0.4.19', '0.5.0']) { + expect(ExtensionProtocolProfile.forTarget(host).manifestVersion, 1); + } + }); + }); } diff --git a/test/core/extensions/harness/extension_protocol_profile_test.dart b/test/core/extensions/harness/extension_protocol_profile_test.dart index 2899a7be..ac89256d 100644 --- a/test/core/extensions/harness/extension_protocol_profile_test.dart +++ b/test/core/extensions/harness/extension_protocol_profile_test.dart @@ -25,6 +25,7 @@ void main() { final p = ExtensionProtocolProfile.forTarget('0.4.11'); expect(p.supports('system.handshake'), isTrue); expect(p.supports('db.query'), isTrue); + expect(p.supports('system.injectCredentials'), isTrue); expect(p.supports('db.getTableSchema'), isFalse); expect(p.supports('commands.execute'), isFalse); }); @@ -37,7 +38,8 @@ void main() { test('0.5.0 speaks everything the current host sends', () { final p = ExtensionProtocolProfile.forTarget('0.5.0'); - expect(p.methods.length, 11); + expect(p.methods.length, 12); + expect(p.supports('system.injectCredentials'), isTrue); }); test('levels distinguish required from recommended methods', () { diff --git a/test/core/extensions/local_extension_installer_test.dart b/test/core/extensions/local_extension_installer_test.dart index 81bc6247..a70f72e4 100644 --- a/test/core/extensions/local_extension_installer_test.dart +++ b/test/core/extensions/local_extension_installer_test.dart @@ -117,6 +117,36 @@ void main() { expect(await Directory(p.join(tempDir.path, 'test.evil')).exists(), isFalse); }); + test('rejects a manifest id outside the extensions folder (#1560)', + () async { + final extensionsDir = Directory(p.join(tempDir.path, 'extensions')) + ..createSync(); + ExtensionPaths.mockExtensionsDirectory = extensionsDir; + final victim = File(p.join(tempDir.path, 'victim', 'keep.txt')) + ..createSync(recursive: true); + + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': '../victim', + 'name': 'Evil', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + })); + + final zip = await _writeZip(tempDir, archive, 'evil-id.zip'); + await expectLater( + LocalExtensionInstaller().installFromArchive(zip), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Invalid manifest.json'), + )), + ); + expect(victim.existsSync(), isTrue); + }); + test('rejects preview database drivers without process sandbox', () async { final archive = Archive() ..addFile(_jsonFile('manifest.json', { @@ -312,5 +342,76 @@ void main() { expect(extDirExistedWhenStopped, isTrue); expect(await File(p.join(extDir.path, 'stale.txt')).exists(), isFalse); }); + + Map themeManifest(String version) => { + 'id': 'test.upgrade', + 'name': 'Upgrade', + 'version': version, + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + }; + + test('a failed upgrade keeps the working version (#1562)', () async { + final v1 = await _writeZip( + tempDir, + Archive() + ..addFile(_jsonFile('manifest.json', themeManifest('1.0.0'))) + ..addFile(ArchiveFile('old.txt', 3, utf8.encode('old'))), + 'v1.zip', + ); + await LocalExtensionInstaller().installFromArchive(v1); + + // The new version fails during extraction, after the old one was + // already on disk. + final broken = await _writeZip( + tempDir, + Archive() + ..addFile(_jsonFile('manifest.json', themeManifest('2.0.0'))) + ..addFile(ArchiveFile('../evil.txt', 4, utf8.encode('evil'))), + 'v2.zip', + ); + await expectLater( + LocalExtensionInstaller().installFromArchive(broken), + throwsA(isA()), + ); + + final extDir = Directory(p.join(tempDir.path, 'test.upgrade')); + expect(await File(p.join(extDir.path, 'old.txt')).exists(), isTrue); + final kept = jsonDecode( + await File(p.join(extDir.path, 'manifest.json')).readAsString(), + ) as Map; + expect(kept['version'], '1.0.0'); + final leftovers = tempDir + .listSync() + .whereType() + .where((d) => p.basename(d.path).startsWith('.')); + expect(leftovers, isEmpty); + }); + + test('an upgrade replaces the old files instead of mixing versions (#1562)', + () async { + final v1 = await _writeZip( + tempDir, + Archive() + ..addFile(_jsonFile('manifest.json', themeManifest('1.0.0'))) + ..addFile(ArchiveFile('old.txt', 3, utf8.encode('old'))), + 'v1.zip', + ); + await LocalExtensionInstaller().installFromArchive(v1); + + final v2 = await _writeZip( + tempDir, + Archive() + ..addFile(_jsonFile('manifest.json', themeManifest('2.0.0'))) + ..addFile(ArchiveFile('new.txt', 3, utf8.encode('new'))), + 'v2.zip', + ); + await LocalExtensionInstaller().installFromArchive(v2); + + final extDir = Directory(p.join(tempDir.path, 'test.upgrade')); + expect(await File(p.join(extDir.path, 'new.txt')).exists(), isTrue); + expect(await File(p.join(extDir.path, 'old.txt')).exists(), isFalse); + }); }); } diff --git a/test/core/extensions/local_extension_registry_test.dart b/test/core/extensions/local_extension_registry_test.dart index 21d43ab3..ff02ad8c 100644 --- a/test/core/extensions/local_extension_registry_test.dart +++ b/test/core/extensions/local_extension_registry_test.dart @@ -29,6 +29,31 @@ void main() { } }); + test('skips an extension whose manifest format is newer than this build reads', + () async { + Future write(String dir, int? format) async { + final d = Directory(p.join(tempDir.path, dir))..createSync(); + File(p.join(d.path, 'manifest.json')).writeAsStringSync(jsonEncode({ + 'id': 'test.$dir', + 'name': dir, + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + if (format != null) 'manifestVersion': format, + })); + } + + await write('plain', null); + await write('current', 1); + await write('future', 99); + + await LocalExtensionRegistry.instance.reload(); + final ids = LocalExtensionRegistry.instance.manifests.map((m) => m.id); + expect(ids, containsAll(['test.plain', 'test.current'])); + expect(ids, isNot(contains('test.future'))); + }); + test('loads extensions from manifest files', () async { // Create a valid extension folder final ext1Dir = Directory(p.join(tempDir.path, 'ext1')); diff --git a/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart b/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart index db6bf48f..c863c6df 100644 --- a/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart +++ b/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart @@ -59,6 +59,37 @@ void main() { expect(result.value, 'SELECT * FROM users WHERE id=1;\n'); }); + test('sql.format leaves literals, quoted identifiers and comments alone (#1567)', + () async { + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlFormat, + source: '{"kind":"sql.format"}', + args: { + 'sql': "select 'a b; order by', \"select\" from t -- from x\n" + 'where id=1;', + }, + ), + ); + expect(result.ok, isTrue); + expect( + result.value, + "SELECT 'a b; order by', \"select\" FROM t -- from x\n" + 'WHERE id=1;\n', + ); + }); + + test('sql.format can leave keyword case alone', () async { + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlFormat, + source: '{"kind":"sql.format","uppercaseKeywords":false}', + args: {'sql': 'select 1'}, + ), + ); + expect(result.value, 'select 1'); + }); + test('generates hints filtered by prefix', () async { const source = ''' { @@ -95,6 +126,37 @@ void main() { expect(ast['statement'], 'UPDATE'); expect(ast['dialect'], 'postgresql'); }); + + test('sql.parse reads the statement after leading comments (#1567)', + () async { + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlParse, + source: '{"kind":"sql.parse"}', + args: {'sql': '-- note\n/* more */ select 1'}, + ), + ); + expect((result.value! as Map)['statement'], 'SELECT'); + }); + + test('JSONC modules keep // and /* inside strings, allow trailing commas (#1567)', + () async { + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlParse, + source: ''' +{ + // comment + "kind": "sql.parse", + "homepage": "https://example.com/*not a comment*/", + /* block */ "dialect": "pg", +}''', + args: {'sql': 'select 1'}, + ), + ); + expect(result.ok, isTrue); + expect((result.value! as Map)['dialect'], 'pg'); + }); }); group('EmbeddedSandboxRuntime', () { diff --git a/test/core/extensions/sandbox/sandbox_process_runner_test.dart b/test/core/extensions/sandbox/sandbox_process_runner_test.dart index b9e5bc32..ddd2f543 100644 --- a/test/core/extensions/sandbox/sandbox_process_runner_test.dart +++ b/test/core/extensions/sandbox/sandbox_process_runner_test.dart @@ -402,6 +402,44 @@ void main() { // Round-trip through JSON to ensure no control chars. expect(jsonEncode(profile), contains(r'/tmp/querya_sandbox/x')); }); + + test('a quote or backslash in a path cannot end the string (#1590)', () { + final profile = buildMacOsSeatbeltProfile( + scratchPath: r'/tmp/a"b\c', + extensionRoot: r'/ext/x") (allow file-write* (subpath "/', + ); + expect(profile, contains(r'(subpath "/tmp/a\"b\\c")')); + expect(profile, isNot(contains('(allow file-write* (subpath "/")'))); + expect( + profile.split('\n').where((l) => l.startsWith('(allow file-write*')), + hasLength(1), + ); + }); + + test('scratch and extension paths are named by their real path (#1590)', + () async { + final real = await Directory.systemTemp.createTemp('querya_real_'); + addTearDown(() => real.delete(recursive: true)); + final linkDir = await Directory.systemTemp.createTemp('querya_link_'); + addTearDown(() => linkDir.delete(recursive: true)); + final link = Link('${linkDir.path}/scratch'); + await link.create(real.path); + final realPath = real.resolveSymbolicLinksSync(); + + final command = SandboxLaunchCommand.build( + pluginExecutable: '/bin/true', + scratchPath: link.path, + extensionRoot: link.path, + platformOverride: 'macos', + ); + final profile = command.arguments[1]; + expect(profile, contains('(subpath "$realPath")')); + expect(profile, isNot(contains(link.path))); + }, skip: Platform.isWindows ? 'symbolic links need privileges' : null); + + test('a path that does not exist is kept as given', () { + expect(realPathOrSelf('/no/such/querya/dir'), '/no/such/querya/dir'); + }); }); group('SandboxProcessRunner.detectBwrapAvailability', () { diff --git a/test/core/market/extension_manifest_test.dart b/test/core/market/extension_manifest_test.dart index e4f8840d..b7dfd207 100644 --- a/test/core/market/extension_manifest_test.dart +++ b/test/core/market/extension_manifest_test.dart @@ -1,4 +1,8 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/market/extension_manifest.dart'; import 'package:querya_desktop/core/theme/theme_definition.dart'; import 'package:querya_desktop/core/theme/theme_metadata.dart'; @@ -61,5 +65,65 @@ void main() { expect(manifest.sha256Checksum, 'ff00aa11'); expect(manifest.downloadUrl, isEmpty); }); + + group('id (#1560)', () { + Map json(Object? id) => + {'id': id, 'name': 'X', 'type': 'theme'}; + + test('accepts letters, digits, dots, dashes and underscores', () { + for (final id in [ + 'acme.redis-driver', + 'queryahub.clickhouse-driver', + 'my_theme', + '42', + ]) { + expect(ExtensionManifest.fromJson(json(id)).id, id); + } + }); + + test('rejects ids that are not a single folder name', () { + for (final id in [ + '../x', + '..', + '.', + 'a/b', + r'..\x', + r'a\b', + '/etc', + r'C:\x', + '', + ' ', + '.hidden', + 'a..b', + 'CON', + 'nul.txt', + 'a\x00b', + 'x' * 129, + null, + 42, + ]) { + expect( + () => ExtensionManifest.fromJson(json(id)), + throwsFormatException, + reason: 'id: $id', + ); + } + }); + + test('extensionDirectory refuses an unsafe id', () { + final root = Directory.systemTemp; + expect( + ExtensionPaths.extensionDirectory(root, 'acme.theme').path, + p.join(root.path, 'acme.theme'), + ); + for (final id in ['../x', '', '.', 'a/b']) { + expect( + () => ExtensionPaths.extensionDirectory(root, id), + throwsArgumentError, + reason: 'id: $id', + ); + } + }); + }); }); } diff --git a/test/core/market/static_registry_repository_test.dart b/test/core/market/static_registry_repository_test.dart new file mode 100644 index 00000000..ca20b172 --- /dev/null +++ b/test/core/market/static_registry_repository_test.dart @@ -0,0 +1,332 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/market/static_registry_repository.dart'; + +const _sha = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const _asset = 'https://github.com/acme/ext/releases/download/v1.0.0/ext.zip'; + +Map _entry( + String id, { + String type = 'theme', + String url = _asset, + Object? sha = _sha, + String engines = '>=0.4.19', + bool yanked = false, + List tags = const [], +}) => + { + 'id': id, + 'name': 'Name of $id', + 'version': '1.0.0', + 'publisher': 'Acme', + 'type': type, + 'engines': {'querya_desktop': engines}, + 'downloadUrl': url, + 'sha256': sha, + 'tags': tags, + if (yanked) 'yanked': true, + }; + +String _index(List> entries, {Object schema = 1}) => + jsonEncode({'schema': schema, 'extensions': entries}); + +void main() { + group('StaticRegistryRepository.parseIndex', () { + List ids(Object decoded, + {String host = '0.5.0', String platform = 'linux-x64'}) => [ + for (final m + in StaticRegistryRepository.parseIndex(decoded, + hostVersion: host, platform: platform)) + m.id, + ]; + + test('keeps installable entries and drops the rest', () { + final decoded = { + 'schema': 1, + 'extensions': [ + _entry('ok.one'), + _entry('bad.yanked', yanked: true), + _entry('bad.host', url: 'https://example.com/ext.zip'), + _entry('bad.archive', url: 'https://github.com/acme/ext/archive/main.zip'), + _entry('bad.nosha', sha: ''), + _entry('bad.nullsha', sha: null), + _entry('bad.engines', engines: '>=9.0.0'), + {'name': 'no id at all'}, + 'not an object', + _entry('ok.two'), + ], + }; + expect(ids(decoded), ['ok.one', 'ok.two']); + }); + + test('hides extensions the running Querya is too old for', () { + final decoded = { + 'schema': 1, + 'extensions': [_entry('new.only', engines: '>=0.5.0'), _entry('any')], + }; + expect(ids(decoded, host: '0.4.19'), ['any']); + expect(ids(decoded, host: '0.5.0'), ['new.only', 'any']); + }); + + test('hides an extension in a manifest format this Querya cannot read', () { + final decoded = { + 'schema': 1, + 'extensions': [ + _entry('fmt.current')..['manifestVersion'] = 1, + _entry('fmt.absent'), + _entry('fmt.future')..['manifestVersion'] = 99, + ], + }; + expect(ids(decoded), ['fmt.current', 'fmt.absent']); + }); + + test('keeps an engines range it cannot read (the host does not enforce it)', () { + expect(ids({'schema': 1, 'extensions': [_entry('x', engines: '*')]}), ['x']); + }); + + test('refuses an index of another format or shape', () { + expect(() => StaticRegistryRepository.parseIndex({'schema': 2, 'extensions': []}, + hostVersion: '0.5.0', platform: 'linux-x64'), + throwsA(isA())); + expect(() => StaticRegistryRepository.parseIndex([], + hostVersion: '0.5.0', platform: 'linux-x64'), + throwsA(isA())); + }); + }); + + group('StaticRegistryRepository platform builds', () { + Map perPlatform(String id) => { + ...(_entry(id) + ..remove('downloadUrl') + ..remove('sha256')), + 'main': 'bin/driver', + 'platforms': { + 'linux-x64': { + 'downloadUrl': 'https://github.com/acme/ext/releases/download/v1/linux.zip', + 'sha256': _sha, + 'main': 'bin/driver', + }, + 'windows-x64': { + 'downloadUrl': 'https://github.com/acme/ext/releases/download/v1/win.zip', + 'sha256': 'b' * 64, + 'main': 'bin/driver.exe', + }, + }, + }; + + test('the entry becomes the build for this platform', () { + final decoded = {'schema': 1, 'extensions': [perPlatform('acme.native')]}; + final linux = StaticRegistryRepository.parseIndex(decoded, + hostVersion: '0.5.0', platform: 'linux-x64').single; + expect(linux.downloadUrl, endsWith('/linux.zip')); + expect(linux.sha256Checksum, _sha); + expect(linux.main, 'bin/driver'); + + final win = StaticRegistryRepository.parseIndex(decoded, + hostVersion: '0.5.0', platform: 'windows-x64').single; + expect(win.downloadUrl, endsWith('/win.zip')); + expect(win.sha256Checksum, 'b' * 64); + expect(win.main, 'bin/driver.exe'); + }); + + test('an extension with no build for this platform is not offered', () { + final decoded = {'schema': 1, 'extensions': [perPlatform('acme.native'), _entry('acme.any')]}; + final macos = StaticRegistryRepository.parseIndex(decoded, + hostVersion: '0.5.0', platform: 'macos-arm64'); + expect(macos.map((m) => m.id), ['acme.any']); + expect( + StaticRegistryRepository.parseIndex(decoded, + hostVersion: '0.5.0', platform: ''), + hasLength(1)); + }); + + test('a build whose URL is not a release asset is dropped', () { + final entry = perPlatform('acme.native'); + (entry['platforms'] as Map)['linux-x64'] = { + 'downloadUrl': 'https://example.com/linux.zip', + 'sha256': _sha, + }; + final decoded = {'schema': 1, 'extensions': [entry]}; + expect( + StaticRegistryRepository.parseIndex(decoded, + hostVersion: '0.5.0', platform: 'linux-x64'), + isEmpty); + }); + + test('this machine has a platform key', () { + expect(StaticRegistryRepository.currentPlatformKey(), + matches(RegExp(r'^(linux|macos|windows)-[a-z0-9]+$'))); + }); + }); + + group('StaticRegistryRepository.isReleaseAssetUrl', () { + bool ok(String u) => StaticRegistryRepository.isReleaseAssetUrl(Uri.parse(u)); + + test('accepts only a GitHub release asset over https', () { + expect(ok(_asset), isTrue); + expect(ok('http://github.com/acme/ext/releases/download/v1/ext.zip'), isFalse); + expect(ok('https://evil.com/acme/ext/releases/download/v1/ext.zip'), isFalse); + expect(ok('https://github.com.evil.com/acme/ext/releases/download/v1/ext.zip'), isFalse); + expect(ok('https://github.com/acme/ext/archive/refs/heads/main.zip'), isFalse); + expect(ok('https://github.com/acme/ext/releases/latest'), isFalse); + expect(ok('https://github.com/acme/ext/releases/download/v1/'), isFalse); + expect(ok('https://github.com/acme/ext/releases/download/v1/a/b.zip'), isFalse); + }); + }); + + group('StaticRegistryRepository', () { + var requests = 0; + var now = DateTime(2026, 10, 16, 12); + + StaticRegistryRepository repo( + http.Client client, { + String host = '0.5.0', + }) => + StaticRegistryRepository( + hostVersion: host, + platform: 'linux-x64', + client: client, + now: () => now, + ); + + setUp(() { + requests = 0; + now = DateTime(2026, 10, 16, 12); + }); + + MockClient indexClient(List> entries) => + MockClient((request) async { + requests++; + expect(request.url.toString(), StaticRegistryRepository.defaultIndexUrl); + return http.Response(_index(entries), 200); + }); + + test('lists and filters by type', () async { + final r = repo(indexClient([ + _entry('a.theme'), + _entry('b.driver', type: 'databaseDriver'), + ])); + expect((await r.getTrending()).map((m) => m.id), ['a.theme', 'b.driver']); + expect((await r.getTrending(type: ExtensionType.theme)).map((m) => m.id), ['a.theme']); + }); + + test('searches name, id, publisher and tags', () async { + final r = repo(indexClient([ + _entry('a.one', tags: ['nord']), + _entry('b.two'), + ])); + expect((await r.search('NORD')).map((m) => m.id), ['a.one']); + expect((await r.search('b.two')).map((m) => m.id), ['b.two']); + expect((await r.search('acme')).length, 2); + expect(await r.search('nothing like it'), isEmpty); + expect((await r.search(' ')).length, 2); + }); + + test('caches the index for the TTL and refetches after it', () async { + final r = repo(indexClient([_entry('a')])); + await r.getTrending(); + await r.search('a'); + expect(requests, 1); + now = now.add(const Duration(minutes: 11)); + await r.getTrending(); + expect(requests, 2); + }); + + test('serves the last good copy when a refresh fails', () async { + var fail = false; + final r = repo(MockClient((request) async { + if (fail) return http.Response('down', 503); + return http.Response(_index([_entry('a')]), 200); + })); + expect(await r.getTrending(), hasLength(1)); + fail = true; + now = now.add(const Duration(hours: 1)); + expect(await r.getTrending(), hasLength(1)); + }); + + test('reports an unreachable registry when there is no copy yet', () async { + final r = repo(MockClient((request) async => http.Response('nope', 404))); + expect( + r.getTrending(), + throwsA(isA() + .having((e) => e.message, 'message', contains('HTTP 404'))), + ); + }); + + test('download refuses anything but a release asset', () { + final r = repo(indexClient([])); + expect(() => r.download('https://example.com/ext.zip'), + throwsA(isA())); + expect(() => r.download('https://github.com/acme/ext/archive/main.zip'), + throwsA(isA())); + }); + + group('install', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_static_registry_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + }); + + tearDown(() async { + if (await tempDir.exists()) await tempDir.delete(recursive: true); + ExtensionPaths.mockExtensionsDirectory = null; + }); + + test('downloads the release asset, checks its hash and unpacks it', () async { + final archive = Archive() + ..addFile(ArchiveFile('theme.json', 2, utf8.encode('{}'))); + final zip = ZipEncoder().encode(archive); + final sha = sha256.convert(zip).toString(); + + final r = repo(MockClient((request) async { + if (request.url.toString() == StaticRegistryRepository.defaultIndexUrl) { + return http.Response(_index([_entry('acme.theme', sha: sha)]), 200); + } + expect(request.url.toString(), _asset); + return http.Response.bytes(zip, 200); + })); + + final entry = (await r.getTrending()).single; + await r.install(entry); + + expect(File(p.join(tempDir.path, 'acme.theme', 'theme.json')).existsSync(), isTrue); + }); + + test('refuses an archive whose hash is not the one in the index', () async { + final archive = Archive() + ..addFile(ArchiveFile('theme.json', 2, utf8.encode('{}'))); + final zip = ZipEncoder().encode(archive); + + final r = repo(MockClient((request) async { + if (request.url.toString() == StaticRegistryRepository.defaultIndexUrl) { + return http.Response(_index([_entry('acme.theme')]), 200); + } + return http.Response.bytes(zip, 200); + })); + + final entry = (await r.getTrending()).single; + expect( + () => r.install(entry), + throwsA(isA() + .having((e) => e.message, 'message', contains('SHA256 checksum mismatch'))), + ); + expect(Directory(p.join(tempDir.path, 'acme.theme')).existsSync() && + File(p.join(tempDir.path, 'acme.theme', 'theme.json')).existsSync(), isFalse); + }); + }); + }); +} diff --git a/test/core/market/template_manifest_policy_test.dart b/test/core/market/template_manifest_policy_test.dart new file mode 100644 index 00000000..09f6f7e6 --- /dev/null +++ b/test/core/market/template_manifest_policy_test.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/extension_support.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; + +/// `manifest.json` of QueryaHub/extension-template. A starting point that the +/// app then refuses to install (or shows as a "Preview" listing) would defeat +/// it, so this pins the template to the app's sandbox policy. +const _templateManifest = ''' +{ + "manifestVersion": 1, + "id": "example.redis-template", + "name": "Example driver (template)", + "version": "0.1.0", + "publisher": "Your Name", + "type": "database_driver", + "main": "bin/querya-example-driver", + "engines": { "querya_desktop": ">=0.4.19" }, + "capabilities": { "databaseDriver": true }, + "sandbox": { + "engine": "process", + "permissions": { + "network": { "mode": "connection_host_only", "allow_ssl": false }, + "filesystem": { "scratch_mb": 10, "access": "scratch_only" }, + "resources": { "memory_mb": 128, "max_open_files": 32, "timeout_seconds": 60 } + } + }, + "contributions": { + "drivers": [ + { + "driverId": "example-redis", + "displayName": "Example (Redis protocol)", + "defaultPort": 6379, + "connectionFormSchema": "assets/connection_form.json" + } + ] + } +} +'''; + +void main() { + test('the extension template is installable under the sandbox policy', () { + final manifest = ExtensionManifest.fromJson( + jsonDecode(_templateManifest) as Map, + ); + expect(manifest.manifestVersion, 1); + expect(ExtensionSupport.manifestFormatProblem(manifest), isNull); + expect(SandboxPolicy.validate(manifest), isEmpty); + expect(ExtensionSupport.isPreviewOnlyManifest(manifest), isFalse); + }); +} diff --git a/test/core/mcp/mcp_query_service_test.dart b/test/core/mcp/mcp_query_service_test.dart index d51023e4..b515303a 100644 --- a/test/core/mcp/mcp_query_service_test.dart +++ b/test/core/mcp/mcp_query_service_test.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; -import 'package:querya_desktop/core/erd/erd_catalog.dart'; import 'package:querya_desktop/core/mcp/mcp_access_store.dart'; import 'package:querya_desktop/core/mcp/mcp_query_service.dart'; import 'package:querya_desktop/core/security/connection_environment.dart'; @@ -55,6 +54,12 @@ ConnectionRow _row(int id, String type, {String? env}) { } SqlExecutionResult _catalog(String sql) { + if (sql.contains('object_name')) { + return const SqlExecutionResult(columns: ['n'], rows: [ + ['orders'], + ['users'], + ]); + } if (sql.contains('pg_indexes')) { return const SqlExecutionResult(columns: ['n', 'd'], rows: [ ['users_pkey', 'CREATE UNIQUE INDEX users_pkey ON users (id)'], @@ -207,7 +212,7 @@ void main() { test('a timeout becomes a readable error', () async { db.onExecute = (_) => throw TimeoutException('slow'); await expectLater( - service().runQuery(1, 'SELECT pg_sleep(60)'), + service().runQuery(1, 'SELECT count(*) FROM big_table'), throwsA(isA().having( (e) => e.message, 'message', contains('time limit'))), ); @@ -226,7 +231,14 @@ void main() { group('views', () { setUp(() { db.onExecute = (sql) { - if (sql == ErdCatalog.viewColumnsSql(SqlDialect.postgres)) { + if (sql.contains('object_name')) { + return const SqlExecutionResult(columns: ['n'], rows: [ + ['active_users'], + ['orders'], + ['users'], + ]); + } + if (sql.contains("relkind IN ('v', 'm')")) { return const SqlExecutionResult(columns: ['t', 'c', 'ty', 'pk'], rows: [ ['active_users', 'id', 'integer', '0'], ['active_users', 'name', 'text', '0'], @@ -272,6 +284,23 @@ void main() { expect(orders.columns.last.references, 'users.id'); }); + test('describeTable and sampleRows read only the table asked for (#1572)', + () async { + await service().describeTable(1, 'users'); + // Column and view-column queries (the key query has no format_type). + final columnQueries = + db.executed.where((s) => s.contains('format_type')).toList(); + expect(columnQueries, isNotEmpty); + for (final q in columnQueries) { + expect(q, contains("AND c.relname IN ('users')")); + } + + db.executed.clear(); + await service().sampleRows(1, 'USERS', rows: 3); + expect(db.executed.where((s) => s.contains('format_type')), isEmpty); + expect(db.executed.last, 'SELECT * FROM "users"'); + }); + test('an unknown table is an error and runs no table query', () async { await expectLater(service().describeTable(1, 'users; DROP TABLE x'), throwsA(isA())); diff --git a/test/core/mcp/mcp_server_test.dart b/test/core/mcp/mcp_server_test.dart index 1d888234..ed41acb9 100644 --- a/test/core/mcp/mcp_server_test.dart +++ b/test/core/mcp/mcp_server_test.dart @@ -271,6 +271,47 @@ void main() { expect(msg['error']['message'], kMcpAppNotRunning); }); + test('overlapping start calls build one host, and stop closes it (#1563)', + () async { + final ports = {}; + void note() { + final port = controller.status.value.port; + if (port != null) ports.add(port); + } + + controller.status.addListener(note); + addTearDown(() => controller.status.removeListener(note)); + + await Future.wait([controller.start(), controller.start(), controller.start()]); + note(); + expect(ports, hasLength(1)); + + await controller.stop(); + for (final port in ports) { + await expectLater( + Socket.connect(InternetAddress.loopbackIPv4, port, + timeout: const Duration(milliseconds: 500)), + throwsA(isA()), + reason: 'no host may still listen on $port', + ); + } + }); + + test('stop waits for a start that is still running', () async { + final starting = controller.start(); + await controller.stop(); + await starting; + expect(controller.status.value.running, isFalse); + expect(await endpoint.exists(), isFalse); + }); + + test('a start after a stop works again', () async { + await controller.start(); + await controller.stop(); + await controller.start(); + expect(controller.status.value.running, isTrue); + }); + test('stopping the server disconnects a live client', () async { await controller.start(); final s = await _Session.open(endpoint); diff --git a/test/core/mcp/mcp_socket_host_test.dart b/test/core/mcp/mcp_socket_host_test.dart new file mode 100644 index 00000000..94d9a834 --- /dev/null +++ b/test/core/mcp/mcp_socket_host_test.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/mcp/mcp_socket_host.dart'; + +void main() { + late Directory tempDir; + late McpSocketHost host; + + Future startHost({int maxUnauthenticated = 16}) async { + host = McpSocketHost( + endpointFile: File(p.join(tempDir.path, 'mcp.json')), + version: 'test', + onSession: (_) async {}, + maxUnauthenticated: maxUnauthenticated, + maxHandshakeBytes: 64, + ); + await host.start(); + } + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_mcp_host_'); + }); + + tearDown(() async { + if (host.isRunning) await host.stop(); + await tempDir.delete(recursive: true); + }); + + test('a handshake line without a newline is cut off at the limit (#1565)', + () async { + await startHost(); + final socket = await Socket.connect(InternetAddress.loopbackIPv4, host.port!); + final reply = utf8.decodeStream(socket); + socket.add(List.filled(200, 0x41)); + await socket.flush(); + + expect(await reply.timeout(const Duration(seconds: 3)), + contains('Handshake too long')); + socket.destroy(); + }); + + test('sockets beyond the unauthenticated cap are turned away (#1565)', + () async { + await startHost(maxUnauthenticated: 1); + final first = await Socket.connect(InternetAddress.loopbackIPv4, host.port!); + // Let the host register the first one before the second arrives. + await Future.delayed(const Duration(milliseconds: 100)); + final second = await Socket.connect(InternetAddress.loopbackIPv4, host.port!); + + await second.drain().timeout(const Duration(seconds: 3)); + first.destroy(); + second.destroy(); + }); +} diff --git a/test/core/mcp/mcp_sql_guard_test.dart b/test/core/mcp/mcp_sql_guard_test.dart index a3757b3d..d9392012 100644 --- a/test/core/mcp/mcp_sql_guard_test.dart +++ b/test/core/mcp/mcp_sql_guard_test.dart @@ -86,6 +86,24 @@ void main() { refused("SELECT load_extension('evil')", SqlDialect.sqlite); }); + test('functions with side effects beyond the transaction are refused (#1565)', + () { + refused("SELECT query_to_xml('DELETE FROM y', true, true, '')"); + refused("SELECT cursor_to_xml('c', 1, true, true, '')"); + refused("SELECT table_to_xml('t', true, true, '')"); + refused('SELECT pg_advisory_lock(1)'); + refused('SELECT pg_try_advisory_xact_lock(1)'); + refused('SELECT pg_sleep(60)'); + refused("SELECT lo_get(1234)"); + refused("SELECT nextval('s')"); + refused("SELECT setval('s', 10)"); + }); + + test('EXPLAIN of a SELECT that calls REPLACE() is allowed (#1565)', () { + allowed("EXPLAIN SELECT REPLACE(name, 'a', 'b') FROM users"); + refused("EXPLAIN REPLACE INTO users VALUES (1)", SqlDialect.mysql); + }); + test('SQLite schema pragmas are allowed, others are refused', () { allowed('PRAGMA table_info(users)', SqlDialect.sqlite); allowed('PRAGMA index_list(users)', SqlDialect.sqlite); @@ -133,4 +151,23 @@ void main() { 'The query is empty.'); }); }); + + // A backslash before the closing quote is an escape in MySQL only; reading it + // as one in PostgreSQL or SQLite hid the statements after the literal. + test('a trailing backslash in a literal does not hide statements (PostgreSQL, SQLite)', + () { + const smuggled = r"SELECT 'a\'; SELECT pg_terminate_backend(1); --'"; + refused(smuggled, SqlDialect.postgres); + refused(smuggled, SqlDialect.sqlite); + const write = r"SELECT 'a\'; DELETE FROM users; --'"; + refused(write, SqlDialect.postgres); + refused(write, SqlDialect.sqlite); + expect(McpSqlGuard.refusal(write, SqlDialect.postgres)!.rule, 'single_statement'); + }); + + test('the same text is one literal in MySQL, and an E string in PostgreSQL', + () { + allowed(r"SELECT 'a\'; SELECT pg_terminate_backend(1); --'", SqlDialect.mysql); + allowed(r"SELECT E'a\'; SELECT 1'", SqlDialect.postgres); + }); } diff --git a/test/core/motion/polling_controller_test.dart b/test/core/motion/polling_controller_test.dart new file mode 100644 index 00000000..f93cdae0 --- /dev/null +++ b/test/core/motion/polling_controller_test.dart @@ -0,0 +1,220 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/polling_controller.dart'; +import 'package:querya_desktop/shared/widgets/poll_stale_overlay.dart'; + +class _Host extends StatefulWidget { + const _Host({required this.onTick}); + + final VoidCallback onTick; + + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> with PollingState<_Host> { + @override + Duration? get pollInterval => const Duration(milliseconds: 100); + + @override + bool get pollShouldRun => true; + + @override + Future pollTick() async => widget.onTick(); + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +void main() { + const second = Duration(seconds: 1); + + group('PollingController', () { + testWidgets('a slow tick is never overlapped by the next one', + (tester) async { + var started = 0; + var running = 0; + var maxRunning = 0; + final gate = >[]; + final c = PollingController( + interval: second, + tick: () async { + started++; + running++; + maxRunning = running > maxRunning ? running : maxRunning; + final done = Completer(); + gate.add(done); + await done.future; + running--; + }, + ); + + c.setActive(true); + await tester.pump(second); + expect(started, 1); + + await tester.pump(const Duration(seconds: 30)); + expect(started, 1, reason: 'no new request while one is in flight'); + + gate.single.complete(); + await tester.pump(); + await tester.pump(second); + expect(started, 2); + expect(maxRunning, 1); + c.dispose(); + }); + + testWidgets('failures back off up to the cap and a success recovers', + (tester) async { + var calls = 0; + var fail = true; + final c = PollingController( + interval: second, + maxBackoff: const Duration(seconds: 5), + tick: () async { + calls++; + if (fail) throw StateError('down'); + }, + ); + + c.setActive(true); + await tester.pump(second); + expect(calls, 1); + expect(c.status.value.failures, 1); + expect(c.status.value.isStale, isTrue); + expect(c.nextDelay, const Duration(seconds: 2)); + + await tester.pump(const Duration(seconds: 1)); + expect(calls, 1, reason: 'still backing off'); + await tester.pump(const Duration(seconds: 1)); + expect(calls, 2); + expect(c.nextDelay, const Duration(seconds: 4)); + + await tester.pump(const Duration(seconds: 4)); + expect(calls, 3); + expect(c.nextDelay, const Duration(seconds: 5), reason: 'capped'); + + fail = false; + await tester.pump(const Duration(seconds: 5)); + expect(calls, 4); + expect(c.status.value.isStale, isFalse); + expect(c.status.value.lastSuccess, isNotNull); + expect(c.nextDelay, second); + c.dispose(); + }); + + testWidgets('stopping while a tick runs does not schedule another', + (tester) async { + var calls = 0; + final gate = Completer(); + final c = PollingController( + interval: second, + tick: () async { + calls++; + await gate.future; + }, + ); + + c.setActive(true); + await tester.pump(second); + c.setActive(false); + gate.complete(); + await tester.pump(const Duration(seconds: 10)); + expect(calls, 1); + expect(c.isActive, isFalse); + c.dispose(); + }); + + testWidgets('a result that lands after a stop is not reported', + (tester) async { + final gate = Completer(); + final c = PollingController( + interval: second, + tick: () async { + await gate.future; + throw StateError('late'); + }, + ); + + c.setActive(true); + await tester.pump(second); + c.setActive(false); + c.reset(); + gate.complete(); + await tester.pump(); + expect(c.status.value.failures, 0); + c.dispose(); + }); + + testWidgets('changing the interval reschedules', (tester) async { + var calls = 0; + final c = PollingController(interval: second, tick: () async => calls++); + + c.setActive(true); + c.setInterval(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + expect(calls, 1); + c.dispose(); + }); + }); + + group('PollingState', () { + testWidgets('polls only under an enabled TickerMode', (tester) async { + var ticks = 0; + Widget app({required bool enabled}) => TickerMode( + enabled: enabled, + child: _Host(onTick: () => ticks++), + ); + + await tester.pumpWidget(app(enabled: true)); + await tester.pump(const Duration(milliseconds: 350)); + expect(ticks, greaterThan(0)); + + await tester.pumpWidget(app(enabled: false)); + final frozen = ticks; + await tester.pump(const Duration(seconds: 1)); + expect(ticks, frozen); + + await tester.pumpWidget(app(enabled: true)); + await tester.pump(const Duration(milliseconds: 150)); + expect(ticks, greaterThan(frozen)); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('stops when the view is disposed', (tester) async { + var ticks = 0; + await tester.pumpWidget(_Host(onTick: () => ticks++)); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpWidget(const SizedBox.shrink()); + final after = ticks; + await tester.pump(const Duration(seconds: 1)); + expect(ticks, after); + }); + }); + + group('PollStaleOverlay', () { + testWidgets('shows the note only while the latest poll failed', + (tester) async { + final status = ValueNotifier(const PollStatus()); + await tester.pumpWidget(MaterialApp( + home: PollStaleOverlay(status: status, child: const SizedBox()), + )); + expect(find.byKey(const ValueKey('poll_stale_hint')), findsNothing); + + status.value = PollStatus( + lastSuccess: DateTime(2026, 1, 1, 9, 5, 7), + lastError: 'boom', + failures: 2, + ); + await tester.pump(); + expect(find.byKey(const ValueKey('poll_stale_hint')), findsOneWidget); + expect(find.textContaining('09:05:07'), findsOneWidget); + + status.value = PollStatus(lastSuccess: DateTime(2026, 1, 1, 9, 6)); + await tester.pump(); + expect(find.byKey(const ValueKey('poll_stale_hint')), findsNothing); + }); + }); +} diff --git a/test/core/motion/ticker_gated_polling_test.dart b/test/core/motion/ticker_gated_polling_test.dart deleted file mode 100644 index 2028efec..00000000 --- a/test/core/motion/ticker_gated_polling_test.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; - -void main() { - testWidgets('cancels timer when TickerMode is disabled', (tester) async { - Timer? timer; - var ticks = 0; - - await tester.pumpWidget( - TickerMode( - enabled: true, - child: Builder( - builder: (context) { - timer = syncTickerGatedPeriodicTimer( - context: context, - timer: timer, - shouldRun: true, - interval: const Duration(milliseconds: 20), - onTick: () => ticks++, - ); - return const SizedBox.shrink(); - }, - ), - ), - ); - await tester.pump(const Duration(milliseconds: 50)); - expect(ticks, greaterThan(0)); - expect(timer, isNotNull); - - await tester.pumpWidget( - TickerMode( - enabled: false, - child: Builder( - builder: (context) { - timer = syncTickerGatedPeriodicTimer( - context: context, - timer: timer, - shouldRun: true, - interval: const Duration(milliseconds: 20), - onTick: () => ticks++, - ); - return const SizedBox.shrink(); - }, - ), - ), - ); - final afterDisable = ticks; - await tester.pump(const Duration(milliseconds: 60)); - expect(ticks, afterDisable); - expect(timer, isNull); - }); -} diff --git a/test/core/platform/file_launch_service_test.dart b/test/core/platform/file_launch_service_test.dart index c1fdbfef..ac4319af 100644 --- a/test/core/platform/file_launch_service_test.dart +++ b/test/core/platform/file_launch_service_test.dart @@ -90,13 +90,13 @@ void main() { '/home/user/queries/reporting.sql', ]); - expect(FileLaunchService.instance.hasPendingTarget, isTrue); + expect(FileLaunchService.instance.hasPendingTargets, isTrue); expect(FileLaunchService.instance.pendingTarget?.fileName, 'reporting.sql'); expect(FileLaunchService.instance.pendingTarget?.kind, FileLaunchKind.sql); final consumed = FileLaunchService.instance.consumePendingTarget(); expect(consumed?.fileName, 'reporting.sql'); - expect(FileLaunchService.instance.hasPendingTarget, isFalse); + expect(FileLaunchService.instance.hasPendingTargets, isFalse); }); }); diff --git a/test/core/platform/macos_file_open_channel_test.dart b/test/core/platform/macos_file_open_channel_test.dart new file mode 100644 index 00000000..dd27a2dd --- /dev/null +++ b/test/core/platform/macos_file_open_channel_test.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/platform/file_launch_service.dart'; +import 'package:querya_desktop/core/platform/macos_file_open_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MacOsFileOpenChannel (#1589)', () { + final service = FileLaunchService.instance; + setUp(service.consumePendingTargets); + tearDown(service.consumePendingTargets); + + test('queues the paths the app can open and skips the rest', () { + final before = service.pendingRevision.value; + + final added = MacOsFileOpenChannel.queuePaths([ + '/Users/ann/reports/q1.sql', + '/Users/ann/data/shop.sqlite', + '/Users/ann/notes.txt', + 42, + ]); + + expect(added, 2); + expect(service.pendingTargets.map((t) => t.kind), + [FileLaunchKind.sql, FileLaunchKind.sqlite]); + expect(service.pendingRevision.value, before + 2); + }); + + test('anything that is not a list adds nothing', () { + expect(MacOsFileOpenChannel.queuePaths(null), 0); + expect(MacOsFileOpenChannel.queuePaths('/a.sql'), 0); + expect(service.pendingTargets, isEmpty); + }); + + test('install is a no-op off macOS', () async { + if (Platform.isMacOS) return; + await MacOsFileOpenChannel.install(); + expect(service.pendingTargets, isEmpty); + }); + }); +} diff --git a/test/core/platform/macos_sandbox_test.dart b/test/core/platform/macos_sandbox_test.dart new file mode 100644 index 00000000..96fe5b17 --- /dev/null +++ b/test/core/platform/macos_sandbox_test.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/platform/macos_sandbox.dart'; + +void main() { + group('MacOsSandbox (#1593)', () { + tearDown(() => MacOsSandbox.debugOverride = null); + + test('the container id marks a sandboxed process', () { + expect( + MacOsSandbox.detect(const {'APP_SANDBOX_CONTAINER_ID': 'com.queryahub.querya'}), + isTrue, + ); + }); + + test('a HOME inside ~/Library/Containers marks it too', () { + expect( + MacOsSandbox.detect(const { + 'HOME': '/Users/ann/Library/Containers/com.queryahub.querya/Data', + }), + isTrue, + ); + }); + + test('an ordinary environment is not sandboxed', () { + expect(MacOsSandbox.detect(const {'HOME': '/Users/ann'}), isFalse); + expect(MacOsSandbox.detect(const {'APP_SANDBOX_CONTAINER_ID': ''}), isFalse); + expect(MacOsSandbox.detect(const {}), isFalse); + }); + + test('isActive is false off macOS and can be forced for tests', () { + if (!Platform.isMacOS) expect(MacOsSandbox.isActive, isFalse); + MacOsSandbox.debugOverride = true; + expect(MacOsSandbox.isActive, isTrue); + }); + + test('the message names the feature and the way out', () { + final text = MacOsSandbox.unavailable('The MCP server'); + expect(text, startsWith('The MCP server is not available')); + expect(text, contains('release build')); + }); + }); +} diff --git a/test/core/security/pii_masker_scanner_test.dart b/test/core/security/pii_masker_scanner_test.dart new file mode 100644 index 00000000..d1fa4c69 --- /dev/null +++ b/test/core/security/pii_masker_scanner_test.dart @@ -0,0 +1,147 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/security/pii_masker.dart'; + +/// `detectPiiColumn` and `maskPiiValue` as they were before #1540 / #1516 +/// (regex, replaceAll, split / join): the reference the scanners must match. +List _refTokens(String name) { + final spaced = name + .replaceAllMapped( + RegExp(r'([a-z0-9])([A-Z])'), + (m) => '${m[1]} ${m[2]}', + ) + .replaceAll(RegExp(r'[^A-Za-z0-9]+'), ' '); + return spaced.toLowerCase().split(' ').where((t) => t.isNotEmpty).toList(); +} + +PiiKind? _refDetect(String columnName) { + const secret = { + 'password', 'passwd', 'pwd', 'pass', 'passcode', 'passphrase', 'token', + 'secret', 'apikey', 'hash', 'salt', + }; + const email = {'email', 'mail'}; + const phone = {'phone', 'mobile', 'cell', 'tel', 'telephone', 'msisdn', 'fax'}; + const card = {'card', 'cc', 'pan', 'iban'}; + final tokens = _refTokens(columnName); + if (tokens.isEmpty) return null; + final joined = tokens.join('_'); + if (RegExp(r'(^|_)(api|private|access|secret|auth)_key($|_)') + .hasMatch(joined)) { + return PiiKind.secret; + } + const meta = {'at', 'verified', 'confirmed', 'sent', 'count', 'id'}; + if (meta.contains(tokens.last) && tokens.length > 1) return null; + for (final t in tokens) { + if (secret.contains(t)) return PiiKind.secret; + if (card.contains(t)) return PiiKind.card; + if (email.contains(t)) return PiiKind.email; + if (phone.contains(t)) return PiiKind.phone; + } + return null; +} + +String _refMask(PiiKind kind, String value) { + if (value.isEmpty || value == 'NULL') return value; + switch (kind) { + case PiiKind.secret: + return kPiiMaskDots; + case PiiKind.email: + final at = value.indexOf('@'); + if (at <= 0 || at == value.length - 1) return kPiiMaskDots; + final local = value.substring(0, at); + final domain = value.substring(at + 1); + final visible = local.length <= 2 ? 1 : 2; + return '${local.substring(0, visible)}***@$domain'; + case PiiKind.phone: + final digits = value.replaceAll(RegExp(r'\D'), ''); + if (digits.length < 8) return kPiiMaskDots; + return '•••••••${digits.substring(digits.length - 2)}'; + case PiiKind.card: + final digits = value.replaceAll(RegExp(r'\D'), ''); + if (digits.length < 12) return kPiiMaskDots; + return '•••• •••• •••• ${digits.substring(digits.length - 4)}'; + } +} + +void main() { + group('PII column detection scanner (#1540)', () { + test('agrees with the previous implementation on listed names', () { + const names = [ + '', ' ', '_', 'id', 'name', 'password', 'PASSWORD_HASH', 'auth_token', + 'api_key', 'apiKey', 'API_KEY', 'APIKey', 'api2Key', 'private_key', + 'accessKey', 'secret_key_id', 'my_api_key_2', 'monkey', 'key', + 'clientSecret', 'email', 'userEmail', 'contact_email', 'EMAIL', + 'email_verified_at', 'phone', 'mobile_number', 'phoneNumber2', + 'card_number', 'credit_card', 'cardId', 'card_id', 'CreditCardNumber', + 'hotel', 'compass', 'cardinality', 'telegram_handle', 'created_at', + 'user.email', 'users.email', '"email"', 'e-mail', 'Email Address', + 'ÉMAIL', 'телефон', 'tel', 'msisdn_2', 'iban', 'IBAN_code', 'pan', + ]; + for (final n in names) { + expect(detectPiiColumn(n), _refDetect(n), reason: '"$n"'); + // The second call is served from the cache. + expect(detectPiiColumn(n), _refDetect(n), reason: 'cached "$n"'); + } + }); + + test('agrees with the previous implementation on random names', () { + const parts = [ + 'api', 'key', 'Key', 'KEY', 'user', 'User', 'email', 'Email', 'phone', + 'card', 'Card', 'id', 'at', 'count', 'secret', 'token', 'x', 'A', '1', + '_', '-', '.', ' ', 'é', + ]; + final random = Random(1540); + for (var n = 0; n < 4000; n++) { + final name = [ + for (var k = 1 + random.nextInt(5); k > 0; k--) + parts[random.nextInt(parts.length)], + ].join(); + expect(detectPiiColumn(name), _refDetect(name), reason: '"$name"'); + } + }); + + test('more distinct names than the cache holds still answer correctly', () { + for (var i = 0; i < 2500; i++) { + expect(detectPiiColumn('col_$i'), isNull); + } + expect(detectPiiColumn('user_email'), PiiKind.email); + expect(detectPiiColumn('user_email'), PiiKind.email); + }); + }); + + group('PII value masking scanner (#1516)', () { + test('agrees with the previous implementation', () { + const values = [ + '', 'NULL', 'null', 'abc', '1234567', '12345678', '+1 (555) 123-4567', + '555.123.4567 ext 89', '4111 1111 1111 1111', '4111-1111-1111-111', + '41111111111', '411111111111', 'DE89 3704 0044 0532 0130 00', + '٠١٢٣٤٥٦٧٨٩٠١٢', '123456789012', 'a@b.c', 'ab@x.org', + 'alice@example.com', '@x', 'x@', + ]; + for (final kind in PiiKind.values) { + for (final v in values) { + expect(maskPiiValue(kind, v), _refMask(kind, v), + reason: '$kind "$v"'); + } + } + }); + + test('agrees with the previous implementation on random digit soup', () { + const pieces = [ + '0', '1', '7', '9', ' ', '-', '(', ')', '+', '.', 'x', 'é', '٣', + ]; + final random = Random(1516); + for (var n = 0; n < 3000; n++) { + final v = [ + for (var k = random.nextInt(24); k > 0; k--) + pieces[random.nextInt(pieces.length)], + ].join(); + for (final kind in [PiiKind.phone, PiiKind.card]) { + expect(maskPiiValue(kind, v), _refMask(kind, v), + reason: '$kind "$v"'); + } + } + }); + }); +} diff --git a/test/core/security/safe_zip_extractor_test.dart b/test/core/security/safe_zip_extractor_test.dart index 6d9fc28e..6cb14216 100644 --- a/test/core/security/safe_zip_extractor_test.dart +++ b/test/core/security/safe_zip_extractor_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -25,6 +26,23 @@ Future _writeZip(Directory dir, Archive archive, String name) async { return file; } +/// [zip] with the uncompressed size of every entry rewritten to [size], in +/// the local headers and the central directory: a zip that lies about how +/// large its entries are. +Uint8List _withDeclaredSize(List zip, int size) { + final bytes = Uint8List.fromList(zip); + final data = ByteData.sublistView(bytes); + for (var i = 0; i + 30 <= bytes.length; i++) { + switch (data.getUint32(i, Endian.little)) { + case 0x04034b50: // local file header + data.setUint32(i + 22, size, Endian.little); + case 0x02014b50: // central directory header + data.setUint32(i + 24, size, Endian.little); + } + } + return bytes; +} + void main() { group('SafeZipExtractor', () { late Directory tempDir; @@ -133,6 +151,57 @@ void main() { expect(decoded.first.name, 'ok.txt'); }); + group('a header that understates the entry size (#1561)', () { + // 64 KiB of zeros deflate to a few hundred bytes; the header claims 100, + // so every header-based limit and the ratio check pass. + Uint8List lyingZip() => _withDeclaredSize( + ZipEncoder().encode( + _singleFileArchive('bomb.bin', Uint8List(64 * 1024)), + ), + 100, + ); + + test('passes the header checks', () { + final decoded = + SafeZipExtractor.decodeBytes(lyingZip(), limits: _tightLimits); + expect(decoded.first.size, 100); + }); + + test('readEntry stops at the declared size', () { + final decoded = + SafeZipExtractor.decodeBytes(lyingZip(), limits: _tightLimits); + expect( + () => SafeZipExtractor.readEntry(decoded.first), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('larger than its header declares'), + )), + ); + }); + + test('readEntry stops at the declared size when read from a file', + () async { + final file = File(p.join(tempDir.path, 'lying.zip')); + await file.writeAsBytes(lyingZip()); + final decoded = + await SafeZipExtractor.readAndDecodeFile(file, limits: _tightLimits); + expect( + () => SafeZipExtractor.readEntry(decoded.first), + throwsA(isA()), + ); + }); + }); + + test('readEntry returns the content of an honest entry', () { + final zipBytes = ZipEncoder().encode( + _singleFileArchive('hello.txt', utf8.encode('hello')), + ); + final decoded = + SafeZipExtractor.decodeBytes(zipBytes, limits: _tightLimits); + expect(utf8.decode(SafeZipExtractor.readEntry(decoded.first)), 'hello'); + }); + test('ensureCompressedSizeAllowed rejects oversize before decode', () async { final file = File(p.join(tempDir.path, 'big.zip')); await file.writeAsBytes(List.filled(5000, 1)); diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 975ae7a6..2a0b0279 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -1,12 +1,12 @@ import 'dart:io'; +import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; import 'package:querya_desktop/core/updater/update_manifest.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; /// path_provider has no implementation in plain `flutter test`; LocalDb needs a path. class _FakePathProvider extends PathProviderPlatform { @@ -275,22 +275,22 @@ void main() { group('theme settings', () { test('theme mode and preset roundtrip', () async { - expect(await AppSettings.instance.getThemeMode(), ThemeMode.dark); + expect(await AppSettings.instance.getThemeMode(), material.ThemeMode.dark); expect( await AppSettings.instance.getThemePreset(), QueryaThemePreset.queryaDark, ); - await AppSettings.instance.setThemeMode(ThemeMode.light); + await AppSettings.instance.setThemeMode(material.ThemeMode.light); await AppSettings.instance.setThemePreset(QueryaThemePreset.queryaLight); - expect(await AppSettings.instance.getThemeMode(), ThemeMode.light); + expect(await AppSettings.instance.getThemeMode(), material.ThemeMode.light); expect( await AppSettings.instance.getThemePreset(), QueryaThemePreset.queryaLight, ); await AppSettings.instance.clearThemeSettings(); - expect(await AppSettings.instance.getThemeMode(), ThemeMode.dark); + expect(await AppSettings.instance.getThemeMode(), material.ThemeMode.dark); }); test('theme animation defaults off and roundtrip', () async { @@ -419,7 +419,7 @@ void main() { SqlWorkspaceSettingsRevision.listenable.addListener(listener); final before = SqlWorkspaceSettingsRevision.listenable.value; - await AppSettings.instance.setThemeMode(ThemeMode.light); + await AppSettings.instance.setThemeMode(material.ThemeMode.light); expect(SqlWorkspaceSettingsRevision.listenable.value, before); expect(sqlCalls, 0); SqlWorkspaceSettingsRevision.listenable.removeListener(listener); diff --git a/test/core/storage/connection_secrets_store_macos_test.dart b/test/core/storage/connection_secrets_store_macos_test.dart new file mode 100644 index 00000000..e8f88b55 --- /dev/null +++ b/test/core/storage/connection_secrets_store_macos_test.dart @@ -0,0 +1,14 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; + +void main() { + test('macOS secrets go to the login keychain, not the data protection one ' + '(#1586)', () { + // What the native plugin reads: "true" (its default) needs the + // keychain-access-groups entitlement, which the app does not have. + expect( + ConnectionSecretsStore.macOsOptions.toMap()['usesDataProtectionKeychain'], + 'false', + ); + }); +} diff --git a/test/core/storage/local_db_newer_schema_test.dart b/test/core/storage/local_db_newer_schema_test.dart new file mode 100644 index 00000000..dc9f039a --- /dev/null +++ b/test/core/storage/local_db_newer_schema_test.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import '../../support/local_db_test_support.dart'; + +/// A database written by a newer Querya is refused and left as it is (#1497). +void main() { + late Directory dir; + + setUpAll(() async => dir = await initTestLocalDb('newer_schema_')); + tearDownAll(() => disposeTestLocalDb(dir)); + + Future versionOnDisk(String path) async { + final raw = await databaseFactoryFfi.openDatabase(path); + try { + return await raw.getVersion(); + } finally { + await raw.close(); + } + } + + test('a backup before an update, then the database opens again', () async { + final db = LocalDb.instance; + await db.setAppSetting('before_update', 'yes'); + + final copy = await db.backupBeforeUpdate( + fromVersion: '0.4.22', + toVersion: '0.5.0-beta.1', + ); + + expect(File(copy).existsSync(), isTrue); + expect(copy, endsWith('querya.db.pre-0.4.22-0.5.0-beta.1.bak')); + expect(await db.getAppSetting('before_update'), 'yes'); + }); + + test('a newer schema version is refused and not overwritten', () async { + final db = LocalDb.instance; + final path = await db.databasePath(); + final current = await versionOnDisk(path); + await db.close(); + + final raw = await databaseFactoryFfi.openDatabase(path); + await raw.setVersion(current + 7); + await raw.close(); + + await expectLater( + db.open(), + throwsA(isA() + .having((e) => e.found, 'found', current + 7) + .having((e) => e.supported, 'supported', current) + .having((e) => e.toString(), 'message', contains('newer version'))), + ); + expect(await versionOnDisk(path), current + 7, reason: 'left as it was'); + + // Back to the version this build knows, for the other tests. + final fix = await databaseFactoryFfi.openDatabase(path); + await fix.setVersion(current); + await fix.close(); + expect(await db.getAppSetting('before_update'), 'yes'); + }); +} diff --git a/test/core/storage/local_db_schema_test.dart b/test/core/storage/local_db_schema_test.dart new file mode 100644 index 00000000..f3dd3778 --- /dev/null +++ b/test/core/storage/local_db_schema_test.dart @@ -0,0 +1,155 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db_schema.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// The schema is created whole for a new install and reached step by step from +/// every older version (#1502). +void main() { + late Directory dir; + var n = 0; + + setUpAll(() { + sqfliteFfiInit(); + dir = Directory.systemTemp.createTempSync('local_db_schema_'); + }); + tearDownAll(() { + try { + dir.deleteSync(recursive: true); + } catch (_) {} + }); + + String path() => '${dir.path}/schema_${n++}.db'; + + Future open(String file, int version, + {OnDatabaseCreateFn? onCreate, OnDatabaseVersionChangeFn? onUpgrade}) { + return databaseFactoryFfi.openDatabase( + file, + options: OpenDatabaseOptions( + version: version, + onCreate: onCreate, + onUpgrade: onUpgrade, + onOpen: LocalDbSchema.onOpen, + ), + ); + } + + Future> tables(Database db) async => { + for (final r in await db.rawQuery( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name NOT LIKE 'sqlite_%'")) + r['name']! as String, + }; + + Future> columns(Database db, String table) async => { + for (final r in await db.rawQuery('PRAGMA table_info($table)')) + r['name']! as String, + }; + + const allTables = { + 'folders', + 'connections', + 'app_settings', + 'sql_query_history', + 'mutation_audit', + 'mcp_activity', + 'erd_layouts', + }; + + test('a new database has every table and a profile id', () async { + final db = await open(path(), LocalDbSchema.version, + onCreate: LocalDbSchema.onCreate); + expect(await tables(db), allTables); + expect(await columns(db, 'connections'), + containsAll(['extension_id', 'driver_options', 'folder_id'])); + expect(await columns(db, 'mcp_activity'), contains('refusal_rule')); + final id = await db.query('app_settings', + where: 'key = ?', whereArgs: [profileIdSettingKey]); + expect(id.single['value'], isNotEmpty); + await db.close(); + }); + + test('the pragmas of an open are set', () async { + final db = await open(path(), LocalDbSchema.version, + onCreate: LocalDbSchema.onCreate); + expect((await db.rawQuery('PRAGMA foreign_keys')).single.values.single, 1); + expect( + (await db.rawQuery('PRAGMA busy_timeout')).single.values.single, 5000); + await db.close(); + }); + + test('version 1 reaches the current schema and keeps its connections', + () async { + final file = path(); + final v1 = await open(file, 1, onCreate: (db, _) async { + await db.execute(''' + CREATE TABLE folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + sort_order INTEGER NOT NULL DEFAULT 0 + )'''); + await db.execute(''' + CREATE TABLE connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + name TEXT NOT NULL, + host TEXT, + port INTEGER, + username TEXT, + folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + )'''); + await db.insert('folders', {'name': 'work'}); + await db.insert('connections', { + 'type': 'postgresql', + 'name': 'prod', + 'host': 'db.example', + 'port': 5432, + 'username': 'app', + 'folder_id': 1, + 'created_at': '2026-01-01', + }); + }); + await v1.close(); + + final db = await open(file, LocalDbSchema.version, + onUpgrade: LocalDbSchema.onUpgrade); + expect(await tables(db), allTables); + expect(await columns(db, 'connections'), + containsAll(['password', 'use_ssl', 'extension_id', 'driver_options'])); + final rows = await db.query('connections'); + expect(rows.single['name'], 'prod'); + expect(rows.single['host'], 'db.example'); + expect(rows.single['folder_id'], 1); + expect(await db.getVersion(), LocalDbSchema.version); + await db.close(); + }); + + test('version 11 gains the refusal column and the ER layouts table', + () async { + final file = path(); + final v11 = await open(file, 11, onCreate: (db, _) async { + await LocalDbSchema.onCreate(db, 11); + await db.execute('DROP TABLE erd_layouts'); + await db.execute('ALTER TABLE mcp_activity DROP COLUMN refusal_rule'); + }); + await v11.close(); + + final db = await open(file, LocalDbSchema.version, + onUpgrade: LocalDbSchema.onUpgrade); + expect(await columns(db, 'mcp_activity'), contains('refusal_rule')); + expect(await tables(db), contains('erd_layouts')); + await db.close(); + }); + + test('the profile id is made once and kept', () async { + final db = await open(path(), LocalDbSchema.version, + onCreate: LocalDbSchema.onCreate); + final first = await LocalDbSchema.ensureProfileId(db); + expect(await LocalDbSchema.ensureProfileId(db), first); + expect(first, hasLength(32)); + await db.close(); + }); +} diff --git a/test/core/storage/storage_repositories_test.dart b/test/core/storage/storage_repositories_test.dart new file mode 100644 index 00000000..6983c08d --- /dev/null +++ b/test/core/storage/storage_repositories_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/activity_log_repository.dart'; +import 'package:querya_desktop/core/storage/app_settings_repository.dart'; +import 'package:querya_desktop/core/storage/folder_repository.dart'; +import 'package:querya_desktop/core/storage/local_db_models.dart'; +import 'package:querya_desktop/core/storage/local_db_schema.dart'; +import 'package:querya_desktop/core/storage/sql_query_history_repository.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// The repositories on a database of their own (in memory, made by +/// [LocalDbSchema]), without [LocalDb] (#1502). +void main() { + late Database db; + + setUpAll(sqfliteFfiInit); + setUp(() async { + db = await databaseFactoryFfi.openDatabase( + inMemoryDatabasePath, + options: OpenDatabaseOptions( + version: LocalDbSchema.version, + onCreate: LocalDbSchema.onCreate, + onOpen: LocalDbSchema.onOpen, + ), + ); + }); + tearDown(() => db.close()); + + Future open() async => db; + + Future insertConnection({int? folderId}) => db.insert('connections', { + 'type': 'sqlite', + 'name': 'c', + 'folder_id': folderId, + 'created_at': '2026-10-18', + }); + + test('folders: added in order, removed without taking their connections', + () async { + final folders = FolderRepository(open); + await folders.addFolder(' work '); + await folders.addFolder('home'); + await folders.addFolder(' '); + expect(await folders.getFolders(), ['home', 'work']); + + final workId = await folders.getFolderIdByName('work'); + expect(workId, isNotNull); + final conn = await insertConnection(folderId: workId); + await folders.removeFolder('work'); + expect(await folders.getFolders(), ['home']); + final rows = await db.query('connections', where: 'id = ?', whereArgs: [conn]); + expect(rows.single['folder_id'], isNull); + }); + + test('app settings: set, replace, delete and ER layouts per scope', () async { + final settings = AppSettingsRepository(open); + expect(await settings.getAppSetting('k'), isNull); + await settings.setAppSetting('k', 'v1'); + await settings.setAppSetting('k', 'v2'); + expect(await settings.getAppSetting('k'), 'v2'); + await settings.deleteAppSetting('k'); + expect(await settings.getAppSetting('k'), isNull); + + final conn = await insertConnection(); + await settings.writeErdLayout(conn, 'public', '{"a":1}'); + await settings.writeErdLayout(conn, 'other', '{"b":2}'); + await settings.writeErdLayout(conn, 'public', '{"a":3}'); + expect(await settings.readErdLayout(conn, 'public'), '{"a":3}'); + expect(await settings.readErdLayout(conn, 'other'), '{"b":2}'); + expect(await settings.readErdLayout(conn, 'none'), isNull); + }); + + test('query history: newest first, capped per bucket, cleared by bucket', + () async { + final history = SqlQueryHistoryRepository(open); + final conn = await insertConnection(); + for (var i = 0; i < 5; i++) { + await history.recordSqlQueryHistory( + connectionId: conn, + databaseName: 'db', + sqlText: 'SELECT $i', + maxEntries: 3, + forcePrune: true, + ); + } + await history.recordSqlQueryHistory( + connectionId: conn, + databaseName: 'other', + sqlText: 'SELECT other', + maxEntries: 3, + ); + final db1 = await history.listSqlQueryHistory( + connectionId: conn, databaseName: 'db'); + expect([for (final e in db1) e.sqlText], ['SELECT 4', 'SELECT 3', 'SELECT 2']); + + await history.clearSqlQueryHistoryBucket( + connectionId: conn, databaseName: 'db'); + expect( + await history.listSqlQueryHistory( + connectionId: conn, databaseName: 'db'), + isEmpty); + expect( + await history.listSqlQueryHistory( + connectionId: conn, databaseName: 'other'), + hasLength(1)); + }); + + test('activity log: MCP calls are listed newest first and clearable', + () async { + final log = ActivityLogRepository(open); + expect(await log.listMcpActivity(), isEmpty); + for (final tool in ['list_tables', 'run_query']) { + await log.recordMcpActivity(McpActivityEntry( + recordedAt: DateTime.now().toUtc().toIso8601String(), + client: 'test', + tool: tool, + durationMs: 3, + refusalRule: tool == 'run_query' ? 'read_only_only' : null, + )); + } + final entries = await log.listMcpActivity(); + expect([for (final e in entries) e.tool], ['run_query', 'list_tables']); + expect(entries.first.refusalRule, 'read_only_only'); + await log.clearMcpActivity(); + expect(await log.listMcpActivity(), isEmpty); + }); +} diff --git a/test/core/storage/update_backup_test.dart b/test/core/storage/update_backup_test.dart new file mode 100644 index 00000000..45a37275 --- /dev/null +++ b/test/core/storage/update_backup_test.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/storage/update_backup.dart'; + +/// Copies of the local database before an in-app update (#1497). +void main() { + late Directory dir; + late String dbPath; + + setUp(() { + dir = Directory.systemTemp.createTempSync('querya_update_backup_'); + dbPath = p.join(dir.path, 'querya.db'); + File(dbPath).writeAsStringSync('main'); + }); + tearDown(() => dir.deleteSync(recursive: true)); + + test('names the copy after both versions, next to the database', () async { + final copy = await UpdateBackup.create( + dbPath: dbPath, + fromVersion: '0.4.22', + toVersion: '0.5.0-beta.1', + ); + expect(copy, p.join(dir.path, 'querya.db.pre-0.4.22-0.5.0-beta.1.bak')); + expect(File(copy).readAsStringSync(), 'main'); + }); + + test('copies the WAL with it, and only when there is one', () async { + File('$dbPath-wal').writeAsStringSync('wal'); + final copy = await UpdateBackup.create( + dbPath: dbPath, + fromVersion: '1.0.0', + toVersion: '1.0.1', + ); + expect(File('$copy-wal').readAsStringSync(), 'wal'); + + File('$dbPath-wal').deleteSync(); + await UpdateBackup.create(dbPath: dbPath, fromVersion: '1.0.0', toVersion: '1.0.1'); + expect(File('$copy-wal').existsSync(), isFalse, reason: 'stale WAL removed'); + }); + + test('keeps the newest three and deletes the rest with their WAL', () async { + final made = []; + for (var i = 1; i <= 5; i++) { + if (i == 2) File('$dbPath-wal').writeAsStringSync('wal'); + if (i == 3) File('$dbPath-wal').deleteSync(); + made.add(await UpdateBackup.create( + dbPath: dbPath, + fromVersion: '0.4.$i', + toVersion: '0.4.${i + 1}', + )); + await Future.delayed(const Duration(milliseconds: 20)); + } + + final kept = (await UpdateBackup.list(dbPath)).map((f) => f.path).toList(); + expect(kept, [made[4], made[3], made[2]]); + expect(File(made[0]).existsSync(), isFalse); + expect(File(made[1]).existsSync(), isFalse); + expect(File('${made[1]}-wal').existsSync(), isFalse); + expect(File(dbPath).existsSync(), isTrue, reason: 'the database itself stays'); + }); + + test('odd characters in a version cannot leave the folder', () { + expect( + UpdateBackup.pathFor(dbPath, '../x', '1/2'), + p.join(dir.path, 'querya.db.pre-.._x-1_2.bak'), + ); + }); + + test('a missing database is an error, not an empty copy', () async { + File(dbPath).deleteSync(); + await expectLater( + UpdateBackup.create(dbPath: dbPath, fromVersion: 'a', toVersion: 'b'), + throwsA(isA()), + ); + }); +} diff --git a/test/core/team/team_profile_test.dart b/test/core/team/team_profile_test.dart index 6964d138..f8520e89 100644 --- a/test/core/team/team_profile_test.dart +++ b/test/core/team/team_profile_test.dart @@ -88,4 +88,88 @@ void main() { }); expect(TeamProfileCodec.decode(doc).map((r) => r.name), ['ok']); }); + + group('secret keys (#1564)', () { + test('export drops pwd, private keys, access keys, credentials and signatures', + () { + const noisy = ConnectionRow( + type: 'postgres', + name: 'Noisy', + host: 'h', + connectionString: + 'postgres://h:5432/d?db_pwd=s1&privateKey=s2&sslmode=require&authSource=admin', + driverOptions: + '{"pwd":"s3","private_key":"s4","accessKey":"s5","credentials":{"user":"u","x":"s6"},"signature":"s7","keep":"ok"}', + createdAt: '2026-01-01T00:00:00', + ); + final text = TeamProfileCodec.encode([noisy]); + for (final secret in ['s1', 's2', 's3', 's4', 's5', 's6', 's7']) { + expect(text, isNot(contains('"$secret"')), reason: secret); + expect(text, isNot(contains('=$secret')), reason: secret); + } + expect(text, contains('sslmode=require')); + expect(text, contains('authSource=admin')); + expect(text, contains('keep')); + }); + + test('import scrubs the same keys from a hand-edited file', () { + final doc = jsonEncode({ + 'format': TeamProfileCodec.format, + 'version': TeamProfileCodec.version, + 'connections': [ + { + 'type': 'postgres', + 'name': 'Edited', + 'driverOptions': '{"privateKey":"s1","pwd":"s2","ok":1}', + }, + ], + }); + final r = TeamProfileCodec.decode(doc).single; + expect(r.driverOptions, isNot(contains('s1'))); + expect(r.driverOptions, isNot(contains('s2'))); + expect(r.driverOptions, contains('"ok":1')); + }); + }); + + group('unreadable entries (#1564)', () { + String profile(List connections) => jsonEncode({ + 'format': TeamProfileCodec.format, + 'version': TeamProfileCodec.version, + 'connections': connections, + }); + + test('a field of the wrong type skips the entry instead of throwing', () { + final read = TeamProfileCodec.read(profile([ + {'type': 'postgres', 'name': 'good', 'host': 'h', 'port': 5432}, + {'type': 'postgres', 'name': 'port-as-text', 'port': '5432'}, + {'type': 'postgres', 'name': 'host-as-number', 'host': 5}, + {'type': 'postgres', 'name': 'user-as-list', 'username': ['a']}, + {'type': 'postgres', 'name': 'extension-as-map', 'extensionId': {}}, + {'type': 'postgres', 'name': 'url-as-number', 'connectionString': 7}, + ])); + expect(read.rows.map((r) => r.name), ['good']); + expect(read.skipped, 5); + }); + + test('entries without a type or name, or not objects, are counted', () { + final read = TeamProfileCodec.read(profile([ + 'text', + 42, + {'name': 'no-type'}, + {'type': 'mysql', 'name': ' '}, + {'type': 'mysql', 'name': 'ok'}, + ])); + expect(read.rows.map((r) => r.name), ['ok']); + expect(read.skipped, 4); + }); + + test('null fields are fine; decode returns just the rows', () { + final doc = profile([ + {'type': 'sqlite', 'name': 'local', 'host': null, 'port': null}, + {'type': 'sqlite', 'name': 'bad', 'port': 'x'}, + ]); + expect(TeamProfileCodec.read(doc).skipped, 1); + expect(TeamProfileCodec.decode(doc).map((r) => r.name), ['local']); + }); + }); } diff --git a/test/core/theme/animated_querya_theme_test.dart b/test/core/theme/animated_querya_theme_test.dart index 8a2bd286..7a76e449 100644 --- a/test/core/theme/animated_querya_theme_test.dart +++ b/test/core/theme/animated_querya_theme_test.dart @@ -14,7 +14,7 @@ void main() { child: Builder( builder: (context) { return ColoredBox( - color: context.queryaTheme.colorScheme.background, + color: context.queryaTheme.colors.background, child: const SizedBox(width: 10, height: 10), ); }, @@ -25,7 +25,7 @@ void main() { expect( contextBackground(tester), - QueryaTheme.darkDefault.colorScheme.background, + QueryaTheme.darkDefault.colors.background, ); await tester.pumpWidget( @@ -35,7 +35,7 @@ void main() { child: Builder( builder: (context) { return ColoredBox( - color: context.queryaTheme.colorScheme.background, + color: context.queryaTheme.colors.background, child: const SizedBox(width: 10, height: 10), ); }, @@ -45,7 +45,7 @@ void main() { await tester.pump(); expect( contextBackground(tester), - QueryaTheme.lightDefault.colorScheme.background, + QueryaTheme.lightDefault.colors.background, ); }); @@ -59,7 +59,7 @@ void main() { builder: (context) { return ColoredBox( key: const Key('swatch'), - color: context.queryaTheme.colorScheme.background, + color: context.queryaTheme.colors.background, child: const SizedBox(width: 10, height: 10), ); }, @@ -77,7 +77,7 @@ void main() { builder: (context) { return ColoredBox( key: const Key('swatch'), - color: context.queryaTheme.colorScheme.background, + color: context.queryaTheme.colors.background, child: const SizedBox(width: 10, height: 10), ); }, @@ -88,13 +88,13 @@ void main() { await tester.pump(const Duration(milliseconds: 80)); final mid = contextBackground(tester); - expect(mid, isNot(QueryaTheme.darkDefault.colorScheme.background)); - expect(mid, isNot(QueryaTheme.lightDefault.colorScheme.background)); + expect(mid, isNot(QueryaTheme.darkDefault.colors.background)); + expect(mid, isNot(QueryaTheme.lightDefault.colors.background)); await tester.pumpAndSettle(); expect( contextBackground(tester), - QueryaTheme.lightDefault.colorScheme.background, + QueryaTheme.lightDefault.colors.background, ); }); @@ -105,10 +105,10 @@ void main() { ); final mid = tween.lerp(0.5); expect( - mid.colorScheme.background, + mid.colors.background, Color.lerp( - QueryaTheme.darkDefault.colorScheme.background, - QueryaTheme.lightDefault.colorScheme.background, + QueryaTheme.darkDefault.colors.background, + QueryaTheme.lightDefault.colors.background, 0.5, ), ); diff --git a/test/core/theme/parser/color_parser_test.dart b/test/core/theme/parser/color_parser_test.dart index d7b256f8..23a0ae1e 100644 --- a/test/core/theme/parser/color_parser_test.dart +++ b/test/core/theme/parser/color_parser_test.dart @@ -100,4 +100,43 @@ void main() { ); }); }); + + group('hex scanner (#1541)', () { + test('4-digit shorthand is RGBA, each nibble doubled', () { + expect(parseVsCodeColor('#1234'), const Color(0x44112233)); + expect(parseVsCodeColor('f08c'), const Color(0xCCFF0088)); + }); + + test('8-digit VS Code value is RRGGBBAA, Querya value is AARRGGBB', () { + expect(parseVsCodeColor('#11223344'), const Color(0x44112233)); + expect(parseQueryaThemeColor('#44112233'), const Color(0x44112233)); + }); + + test('upper and lower case digits parse the same', () { + expect(parseVsCodeColor('#AbCdEf'), parseVsCodeColor('#abcdef')); + expect(parseVsCodeColor('#AbCdEf'), const Color(0xFFABCDEF)); + }); + + test('signs, spaces and non-hex characters are rejected', () { + for (final bad in ['#+12345', '#-12345', '#12 456', '#12345g', '#zzz']) { + expect(() => parseVsCodeColor(bad), throwsFormatException, + reason: bad); + } + expect(() => parseQueryaThemeColor('+1234567'), throwsFormatException); + }); + + test('formatVsCodeColor writes lowercase hex and alpha only when needed', + () { + expect(formatVsCodeColor(const Color(0xFF000000)), '#000000'); + expect(formatVsCodeColor(const Color(0xFF0A0B0C)), '#0a0b0c'); + expect(formatVsCodeColor(const Color(0x80112233)), '#11223380'); + }); + + test('every channel value round-trips through format and parse', () { + for (var v = 0; v < 256; v++) { + final c = Color.fromARGB(0xFF, v, 255 - v, v ^ 0x5A); + expect(parseVsCodeColor(formatVsCodeColor(c)), c, reason: 'v=$v'); + } + }); + }); } diff --git a/test/core/theme/parser/querya_theme_color_scheme_test.dart b/test/core/theme/parser/querya_theme_color_scheme_test.dart index 8bcea27c..5a66596b 100644 --- a/test/core/theme/parser/querya_theme_color_scheme_test.dart +++ b/test/core/theme/parser/querya_theme_color_scheme_test.dart @@ -8,12 +8,12 @@ import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; void main() { - group('colorSchemeFromQueryaThemeColors', () { + group('uiColorsFromQueryaThemeColors', () { test('full fixture maps custom shadcn values', () { final raw = File('test/fixtures/themes/querya_custom_dark.json') .readAsStringSync(); final manifest = QueryaThemeManifest.fromJsonString(raw); - final scheme = colorSchemeFromQueryaThemeColors( + final scheme = uiColorsFromQueryaThemeColors( colors: manifest.shadcnColors, fallback: QueryaTheme.darkDefault, ); @@ -28,11 +28,11 @@ void main() { }); test('missing keys preserve fallback colorScheme values', () { - final scheme = colorSchemeFromQueryaThemeColors( + final scheme = uiColorsFromQueryaThemeColors( colors: const {'primary': '#FF00AA'}, fallback: QueryaTheme.darkDefault, ); - final fallback = QueryaTheme.darkDefault.colorScheme; + final fallback = QueryaTheme.darkDefault.colors; expect(scheme.primary, parseQueryaThemeColor('#FF00AA')); expect(scheme.background, fallback.background); @@ -42,8 +42,8 @@ void main() { }); test('invalid optional color uses fallback value', () { - final fallback = QueryaTheme.darkDefault.colorScheme; - final scheme = colorSchemeFromQueryaThemeColors( + final fallback = QueryaTheme.darkDefault.colors; + final scheme = uiColorsFromQueryaThemeColors( colors: const { 'primary': 'not-a-color', 'background': '#101014', @@ -56,8 +56,8 @@ void main() { }); test('chart colors fall back when omitted', () { - final fallback = QueryaTheme.lightDefault.colorScheme; - final scheme = colorSchemeFromQueryaThemeColors( + final fallback = QueryaTheme.lightDefault.colors; + final scheme = uiColorsFromQueryaThemeColors( colors: const {'background': '#FFFFFF'}, fallback: QueryaTheme.lightDefault, ); @@ -71,7 +71,7 @@ void main() { }); test('brightness comes from fallback theme, not colors map', () { - final scheme = colorSchemeFromQueryaThemeColors( + final scheme = uiColorsFromQueryaThemeColors( colors: const { 'background': '#101014', 'foreground': '#E2E8F0', diff --git a/test/core/theme/parser/querya_theme_from_manifest_test.dart b/test/core/theme/parser/querya_theme_from_manifest_test.dart index d81ad8f2..796d4bc1 100644 --- a/test/core/theme/parser/querya_theme_from_manifest_test.dart +++ b/test/core/theme/parser/querya_theme_from_manifest_test.dart @@ -16,7 +16,7 @@ void main() { final theme = queryaThemeFromManifest(manifest); expect(theme.brightness, Brightness.dark); - expect(theme.colorScheme.primary, parseQueryaThemeColor('#38BDF8')); + expect(theme.colors.primary, parseQueryaThemeColor('#38BDF8')); expect(theme.workbench.canvas, parseQueryaThemeColor('#09090B')); expect(theme.editor.background, parseQueryaThemeColor('#0F1117')); expect(theme.editor.selection, parseQueryaThemeColor('#264F78')); @@ -29,7 +29,7 @@ void main() { final theme = queryaThemeFromManifest(manifest); expect(theme.brightness, Brightness.light); - expect(theme.colorScheme.background, parseQueryaThemeColor('#F8FAFC')); + expect(theme.colors.background, parseQueryaThemeColor('#F8FAFC')); expect(theme.workbench.surface, parseQueryaThemeColor('#FFFFFF')); expect(theme.editor.foreground, parseQueryaThemeColor('#1E293B')); }); @@ -52,7 +52,7 @@ void main() { const fallback = QueryaTheme.darkDefault; expect(theme.brightness, Brightness.dark); - expect(theme.colorScheme.primary, parseQueryaThemeColor('#FF00AA')); + expect(theme.colors.primary, parseQueryaThemeColor('#FF00AA')); expect(theme.editor.background, parseQueryaThemeColor('#010203')); expect(theme.editor.foreground, fallback.editor.foreground); expect(theme.workbench.canvas, fallback.workbench.canvas); diff --git a/test/core/theme/parser/token_style_resolver_test.dart b/test/core/theme/parser/token_style_resolver_test.dart index c2badd7e..cee943f6 100644 --- a/test/core/theme/parser/token_style_resolver_test.dart +++ b/test/core/theme/parser/token_style_resolver_test.dart @@ -37,4 +37,30 @@ void main() { final b = resolver.resolve('string.quoted.double'); expect(identical(a, b), isTrue); }); + + test('walks prefixes by dot, including empty segments (#1530)', () { + const rules = [ + TokenColorRule(scopes: ['a'], foreground: '#010101'), + TokenColorRule(scopes: ['a.b'], foreground: '#020202'), + TokenColorRule(scopes: ['a.'], foreground: '#030303'), + TokenColorRule(scopes: [''], foreground: '#040404'), + TokenColorRule(scopes: ['x.y.z'], foreground: '#050505'), + ]; + final resolver = TokenStyleResolver( + rules: rules, + defaultStyle: const TextStyle(color: Color(0xFFFFFFFF)), + ); + + // The longest prefix wins. + expect(resolver.resolve('a.b.c.d').color, const Color(0xFF020202)); + expect(resolver.resolve('a.c.d').color, const Color(0xFF010101)); + expect(resolver.resolve('x.y.z.w').color, const Color(0xFF050505)); + // `a..b` -> `a.` -> `a`: the empty segment is a prefix of its own. + expect(resolver.resolve('a..b').color, const Color(0xFF030303)); + // The leading dot gives the empty prefix; a bare empty scope is it too. + expect(resolver.resolve('.q').color, const Color(0xFF040404)); + expect(resolver.resolve('').color, const Color(0xFF040404)); + // Nothing matches: the default style. + expect(resolver.resolve('zzz.top').color, const Color(0xFFFFFFFF)); + }); } diff --git a/test/core/theme/parser/vscode_color_map_test.dart b/test/core/theme/parser/vscode_color_map_test.dart index 224ef755..336fe635 100644 --- a/test/core/theme/parser/vscode_color_map_test.dart +++ b/test/core/theme/parser/vscode_color_map_test.dart @@ -36,7 +36,7 @@ void main() { expect(theme.workbench.accent, const Color(0xFF007FD4)); expect(theme.workbench.gitModified, const Color(0xFFE2C08D)); expect(theme.workbench.gitUntracked, const Color(0xFF73C991)); - expect(theme.colorScheme.foreground, const Color(0xFFD4D4D4)); + expect(theme.colors.foreground, const Color(0xFFD4D4D4)); expect(theme.editor.background, const Color(0xFF1E1E1E)); }); diff --git a/test/core/theme/querya_app_theme_test.dart b/test/core/theme/querya_app_theme_test.dart index 612e83db..32b30b1d 100644 --- a/test/core/theme/querya_app_theme_test.dart +++ b/test/core/theme/querya_app_theme_test.dart @@ -6,7 +6,7 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:flutter/material.dart' as material; class _FakePathProvider extends PathProviderPlatform { _FakePathProvider(this._root); @@ -44,43 +44,43 @@ void main() { await AppSettings.instance.clearThemeSettings(); }); - test('ThemeController shadcn themes differ for light vs dark background', + test('ThemeController material themes differ for light vs dark background', () async { final controller = ThemeController.instance; await controller.load(); expect( - controller.darkShadcnTheme.colorScheme.background, - QueryaTheme.darkDefault.colorScheme.background, + controller.darkMaterialTheme.colorScheme.surface, + QueryaTheme.darkDefault.colors.popover, ); expect( - controller.lightShadcnTheme.colorScheme.background, - QueryaTheme.lightDefault.colorScheme.background, + controller.lightMaterialTheme.colorScheme.surface, + QueryaTheme.lightDefault.colors.popover, ); expect( - controller.darkShadcnTheme.colorScheme.background, - isNot(controller.lightShadcnTheme.colorScheme.background), + controller.darkMaterialTheme.colorScheme.surface, + isNot(controller.lightMaterialTheme.colorScheme.surface), ); }); - test('setThemeMode switches activeTheme and shadcn background', () async { + test('setThemeMode switches activeTheme and material surface', () async { final controller = ThemeController.instance; await controller.load(); expect( - controller.activeTheme.colorScheme.background, - QueryaTheme.darkDefault.colorScheme.background, + controller.activeTheme.colors.background, + QueryaTheme.darkDefault.colors.background, ); - await controller.setThemeMode(ThemeMode.light); + await controller.setThemeMode(material.ThemeMode.light); expect( - controller.activeTheme.colorScheme.background, - QueryaTheme.lightDefault.colorScheme.background, + controller.activeTheme.colors.background, + QueryaTheme.lightDefault.colors.background, ); expect( - controller.darkShadcnTheme.colorScheme.background, - isNot(controller.lightShadcnTheme.colorScheme.background), + controller.darkMaterialTheme.colorScheme.surface, + isNot(controller.lightMaterialTheme.colorScheme.surface), ); }); } diff --git a/test/core/theme/querya_light_theme_test.dart b/test/core/theme/querya_light_theme_test.dart index d9de48e2..41ed571b 100644 --- a/test/core/theme/querya_light_theme_test.dart +++ b/test/core/theme/querya_light_theme_test.dart @@ -1,27 +1,20 @@ import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/theme/querya_color_scheme.dart'; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; void main() { group('QueryaTheme.lightDefault', () { - test('colorScheme differs from dark preset', () { - final light = QueryaTheme.lightDefault.colorScheme; - final dark = QueryaTheme.darkDefault.colorScheme; + test('colors differ from dark preset', () { + final light = QueryaTheme.lightDefault.colors; + final dark = QueryaTheme.darkDefault.colors; expect(light.background, isNot(dark.background)); expect(light.foreground, isNot(dark.foreground)); expect(light.brightness, Brightness.light); }); - test('QueryaColorScheme.light matches lightDefault', () { - expect( - QueryaColorScheme.light.background, - QueryaTheme.lightDefault.colorScheme.background, - ); - }); - test('primary text on canvas meets WCAG AA contrast (4.5:1)', () { final w = QueryaTheme.lightDefault.workbench; final ratio = contrastRatio(w.canvas, const Color(0xFF0F172A)); @@ -34,8 +27,8 @@ void main() { expect(ratio, greaterThanOrEqualTo(4.5)); }); - test('toShadcnThemeData uses light brightness', () { - final td = QueryaTheme.lightDefault.toShadcnThemeData(); + test('material theme uses light brightness', () { + final td = materialThemeFromQuerya(QueryaTheme.lightDefault.colors); expect(td.brightness, Brightness.light); expect(td.colorScheme.primary, QueryaTheme.lightDefault.workbench.accent); }); diff --git a/test/core/theme/querya_material_theme_test.dart b/test/core/theme/querya_material_theme_test.dart index 1c146299..af97ca32 100644 --- a/test/core/theme/querya_material_theme_test.dart +++ b/test/core/theme/querya_material_theme_test.dart @@ -5,14 +5,14 @@ import 'package:querya_desktop/core/theme/querya_theme.dart'; void main() { test('dark material theme uses light onSurface for dropdowns', () { - final td = materialThemeFromQuerya(QueryaTheme.darkDefault.colorScheme); + final td = materialThemeFromQuerya(QueryaTheme.darkDefault.colors); expect(td.colorScheme.brightness, material.Brightness.dark); expect(td.colorScheme.onSurface, const material.Color(0xFFF8FAFC)); expect(td.textTheme.bodyLarge?.color, const material.Color(0xFFF8FAFC)); }); test('light material theme uses dark onSurface', () { - final td = materialThemeFromQuerya(QueryaTheme.lightDefault.colorScheme); + final td = materialThemeFromQuerya(QueryaTheme.lightDefault.colors); expect(td.colorScheme.onSurface, const material.Color(0xFF0F172A)); }); } diff --git a/test/core/theme/querya_semantic_palette_test.dart b/test/core/theme/querya_semantic_palette_test.dart index 5e49f220..39e90114 100644 --- a/test/core/theme/querya_semantic_palette_test.dart +++ b/test/core/theme/querya_semantic_palette_test.dart @@ -23,12 +23,12 @@ void main() { destructive: destructive, mutedForeground: muted, ), - colorScheme: base.colorScheme.copyWith( - chart1: () => chart1, - chart2: () => chart2, - chart3: () => chart3, - chart4: () => chart4, - chart5: () => chart5, + colors: base.colors.copyWith( + chart1: chart1, + chart2: chart2, + chart3: chart3, + chart4: chart4, + chart5: chart5, ), ); diff --git a/test/core/theme/querya_theme_scope_test.dart b/test/core/theme/querya_theme_scope_test.dart index 8f125b34..0b09d8d5 100644 --- a/test/core/theme/querya_theme_scope_test.dart +++ b/test/core/theme/querya_theme_scope_test.dart @@ -1,6 +1,7 @@ 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:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -10,10 +11,10 @@ void main() { late Color seenAccent; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: QueryaThemeScope( data: QueryaTheme.darkDefault, child: material.Builder( @@ -63,10 +64,10 @@ class _ScopeHost extends StatelessWidget { @override Widget build(BuildContext context) { - return ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + return MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: QueryaThemeScope( data: data, child: material.Builder( diff --git a/test/core/theme/querya_theme_test.dart b/test/core/theme/querya_theme_test.dart index fa0af679..a8c2e4f1 100644 --- a/test/core/theme/querya_theme_test.dart +++ b/test/core/theme/querya_theme_test.dart @@ -1,10 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/theme/querya_color_scheme.dart'; import 'package:querya_desktop/core/theme/querya_colors.dart'; import 'package:querya_desktop/core/theme/querya_editor_theme.dart'; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:flutter/material.dart' show Brightness, Color; void main() { group('QueryaWorkbenchTheme', () { @@ -42,16 +42,7 @@ void main() { }); group('QueryaTheme', () { - test('darkDefault colorScheme matches legacy QueryaColorScheme.dark', () { - final legacy = QueryaColorScheme.dark; - final next = QueryaTheme.darkDefault.colorScheme; - expect(next.background, legacy.background); - expect(next.primary, legacy.primary); - expect(next.card, legacy.card); - expect(next.border, legacy.border); - }); - - test('colorSchemeFromWorkbench uses workbench tokens', () { + test('colorsFromWorkbench uses workbench tokens', () { const w = QueryaWorkbenchTheme( canvas: Color(0xFF111111), surface: Color(0xFF222222), @@ -67,7 +58,7 @@ void main() { gitModified: Color(0xFFFF00FF), gitUntracked: Color(0xFF00FF00), ); - final cs = QueryaTheme.colorSchemeFromWorkbench( + final cs = QueryaTheme.colorsFromWorkbench( w, brightness: Brightness.dark, ); @@ -83,23 +74,10 @@ void main() { expect(mid.editor.foreground, isNot(equals(a.editor.foreground))); }); - test('toShadcnThemeData preserves brightness', () { - final td = QueryaTheme.darkDefault.toShadcnThemeData(); + test('material theme preserves brightness and accent', () { + final td = materialThemeFromQuerya(QueryaTheme.darkDefault.colors); expect(td.brightness, Brightness.dark); expect(td.colorScheme.primary, QueryaColors.accentCyan); }); - - test('ThemeData.lerp at 0.5 matches QueryaTheme.lerp colorScheme', () { - const a = QueryaTheme.darkDefault; - const b = QueryaTheme.lightDefault; - final qaMid = QueryaTheme.lerp(a, b, 0.5); - final tdMid = ThemeData.lerp( - a.toShadcnThemeData(), - b.toShadcnThemeData(), - 0.5, - ); - expect(tdMid.colorScheme.primary, qaMid.colorScheme.primary); - expect(tdMid.colorScheme.background, qaMid.colorScheme.background); - }); }); } diff --git a/test/core/theme/querya_typography_test.dart b/test/core/theme/querya_typography_test.dart index 8e43d56d..f9b136b9 100644 --- a/test/core/theme/querya_typography_test.dart +++ b/test/core/theme/querya_typography_test.dart @@ -17,8 +17,20 @@ void main() { expect(stack, contains('DejaVu Sans Mono')); expect(stack, contains('monospace')); - // Ensure fallback ends with generic 'monospace' - expect(stack.last, equals('monospace')); + // The generic 'monospace' closes the Latin stack; the CJK tail follows it + // (#1553), so Latin text keeps rendering in the monospace fonts. + const cjk = QueryaTypography.cjkFontFamilyFallback; + expect(stack.sublist(stack.length - cjk.length), equals(cjk)); + expect(stack[stack.length - cjk.length - 1], equals('monospace')); + }); + + test('CJK fallback names a family for each desktop platform (#1553)', () { + const cjk = QueryaTypography.cjkFontFamilyFallback; + expect(cjk, contains('Noto Sans CJK SC')); + expect(cjk, contains('PingFang SC')); + expect(cjk, contains('Microsoft YaHei')); + expect(cjk, isNot(contains('monospace'))); + expect(cjk.toSet().length, cjk.length); // no duplicates }); test('QueryaEditorTheme wires monospace typography stack by default', () { diff --git a/test/core/theme/querya_ui_colors_test.dart b/test/core/theme/querya_ui_colors_test.dart new file mode 100644 index 00000000..18cda112 --- /dev/null +++ b/test/core/theme/querya_ui_colors_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +/// QueryaUiColors interpolation and `context.colors` lookup (#1730, #1735). +void main() { + test('lerp: the ends are the themes, the middle is between', () { + final a = QueryaTheme.darkDefault.colors; + final b = QueryaTheme.lightDefault.colors; + expect(QueryaUiColors.lerp(a, b, 0), a); + expect(QueryaUiColors.lerp(a, b, 1), b); + final mid = QueryaUiColors.lerp(a, b, 0.5); + expect(mid.background, material.Color.lerp(a.background, b.background, 0.5)); + expect(mid.brightness, b.brightness); + }); + + group('context.colors', () { + testWidgets('under the shell: the shell theme\'s colours', (tester) async { + late QueryaUiColors seen; + await tester.pumpWidget(queryaThemeTestShell( + data: QueryaTheme.lightDefault, + child: material.Builder(builder: (context) { + seen = context.colors; + return const material.SizedBox.shrink(); + }), + )); + expect(seen, QueryaTheme.lightDefault.colors); + }); + + testWidgets('a bare app: the default theme of its brightness', + (tester) async { + late QueryaUiColors seen; + await tester.pumpWidget(material.MaterialApp( + theme: material.ThemeData(brightness: material.Brightness.light), + home: material.Builder(builder: (context) { + seen = context.colors; + return const material.SizedBox.shrink(); + }), + )); + expect(seen, QueryaTheme.lightDefault.colors); + }); + + testWidgets('a scope without the shell: the scope\'s colours', + (tester) async { + late QueryaUiColors seen; + await tester.pumpWidget(material.MaterialApp( + home: QueryaThemeScope( + data: QueryaTheme.lightDefault, + child: material.Builder(builder: (context) { + seen = context.colors; + return const material.SizedBox.shrink(); + }), + ), + )); + expect(seen, QueryaTheme.lightDefault.colors); + }); + }); +} diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 44bf7d39..db587ef8 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/material.dart' as material; 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'; @@ -120,7 +121,7 @@ void main() { test('load defaults to dark preset', () async { final c = ThemeController.instance; await c.load(); - expect(c.themeMode, ThemeMode.dark); + expect(c.themeMode, material.ThemeMode.dark); expect(c.preset, QueryaThemePreset.queryaDark); expect(c.activeTheme, QueryaTheme.darkDefault); expect(c.isLoaded, isTrue); @@ -136,9 +137,9 @@ void main() { test('setThemeMode light persists and updates activeTheme', () async { final c = ThemeController.instance; await c.load(); - await c.setThemeMode(ThemeMode.light); + await c.setThemeMode(material.ThemeMode.light); expect(c.activeTheme, QueryaTheme.lightDefault); - expect(await AppSettings.instance.getThemeMode(), ThemeMode.light); + expect(await AppSettings.instance.getThemeMode(), material.ThemeMode.light); expect( await AppSettings.instance.getThemePreset(), QueryaThemePreset.queryaLight, @@ -148,9 +149,9 @@ void main() { test('resetToDefaults restores dark', () async { final c = ThemeController.instance; await c.load(); - await c.setThemeMode(ThemeMode.light); + await c.setThemeMode(material.ThemeMode.light); await c.resetToDefaults(); - expect(c.themeMode, ThemeMode.dark); + expect(c.themeMode, material.ThemeMode.dark); expect(c.activeTheme, QueryaTheme.darkDefault); }); @@ -198,10 +199,10 @@ void main() { test('clearColorOverrides does not reset theme mode', () async { final c = ThemeController.instance; await c.load(); - await c.setThemeMode(ThemeMode.light); + await c.setThemeMode(material.ThemeMode.light); await c.setWorkbenchColor('editor.background', const Color(0xFF111111)); await c.clearColorOverrides(); - expect(c.themeMode, ThemeMode.light); + expect(c.themeMode, material.ThemeMode.light); expect(c.activeTheme, QueryaTheme.lightDefault); }); @@ -220,7 +221,7 @@ void main() { expect(c.selectedThemeId, 'fixture-custom-dark'); expect(c.selectedThemeLoadError, isNull); expect( - c.activeTheme.colorScheme.primary, parseQueryaThemeColor('#38BDF8')); + c.activeTheme.colors.primary, parseQueryaThemeColor('#38BDF8')); expect(await AppSettings.instance.getSelectedThemeId(), 'fixture-custom-dark'); expect( @@ -334,7 +335,7 @@ void main() { expect(c.selectedThemeLoadError, isNull); expect( - c.activeTheme.colorScheme.primary, parseQueryaThemeColor('#38BDF8')); + c.activeTheme.colors.primary, parseQueryaThemeColor('#38BDF8')); }); test('setPreset clears registry selection', () async { @@ -354,6 +355,27 @@ void main() { expect(await AppSettings.instance.getSelectedThemeId(), isNull); }); + test( + 'setThemeMode clears conflicting registry selection and aligns preset (#1577)', + () async { + final c = ThemeController.instance; + await _copyFixture( + 'querya_custom_dark.json', + File(p.join(themesDir.path, 'querya_custom_dark.json')), + ); + await c.load(); + await c.setThemeById('fixture-custom-dark'); + expect(c.selectedThemeId, 'fixture-custom-dark'); + expect(c.activeTheme.brightness, Brightness.dark); + + await c.setThemeMode(material.ThemeMode.light); + + expect(c.selectedThemeId, isNull); + expect(c.activeTheme, QueryaTheme.lightDefault); + expect(c.preset, QueryaThemePreset.queryaLight); + expect(await AppSettings.instance.getSelectedThemeId(), isNull); + }); + test('setThemeById applies built-in asset theme from registry', () async { final c = ThemeController.instance; await c.load(); @@ -396,7 +418,7 @@ void main() { test('resetToDefaults keeps built-in themes in picker list', () async { final c = ThemeController.instance; await c.load(); - await c.setThemeMode(ThemeMode.light); + await c.setThemeMode(material.ThemeMode.light); await c.resetToDefaults(); expect( @@ -496,26 +518,26 @@ void main() { contains('fixture-custom-dark'), ); expect( - c.activeTheme.colorScheme.primary, parseQueryaThemeColor('#38BDF8')); + c.activeTheme.colors.primary, parseQueryaThemeColor('#38BDF8')); }); }); - group('ThemeMode.system platform brightness listener (#899)', () { + group('material.ThemeMode.system platform brightness listener (#899)', () { test( - 'listens to platform brightness when ThemeMode.system and updates activeTheme', + 'listens to platform brightness when material.ThemeMode.system and updates activeTheme', () async { final c = ThemeController.instance; await c.load(); // Initially not system, so should not observe - await c.setThemeMode(ThemeMode.dark); + await c.setThemeMode(material.ThemeMode.dark); expect(c.isObservingPlatformBrightness, isFalse); // Set to system mode with dark platform brightness TestWidgetsFlutterBinding.instance.platformDispatcher .platformBrightnessTestValue = Brightness.dark; - await c.setThemeMode(ThemeMode.system); - expect(c.themeMode, ThemeMode.system); + await c.setThemeMode(material.ThemeMode.system); + expect(c.themeMode, material.ThemeMode.system); expect(c.isObservingPlatformBrightness, isTrue); expect(c.activeTheme.brightness, Brightness.dark); @@ -545,7 +567,7 @@ void main() { expect(c.activeTheme.brightness, Brightness.dark); // Switching away from system unregisters observer - await c.setThemeMode(ThemeMode.light); + await c.setThemeMode(material.ThemeMode.light); expect(c.isObservingPlatformBrightness, isFalse); } finally { c.removeListener(listener); @@ -557,11 +579,11 @@ void main() { test('cleans up brightness observer on resetToDefaults', () async { final c = ThemeController.instance; await c.load(); - await c.setThemeMode(ThemeMode.system); + await c.setThemeMode(material.ThemeMode.system); expect(c.isObservingPlatformBrightness, isTrue); await c.resetToDefaults(); - expect(c.themeMode, ThemeMode.dark); + expect(c.themeMode, material.ThemeMode.dark); expect(c.isObservingPlatformBrightness, isFalse); }); }); diff --git a/test/core/theme/theme_editor_draft_test.dart b/test/core/theme/theme_editor_draft_test.dart index 717a7b4f..00f700a5 100644 --- a/test/core/theme/theme_editor_draft_test.dart +++ b/test/core/theme/theme_editor_draft_test.dart @@ -20,7 +20,7 @@ void main() { ); expect(draft.shadcnColors['primary'], - formatVsCodeColor(theme.colorScheme.primary)); + formatVsCodeColor(theme.colors.primary)); expect(draft.editorColors['background'], formatVsCodeColor(theme.editor.background)); expect(draft.editorColors['canvas'], @@ -43,7 +43,7 @@ void main() { expect(reparsed.shadcnColors['primary'], '#ff00aa'); final theme = queryaThemeFromManifest(reparsed); - expect(theme.colorScheme.primary, parseQueryaThemeColor('#FF00AA')); + expect(theme.colors.primary, parseQueryaThemeColor('#FF00AA')); }); test('forExport assigns new id for read-only built-in source', () { diff --git a/test/core/theme/theme_editor_loader_test.dart b/test/core/theme/theme_editor_loader_test.dart index 5b83cc66..bee32b23 100644 --- a/test/core/theme/theme_editor_loader_test.dart +++ b/test/core/theme/theme_editor_loader_test.dart @@ -90,7 +90,7 @@ void main() { test('previewEditorManifest updates active theme and restores', () async { final c = ThemeController.instance; await c.load(); - final beforePrimary = c.activeTheme.colorScheme.primary; + final beforePrimary = c.activeTheme.colors.primary; final manifest = QueryaThemeManifest.fromJsonString(''' { @@ -106,11 +106,11 @@ void main() { await c.previewEditorManifest(manifest); expect(c.isEditorPreviewActive, isTrue); expect( - c.activeTheme.colorScheme.primary, parseQueryaThemeColor('#FF00AA')); + c.activeTheme.colors.primary, parseQueryaThemeColor('#FF00AA')); await c.endEditorPreview(); expect(c.isEditorPreviewActive, isFalse); - expect(c.activeTheme.colorScheme.primary, beforePrimary); + expect(c.activeTheme.colors.primary, beforePrimary); }); }); } diff --git a/test/core/theme/theme_registry_service_test.dart b/test/core/theme/theme_registry_service_test.dart index 3f886a98..4288ce68 100644 --- a/test/core/theme/theme_registry_service_test.dart +++ b/test/core/theme/theme_registry_service_test.dart @@ -214,7 +214,7 @@ void main() { expect(success.definition, definition); expect(success.theme.brightness, Brightness.dark); expect( - success.theme.colorScheme.primary, parseQueryaThemeColor('#38BDF8')); + success.theme.colors.primary, parseQueryaThemeColor('#38BDF8')); }); test('loads VS Code theme successfully', () async { diff --git a/test/core/theme/theme_remote_install_service_test.dart b/test/core/theme/theme_remote_install_service_test.dart index 8f10ac5a..7a1cd16c 100644 --- a/test/core/theme/theme_remote_install_service_test.dart +++ b/test/core/theme/theme_remote_install_service_test.dart @@ -274,7 +274,7 @@ void main() { expect(result, isA()); expect(controller.selectedThemeId, 'fixture-custom-dark'); expect( - controller.activeTheme.colorScheme.primary, + controller.activeTheme.colors.primary, parseQueryaThemeColor('#38BDF8'), ); }); diff --git a/test/core/ui/querya_shell_status_test.dart b/test/core/ui/querya_shell_status_test.dart index 710af6c9..7053e5e8 100644 --- a/test/core/ui/querya_shell_status_test.dart +++ b/test/core/ui/querya_shell_status_test.dart @@ -48,6 +48,42 @@ void main() { expect(status.statusMessage, 'Explain failed'); }); + test('a plain-count result leaves no "Running query…" behind (#1556)', () { + status.beginBusy(message: 'Running query…'); + + status.reportQueryResult( + duration: const Duration(milliseconds: 1), + rowCount: 12, + columnCount: 8, + ); + + expect(status.isBusy, isFalse); + expect(status.statusMessage, isNull); + expect(status.rowCount, 12); + expect(status.columnCount, 8); + + status.endBusy(); + expect(status.statusMessage, isNull); + }); + + test('a result without a message does not keep the text of an earlier run', + () { + status.reportQueryResult(rowCount: 3, columnCount: 1, message: 'Script done.'); + status.beginBusy(message: 'Running query…'); + + status.reportQueryResult(rowCount: 12, columnCount: 8); + + expect(status.statusMessage, isNull); + }); + + test('a result with a message shows it after the busy message', () { + status.beginBusy(message: 'Running query…'); + + status.reportQueryResult(message: '3 statements, 5 row(s) affected.'); + + expect(status.statusMessage, '3 statements, 5 row(s) affected.'); + }); + test('a result message is not cleared by a later endBusy', () { status.reportQueryResult(rowCount: 1, columnCount: 1, message: '1 row(s).'); diff --git a/test/core/unsaved_work_guard_test.dart b/test/core/unsaved_work_guard_test.dart index df96b35e..f093d48f 100644 --- a/test/core/unsaved_work_guard_test.dart +++ b/test/core/unsaved_work_guard_test.dart @@ -1,10 +1,10 @@ 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:flutter/material.dart' show MaterialApp; +import '../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/core/unsaved_work_guard.dart'; import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; void main() { tearDown(UnsavedWorkRegistry.instance.resetForTest); @@ -12,8 +12,8 @@ void main() { testWidgets('returns true immediately when nothing is unsaved', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: const material.Scaffold(body: material.SizedBox()), ), ); @@ -35,8 +35,8 @@ void main() { var navigated = false; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: material.Builder( builder: (context) { return material.TextButton( diff --git a/test/core/updater/app_updater_service_test.dart b/test/core/updater/app_updater_service_test.dart index 9acccb6c..178439ee 100644 --- a/test/core/updater/app_updater_service_test.dart +++ b/test/core/updater/app_updater_service_test.dart @@ -11,6 +11,7 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/updater/app_updater_service.dart'; import 'package:querya_desktop/core/updater/github_releases_client.dart'; +import 'package:querya_desktop/core/updater/installers/update_install_context.dart'; import 'package:querya_desktop/core/updater/sha256_checksums.dart'; import 'package:querya_desktop/core/updater/update_manifest.dart'; @@ -154,6 +155,32 @@ void main() { service.dispose(); }); + test('a package install reads releases/latest even on the Beta channel (#1497)', + () async { + await AppSettings.instance.setUpdateChannel(UpdateChannel.dev); + UpdateInstallContext.debugPackageManagerInstall = true; + addTearDown(() => UpdateInstallContext.debugPackageManagerInstall = null); + + final client = MockClient((request) async { + expect(request.url.toString(), kGitHubReleasesLatestUrl); + return http.Response( + jsonEncode(_sampleRelease(version: '0.5.1')), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + final result = await service.checkForUpdates(); + expect(result.availableUpdate?.version, '0.5.1'); + service.dispose(); + }); + test('background mode returns error message instead of throwing', () async { final client = MockClient((request) async { return http.Response('rate limited', 403); diff --git a/test/core/updater/github_releases_beta_test.dart b/test/core/updater/github_releases_beta_test.dart new file mode 100644 index 00000000..10e6a787 --- /dev/null +++ b/test/core/updater/github_releases_beta_test.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:querya_desktop/core/updater/github_releases_client.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; + +Map _release(String tag, {bool draft = false}) => { + 'tag_name': tag, + 'draft': draft, + 'published_at': '2026-10-01T00:00:00Z', + 'body': '', + 'assets': const [], + }; + +// The Beta channel reads `/releases`, which is ordered by creation date; the +// answer must be the highest version, whatever the order (#1493). +void main() { + group('pickNewestRelease', () { + String newest(List> list) => + pickNewestRelease(list).version; + + test('a hotfix published after a beta does not hide the newer beta', () { + expect( + newest([ + _release('0.4.20'), + _release('0.5.0-beta.2'), + _release('0.5.0-beta.1'), + ]), + '0.5.0-beta.2', + ); + }); + + test('a stable release outranks its own beta', () { + expect(newest([_release('0.5.0-beta.1'), _release('0.5.0')]), '0.5.0'); + expect(newest([_release('0.5.0'), _release('0.5.0-beta.3')]), '0.5.0'); + }); + + test('pre-release numbers compare as numbers: beta.10 is above beta.2', () { + expect( + newest([_release('0.5.0-beta.2'), _release('0.5.0-beta.10')]), + '0.5.0-beta.10', + ); + }); + + test('a leading v is ignored', () { + expect(newest([_release('v0.4.19'), _release('v0.4.21')]), '0.4.21'); + }); + + test('drafts and tags that are not versions are skipped', () { + expect( + newest([ + _release('0.9.0', draft: true), + _release('nightly'), + _release('0.4.19'), + ]), + '0.4.19', + ); + }); + + test('nothing usable is "No published releases found"', () { + expect( + () => pickNewestRelease([_release('0.9.0', draft: true), _release('x')]), + throwsA(isA().having( + (e) => e.message, + 'message', + 'No published releases found', + )), + ); + expect(() => pickNewestRelease(const []), + throwsA(isA())); + }); + }); + + group('GitHubReleasesClient.fetchLatest', () { + GitHubReleasesClient clientFor(List> releases) => + GitHubReleasesClient( + httpClient: MockClient((request) async { + expect(request.url.toString(), kGitHubReleasesListUrl); + return http.Response(jsonEncode(releases), 200); + }), + ); + + test('the Beta channel returns the highest version of the list', () async { + final manifest = await clientFor([ + _release('0.4.20'), + _release('0.5.0-beta.2'), + ]).fetchLatest(channel: UpdateChannel.dev); + expect(manifest.version, '0.5.0-beta.2'); + }); + }); +} diff --git a/test/core/updater/macos_app_replace_script_test.dart b/test/core/updater/macos_app_replace_script_test.dart new file mode 100644 index 00000000..39bfbf57 --- /dev/null +++ b/test/core/updater/macos_app_replace_script_test.dart @@ -0,0 +1,185 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/updater/installers/update_install_context.dart'; +import 'package:querya_desktop/core/updater/installers/update_install_utils.dart'; + +/// #1587: the macOS replace script runs under `/bin/sh` with an `open` that +/// records what it was asked to open, against bundles in a temp directory. +/// `ditto` is macOS-only, so on Linux the script's `cp -R` path runs. +class _Setup { + _Setup(this.root); + + final Directory root; + + String get apps => p.join(root.path, 'Applications'); + String get target => p.join(apps, 'Querya.app'); + String get work => p.join(root.path, 'work'); + String get newApp => p.join(work, 'Querya.app'); + String get exe => p.join(target, 'Contents', 'MacOS', 'querya_desktop'); + String get log => + p.join(root.path, 'Library', 'Logs', 'Querya', 'querya-update.log'); + File get opened => File(p.join(root.path, 'opened')); + + String contentOf(String bundle) => File( + p.join(bundle, 'Contents', 'MacOS', 'querya_desktop'), + ).readAsStringSync(); + + static Future<_Setup> create() async { + final root = await Directory.systemTemp.createTemp('querya_mac_update_'); + final s = _Setup(root); + for (final (bundle, content) in [(s.target, 'old'), (s.newApp, 'new')]) { + final exe = File(p.join(bundle, 'Contents', 'MacOS', 'querya_desktop')); + await exe.create(recursive: true); + await exe.writeAsString(content); + } + final bin = Directory(p.join(root.path, 'bin'))..createSync(); + final open = File(p.join(bin.path, 'open')) + ..writeAsStringSync('#!/bin/sh\necho "\$1" >> "\$HOME/opened"\n'); + await Process.run('chmod', ['+x', open.path]); + return s; + } + + /// Writes and runs the script for a process that has already exited. + Future run() async { + final done = await Process.start('true', const []); + await done.exitCode; + final script = File(p.join(root.path, 'update.sh')) + ..writeAsStringSync(buildMacAppReplaceScript( + pid: done.pid, + newAppBundle: newApp, + targetAppBundle: target, + executable: exe, + workDir: work, + )); + return Process.run( + '/bin/sh', + [script.path], + environment: { + 'HOME': root.path, + 'PATH': '${p.join(root.path, 'bin')}:${Platform.environment['PATH']}', + }, + ); + } +} + +bool get _isRoot => + (Process.runSync('id', const ['-u']).stdout as String).trim() == '0'; + +void main() { + group('buildMacAppReplaceScript (#1587)', () { + late _Setup s; + + setUp(() async => s = await _Setup.create()); + tearDown(() async { + await Process.run('chmod', ['-R', 'u+rwx', s.root.path]); + await s.root.delete(recursive: true); + }); + + test('replaces the bundle, removes .old and the work dir, opens it', + () async { + final result = await s.run(); + + expect(result.exitCode, 0, reason: '${result.stderr}'); + expect(s.contentOf(s.target), 'new'); + expect(Directory('${s.target}.old').existsSync(), isFalse); + expect(Directory(s.work).existsSync(), isFalse); + expect(File(p.join(s.root.path, 'update.sh')).existsSync(), isFalse); + expect(s.opened.readAsStringSync().trim(), s.target); + }); + + test('a stale .old from an earlier update does not block it', () async { + Directory('${s.target}.old').createSync(); + final result = await s.run(); + expect(result.exitCode, 0, reason: '${result.stderr}'); + expect(s.contentOf(s.target), 'new'); + expect(Directory('${s.target}.old').existsSync(), isFalse); + }); + + test('a failed copy restores the old app, logs and opens it', () async { + final unreadable = File(p.join(s.newApp, 'Contents', 'secret')) + ..writeAsStringSync('x'); + await Process.run('chmod', ['000', unreadable.path]); + + final result = await s.run(); + + expect(result.exitCode, 1); + expect(s.contentOf(s.target), 'old'); + expect(Directory('${s.target}.old').existsSync(), isFalse); + expect(Directory(s.work).existsSync(), isFalse); + expect(File(s.log).readAsStringSync(), contains('the old one is restored')); + expect(s.opened.readAsStringSync().trim(), s.target); + }, skip: _isRoot ? 'root can read a 000 file' : null); + + test('a folder that cannot be written leaves the app as it was', + () async { + await Process.run('chmod', ['555', s.apps]); + + final result = await s.run(); + + expect(result.exitCode, 1); + expect(s.contentOf(s.target), 'old'); + expect(File(s.log).readAsStringSync(), contains('could not move')); + expect(s.opened.readAsStringSync().trim(), s.target); + }, skip: _isRoot ? 'root can write a 555 folder' : null); + + test('quotes paths with spaces and quotes', () { + final script = buildMacAppReplaceScript( + pid: 1, + newAppBundle: "/tmp/it's new/Querya.app", + targetAppBundle: '/Applications/Querya Desktop.app', + executable: '/Applications/Querya Desktop.app/Contents/MacOS/q', + workDir: "/tmp/it's new", + ); + expect(script, contains(r"NEW='/tmp/it'\''s new/Querya.app'")); + expect(script, contains("TARGET='/Applications/Querya Desktop.app'")); + expect(script, isNot(contains('|| true'))); + }); + }, skip: Platform.isWindows ? 'POSIX shell script' : null); + + group('macAppBundleReplaceProblemFor (#1587)', () { + UpdateInstallContext context({bool writable = true}) => + UpdateInstallContext( + environment: const {}, + resolvedExecutable: '/Applications/Querya.app/Contents/MacOS/q', + isDirectoryWritable: (_) => writable, + ); + + test('null for a writable Applications folder', () { + expect( + context().macAppBundleReplaceProblemFor('/Applications/Querya.app'), + isNull, + ); + }); + + test('a translocated copy must be moved first', () { + expect( + context().macAppBundleReplaceProblemFor( + '/private/var/folders/x/T/AppTranslocation/ABC/d/Querya.app', + ), + contains('Applications folder'), + ); + }); + + test('a sandboxed app cannot replace itself (#1593)', () { + final sandboxed = UpdateInstallContext( + environment: const {'APP_SANDBOX_CONTAINER_ID': 'com.queryahub.querya'}, + resolvedExecutable: '/Applications/Querya.app/Contents/MacOS/q', + isDirectoryWritable: (_) => true, + ); + expect( + sandboxed.macAppBundleReplaceProblemFor('/Applications/Querya.app'), + contains('App Sandbox'), + ); + }); + + test('a folder this user cannot write, such as a disk image', () { + expect( + context(writable: false) + .macAppBundleReplaceProblemFor('/Volumes/Querya/Querya.app'), + contains('/Volumes/Querya'), + ); + }); + }); +} diff --git a/test/core/updater/macos_signer_identity_test.dart b/test/core/updater/macos_signer_identity_test.dart new file mode 100644 index 00000000..ddb8b278 --- /dev/null +++ b/test/core/updater/macos_signer_identity_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/updater/installers/macos_sparkle_installer.dart'; + +void main() { + group('parseCodesignTeamId (#1588)', () { + test('reads the Team ID of a Developer ID signature', () { + const out = 'Executable=/Applications/Querya.app/Contents/MacOS/querya\n' + 'Identifier=com.queryahub.querya\n' + 'Authority=Developer ID Application: QueryaHub (AB12CD34EF)\n' + 'TeamIdentifier=AB12CD34EF\n' + 'Sealed Resources version=2 rules=13 files=120\n'; + expect(parseCodesignTeamId(out), 'AB12CD34EF'); + }); + + test('an ad-hoc signature has none', () { + const out = 'Identifier=querya_desktop\n' + 'Signature=adhoc\n' + 'TeamIdentifier=not set\n'; + expect(parseCodesignTeamId(out), isNull); + }); + + test('no TeamIdentifier line has none', () { + expect(parseCodesignTeamId('code object is not signed at all'), isNull); + expect(parseCodesignTeamId(''), isNull); + }); + }); + + group('signerMismatchProblem (#1588)', () { + test('the same team may replace the app', () { + expect( + signerMismatchProblem(installedTeam: 'AB12CD34EF', updateTeam: 'AB12CD34EF'), + isNull, + ); + }); + + test('another team may not', () { + expect( + signerMismatchProblem(installedTeam: 'AB12CD34EF', updateTeam: 'ZZ99ZZ99ZZ'), + allOf(contains('different developer'), contains('ZZ99ZZ99ZZ'), + contains('AB12CD34EF')), + ); + }); + + test('an ad-hoc or unsigned update may not replace a signed app', () { + expect( + signerMismatchProblem(installedTeam: 'AB12CD34EF', updateTeam: null), + contains('not signed by the developer'), + ); + }); + + test('an unsigned installed app has nothing to compare with', () { + expect(signerMismatchProblem(installedTeam: null, updateTeam: null), isNull); + expect( + signerMismatchProblem(installedTeam: null, updateTeam: 'AB12CD34EF'), + isNull, + ); + }); + }); +} diff --git a/test/core/updater/update_platform_installer_test.dart b/test/core/updater/update_platform_installer_test.dart index 686000f6..2e1253e4 100644 --- a/test/core/updater/update_platform_installer_test.dart +++ b/test/core/updater/update_platform_installer_test.dart @@ -47,6 +47,80 @@ void main() { expect(docker.packageManagerUpdateCommand, isNull); }); + test('an AUR / pacman install under /opt is updated outside the app', + () { + final ctx = UpdateInstallContext( + environment: const {}, + resolvedExecutable: '/opt/querya-desktop/querya_desktop', + listDirectory: (dir) => dir == '/var/lib/pacman/local' + ? const ['glibc-2.40-1', 'querya-desktop-0.4.19-1'] + : const [], + fileExists: (_) => false, + isDirectoryWritable: (_) => false, + ); + expect(ctx.linuxSystemPackageManager, 'pacman'); + expect(ctx.isManagedPackage, isTrue); + expect(ctx.packageManagerUpdateCommand, 'yay -Syu querya-desktop'); + expect(ctx.managedUpdateHint, contains('AUR')); + }, skip: !Platform.isLinux); + + test('a .deb install is detected from the dpkg database', () { + final ctx = UpdateInstallContext( + environment: const {}, + resolvedExecutable: '/opt/querya-desktop/querya_desktop', + listDirectory: (_) => const [], + fileExists: (path) => path == '/var/lib/dpkg/info/querya-desktop.list', + isDirectoryWritable: (_) => false, + ); + expect(ctx.linuxSystemPackageManager, 'dpkg'); + expect(ctx.packageManagerUpdateCommand, contains('apt')); + }, skip: !Platform.isLinux); + + test('a portable bundle elsewhere is not blocked by an installed package', + () { + final ctx = UpdateInstallContext( + environment: const {}, + resolvedExecutable: '/home/me/Querya/querya_desktop', + listDirectory: (_) => const ['querya-desktop-0.4.19-1'], + fileExists: (_) => true, + isDirectoryWritable: (_) => true, + ); + expect(ctx.linuxSystemPackageManager, isNull); + expect(ctx.isManagedPackage, isFalse); + }, skip: !Platform.isLinux); + + test('a bundle the user cannot write is blocked, with a hint and no command', + () { + final ctx = UpdateInstallContext( + environment: const {}, + resolvedExecutable: '/srv/apps/querya/querya_desktop', + listDirectory: (_) => const [], + fileExists: (_) => false, + isDirectoryWritable: (dir) => dir != '/srv/apps/querya', + ); + expect(ctx.isLinuxBundleReadOnly, isTrue); + expect(ctx.isManagedPackage, isTrue); + expect(ctx.packageManagerUpdateCommand, isNull); + expect(ctx.managedUpdateHint, contains('/srv/apps/querya')); + }, skip: !Platform.isLinux); + + test('the installer refuses a system install before the app quits', + () async { + final ctx = UpdateInstallContext( + environment: const {}, + resolvedExecutable: '/opt/querya-desktop/querya_desktop', + listDirectory: (_) => const ['querya-desktop-0.4.19-1'], + fileExists: (_) => false, + isDirectoryWritable: (_) => false, + ); + final installer = UpdatePlatformInstaller.forCurrentPlatform(context: ctx); + await expectLater( + installer.install(File('/tmp/Querya-Desktop-0.4.20-linux.zip')), + throwsA(isA() + .having((e) => e.manager, 'manager', 'pacman')), + ); + }, skip: !Platform.isLinux); + test('finds macOS .app bundle from executable path', () { expect( UpdateInstallContext.macAppBundlePathFromExecutable( @@ -73,6 +147,9 @@ void main() { expect(script, contains("! -name 'QueryaData'")); expect(script, contains("! -name '.portable'")); expect(script, contains('exec "\$EXE"')); + // A replace that cannot write relaunches the old app and logs why. + expect(script, contains('[ ! -w "\$DST" ]')); + expect(script, contains('querya-update.log')); }); test('resolves nested Flutter zip layout and refuses garbage', () async { diff --git a/test/core/widgets/virtual_selectable_text_view_test.dart b/test/core/widgets/virtual_selectable_text_view_test.dart index 3cf5439f..5cdfb20a 100644 --- a/test/core/widgets/virtual_selectable_text_view_test.dart +++ b/test/core/widgets/virtual_selectable_text_view_test.dart @@ -40,5 +40,65 @@ void main() { expect(find.text('Virtual Line 0'), findsOneWidget); expect(find.text('Virtual Line 1'), findsOneWidget); }); + + testWidgets('a new text re-splits the lines (#1532)', (tester) async { + final large = List.generate(50, (i) => 'Virtual Line $i').join('\n'); + var text = 'a\nb'; + late void Function(void Function()) rebuild; + + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: material.StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return VirtualSelectableTextView(text: text, threshold: 10); + }, + ), + ), + ), + ); + expect(find.byType(material.ListView), findsNothing); + + rebuild(() => text = large); + await tester.pump(); + expect(find.byType(material.ListView), findsOneWidget); + expect(find.text('Virtual Line 0'), findsOneWidget); + + rebuild(() => text = 'only\none'); + await tester.pump(); + expect(find.byType(material.ListView), findsNothing); + expect(find.text('only\none'), findsOneWidget); + }); + + testWidgets('a rebuild with the same text keeps the same lines (#1532)', + (tester) async { + final text = List.generate(50, (i) => 'Virtual Line $i').join('\n'); + var style = const material.TextStyle(fontSize: 12); + late void Function(void Function()) rebuild; + + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: material.StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return VirtualSelectableTextView( + text: text, + style: style, + threshold: 10, + ); + }, + ), + ), + ), + ); + expect(find.text('Virtual Line 0'), findsOneWidget); + + rebuild(() => style = const material.TextStyle(fontSize: 14)); + await tester.pump(); + expect(find.byType(material.ListView), findsOneWidget); + expect(find.text('Virtual Line 0'), findsOneWidget); + }); }); } diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart index 6f40d945..732962bd 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart @@ -1,13 +1,13 @@ import 'package:flutter/gestures.dart' show kSecondaryButton; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; import 'package:querya_desktop/core/motion/querya_hover_surface.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' show Text, TextField; import '../helpers/e2e_app_harness.dart'; import '../helpers/e2e_connection_helper.dart'; @@ -30,9 +30,7 @@ void main() { }; Finder byPlaceholder(String text) => find.byWidgetPredicate((w) => - w is TextField && - w.placeholder is Text && - (w.placeholder as Text).data == text); + w is QueryaTextField && w.placeholder == text); Finder inSidebar(String text) => find.descendant( of: find.byType(ConnectionsPanel), @@ -226,7 +224,8 @@ void main() { await tester.tap(inSidebar('Team Forms'), buttons: kSecondaryButton); await step(tester); - await tester.tap(find.text('New connection').last); + // ignore: avoid_print + await tester.tap(find.text('New connection').first); // The handler reads the folder id from disk before the dialog. await tester.pump(const Duration(milliseconds: 150)); await step(tester); diff --git a/test/e2e/02_sql_workspaces/e2e_transaction_guard_test.dart b/test/e2e/02_sql_workspaces/e2e_transaction_guard_test.dart index aa8b1bd6..32032e11 100644 --- a/test/e2e/02_sql_workspaces/e2e_transaction_guard_test.dart +++ b/test/e2e/02_sql_workspaces/e2e_transaction_guard_test.dart @@ -55,19 +55,23 @@ void main() { final delegate = _TxDelegate(); await ws.pump(tester, delegate); await E2eSqlWorkspace.settle(tester); - // Unknown until the first command refreshes the state. - expect(find.text('Auto-commit'), findsOneWidget); + // Unknown until the first command refreshes the state: the default, + // which has no badge; the Session menu names it. + expect(find.text('Transaction open'), findsNothing); + expect(find.byTooltip('Session: auto-commit'), findsOneWidget); await beginFromSessionMenu(tester); expect(find.text('Transaction open'), findsOneWidget); expect(find.text('OK: BEGIN'), findsWidgets); await tapAndSettle(tester, 'Rollback'); - expect(find.text('Auto-commit'), findsOneWidget); + expect(find.text('Transaction open'), findsNothing); + expect(find.byTooltip('Session: auto-commit'), findsOneWidget); await beginFromSessionMenu(tester); await tapAndSettle(tester, 'Commit'); - expect(find.text('Auto-commit'), findsOneWidget); + expect(find.text('Transaction open'), findsNothing); + expect(find.byTooltip('Session: auto-commit'), findsOneWidget); expect(delegate.commands, ['BEGIN', 'ROLLBACK', 'BEGIN', 'COMMIT']); }); diff --git a/test/e2e/04_nosql_workspaces/e2e_mongo_aggregation_runner_test.dart b/test/e2e/04_nosql_workspaces/e2e_mongo_aggregation_runner_test.dart index 86a53aec..4da95d10 100644 --- a/test/e2e/04_nosql_workspaces/e2e_mongo_aggregation_runner_test.dart +++ b/test/e2e/04_nosql_workspaces/e2e_mongo_aggregation_runner_test.dart @@ -4,7 +4,7 @@ import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/mongodb/mongo_aggregation_stage.dart'; import 'package:querya_desktop/features/mongodb/mongo_aggregation_workspace.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' show Switch; +import 'package:flutter/material.dart' show Switch; import '../../support/querya_theme_test_shell.dart'; diff --git a/test/e2e/04_nosql_workspaces/e2e_redis_cli_terminal_test.dart b/test/e2e/04_nosql_workspaces/e2e_redis_cli_terminal_test.dart index 213f6174..ad91ca42 100644 --- a/test/e2e/04_nosql_workspaces/e2e_redis_cli_terminal_test.dart +++ b/test/e2e/04_nosql_workspaces/e2e_redis_cli_terminal_test.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/redis/redis_cli_workspace.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; +import '../../support/redis_connection_test_fake.dart'; const _timeout = Timeout(Duration(seconds: 60)); @@ -54,7 +54,7 @@ void main() { } Future run(WidgetTester tester, String command) async { - await tester.enterText(find.byType(TextField), command); + await tester.enterText(find.byType(QueryaTextField), command); await tester.pump(); await tester.tap(find.text('Run')); await tester.pump(); @@ -62,7 +62,7 @@ void main() { } String input(WidgetTester tester) => - tester.widget(find.byType(TextField)).controller!.text; + tester.widget(find.byType(QueryaTextField)).controller!.text; testWidgets('Up / Down walk through the command history', timeout: _timeout, (tester) async { @@ -89,7 +89,7 @@ void main() { testWidgets('Tab completes a unique command name', timeout: _timeout, (tester) async { await open(tester); - await tester.enterText(find.byType(TextField), 'flushal'); + await tester.enterText(find.byType(QueryaTextField), 'flushal'); await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); diff --git a/test/e2e/04_nosql_workspaces/e2e_redis_keys_tree_and_types_test.dart b/test/e2e/04_nosql_workspaces/e2e_redis_keys_tree_and_types_test.dart index 9972586e..65525714 100644 --- a/test/e2e/04_nosql_workspaces/e2e_redis_keys_tree_and_types_test.dart +++ b/test/e2e/04_nosql_workspaces/e2e_redis_keys_tree_and_types_test.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/features/redis/redis_keys_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import '../../support/querya_theme_test_shell.dart'; +import '../../support/redis_connection_test_fake.dart'; const _timeout = Timeout(Duration(seconds: 60)); @@ -141,7 +142,7 @@ void main() { await tester.enterText( find.descendant( of: find.byType(QueryaModalDialog), - matching: find.byType(TextField), + matching: find.byType(QueryaTextField), ), '120', ); @@ -174,7 +175,7 @@ void main() { await tester.enterText( find.descendant( of: find.byType(QueryaModalDialog), - matching: find.byType(TextField), + matching: find.byType(QueryaTextField), ), '0', ); diff --git a/test/e2e/06_shell_theme_and_command_palette/e2e_command_palette_navigation_test.dart b/test/e2e/06_shell_theme_and_command_palette/e2e_command_palette_navigation_test.dart index 475a185b..eacfbda6 100644 --- a/test/e2e/06_shell_theme_and_command_palette/e2e_command_palette_navigation_test.dart +++ b/test/e2e/06_shell_theme_and_command_palette/e2e_command_palette_navigation_test.dart @@ -1,4 +1,5 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart' show ThemeMode, ValueKey; +import 'package:flutter/material.dart' as material; +import 'package:flutter/widgets.dart' show ValueKey; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; @@ -25,21 +26,21 @@ void main() { (tester) async { final controller = ThemeController.instance; // Settings I/O is real async work: run it outside the fake-async zone. - await tester.runAsync(() => controller.setThemeMode(ThemeMode.dark)); + await tester.runAsync(() => controller.setThemeMode(material.ThemeMode.dark)); await app.launch(tester); await E2ePalette.run(tester, 'dark', 'querya.theme.toggle'); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 300))); await E2eAppHarness.settle(tester); - expect(controller.themeMode, ThemeMode.light); + expect(controller.themeMode, material.ThemeMode.light); expect(E2ePalette.field, findsNothing, reason: 'palette closes after run'); await E2ePalette.run(tester, 'dark', 'querya.theme.toggle'); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 300))); await E2eAppHarness.settle(tester); - expect(controller.themeMode, ThemeMode.dark); + expect(controller.themeMode, material.ThemeMode.dark); await app.close(tester); }); diff --git a/test/e2e/helpers/e2e_command_palette_helper.dart b/test/e2e/helpers/e2e_command_palette_helper.dart index 57206b6e..641ee71d 100644 --- a/test/e2e/helpers/e2e_command_palette_helper.dart +++ b/test/e2e/helpers/e2e_command_palette_helper.dart @@ -1,6 +1,7 @@ import 'package:flutter/services.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; /// Drives the Command Palette (`Ctrl+P`) the way a user does. class E2ePalette { @@ -15,7 +16,7 @@ class E2ePalette { } static Future search(WidgetTester tester, String query) async { - await tester.enterText(find.byType(TextField).last, query); + await tester.enterText(find.byType(QueryaTextField).last, query); await tester.pump(const Duration(milliseconds: 50)); } diff --git a/test/features/command_palette/command_palette_dialog_test.dart b/test/features/command_palette/command_palette_dialog_test.dart index 6b5d020c..6a31b5e0 100644 --- a/test/features/command_palette/command_palette_dialog_test.dart +++ b/test/features/command_palette/command_palette_dialog_test.dart @@ -1,6 +1,8 @@ import 'dart:ui'; import 'package:flutter/services.dart'; +import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; import 'package:querya_desktop/core/actions/querya_command.dart'; import 'package:querya_desktop/core/actions/querya_command_registry.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; @@ -24,7 +26,7 @@ void main() { child: Builder( builder: (context) { return Center( - child: Button.primary( + child: material.ElevatedButton( onPressed: () => showCommandPalette(context), child: const Text('Open palette'), ), @@ -46,7 +48,7 @@ void main() { expect(find.text('Type a command…'), findsOneWidget); expect(find.text('Toggle Dark/Light Theme'), findsOneWidget); - await tester.enterText(find.byType(TextField), 'dark'); + await tester.enterText(find.byType(QueryaTextField), 'dark'); await tester.pump(); await tester.pump(const Duration(milliseconds: 20)); diff --git a/test/features/command_palette/palette_keyboard_scroll_test.dart b/test/features/command_palette/palette_keyboard_scroll_test.dart index adfa3539..6ee3d71a 100644 --- a/test/features/command_palette/palette_keyboard_scroll_test.dart +++ b/test/features/command_palette/palette_keyboard_scroll_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/services.dart'; +import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; import 'package:querya_desktop/core/actions/querya_command.dart'; import 'package:querya_desktop/core/actions/querya_command_registry.dart'; import 'package:querya_desktop/core/actions/querya_schema_object.dart'; @@ -32,7 +34,7 @@ Future _openDialog( child: Builder( builder: (context) { return Center( - child: Button.primary( + child: material.ElevatedButton( onPressed: () => open(context), child: const Text('Open dialog'), ), @@ -100,7 +102,7 @@ void main() { await _press(tester, LogicalKeyboardKey.arrowDown, 20); expect(_listOffset(tester), greaterThan(0)); - await tester.enterText(find.byType(TextField), 'Scroll test'); + await tester.enterText(find.byType(QueryaTextField), 'Scroll test'); await tester.pump(); await tester.pump(); diff --git a/test/features/command_palette/quick_switcher_dialog_test.dart b/test/features/command_palette/quick_switcher_dialog_test.dart index 43b049b2..edd2b3b3 100644 --- a/test/features/command_palette/quick_switcher_dialog_test.dart +++ b/test/features/command_palette/quick_switcher_dialog_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/services.dart'; +import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; import 'package:querya_desktop/core/actions/querya_command_host.dart'; import 'package:querya_desktop/core/actions/querya_schema_object.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; @@ -38,7 +40,7 @@ void main() { child: Builder( builder: (context) { return Center( - child: Button.primary( + child: material.ElevatedButton( onPressed: () => showQuickSwitcher( context, initialQuery: initialQuery, @@ -65,7 +67,7 @@ void main() { expect(find.text('Go to table, view, collection…'), findsOneWidget); expect(find.byKey(const ValueKey('pg:app:public:table:users')), findsOneWidget); - await tester.enterText(find.byType(TextField), 'users'); + await tester.enterText(find.byType(QueryaTextField), 'users'); await tester.pump(); await tester.pump(const Duration(milliseconds: 20)); @@ -99,7 +101,7 @@ void main() { child: Builder( builder: (context) { return Center( - child: Button.primary( + child: material.ElevatedButton( onPressed: () => showQuickSwitcher( context, seed: const [], diff --git a/test/features/connections/connection_form_sections_test.dart b/test/features/connections/connection_form_sections_test.dart index 975906a4..b8ca1048 100644 --- a/test/features/connections/connection_form_sections_test.dart +++ b/test/features/connections/connection_form_sections_test.dart @@ -1,6 +1,7 @@ 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:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/features/connections/forms/connection_auth_section.dart'; import 'package:querya_desktop/features/connections/forms/connection_database_section.dart'; import 'package:querya_desktop/features/connections/forms/connection_host_port_section.dart'; @@ -8,10 +9,10 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; void main() { Widget buildApp(Widget child) { - return ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + return MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Scaffold( body: material.SingleChildScrollView( child: child, diff --git a/test/features/connections/connection_tree_quick_search_test.dart b/test/features/connections/connection_tree_quick_search_test.dart index 1c7b7bca..13dcdda9 100644 --- a/test/features/connections/connection_tree_quick_search_test.dart +++ b/test/features/connections/connection_tree_quick_search_test.dart @@ -5,7 +5,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -47,8 +48,8 @@ void main() { bool cleared = false; Widget buildWidget({required int filtered, required int total}) { - return ShadcnApp( - theme: AppTheme.dark, + return MaterialApp( + theme: queryaDarkMaterialTheme, home: material.Scaffold( body: material.SizedBox( width: 300, @@ -190,8 +191,9 @@ void main() { addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + builder: (context, child) => material.Material(child: child), home: material.SizedBox( width: 400, height: 700, @@ -230,8 +232,9 @@ void main() { addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + builder: (context, child) => material.Material(child: child), home: material.SizedBox( width: 400, height: 700, @@ -270,8 +273,9 @@ void main() { addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + builder: (context, child) => material.Material(child: child), home: material.SizedBox( width: 400, height: 700, diff --git a/test/features/connections/connections_panel_layout_test.dart b/test/features/connections/connections_panel_layout_test.dart index 1e2c4982..e81c5f41 100644 --- a/test/features/connections/connections_panel_layout_test.dart +++ b/test/features/connections/connections_panel_layout_test.dart @@ -5,7 +5,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -99,10 +100,11 @@ void main() { await pumpWidgetWithSurfaceSize( tester, entry.value, - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, + builder: (context, child) => material.Material(child: child), home: material.SizedBox.expand( child: ConnectionsPanel( onPostgresOpenSqlWorkspace: (_, @@ -185,10 +187,11 @@ void main() { addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, + builder: (context, child) => material.Material(child: child), home: material.SizedBox.expand( child: ConnectionsPanel( skipInitialDbLoadForTest: true, @@ -281,8 +284,9 @@ void main() { testWidgets('connect, disconnect, disconnectAll, and disconnectOthers update state', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + builder: (context, child) => material.Material(child: child), home: material.SizedBox.expand( child: ConnectionsPanel( skipInitialDbLoadForTest: true, @@ -372,10 +376,11 @@ void main() { await pumpWidgetWithSurfaceSize( tester, const material.Size(400, 600), - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, + builder: (context, child) => material.Material(child: child), home: const material.SizedBox( width: 400, height: 600, @@ -397,10 +402,11 @@ void main() { await pumpWidgetWithSurfaceSize( tester, const material.Size(400, 600), - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, + builder: (context, child) => material.Material(child: child), home: material.SizedBox( width: 400, height: 600, @@ -424,10 +430,11 @@ void main() { await pumpWidgetWithSurfaceSize( tester, const material.Size(400, 600), - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, + builder: (context, child) => material.Material(child: child), home: const material.SizedBox( width: 400, height: 600, diff --git a/test/features/connections/new_connection_url_dialog_test.dart b/test/features/connections/new_connection_url_dialog_test.dart index 81497c21..14babc3a 100644 --- a/test/features/connections/new_connection_url_dialog_test.dart +++ b/test/features/connections/new_connection_url_dialog_test.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; @@ -90,7 +90,7 @@ void main() { await tester.pumpAndSettle(); await tester.enterText( - find.byType(TextField), + find.byType(QueryaTextField), 'postgresql://user:pass@localhost:5432/mydb', ); await tester.tap(find.text('Create')); diff --git a/test/features/connections/querya_connection_tree_row_test.dart b/test/features/connections/querya_connection_tree_row_test.dart new file mode 100644 index 00000000..4b89d72e --- /dev/null +++ b/test/features/connections/querya_connection_tree_row_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/features/connections/querya_connection_tree_row.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('QueryaConnectionTreeRow', () { + testWidgets('renders pinned star icon with workbench.warning (#1643)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + data: QueryaTheme.darkDefault, + child: const material.Material( + child: QueryaConnectionTreeRow( + label: 'Production DB', + isPinned: true, + textStyle: material.TextStyle(fontSize: 13), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final starIconFinder = find.byIcon(material.Icons.star_rounded); + expect(starIconFinder, findsOneWidget); + + final starIcon = tester.widget(starIconFinder); + expect(starIcon.color, QueryaTheme.darkDefault.workbench.warning); + }); + + testWidgets('adapts pinned star icon to light theme workbench.warning (#1643)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + data: QueryaTheme.lightDefault, + child: const material.Material( + child: QueryaConnectionTreeRow( + label: 'Staging DB', + isPinned: true, + textStyle: material.TextStyle(fontSize: 13), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final starIconFinder = find.byIcon(material.Icons.star_rounded); + expect(starIconFinder, findsOneWidget); + + final starIcon = tester.widget(starIconFinder); + expect(starIcon.color, QueryaTheme.lightDefault.workbench.warning); + }); + }); +} diff --git a/test/features/connections/sqlite_connection_form_test.dart b/test/features/connections/sqlite_connection_form_test.dart index 4aa0dcad..c8a70ca9 100644 --- a/test/features/connections/sqlite_connection_form_test.dart +++ b/test/features/connections/sqlite_connection_form_test.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -11,10 +12,10 @@ void main() { (tester) async { await tester.binding.setSurfaceSize(const Size(800, 600)); await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (context) => material.ElevatedButton( onPressed: () => showSqliteConnectionForm(context), @@ -50,10 +51,10 @@ void main() { ); await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (context) => material.ElevatedButton( onPressed: () => diff --git a/test/features/erd/erd_groups_view_test.dart b/test/features/erd/erd_groups_view_test.dart index 873c7f77..0d03a79e 100644 --- a/test/features/erd/erd_groups_view_test.dart +++ b/test/features/erd/erd_groups_view_test.dart @@ -125,6 +125,7 @@ void main() { await t.pump(const Duration(milliseconds: 300)); await t.tap(find.byKey(const material.ValueKey('erd_group_ungroup_g1'))); await t.pump(const Duration(milliseconds: 300)); + await t.pump(); expect(_frame, findsNothing); await t.pump(const Duration(seconds: 1)); expect(store.layouts[_key]!.groups, isEmpty); @@ -173,6 +174,7 @@ void main() { await t.pump(const Duration(milliseconds: 300)); await t.tap(find.byKey(const material.ValueKey('erd_menu_ungroup_tags'))); await t.pump(const Duration(milliseconds: 300)); + await t.pump(); expect(_frame, findsNothing); }); diff --git a/test/features/erd/erd_layout_persistence_test.dart b/test/features/erd/erd_layout_persistence_test.dart index d9bf63a8..41f19f78 100644 --- a/test/features/erd/erd_layout_persistence_test.dart +++ b/test/features/erd/erd_layout_persistence_test.dart @@ -30,7 +30,7 @@ const _key = ErdLayoutKey(connectionId: 1, scope: 'shop'); FakeSqlExecutionDelegate _catalog({bool withPayments = false}) => FakeSqlExecutionDelegate(onExecute: (sql) { - if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { return SqlExecutionResult(rows: [ const ['users', 'id', 'INTEGER', '1'], const ['orders', 'user_id', 'INTEGER', '0'], @@ -45,6 +45,10 @@ FakeSqlExecutionDelegate _catalog({bool withPayments = false}) => Finder _card(String table) => find.byKey(material.ValueKey('erd_table_$table')); +/// [sql] without the table-name filter `columnsSql(tables: ...)` adds (#1572). +String _unfiltered(String sql) => sql.replaceAll( + RegExp(r"\n AND (c\.relname|c\.table_name|m\.name) IN \([^)]*\)"), ''); + void main() { Future pumpDiagram( WidgetTester t, diff --git a/test/features/erd/erd_relation_painter_test.dart b/test/features/erd/erd_relation_painter_test.dart new file mode 100644 index 00000000..b62e98a9 --- /dev/null +++ b/test/features/erd/erd_relation_painter_test.dart @@ -0,0 +1,98 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_router.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_relation_painter.dart'; + +ErdRoute _route(String from, String to, {bool oneToOne = false}) => ErdRoute( + ErdRelation( + fromTable: from, + fromColumn: 'id', + toTable: to, + toColumn: 'id', + oneToOne: oneToOne, + ), + const [Offset(0, 0), Offset(40, 0), Offset(40, 60), Offset(120, 60)], + ); + +void _paint(ErdRelationPainter painter) { + final recorder = ui.PictureRecorder(); + painter.paint(Canvas(recorder), const Size(200, 100)); + recorder.endRecording().dispose(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final routes = [_route('a', 'b'), _route('b', 'c', oneToOne: true)]; + + // The painter reuses one Paint, the rounded paths and the laid-out labels + // (#1518); these pin that every state still draws. + group('ErdRelationPainter', () { + test('paints with nothing focused, a focused table and a picked relation', + () { + const blue = Color(0xFF2196F3); + const orange = Color(0xFFFF9800); + _paint(ErdRelationPainter( + routes: routes, color: blue, highlight: orange, focus: null)); + _paint(ErdRelationPainter( + routes: routes, color: blue, highlight: orange, focus: 'b')); + _paint(ErdRelationPainter( + routes: routes, + color: blue, + highlight: orange, + focus: null, + picked: routes.first.relation)); + }); + + test('repeated paints of the same routes keep working', () { + final painter = ErdRelationPainter( + routes: routes, + color: Colors.grey, + highlight: Colors.red, + focus: 'a', + ); + for (var i = 0; i < 5; i++) { + _paint(painter); + } + }); + + test('more distinct colours than the label cache holds still paint', () { + for (var i = 0; i < 40; i++) { + _paint(ErdRelationPainter( + routes: routes, + color: Color(0xFF000000 + i * 0x010203), + highlight: Color(0xFF800000 + i), + focus: i.isEven ? 'a' : null, + )); + } + }); + + test('a route with fewer than two points is skipped', () { + _paint(ErdRelationPainter( + routes: [ + ErdRoute(routes.first.relation, const [Offset(1, 1)]), + ...routes, + ], + color: Colors.grey, + highlight: Colors.red, + focus: null, + )); + }); + + test('repaints only when something it draws changed', () { + ErdRelationPainter make({String? focus, List? r}) => + ErdRelationPainter( + routes: r ?? routes, + color: Colors.grey, + highlight: Colors.red, + focus: focus, + ); + expect(make().shouldRepaint(make()), isFalse); + expect(make(focus: 'a').shouldRepaint(make()), isTrue); + expect(make(r: [...routes]).shouldRepaint(make()), isTrue); + }); + }); +} diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index b5b140a7..ed4496be 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -13,6 +13,7 @@ import 'package:querya_desktop/core/erd/erd_layout.dart'; import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/erd/erd_router.dart'; import 'package:querya_desktop/features/erd/erd_view.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_search_panel.dart'; import 'package:querya_desktop/features/erd/erd_source.dart'; import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; @@ -33,6 +34,11 @@ ErdSchema sample() => ErdSchema.fromCatalog( ], ); +/// [sql] without the table-name filter `columnsSql(tables: ...)` adds, so a +/// fake can recognise the catalog query whatever tables it asks for. +String _unfiltered(String sql) => sql.replaceAll( + RegExp(r"\n AND (c\.relname|c\.table_name|m\.name) IN \([^)]*\)"), ''); + void main() { group('ErdSchema.fromCatalog', () { test('marks PK/FK and drops relations to unknown tables', () { @@ -148,7 +154,7 @@ void main() { test('loadNeighbourhood loads only the tables around the one asked for', () async { final delegate = FakeSqlExecutionDelegate(onExecute: (sql) { - if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { return const SqlExecutionResult(rows: [ ['a', 'id', 'INTEGER', '1'], ['b', 'id', 'INTEGER', '1'], @@ -181,7 +187,7 @@ void main() { ['users', 'org_id', 'orgs', 'id'], ]); } - if (sql == ErdCatalog.columnsSql(SqlDialect.postgres)) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.postgres)) { return const SqlExecutionResult(rows: [ ['users', 'id', 'integer', '1'], ['users', 'org_id', 'integer', '0'], @@ -205,7 +211,7 @@ void main() { test('a PostgreSQL table with no keys is found by its columns', () async { final delegate = FakeSqlExecutionDelegate(onExecute: (sql) { - if (sql == ErdCatalog.columnsSql(SqlDialect.postgres, schema: 'public')) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.postgres, schema: 'public')) { return const SqlExecutionResult(rows: [ ['lonely', 'id', 'integer', '1'], ['other', 'id', 'integer', '1'], @@ -230,13 +236,13 @@ void main() { ['sales.orders', 'customer_id', 'customers', 'id'], ]); } - if (sql == ErdCatalog.columnsSql(SqlDialect.postgres, schema: 'sales')) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.postgres, schema: 'sales')) { return const SqlExecutionResult(rows: [ ['sales.orders', 'id', 'integer', '1'], ['sales.orders', 'customer_id', 'integer', '0'], ]); } - if (sql == ErdCatalog.columnsSql(SqlDialect.postgres)) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.postgres)) { return const SqlExecutionResult(rows: [ ['customers', 'id', 'integer', '1'], ['users', 'id', 'integer', '1'], @@ -253,9 +259,66 @@ void main() { expect(s.relations, hasLength(1)); }); - test('load runs both queries through the delegate', () async { + test('the neighbourhood asks only for its tables (#1572)', () async { + final asked = []; final delegate = FakeSqlExecutionDelegate(onExecute: (sql) { + if (sql == ErdCatalog.foreignKeysSql(SqlDialect.sqlite)) { + return const SqlExecutionResult(rows: [ + ['b', 'a_id', 'a', 'id'], + ]); + } + asked.add(sql); + return const SqlExecutionResult(); + }); + await ErdCatalog.loadNeighbourhood(delegate, SqlDialect.sqlite, table: 'a'); + expect(asked.single, contains("AND m.name IN ('a', 'b')")); + }); + + test('a schema larger than the row limit does not lose the tables asked for', + () async { + // The whole-schema query is cut at the row limit before `target`; the + // filtered one returns it. + final delegate = FakeSqlExecutionDelegate(onExecute: (sql) { + if (sql.contains("m.name IN ('target')")) { + return const SqlExecutionResult(rows: [ + ['target', 'id', 'INTEGER', '1'], + ['target', 'name', 'TEXT', '0'], + ]); + } if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + return SqlExecutionResult( + isTruncated: true, + rows: [for (var i = 0; i < 100; i++) ['t$i', 'id', 'INTEGER', '1']], + ); + } + return const SqlExecutionResult(); + }); + final s = await ErdCatalog.loadNeighbourhood( + delegate, + SqlDialect.sqlite, + table: 'target', + ); + expect(s.tables.single.columns.map((c) => c.name), ['id', 'name']); + expect(s.truncated, isFalse); + }); + + test('columnsSql filters by table name in every dialect', () { + expect( + ErdCatalog.columnsSql(SqlDialect.postgres, schema: 'sales', tables: ["o'rders"]), + allOf(contains("nspname = 'sales'"), contains("AND c.relname IN ('o''rders')")), + ); + expect(ErdCatalog.columnsSql(SqlDialect.mysql, tables: ['a', 'b']), + contains("AND c.table_name IN ('a', 'b')")); + expect(ErdCatalog.viewColumnsSql(SqlDialect.sqlite, tables: ['v']), + contains("AND m.name IN ('v')")); + expect(ErdCatalog.columnsSql(SqlDialect.sqlite, tables: const []), + contains('AND 1 = 0')); + expect(ErdCatalog.columnsSql(SqlDialect.sqlite), isNot(contains(' IN ('))); + }); + + test('load runs both queries through the delegate', () async { + final delegate = FakeSqlExecutionDelegate(onExecute: (sql) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { return const SqlExecutionResult(rows: [ ['a', 'id', 'INTEGER', '1'], ]); @@ -655,7 +718,7 @@ void main() { group('ErdView', () { FakeSqlExecutionDelegate delegate() => FakeSqlExecutionDelegate(onExecute: (sql) { - if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { return const SqlExecutionResult(rows: [ ['users', 'id', 'INTEGER', '1'], ['orders', 'user_id', 'INTEGER', '0'], @@ -902,7 +965,7 @@ void main() { (t) async { await t.binding.setSurfaceSize(const material.Size(1200, 800)); final wide = FakeSqlExecutionDelegate(onExecute: (sql) { - if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { return SqlExecutionResult(rows: [ for (var i = 0; i < 20; i++) ['t$i', 'id', 'INTEGER', '1'], ['child', 'id', 'INTEGER', '1'], @@ -942,6 +1005,67 @@ void main() { expect(erdCardBuilds, lessThanOrEqualTo(3)); }); + testWidgets('a diagram of many tables mounts its cards over several frames', + (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + final big = FakeSqlExecutionDelegate(onExecute: (sql) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + return SqlExecutionResult(rows: [ + for (var i = 0; i < 100; i++) ['t$i', 'id', 'INTEGER', '1'], + ]); + } + return const SqlExecutionResult(); + }); + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: big, dialect: SqlDialect.sqlite)), + )); + int cards() => find + .byWidgetPredicate((w) => + w.key is material.ValueKey && + (w.key! as material.ValueKey).value.startsWith('erd_table_t')) + .evaluate() + .length; + await t.pump(); + await t.pump(); + expect(cards(), inInclusiveRange(1, 99), + reason: 'the first frame does not carry every card'); + for (var i = 0; i < 10; i++) { + await t.pump(); + } + expect(cards(), 100); + }); + + testWidgets('opening the search rebuilds the diagram but not its cards', + (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + final wide = FakeSqlExecutionDelegate(onExecute: (sql) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + return SqlExecutionResult(rows: [ + for (var i = 0; i < 20; i++) ['t$i', 'id', 'INTEGER', '1'], + ]); + } + return const SqlExecutionResult(); + }); + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: wide, dialect: SqlDialect.sqlite)), + )); + await t.pump(); + await t.pump(); + await t.pump(); + + // Ctrl+F sets state on the diagram; no card looks different afterwards. + erdCardBuilds = 0; + await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await t.sendKeyEvent(LogicalKeyboardKey.keyF); + await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await t.pump(); + expect(find.byType(ErdSearchPanel), findsOneWidget); + expect(erdCardBuilds, 0, + reason: 'the diagram rebuilt, the 20 cards were reused'); + }); + testWidgets('Copy Mermaid puts the diagram on the clipboard', (t) async { await t.binding.setSurfaceSize(const material.Size(1200, 800)); String? copied; @@ -1051,7 +1175,7 @@ void main() { (t) async { await t.binding.setSurfaceSize(const material.Size(1200, 800)); final junction = FakeSqlExecutionDelegate(onExecute: (sql) { - if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { return const SqlExecutionResult(rows: [ ['orders', 'id', 'INTEGER', '1'], ['order_items', 'order_id', 'INTEGER', '1'], @@ -1087,7 +1211,7 @@ void main() { /// users <- orders; lonely has no relation. FakeSqlExecutionDelegate threeTables() => FakeSqlExecutionDelegate(onExecute: (sql) { - if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { return const SqlExecutionResult(rows: [ ['users', 'id', 'INTEGER', '1'], ['orders', 'id', 'INTEGER', '1'], @@ -1180,7 +1304,7 @@ void main() { testWidgets('a large diagram fits the viewport on first load', (t) async { await t.binding.setSurfaceSize(const material.Size(1200, 800)); final tall = FakeSqlExecutionDelegate(onExecute: (sql) { - if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { return SqlExecutionResult(rows: [ for (final table in ['a', 'b', 'c']) for (var i = 0; i < 400; i++) [table, 'col$i', 'INTEGER', '0'], @@ -1244,7 +1368,7 @@ void main() { await t.binding.setSurfaceSize(const material.Size(1200, 800)); String? opened; final sales = FakeSqlExecutionDelegate(onExecute: (sql) { - if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { return const SqlExecutionResult(rows: [ ['sales.orders', 'id', 'INTEGER', '1'], ]); @@ -1335,7 +1459,7 @@ void main() { (t) async { await t.binding.setSurfaceSize(const material.Size(1200, 800)); final wide = FakeSqlExecutionDelegate(onExecute: (sql) { - if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { return const SqlExecutionResult(rows: [ ['users', 'id', 'INTEGER', '1'], ['orders', 'id', 'INTEGER', '1'], @@ -1414,7 +1538,7 @@ void main() { test('a cut catalog is reported as truncated', () async { final delegate = FakeSqlExecutionDelegate(onExecute: (sql) { - if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + if (_unfiltered(sql) == ErdCatalog.columnsSql(SqlDialect.sqlite)) { return const SqlExecutionResult( rows: [ ['users', 'id', 'INTEGER', '1'], diff --git a/test/features/erd/erd_view_controller_test.dart b/test/features/erd/erd_view_controller_test.dart new file mode 100644 index 00000000..c4109c33 --- /dev/null +++ b/test/features/erd/erd_view_controller_test.dart @@ -0,0 +1,362 @@ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:querya_desktop/features/erd/erd_view_controller.dart'; + +/// The diagram's user state and its rules, without a widget tree (#1690). +void main() { + late ErdViewController c; + setUp(() => c = ErdViewController()); + + const names = ['users', 'orders', 'items', 'tags']; + + group('groups', () { + test('a new group takes the tables out of other groups and clears marks', + () { + c.marked.addAll({'users', 'orders'}); + final first = c.createGroup( + tables: {'users', 'orders'}, tableNames: names, name: 'Core'); + expect(c.groupOf('users')?.name, 'Core'); + expect(c.marked, isEmpty); + + final second = c.createGroup( + tables: {'orders', 'items'}, tableNames: names, name: 'Sales'); + expect(second, isNot(first)); + expect(c.groupOf('orders')?.name, 'Sales'); + expect(c.groupOf('users')?.name, 'Core'); + expect(c.groups.last.id, second); + }); + + test('adding to a missing group changes nothing and keeps the marks', () { + c.marked.add('tags'); + c.addToGroup('nope', {'tags'}); + expect(c.groups, isEmpty); + expect(c.marked, {'tags'}); + }); + + test('adding to a group clears the marks; update finds by id', () { + final id = c.createGroup( + tables: {'users'}, tableNames: names, name: 'A'); + c.marked.add('tags'); + c.addToGroup(id, {'tags'}); + expect(c.groupOf('tags')?.id, id); + expect(c.marked, isEmpty); + + expect(c.updateGroup(id, (g) => g.copyWith(name: 'B')), isTrue); + expect(c.groups.single.name, 'B'); + expect(c.updateGroup('nope', (g) => g), isFalse); + }); + + test('a table leaving its only group removes the empty group', () { + final id = c.createGroup( + tables: {'users'}, tableNames: names, name: 'A'); + c.removeFromGroup('users'); + expect(c.groups, isEmpty); + c.createGroup(tables: {'users'}, tableNames: names, name: 'A'); + c.ungroup(c.groups.single.id); + expect(c.groups, isEmpty); + expect(id, isNotEmpty); + }); + + test('allGroups adds one group per schema when there are several', () { + final schema = ErdSchema(tables: [ + for (final n in ['a.users', 'a.orders', 'b.logs']) + ErdTable(name: n, columns: const []), + ], relations: const []); + final all = c.allGroups(schema); + expect(all.length, greaterThan(c.groups.length)); + expect(c.allGroups(null), isEmpty); + }); + }); + + group('marks, density, colours', () { + test('toggleMarked marks and unmarks', () { + c.toggleMarked('users'); + expect(c.marked, {'users'}); + c.toggleMarked('users'); + expect(c.marked, isEmpty); + }); + + test('hiding a table drops its mark and a pick on it', () { + c.marked.addAll({'users', 'tags'}); + c.selected = 'users'; + c.hide('users'); + expect(c.hidden, {'users'}); + expect(c.marked, {'tags'}); + expect(c.selected, isNull); + + c.selected = 'tags'; + c.hide('items'); + expect(c.selected, 'tags'); + }); + + test('collapsing toggles; a header colour is set and reset', () { + c.toggleCollapsed('users'); + expect(c.collapsed, {'users'}); + c.toggleCollapsed('users'); + expect(c.collapsed, isEmpty); + c.setHeaderColor('users', 'type2'); + expect(c.headerColors['users'], 'type2'); + c.setHeaderColor('users', null); + expect(c.headerColors, isEmpty); + }); + + test('applying a saved state replaces density, groups and notes; colours ' + 'only when asked', () { + c.headerColors['users'] = 'type1'; + c.notes.add(const ErdNote(id: 'n9', text: 'old', x: 0, y: 0)); + const saved = ErdSavedLayout( + collapsed: {'orders'}, + hidden: {'tags'}, + detail: ErdDetail.keys, + headerColors: {'orders': 'type3'}, + notes: [ErdNote(id: 'n1', text: 'new', x: 1, y: 2)], + ); + c.applyState(saved, colours: false); + expect(c.collapsed, {'orders'}); + expect(c.hidden, {'tags'}); + expect(c.detail, ErdDetail.keys); + expect(c.notes.single.id, 'n1'); + expect(c.headerColors, {'users': 'type1'}); + + c.applyState(saved, colours: true); + expect(c.headerColors, {'orders': 'type3'}); + }); + }); + + group('notes', () { + test('update, delete, move and resize find the note by id', () { + c.addNote(const ErdNote(id: 'n1', text: 'a', x: 100, y: 100)); + expect(c.updateNote('n1', (n) => n.copyWith(text: 'b')), isTrue); + expect(c.notes.single.text, 'b'); + expect(c.updateNote('zz', (n) => n), isFalse); + + c.moveNote('n1', const Offset(20, 40), 2); // zoom 2: half the canvas move + expect(c.notes.single.x, 110); + expect(c.notes.single.y, 120); + c.resizeNote('n1', const Offset(40, 20), 2); + expect(c.notes.single.width, ErdNote.defaultWidth + 20); + c.moveNote('zz', const Offset(1, 1), 1); + + c.deleteNote('n1'); + expect(c.notes, isEmpty); + }); + + test('a note attached to a table follows it; a loose one stays', () { + final schema = ErdSchema(tables: [ + for (final n in ['a', 'b']) ErdTable(name: n, columns: const []), + ], relations: const []); + final before = ErdLayout.compute(schema); + final after = before.withPosition('a', before.positions['a']! + const Offset(30, 10)); + c.notes.addAll([ + const ErdNote(id: 'n1', text: '', x: 5, y: 5, attachedTo: 'a'), + const ErdNote(id: 'n2', text: '', x: 5, y: 5), + ]); + c.followTables(before, after); + expect(c.notes[0].x, 35); + expect(c.notes[0].y, 15); + expect(c.notes[1].x, 5); + }); + + test('the canvas grows to hold a note placed beyond the cards', () { + const schema = ErdSchema( + tables: [ErdTable(name: 'a', columns: [])], relations: []); + final layout = ErdLayout.compute(schema); + final plain = c.canvasSize(layout); + expect(plain, layout.size); + c.notes.add(const ErdNote(id: 'far', text: '', x: 5000, y: 4000)); + final grown = c.canvasSize(layout); + expect(grown.width, 5000 + ErdNote.defaultWidth + ErdLayout.margin); + expect(grown.height, 4000 + ErdNote.defaultHeight + ErdLayout.margin); + }); + }); + + group('saved views', () { + ErdSavedView view(String id, String name) => ErdSavedView( + id: id, name: name, tables: const {}, layout: const ErdSavedLayout()); + + test('ids count up and fill a gap', () { + expect(c.nextViewId(), 'v1'); + c.views.addAll([view('v1', 'A'), view('v3', 'C')]); + expect(c.nextViewId(), 'v2'); + }); + + test('rename and remove by id', () { + c.views.add(view('v1', 'A')); + expect(c.renameView('v1', 'Z'), isTrue); + expect(c.viewById('v1')?.name, 'Z'); + expect(c.renameView('v9', 'Q'), isFalse); + expect(c.viewById(null), isNull); + c.removeView('v1'); + expect(c.views, isEmpty); + }); + }); + + group('picks and Esc', () { + test('picking a table drops a picked relation and the other way round', () { + const r = ErdRelation( + fromTable: 'orders', + fromColumn: 'user_id', + toTable: 'users', + toColumn: 'id'); + c.pickRelation(r); + expect(c.pickedRelation, r); + c.pick('users'); + expect(c.selected, 'users'); + expect(c.pickedRelation, isNull); + c.pickRelation(r); + expect(c.selected, isNull); + }); + + test('clearPicks says whether there was anything to clear', () { + expect(c.clearPicks(), isFalse); + c.marked.add('users'); + expect(c.clearPicks(), isTrue); + expect(c.marked, isEmpty); + }); + + test('Esc takes one step per press, in order', () { + const r = ErdRelation( + fromTable: 'a', fromColumn: 'x', toTable: 'b', toColumn: 'y'); + c + ..searchOpen = true + ..query = 'ord' + ..pickedRelation = r + ..marked.add('a') + ..selected = 'b'; + expect(c.escape(), ErdEscape.closedSearch); + expect(c.query, isEmpty); + expect(c.selected, 'b', reason: 'closing the search keeps the pick'); + expect(c.escape(), ErdEscape.droppedRelation); + expect(c.escape(), ErdEscape.clearedMarks); + expect(c.escape(), ErdEscape.clearedPick); + expect(c.escape(), ErdEscape.nothing); + }); + }); + + group('focus', () { + test('the dragged table beats the picked one, then the hovered one', () { + expect(c.currentFocus('h'), 'h'); + c.selected = 's'; + expect(c.currentFocus('h'), 's'); + c.dragging = 'd'; + expect(c.currentFocus('h'), 'd'); + }); + + test('a pick fades the cards that are not next to it', () { + c.neighbours = { + 'users': {'orders'}, + 'orders': {'users'}, + 'tags': {}, + }; + expect(c.cardFocusFor('tags', null).faded, isFalse, + reason: 'nothing is picked'); + c.selected = 'users'; + expect(c.cardFocusFor('orders', 'users').highlighted, isTrue); + expect(c.cardFocusFor('tags', 'users').faded, isTrue); + }); + + test('a picked relation lights its two tables and names their columns', + () { + c.pickedRelation = const ErdRelation( + fromTable: 'orders', + fromColumn: 'user_id', + toTable: 'users', + toColumn: 'id'); + final from = c.cardFocusFor('orders', null); + expect(from.highlighted, isTrue); + expect(from.column, 'user_id'); + expect(c.cardFocusFor('users', null).column, 'id'); + final other = c.cardFocusFor('tags', null); + expect(other.faded, isTrue); + expect(other.column, isNull); + }); + }); + + group('saving and views', () { + const here = Offset(10, 20); + + test('a snapshot copies the state with the positions and the viewport', () { + c + ..collapsed.add('users') + ..hidden.add('tags') + ..detail = ErdDetail.keys + ..headerColors['users'] = 'type2'; + final snap = c.snapshot( + positions: {'users': here}, scale: 1.5, translation: const Offset(3, 4)); + expect(snap.positions, {'users': here}); + expect(snap.collapsed, {'users'}); + expect(snap.hidden, {'tags'}); + expect(snap.detail, ErdDetail.keys); + expect(snap.scale, 1.5); + expect(snap.translation, const Offset(3, 4)); + + // A copy: later changes do not reach it. + c.collapsed.add('orders'); + expect(snap.collapsed, {'users'}); + }); + + test('with no active view the snapshot becomes the diagram own state', () { + final snap = c.snapshot(positions: {}, scale: 1, translation: Offset.zero); + c.capture(snap, names); + expect(c.base, same(snap)); + }); + + test('with a view active the snapshot goes to that view, tables minus hidden', + () { + final id = c.addViewFromCurrent( + name: 'Sales', + current: c.snapshot(positions: {}, scale: 1, translation: Offset.zero), + tableNames: names, + ); + c.hidden.add('tags'); + final snap = c.snapshot( + positions: {'users': here}, scale: 2, translation: Offset.zero); + c.capture(snap, names); + final view = c.viewById(id)!; + expect(view.layout.positions, {'users': here}); + expect(view.tables, {'users', 'orders', 'items'}); + expect(view.name, 'Sales'); + }); + + test('a new view from All tables shows every table there afterwards', () { + c.hidden.add('tags'); + c.base = c.snapshot(positions: {}, scale: 1, translation: Offset.zero); + final id = c.addViewFromCurrent( + name: 'Core', + current: c.snapshot(positions: {}, scale: 1, translation: Offset.zero), + tableNames: names, + ); + expect(c.activeView, id); + expect(c.base.hidden, isEmpty, reason: 'All tables always shows all'); + expect(c.viewById(id)!.tables, isNot(contains('tags'))); + }); + + test('stateOfView: null is All tables, an id is its view, a stranger is none', + () { + expect(c.stateOfView(null, names.toSet()), same(c.base)); + final id = c.addViewFromCurrent( + name: 'V', + current: c.snapshot(positions: {}, scale: 1, translation: Offset.zero), + tableNames: names, + ); + expect(c.stateOfView(id, names.toSet()), isNotNull); + expect(c.stateOfView('v99', names.toSet()), isNull); + }); + + test('toStore carries the views, the active one and the colours', () { + c.headerColors['users'] = 'type4'; + final id = c.addViewFromCurrent( + name: 'V', + current: c.snapshot(positions: {}, scale: 1, translation: Offset.zero), + tableNames: names, + ); + final stored = c.toStore(); + expect(stored.views.single.id, id); + expect(stored.activeView, id); + expect(stored.headerColors, {'users': 'type4'}); + }); + }); +} diff --git a/test/features/erd/erd_viewport_test.dart b/test/features/erd/erd_viewport_test.dart new file mode 100644 index 00000000..f55299a0 --- /dev/null +++ b/test/features/erd/erd_viewport_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/erd/erd_viewport.dart'; + +void main() { + group('zoom and pan', () { + late ErdViewport v; + setUp(() => v = ErdViewport()); + tearDown(() => v.dispose()); + + test('set and read back the scale and the translation', () { + v.set(scale: 1.5, translation: const Offset(30, 40)); + expect(v.scale, closeTo(1.5, 1e-9)); + expect(v.translation, const Offset(30, 40)); + }); + + test('zooming about a point keeps that point still', () { + v.set(scale: 1, translation: const Offset(10, 10)); + const focal = Offset(200, 100); + final before = v.toCanvas(focal); + v.zoomTo(2, focal); + expect(v.scale, closeTo(2, 1e-9)); + final after = v.toCanvas(focal); + expect(after.dx, closeTo(before.dx, 1e-9)); + expect(after.dy, closeTo(before.dy, 1e-9)); + }); + + test('the zoom stays between the floor and the maximum', () { + v.zoomTo(100, Offset.zero); + expect(v.scale, ErdViewport.maxScale); + v.zoomTo(0.0001, Offset.zero); + expect(v.scale, ErdViewport.minScale); + v.zoomFloor = 0.05; + v.zoomTo(0.0001, Offset.zero); + expect(v.scale, closeTo(0.05, 1e-9)); + }); + + test('screen and canvas positions convert both ways', () { + v.set(scale: 2, translation: const Offset(10, 20)); + expect(v.toScreen(const Offset(5, 5)), const Offset(20, 30)); + expect(v.toCanvas(const Offset(20, 30)), const Offset(5, 5)); + }); + + test('before it is laid out there is no size, no fit, no centre', () { + expect(v.size, isNull); + expect(v.fit(const Size(100, 100)), isNull); + expect(v.centre(), const Offset(60, 60)); + v.zoomBy(2); // nothing to zoom about: no change + expect(v.scale, 1); + }); + }); + + group('in a laid out viewport', () { + testWidgets('fit centres a small diagram without enlarging it', (t) async { + final v = ErdViewport(); + addTearDown(v.dispose); + await t.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: SizedBox(width: 800, height: 600, child: SizedBox(key: v.key)), + )); + expect(v.size, const Size(800, 600)); + + expect(v.fit(const Size(400, 300)), isFalse); + expect(v.scale, closeTo(1, 1e-9)); + expect(v.translation, const Offset(200, 150)); + expect(v.centre(), const Offset(200, 150)); + }); + + testWidgets('fit shrinks a big diagram and lowers the zoom floor', + (t) async { + final v = ErdViewport(); + addTearDown(v.dispose); + await t.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: SizedBox(width: 800, height: 600, child: SizedBox(key: v.key)), + )); + final moved = v.fit(const Size(16000, 12000)); + expect(moved, isTrue); + expect(v.scale, lessThan(ErdViewport.minScale)); + expect(v.zoomFloor, closeTo(v.scale, 1e-9)); + }); + + testWidgets('centreOn puts a canvas point in the middle, keeping the zoom', + (t) async { + final v = ErdViewport(); + addTearDown(v.dispose); + await t.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: SizedBox(width: 800, height: 600, child: SizedBox(key: v.key)), + )); + v.set(scale: 2, translation: Offset.zero); + v.centreOn(const Offset(1000, 500)); + expect(v.scale, closeTo(2, 1e-9)); + final c = v.toCanvas(const Offset(400, 300)); + expect(c.dx, closeTo(1000, 1e-9)); + expect(c.dy, closeTo(500, 1e-9)); + }); + }); +} diff --git a/test/features/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart index 36a796d7..7d0c491d 100644 --- a/test/features/extensions/extension_manager_test.dart +++ b/test/features/extensions/extension_manager_test.dart @@ -99,7 +99,7 @@ void main() { child: material.Builder( builder: (ctx) => material.Scaffold( body: material.Center( - child: PrimaryButton( + child: material.ElevatedButton( onPressed: () => showExtensionManagerDialog(ctx), child: const Text('Open Dialog'), ), @@ -141,6 +141,53 @@ void main() { expect(find.textContaining('Marketplace API'), findsOneWidget); }); + group('marketplace catalog states', () { + Future openMarketplaceTab( + WidgetTester tester, + MarketplaceRepository repo, + ) async { + MarketplaceRepository.instance = repo; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (ctx) => material.Scaffold( + body: material.Center( + child: material.ElevatedButton( + onPressed: () => showExtensionManagerDialog(ctx), + child: const Text('Open Dialog'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Marketplace')); + await tester.pumpAndSettle(); + } + + testWidgets('an empty registry says so', (tester) async { + await openMarketplaceTab(tester, MockMarketplaceRepository(seed: const [])); + expect(find.text('No extensions in the registry yet'), findsOneWidget); + expect(find.text('Retry'), findsNothing); + }); + + testWidgets('an unreachable registry shows the reason and retries', + (tester) async { + final repo = _FailingMarketplace(); + await openMarketplaceTab(tester, repo); + expect(find.text('Could not load the Marketplace'), findsOneWidget); + expect(find.text('registry is down'), findsOneWidget); + + repo.failing = false; + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + expect(find.text('Could not load the Marketplace'), findsNothing); + expect(find.text('Back Again'), findsOneWidget); + }); + }); + group('marketplace search', () { Future<_ControlledMarketplace> openMarketplace( WidgetTester tester, @@ -152,7 +199,7 @@ void main() { child: material.Builder( builder: (ctx) => material.Scaffold( body: material.Center( - child: PrimaryButton( + child: material.ElevatedButton( onPressed: () => showExtensionManagerDialog(ctx), child: const Text('Open Dialog'), ), @@ -171,7 +218,7 @@ void main() { testWidgets('debounces keystrokes into a single request', (tester) async { final repo = await openMarketplace(tester); - final field = find.byType(TextField); + final field = find.byType(QueryaTextField); for (final q in ['c', 'cl', 'cli', 'click']) { await tester.enterText(field, q); @@ -188,7 +235,7 @@ void main() { testWidgets('a stale response arriving late is ignored', (tester) async { final repo = await openMarketplace(tester); - final field = find.byType(TextField); + final field = find.byType(QueryaTextField); await tester.enterText(field, 'click'); await tester.pump(kMarketplaceSearchDebounce); @@ -210,7 +257,7 @@ void main() { (tester) async { final repo = await openMarketplace(tester); - await tester.enterText(find.byType(TextField), 'click'); + await tester.enterText(find.byType(QueryaTextField), 'click'); await tester.pump(kMarketplaceSearchDebounce); expect(repo.searches, ['click']); @@ -226,7 +273,7 @@ void main() { (tester) async { final repo = await openMarketplace(tester); - await tester.enterText(find.byType(TextField), 'click'); + await tester.enterText(find.byType(QueryaTextField), 'click'); await tester.pumpWidget(const material.SizedBox()); await tester.pump(kMarketplaceSearchDebounce * 2); @@ -259,3 +306,15 @@ class _ControlledMarketplace extends MockMarketplaceRepository { void complete(String query, List result) => _pending[query]!.complete(result); } + +class _FailingMarketplace extends MockMarketplaceRepository { + _FailingMarketplace() : super(seed: const []); + + bool failing = true; + + @override + Future> getTrending({ExtensionType? type}) async { + if (failing) throw MarketplaceException('registry is down'); + return [_manifest('back.ext', 'Back Again')]; + } +} diff --git a/test/features/help/about_dialog_test.dart b/test/features/help/about_dialog_test.dart index 0e3d2178..42c58cab 100644 --- a/test/features/help/about_dialog_test.dart +++ b/test/features/help/about_dialog_test.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/features/help/about_dialog.dart'; import '../../support/querya_theme_test_shell.dart'; @@ -23,7 +22,16 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Querya'), findsOneWidget); - expect(find.byIcon(QueryaIcons.database), findsOneWidget); + expect( + find.byWidgetPredicate( + (w) => + w is material.Image && + w.image is material.AssetImage && + (w.image as material.AssetImage).assetName == + 'assets/images/querya_logo.png', + ), + findsOneWidget, + ); expect(find.textContaining('Version'), findsOneWidget); expect(find.text('Licensed under the MIT License.'), findsOneWidget); expect(find.text('View repository'), findsOneWidget); diff --git a/test/features/main_screen/goldens/app_dialog_overlay.png b/test/features/main_screen/goldens/app_dialog_overlay.png index d7529b52..047d331d 100644 Binary files a/test/features/main_screen/goldens/app_dialog_overlay.png and b/test/features/main_screen/goldens/app_dialog_overlay.png differ diff --git a/test/features/main_screen/goldens/querya_tab_strip.png b/test/features/main_screen/goldens/querya_tab_strip.png index 6f0176a3..23c227c3 100644 Binary files a/test/features/main_screen/goldens/querya_tab_strip.png and b/test/features/main_screen/goldens/querya_tab_strip.png differ diff --git a/test/features/main_screen/goldens/title_bar_read_only_badge.png b/test/features/main_screen/goldens/title_bar_read_only_badge.png index cc90d95f..09c3b837 100644 Binary files a/test/features/main_screen/goldens/title_bar_read_only_badge.png and b/test/features/main_screen/goldens/title_bar_read_only_badge.png differ diff --git a/test/features/main_screen/goldens/workspace_empty_hero.png b/test/features/main_screen/goldens/workspace_empty_hero.png index 1f55decd..49aec1c0 100644 Binary files a/test/features/main_screen/goldens/workspace_empty_hero.png and b/test/features/main_screen/goldens/workspace_empty_hero.png differ diff --git a/test/features/main_screen/main_screen_file_launch_test.dart b/test/features/main_screen/main_screen_file_launch_test.dart index a3ce28c8..f1a902e6 100644 --- a/test/features/main_screen/main_screen_file_launch_test.dart +++ b/test/features/main_screen/main_screen_file_launch_test.dart @@ -56,7 +56,7 @@ void main() { final target = FileLaunchTarget(path: dbFile.path, kind: FileLaunchKind.sqlite); FileLaunchService.instance.setPendingTarget(target); - expect(FileLaunchService.instance.hasPendingTarget, isTrue); + expect(FileLaunchService.instance.hasPendingTargets, isTrue); final consumed = FileLaunchService.instance.consumePendingTarget(); expect(consumed, isNotNull); diff --git a/test/features/main_screen/main_screen_navigator_test.dart b/test/features/main_screen/main_screen_navigator_test.dart new file mode 100644 index 00000000..485c711c --- /dev/null +++ b/test/features/main_screen/main_screen_navigator_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart' + show SqliteObjectKind; +import 'package:querya_desktop/features/main_screen/main_screen_navigator.dart'; +import 'package:querya_desktop/features/main_screen/main_screen_workspace_state.dart'; + +const _sqlite = ConnectionRow(type: 'sqlite', name: 'local', createdAt: '2026-10-18'); +const _mongo = ConnectionRow(type: 'mongodb', name: 'docs', createdAt: '2026-10-18'); + +void main() { + late ValueNotifier workspace; + late MainScreenNavigator nav; + var pendingChanges = 0; + + Future pumpNavigator(WidgetTester tester) async { + workspace = ValueNotifier(MainScreenWorkspaceState.empty); + pendingChanges = 0; + late BuildContext context; + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (c) { + context = c; + return const SizedBox(); + }), + )); + nav = MainScreenNavigator( + workspace: workspace, + context: () => context, + mounted: () => true, + onPendingMongoCollectionChanged: () => pendingChanges++, + ); + } + + testWidgets('a Mongo collection pick is pending until another connection', + (tester) async { + await pumpNavigator(tester); + await nav.selectMongoCollection(_mongo, 'app', 'users'); + expect(workspace.value.selectedMongoCollection, 'users'); + expect(nav.pendingMongoCollection, 'users'); + expect(pendingChanges, 1); + + // The same pick again changes nothing and does not notify. + await nav.selectMongoCollection(_mongo, 'app', 'users'); + expect(pendingChanges, 1); + + await nav.selectMongoDatabase(_mongo, 'app'); + expect(nav.pendingMongoCollection, isNull); + expect(workspace.value.selectedMongoCollection, isNull); + }); + + testWidgets('picking the object that is open does nothing', (tester) async { + await pumpNavigator(tester); + await nav.selectSqliteObject(_sqlite, 'users', SqliteObjectKind.table); + final first = workspace.value; + expect(first.selectedSqliteObject?.name, 'users'); + await nav.selectSqliteObject(_sqlite, 'users', SqliteObjectKind.table); + expect(identical(workspace.value, first), isTrue); + }); + + testWidgets('Redis picks keep the database and skip the repeat', + (tester) async { + await pumpNavigator(tester); + const redis = ConnectionRow(type: 'redis', name: 'cache', createdAt: '2026-10-18'); + await nav.selectRedisDatabase(redis, 3); + expect(workspace.value.activeRedisDb, 3); + final first = workspace.value; + await nav.selectRedisDatabase(redis, 3); + expect(identical(workspace.value, first), isTrue); + }); + + testWidgets('home empties the workspace, close keeps the connection', + (tester) async { + await pumpNavigator(tester); + await nav.selectSqliteObject(_sqlite, 'users', SqliteObjectKind.table); + expect(nav.hasCloseableWorkspace(workspace.value), isTrue); + + await nav.closeWorkspace(); + expect(workspace.value.selectedSqliteObject, isNull); + expect(workspace.value.activeConnection?.name, 'local'); + expect(nav.hasCloseableWorkspace(workspace.value), isFalse); + + await nav.goHome(); + expect(workspace.value.activeConnection, isNull); + }); +} diff --git a/test/features/main_screen/main_screen_shortcuts_test.dart b/test/features/main_screen/main_screen_shortcuts_test.dart new file mode 100644 index 00000000..57b5ecfb --- /dev/null +++ b/test/features/main_screen/main_screen_shortcuts_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/main_screen_shortcuts.dart'; + +KeyEvent _down(PhysicalKeyboardKey p, LogicalKeyboardKey l) => + KeyDownEvent(physicalKey: p, logicalKey: l, timeStamp: Duration.zero); + +MainShortcut? _resolve( + PhysicalKeyboardKey p, + LogicalKeyboardKey l, { + bool cmd = true, + bool shift = false, + bool alt = false, +}) => + MainScreenShortcuts.resolve(_down(p, l), + cmdOrCtrl: cmd, shift: shift, alt: alt); + +void main() { + group('Ctrl/Cmd letters', () { + final cases = <(String, PhysicalKeyboardKey, LogicalKeyboardKey, + MainShortcut)>[ + ('B', PhysicalKeyboardKey.keyB, LogicalKeyboardKey.keyB, + MainShortcut.toggleSidebar), + ('P', PhysicalKeyboardKey.keyP, LogicalKeyboardKey.keyP, + MainShortcut.commandPalette), + ('K', PhysicalKeyboardKey.keyK, LogicalKeyboardKey.keyK, + MainShortcut.quickSwitcher), + ('T', PhysicalKeyboardKey.keyT, LogicalKeyboardKey.keyT, + MainShortcut.newQueryTab), + ('W', PhysicalKeyboardKey.keyW, LogicalKeyboardKey.keyW, + MainShortcut.closeQueryTab), + ('O', PhysicalKeyboardKey.keyO, LogicalKeyboardKey.keyO, + MainShortcut.openSql), + ('S', PhysicalKeyboardKey.keyS, LogicalKeyboardKey.keyS, + MainShortcut.saveSql), + (',', PhysicalKeyboardKey.comma, LogicalKeyboardKey.comma, + MainShortcut.preferences), + ('R', PhysicalKeyboardKey.keyR, LogicalKeyboardKey.keyR, + MainShortcut.runStatement), + ('Enter', PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter, + MainShortcut.runStatement), + ]; + for (final (name, p, l, shortcut) in cases) { + test('Ctrl+$name is $shortcut', () { + expect(_resolve(p, l), shortcut); + }); + test('Ctrl+Alt+$name and a bare $name are not shortcuts', () { + expect(_resolve(p, l, alt: true), isNull); + expect(_resolve(p, l, cmd: false), isNull); + }); + } + }); + + test('Ctrl+N is a new connection, Ctrl+Shift+N a new query', () { + expect(_resolve(PhysicalKeyboardKey.keyN, LogicalKeyboardKey.keyN), + MainShortcut.newConnection); + expect( + _resolve(PhysicalKeyboardKey.keyN, LogicalKeyboardKey.keyN, + shift: true), + MainShortcut.newQuery); + }); + + test('Ctrl+Tab goes forward, Ctrl+Shift+Tab back', () { + expect(_resolve(PhysicalKeyboardKey.tab, LogicalKeyboardKey.tab), + MainShortcut.nextQueryTab); + expect( + _resolve(PhysicalKeyboardKey.tab, LogicalKeyboardKey.tab, shift: true), + MainShortcut.prevQueryTab); + }); + + test('F5 and Ctrl+Shift+Enter run the script, with or without Ctrl', () { + expect(_resolve(PhysicalKeyboardKey.f5, LogicalKeyboardKey.f5, cmd: false), + MainShortcut.runScript); + expect( + _resolve(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter, + shift: true), + MainShortcut.runScript); + expect( + _resolve(PhysicalKeyboardKey.numpadEnter, LogicalKeyboardKey.numpadEnter, + shift: true), + MainShortcut.runScript); + }); + + test('F1 and Ctrl+Shift+H open the tour; Ctrl+Shift+0 goes home', () { + expect(_resolve(PhysicalKeyboardKey.f1, LogicalKeyboardKey.f1, cmd: false), + MainShortcut.welcomeTour); + expect( + _resolve(PhysicalKeyboardKey.keyH, LogicalKeyboardKey.keyH, + shift: true), + MainShortcut.welcomeTour); + expect( + _resolve(PhysicalKeyboardKey.digit0, LogicalKeyboardKey.digit0, + shift: true), + MainShortcut.goHome); + expect(_resolve(PhysicalKeyboardKey.digit0, LogicalKeyboardKey.digit0), + isNull); + }); + + test('a Russian layout reaches the same shortcuts', () { + // The physical key is whatever the layout sends; the logical one is the + // Cyrillic letter. + final ru = <(int, MainShortcut)>[ + (0x0000043a, MainShortcut.runStatement), // к + (0x00000431, MainShortcut.preferences), // б + (0x00000438, MainShortcut.toggleSidebar), // и + (0x00000437, MainShortcut.commandPalette), // з + (0x00000417, MainShortcut.commandPalette), // З + (0x0000043b, MainShortcut.quickSwitcher), // л + (0x0000041b, MainShortcut.quickSwitcher), // Л + (0x00000435, MainShortcut.newQueryTab), // е + (0x00000446, MainShortcut.closeQueryTab), // ц + (0x00000449, MainShortcut.openSql), // щ + (0x0000044b, MainShortcut.saveSql), // ы + ]; + for (final (code, shortcut) in ru) { + expect(_resolve(PhysicalKeyboardKey.abort, LogicalKeyboardKey(code)), + shortcut, + reason: code.toRadixString(16)); + } + expect( + _resolve(PhysicalKeyboardKey.abort, const LogicalKeyboardKey(0x00000442)), + MainShortcut.newConnection); // т + }); + + test('other keys are not shortcuts', () { + expect(_resolve(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA), isNull); + expect(_resolve(PhysicalKeyboardKey.escape, LogicalKeyboardKey.escape), + isNull); + }); +} diff --git a/test/features/main_screen/menubar_dropdown_speed_test.dart b/test/features/main_screen/menubar_dropdown_speed_test.dart index d8f55d3c..a09b7f96 100644 --- a/test/features/main_screen/menubar_dropdown_speed_test.dart +++ b/test/features/main_screen/menubar_dropdown_speed_test.dart @@ -1,40 +1,35 @@ import 'package:flutter/material.dart' as material; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:querya_desktop/shared/widgets/querya_context_menu.dart'; +import 'package:querya_desktop/shared/widgets/querya_menu_bar.dart'; void main() { - testWidgets('Menubar dropdown opens fast and toggles on repeated tap', + testWidgets('QueryaMenuBar dropdown opens and toggles on repeated tap', (tester) async { await tester.pumpWidget( - ShadcnApp( - menuHandler: const PopoverOverlayHandler( - defaultShowDuration: Duration(milliseconds: 60), - defaultDismissDuration: Duration(milliseconds: 50), - showCurve: Curves.easeOutCubic, - dismissCurve: Curves.easeIn, - ), + material.MaterialApp( home: material.Scaffold( body: material.Column( children: [ - Menubar( - border: false, + QueryaMenuBar( children: [ - MenuButton( + QueryaMenuItem( subMenu: [ - MenuButton( + QueryaMenuItem( onPressed: (_) {}, child: const Text('New File'), ), - MenuButton( + QueryaMenuItem( onPressed: (_) {}, child: const Text('Save File'), ), ], child: const Text('File'), ), - MenuButton( + QueryaMenuItem( subMenu: [ - MenuButton( + QueryaMenuItem( onPressed: (_) {}, child: const Text('Undo Action'), ), @@ -55,8 +50,7 @@ void main() { // Tap 'File' await tester.tap(find.text('File')); - // Advance by 60ms (the fast open duration) - await tester.pump(const Duration(milliseconds: 60)); + await tester.pumpAndSettle(); expect(find.text('New File'), findsOneWidget); expect(find.text('Save File'), findsOneWidget); @@ -69,12 +63,12 @@ void main() { // Open 'File' again await tester.tap(find.text('File')); - await tester.pump(const Duration(milliseconds: 60)); + await tester.pumpAndSettle(); expect(find.text('New File'), findsOneWidget); - // Tap 'Edit' while 'File' is open -> closes 'File' immediately and opens 'Edit' + // Tap 'Edit' while 'File' is open -> closes 'File' and opens 'Edit' await tester.tap(find.text('Edit')); - await tester.pump(const Duration(milliseconds: 60)); + await tester.pumpAndSettle(); expect(find.text('New File'), findsNothing); expect(find.text('Undo Action'), findsOneWidget); diff --git a/test/features/main_screen/querya_status_bar_test.dart b/test/features/main_screen/querya_status_bar_test.dart index d7fcbdf8..f1189609 100644 --- a/test/features/main_screen/querya_status_bar_test.dart +++ b/test/features/main_screen/querya_status_bar_test.dart @@ -26,6 +26,20 @@ void main() { expect(find.text('UTF-8'), findsNothing); }); + testWidgets('the row and column counts say one, not 1 rows (#1558)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: QueryaStatusBar(rowCount: 1, columnCount: 1), + ), + ), + ); + await tester.pump(); + + expect(find.text('1 row, 1 col'), findsOneWidget); + }); + testWidgets('QueryaStatusBar renders active connection and read-only mode', (tester) async { final conn = ConnectionRow( diff --git a/test/features/main_screen/shell_chrome_golden_test.dart b/test/features/main_screen/shell_chrome_golden_test.dart index cfd31107..92fe7247 100644 --- a/test/features/main_screen/shell_chrome_golden_test.dart +++ b/test/features/main_screen/shell_chrome_golden_test.dart @@ -6,7 +6,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; import 'package:querya_desktop/shared/widgets/app_dialog.dart'; import 'package:querya_desktop/shared/widgets/querya_tab_strip.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; @@ -108,7 +107,7 @@ void main() { await tester.pump(const Duration(milliseconds: 400)); await expectLater( - find.byType(ShadcnApp), + find.byType(material.MaterialApp), matchesGoldenFile('goldens/app_dialog_overlay.png'), ); diff --git a/test/features/main_screen/workspace_homes_and_preferences_test.dart b/test/features/main_screen/workspace_homes_and_preferences_test.dart index 9f3e5c45..cda027c3 100644 --- a/test/features/main_screen/workspace_homes_and_preferences_test.dart +++ b/test/features/main_screen/workspace_homes_and_preferences_test.dart @@ -6,11 +6,10 @@ import 'package:path_provider_platform_interface/path_provider_platform_interfac import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:flutter/material.dart' show MaterialApp; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/mysql/mysql_workspace_home.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; @@ -113,10 +112,10 @@ void main() { late material.BuildContext ctx; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (c) { ctx = c; @@ -161,10 +160,10 @@ void main() { (tester) async { late material.BuildContext ctx; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (c) { ctx = c; diff --git a/test/features/mongodb/mongo_aggregation_workspace_test.dart b/test/features/mongodb/mongo_aggregation_workspace_test.dart index 8f94df72..e549c353 100644 --- a/test/features/mongodb/mongo_aggregation_workspace_test.dart +++ b/test/features/mongodb/mongo_aggregation_workspace_test.dart @@ -4,7 +4,6 @@ import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/mongodb/mongo_aggregation_stage.dart'; import 'package:querya_desktop/features/mongodb/mongo_aggregation_workspace.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; @@ -162,7 +161,7 @@ void main() { expect(find.text('Disabled'), findsNothing); // Tap Switch - final switchWidget = find.byType(Switch).first; + final switchWidget = find.byType(material.Switch).first; await tester.tap(switchWidget); await tester.pumpAndSettle(); diff --git a/test/features/mongodb/mongo_database_dialog_test.dart b/test/features/mongodb/mongo_database_dialog_test.dart index ff9846f2..38b20ba0 100644 --- a/test/features/mongodb/mongo_database_dialog_test.dart +++ b/test/features/mongodb/mongo_database_dialog_test.dart @@ -1,17 +1,17 @@ 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:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/features/mongodb/mongo_database_dialog.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; void main() { group('showCreateMongoDBDialog', () { testWidgets('dialog shows Create Database title and form', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (context) => material.ElevatedButton( onPressed: () => showCreateMongoDBDialog(context), @@ -33,10 +33,10 @@ void main() { testWidgets('Cancel closes dialog and returns null', (tester) async { String? result; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (context) => material.ElevatedButton( onPressed: () async { diff --git a/test/features/mongodb/mongo_documents_view_modes_test.dart b/test/features/mongodb/mongo_documents_view_modes_test.dart index efb041fd..c3041c1d 100644 --- a/test/features/mongodb/mongo_documents_view_modes_test.dart +++ b/test/features/mongodb/mongo_documents_view_modes_test.dart @@ -1,8 +1,11 @@ +import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:mongo_dart/mongo_dart.dart' show ObjectId; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_highlight_controller.dart'; import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; +import 'package:querya_desktop/core/theme/querya_semantic_palette.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/mongodb/mongo_document_tree_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_table_view.dart'; @@ -30,6 +33,17 @@ void main() { expect(appendJsonPath('address.geo', 0), 'address.geo[0]'); expect(appendJsonPath('address.geo[0]', 'lat'), 'address.geo[0].lat'); expect(appendJsonPath('user', 'first-name'), 'user["first-name"]'); + // Identifier characters (#1525): `_`, `$`, digits after the first. + expect(appendJsonPath('doc', r'$id'), r'doc.$id'); + expect(appendJsonPath('doc', '_x9'), 'doc._x9'); + expect(appendJsonPath('doc', 'A'), 'doc.A'); + // Not identifiers: leading digit, empty, space, dot, non-ASCII, quote. + expect(appendJsonPath('doc', '9x'), 'doc["9x"]'); + expect(appendJsonPath('doc', ''), 'doc[""]'); + expect(appendJsonPath('doc', 'a b'), 'doc["a b"]'); + expect(appendJsonPath('doc', 'a.b'), 'doc["a.b"]'); + expect(appendJsonPath('doc', 'caf\u00e9'), 'doc["caf\u00e9"]'); + expect(appendJsonPath('doc', 'say"hi'), r'doc["say\"hi"]'); expect(appendJsonPath('data', 'space key'), 'data["space key"]'); }); }); @@ -182,6 +196,54 @@ void main() { await tester.pumpAndSettle(); expect(find.text('address'), findsOneWidget); }); + + testWidgets('colors leaf values using semantic palette tokens (#1644)', + (tester) async { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + final date = DateTime.utc(2026, 1, 1); + final docs = [ + { + '_id': id, + 'strField': 'hello', + 'numField': 42, + 'boolField': true, + 'dateField': date, + 'nullField': null, + }, + ]; + + const theme = QueryaTheme.darkDefault; + final palette = QueryaSemanticPalette.fromTheme(theme); + + await tester.pumpWidget( + queryaThemeTestShell( + data: theme, + child: MongoDocumentTreeView( + documents: docs, + ), + ), + ); + await tester.pumpAndSettle(); + + final strText = tester.widget(find.text('"hello"')); + expect(strText.style?.color, palette.type1); + + final numText = tester.widget(find.text('42')); + expect(numText.style?.color, palette.type2); + + final boolText = tester.widget(find.text('true')); + expect(boolText.style?.color, palette.type3); + + final nullText = tester.widget( + find.byWidgetPredicate( + (w) => + w is material.Text && + w.data == 'null' && + w.style?.fontStyle == material.FontStyle.italic, + ), + ); + expect(nullText.style?.color, palette.muted); + }); }); group('MongoDocumentsJsonView widget', () { diff --git a/test/features/mongodb/mongo_field_list_perf_test.dart b/test/features/mongodb/mongo_field_list_perf_test.dart index 7dff62c8..6f2937ab 100644 --- a/test/features/mongodb/mongo_field_list_perf_test.dart +++ b/test/features/mongodb/mongo_field_list_perf_test.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_view.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import '../../support/querya_theme_test_shell.dart'; @@ -23,11 +22,11 @@ void main() { queryaThemeTestShell( child: material.Builder( builder: (context) { - final scs = shadcn.Theme.of(context).colorScheme; + final scs = context.colors; return material.Scaffold( body: MongoFieldList( document: doc, - colorScheme: scs, + colors: scs, shadcnCs: scs, onInspectField: (k) => inspectedKey = k, ), @@ -66,11 +65,11 @@ void main() { queryaThemeTestShell( child: material.Builder( builder: (context) { - final scs = shadcn.Theme.of(context).colorScheme; + final scs = context.colors; return material.Scaffold( body: MongoFieldList( document: doc, - colorScheme: scs, + colors: scs, shadcnCs: scs, onInspectField: (_) {}, ), diff --git a/test/features/mongodb/mongo_find_summary_test.dart b/test/features/mongodb/mongo_find_summary_test.dart new file mode 100644 index 00000000..381c3df2 --- /dev/null +++ b/test/features/mongodb/mongo_find_summary_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/features/mongodb/mongo_query_executor.dart'; + +void main() { + group('mongoFindSummary (#1357)', () { + test('an ordinary find just says how many documents came back', () { + expect(mongoFindSummary(25, null), 'Fetched 25 documents'); + expect(mongoFindSummary(100, 100), 'Fetched 100 documents'); + expect(mongoFindSummary(kMongoDefaultFindLimit, 5000), 'Fetched 1000 documents'); + }); + + test('.limit(0) that hit the cap says so', () { + expect( + mongoFindSummary(kMongoDefaultFindLimit, 0), + contains('capped at $kMongoDefaultFindLimit'), + ); + }); + + test('.limit(0) that did not reach the cap is not marked', () { + expect(mongoFindSummary(12, 0), 'Fetched 12 documents'); + }); + }); +} diff --git a/test/features/mongodb/mql_parser_test.dart b/test/features/mongodb/mql_parser_test.dart index 4791fea0..ce9959ca 100644 --- a/test/features/mongodb/mql_parser_test.dart +++ b/test/features/mongodb/mql_parser_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:mongo_dart/mongo_dart.dart' show ObjectId; import 'package:querya_desktop/features/mongodb/mql_parser.dart'; void main() { @@ -120,4 +121,98 @@ void main() { expect(() => MqlParser.parse(' ; '), throwsA(isA())); }); }); + + group('relaxed object literals (#1513, #1539)', () { + Map? filterOf(String args) => + MqlParser.parse('db.c.find($args)').filter; + + test('JavaScript escapes inside strings are decoded', () { + expect(filterOf(r'{ name: "a\"b" }'), {'name': 'a"b'}); + expect(filterOf(r"{ s: 'it\'s' }"), {'s': "it's"}); + expect(filterOf(r'{ s: "line\nbreak\ttab" }'), {'s': 'line\nbreak\ttab'}); + expect(filterOf(r'{ path: "C:\\dir" }'), {'path': r'C:\dir'}); + expect(filterOf(r'{ u: "\u00e9\x41" }'), {'u': 'éA'}); + }); + + test('text inside a string is never rewritten', () { + expect( + filterOf(r'''{ note: "see ISODate('2020-01-01') and ObjectId('5f1d7f0b9d3e2a0017a1b2c3')" }'''), + {'note': "see ISODate('2020-01-01') and ObjectId('5f1d7f0b9d3e2a0017a1b2c3')"}, + ); + expect( + filterOf('{ a: "@@@STR_1@@@", b: "x", c: "key: value" }'), + {'a': '@@@STR_1@@@', 'b': 'x', 'c': 'key: value'}, + ); + }); + + test('constructor calls outside strings', () { + final filter = filterOf( + "{ id: ObjectId('5f1d7f0b9d3e2a0017a1b2c3'), at: ISODate(\"2026-01-02\"), " + 'big: NumberLong(-5), n: NumberInt("7") }', + ); + expect(filter?['big'], -5); + expect(filter?['n'], 7); + expect(filter?['id'], isNotNull); + expect(filter?['at'], isNotNull); + }); + + test('a large insertMany is parsed in one pass', () { + final docs = [ + for (var i = 0; i < 3000; i++) + '{ name: "user $i", email: "u$i@example.com", tags: ["a", "b"], age: $i }', + ].join(', '); + final sw = Stopwatch()..start(); + final cmd = MqlParser.parse('db.users.insertMany([$docs])'); + sw.stop(); + expect(cmd.documents?.length, 3000); + expect(cmd.documents?.last['name'], 'user 2999'); + // Quadratic before: ~5 s for 1,000 documents; linear now (~20 ms). + expect(sw.elapsedMilliseconds, lessThan(2000)); + }); + }); + + group('Extended JSON wrappers become BSON values (#1667)', () { + const hex = '5f1d7f0b9d3e2a0017a1b2c3'; + + test('a nested ObjectId(...) in a filter is an ObjectId', () { + final cmd = MqlParser.parse('db.users.find({ _id: ObjectId("$hex") })'); + expect(cmd.filter?['_id'], isA()); + expect((cmd.filter?['_id'] as ObjectId).toHexString(), hex); + }); + + test('ISODate(...) under an operator is a UTC DateTime', () { + final cmd = MqlParser.parse( + 'db.events.find({ at: { \$gte: ISODate("2026-01-01") } })'); + final at = (cmd.filter?['at'] as Map)['\$gte']; + expect(at, DateTime.utc(2026)); + expect((at as DateTime).isUtc, isTrue); + }); + + test('insertOne stores an ObjectId, not a {\$oid} sub-document', () { + final cmd = + MqlParser.parse('db.users.insertOne({ owner: ObjectId(\'$hex\') })'); + expect(cmd.document?['owner'], isA()); + }); + + test('Extended JSON typed in strict JSON is converted too', () { + final cmd = MqlParser.parse( + 'db.c.find({ "_id": { "\$oid": "$hex" }, ' + '"at": { "\$date": { "\$numberLong": "0" } }, ' + '"n": { "\$numberLong": "9007199254740993" } })', + ); + expect(cmd.filter?['_id'], isA()); + expect(cmd.filter?['at'], DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)); + expect(cmd.filter?['n'], 9007199254740993); + }); + + test('query operators and look-alikes stay as written', () { + final cmd = MqlParser.parse( + 'db.c.find({ name: { \$regex: "^a", \$options: "i" }, ' + 'o: { \$oid: "not-hex" }, two: { \$date: "2026-01-01", x: 1 } })', + ); + expect(cmd.filter?['name'], {'\$regex': '^a', '\$options': 'i'}); + expect(cmd.filter?['o'], {'\$oid': 'not-hex'}); + expect(cmd.filter?['two'], {'\$date': '2026-01-01', 'x': 1}); + }); + }); } diff --git a/test/features/mysql/mysql_sql_tx_guard_test.dart b/test/features/mysql/mysql_sql_tx_guard_test.dart index c771fa00..97f7877f 100644 --- a/test/features/mysql/mysql_sql_tx_guard_test.dart +++ b/test/features/mysql/mysql_sql_tx_guard_test.dart @@ -1,9 +1,9 @@ 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:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; void main() { test('mysqlObjectOpensTableBrowser covers table and view only', () { @@ -22,8 +22,8 @@ void main() { testWidgets('Stay keeps the caller on the SQL session', (tester) async { var left = false; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: material.Builder( builder: (context) { return material.TextButton( diff --git a/test/features/onboarding/welcome_tour_dialog_test.dart b/test/features/onboarding/welcome_tour_dialog_test.dart index 2d931cfa..a3e5e3fd 100644 --- a/test/features/onboarding/welcome_tour_dialog_test.dart +++ b/test/features/onboarding/welcome_tour_dialog_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -108,6 +109,46 @@ void main() { expect(find.text('Compound Filter Bar & Quick Calc'), findsOneWidget); }); + testWidgets('Back slides the earlier step in from the left (#1645)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold(body: WelcomeTourDialog()), + ), + ); + await tester.pumpAndSettle(); + + double dxOf(int step) => tester + .widget( + find + .ancestor( + of: find.byKey(ValueKey(step)), + matching: find.byType(material.SlideTransition), + ) + .first, + ) + .position + .value + .dx; + + // Forward: the new step comes from the right, the old one leaves left. + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + expect(dxOf(1), greaterThan(0)); + expect(dxOf(0), lessThan(0)); + await tester.pumpAndSettle(); + + // Back: mirrored. + await tester.tap(find.byKey(const Key('welcome_tour_prev_button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + expect(dxOf(0), lessThan(0)); + expect(dxOf(1), greaterThan(0)); + await tester.pumpAndSettle(); + expect(find.text('Step 1 of 6'), findsOneWidget); + }); + testWidgets('WelcomeTourDialog launches demo playground on action button tap', (tester) async { var demoLaunched = false; @@ -200,4 +241,42 @@ void main() { Duration.zero, ); }); + + testWidgets('slide icons and shortcut badges adapt to theme tokens (#1509)', + (tester) async { + const lightTheme = QueryaTheme.lightDefault; + await tester.pumpWidget( + queryaThemeTestShell( + data: lightTheme, + child: const material.Scaffold( + body: WelcomeTourDialog(), + ), + ), + ); + await tester.pumpAndSettle(); + + // Slide 1: Connect in Seconds icon uses lightTheme.workbench.accent + final hubIconFinder = find.byIcon(material.Icons.hub_rounded); + expect(hubIconFinder, findsOneWidget); + final hubIcon = tester.widget(hubIconFinder); + expect(hubIcon.color, lightTheme.workbench.accent); + + // KbdBadge uses lightTheme.workbench.surface + final containerFinder = find.ancestor( + of: find.text('N'), + matching: find.byType(material.Container), + ); + final keycapContainer = tester.widget(containerFinder.first); + final boxDecoration = keycapContainer.decoration! as material.BoxDecoration; + expect(boxDecoration.color, lightTheme.workbench.surface); + + // Next -> Slide 2: Fluid Sidebar uses lightTheme.colors.chart4 + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + final sidebarIconFinder = find.byIcon(material.Icons.view_sidebar_rounded); + expect(sidebarIconFinder, findsOneWidget); + final sidebarIcon = tester.widget(sidebarIconFinder); + expect(sidebarIcon.color, lightTheme.colors.chart4); + }); } diff --git a/test/features/postgresql/postgres_sql_tx_guard_test.dart b/test/features/postgresql/postgres_sql_tx_guard_test.dart index 25ea95be..905a7ac7 100644 --- a/test/features/postgresql/postgres_sql_tx_guard_test.dart +++ b/test/features/postgresql/postgres_sql_tx_guard_test.dart @@ -1,9 +1,9 @@ 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:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_tx_guard.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; void main() { test('postgresObjectOpensTableBrowser covers grid kinds only', () { @@ -22,8 +22,8 @@ void main() { testWidgets('Stay keeps the caller on the SQL session', (tester) async { var left = false; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: material.Builder( builder: (context) { return material.TextButton( @@ -49,8 +49,8 @@ void main() { testWidgets('Leave confirms abandoning the open transaction', (tester) async { var left = false; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: material.Builder( builder: (context) { return material.TextButton( diff --git a/test/features/postgresql/postgres_table_toolbar_layout_test.dart b/test/features/postgresql/postgres_table_toolbar_layout_test.dart index a03bd968..aba9b686 100644 --- a/test/features/postgresql/postgres_table_toolbar_layout_test.dart +++ b/test/features/postgresql/postgres_table_toolbar_layout_test.dart @@ -1,6 +1,7 @@ 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:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_toolbar.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -46,10 +47,10 @@ void main() { await pumpWidgetWithSurfaceSize( tester, entry.value, - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Scaffold( body: _toolbar( title: @@ -67,10 +68,10 @@ void main() { await pumpWidgetWithSurfaceSize( tester, const material.Size(400, 140), - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Scaffold( body: _toolbar( title: 'mv_schema.long_materialized_view_name', @@ -87,10 +88,10 @@ void main() { await pumpWidgetWithSurfaceSize( tester, const material.Size(400, 140), - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Scaffold( body: PostgresTableToolbar( title: 'public.users', @@ -112,13 +113,11 @@ void main() { mainAxisSize: material.MainAxisSize.min, children: [ const material.Text('2 pending changes'), - OutlineButton( - size: ButtonSize.small, + material.ElevatedButton( onPressed: () {}, child: const Text('Revert'), ), - PrimaryButton( - size: ButtonSize.small, + material.ElevatedButton( onPressed: () {}, child: const Text('Save'), ), diff --git a/test/features/postgresql/postgres_table_utils_test.dart b/test/features/postgresql/postgres_table_utils_test.dart index db70c9b9..62d5e1bf 100644 --- a/test/features/postgresql/postgres_table_utils_test.dart +++ b/test/features/postgresql/postgres_table_utils_test.dart @@ -76,6 +76,36 @@ void main() { ); }); + test('splits like PostgreSQL, not like the script splitter (#1517)', () { + // Standard strings: a backslash is an ordinary character, so the literal + // ends at the second quote and the DELETE is a second statement. + expect( + isAllowedPostgresSelectQuery("SELECT 'a\\'; DELETE FROM t; --'"), + isFalse, + ); + // A backtick is an operator character in PostgreSQL, not a quote. + expect( + isAllowedPostgresSelectQuery('SELECT 1 ` 2; DELETE FROM t; SELECT 1 ` 3'), + isFalse, + ); + // Block comments nest: the whole tail is one comment. + expect( + isAllowedPostgresSelectQuery( + 'SELECT 1 /* a /* b */ ; DELETE FROM t; */', + ), + isTrue, + ); + // Semicolons in quoted identifiers and dollar quotes do not split. + expect( + isAllowedPostgresSelectQuery('SELECT "a;b" FROM t'), + isTrue, + ); + expect( + isAllowedPostgresSelectQuery(r'SELECT $$ x; y $$'), + isTrue, + ); + }); + test('rejects empty', () { expect(isAllowedPostgresSelectQuery(''), isFalse); }); diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart index 477a2429..6b3f7cd1 100644 --- a/test/features/postgresql/postgresql_connection_form_test.dart +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -11,10 +13,10 @@ void main() { (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (context) => material.ElevatedButton( onPressed: () => showPostgresConnectionForm(context), @@ -43,10 +45,10 @@ void main() { testWidgets('dialog shows Use SSL/TLS option', (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (context) => material.ElevatedButton( onPressed: () => showPostgresConnectionForm(context), @@ -66,10 +68,10 @@ void main() { await tester.binding.setSurfaceSize(const Size(800, 700)); dynamic result; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (context) => material.ElevatedButton( onPressed: () async { @@ -95,10 +97,10 @@ void main() { await tester.binding.setSurfaceSize(const Size(800, 700)); ConnectionRow? result; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (context) => material.ElevatedButton( onPressed: () async { @@ -116,10 +118,7 @@ void main() { await tester.enterText( find.byWidgetPredicate( (w) => - w is TextField && - w.placeholder is Text && - (w.placeholder as Text).data == - 'postgresql://user:pass@host:5432/dbname?sslmode=require', + w is QueryaTextField && w.placeholder == 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), 'postgresql://u:p@remote.example.com:5433/db', ); @@ -137,10 +136,10 @@ void main() { testWidgets('SSL certificate path is appended to the URI', (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (context) => material.ElevatedButton( onPressed: () => showPostgresConnectionForm(context), @@ -156,10 +155,7 @@ void main() { await tester.enterText( find.byWidgetPredicate( (w) => - w is TextField && - w.placeholder is Text && - (w.placeholder as Text).data == - 'postgresql://user:pass@host:5432/dbname?sslmode=require', + w is QueryaTextField && w.placeholder == 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), 'postgresql://u:p@remote.example.com:5433/db', ); @@ -184,13 +180,10 @@ void main() { ); await tester.pumpAndSettle(); - final uriField = tester.widget( + final uriField = tester.widget( find.byWidgetPredicate( (w) => - w is TextField && - w.placeholder is Text && - (w.placeholder as Text).data == - 'postgresql://user:pass@host:5432/dbname?sslmode=require', + w is QueryaTextField && w.placeholder == 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), ); expect(uriField.controller?.text, contains('sslrootcert')); @@ -202,10 +195,10 @@ void main() { await tester.binding.setSurfaceSize(const Size(800, 700)); ConnectionRow? result; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (context) => material.ElevatedButton( onPressed: () async { @@ -223,9 +216,7 @@ void main() { await tester.enterText( find.byWidgetPredicate( (w) => - w is TextField && - w.placeholder is Text && - (w.placeholder as Text).data == 'My PostgreSQL Server', + w is QueryaTextField && w.placeholder == 'My PostgreSQL Server', ), 'Cert PG', ); @@ -233,9 +224,7 @@ void main() { find .byWidgetPredicate( (w) => - w is TextField && - w.placeholder is Text && - (w.placeholder as Text).data == 'localhost', + w is QueryaTextField && w.placeholder == 'localhost', ) .first, 'pg.example.com', @@ -244,9 +233,7 @@ void main() { find .byWidgetPredicate( (w) => - w is TextField && - w.placeholder is Text && - (w.placeholder as Text).data == 'postgres', + w is QueryaTextField && w.placeholder == 'postgres', ) .first, 'appdb', @@ -255,9 +242,7 @@ void main() { find .byWidgetPredicate( (w) => - w is TextField && - w.placeholder is Text && - (w.placeholder as Text).data == 'postgres', + w is QueryaTextField && w.placeholder == 'postgres', ) .last, 'admin', @@ -265,9 +250,7 @@ void main() { await tester.enterText( find.byWidgetPredicate( (w) => - w is TextField && - w.placeholder is Text && - (w.placeholder as Text).data == 'Password', + w is QueryaTextField && w.placeholder == 'Password', ), 'secret', ); @@ -323,10 +306,10 @@ void main() { ConnectionRow? result; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Builder( builder: (context) => material.ElevatedButton( onPressed: () async { diff --git a/test/features/redis/redis_cli_workspace_test.dart b/test/features/redis/redis_cli_workspace_test.dart index e69b0bff..ab2f3f76 100644 --- a/test/features/redis/redis_cli_workspace_test.dart +++ b/test/features/redis/redis_cli_workspace_test.dart @@ -2,13 +2,13 @@ import 'dart:typed_data'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; -import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/redis/redis_cli_workspace.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; +import '../../support/redis_connection_test_fake.dart'; class _CliTestRedisConnection extends RedisConnectionTestFake { @override @@ -306,7 +306,7 @@ void main() { await tester.pump(const Duration(milliseconds: 50)); // Enter PING - final inputField = find.byType(TextField); + final inputField = find.byType(QueryaTextField); expect(inputField, findsOneWidget); await tester.enterText(inputField, 'PING'); await tester.pump(); @@ -356,7 +356,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); - final inputField = find.byType(TextField); + final inputField = find.byType(QueryaTextField); await tester.enterText(inputField, 'help'); await tester.pump(); diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart index 9086cdec..dbad505c 100644 --- a/test/features/redis/redis_key_editor_test.dart +++ b/test/features/redis/redis_key_editor_test.dart @@ -3,13 +3,11 @@ import 'dart:typed_data'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; -import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/features/redis/redis_key_editor.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import '../../support/querya_theme_test_shell.dart'; +import '../../support/redis_connection_test_fake.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -299,7 +297,7 @@ void main() { expect(find.text('Rename Key'), findsOneWidget); // Enter new key name - final textField = find.byType(shadcn.TextField); + final textField = find.byType(QueryaTextField).last; // the dialog, over the editor await tester.enterText(textField, 'session:renamed'); await tester.pumpAndSettle(); @@ -334,7 +332,7 @@ void main() { await tester.tap(find.byTooltip('Rename key')); await tester.pumpAndSettle(); - final textField = find.byType(shadcn.TextField); + final textField = find.byType(QueryaTextField).last; // the dialog, over the editor await tester.enterText(textField, 'session:exists'); await tester.pumpAndSettle(); @@ -355,7 +353,7 @@ void main() { await tester.tap(find.byTooltip('Rename key')); await tester.pumpAndSettle(); - await tester.enterText(find.byType(shadcn.TextField), 'session:exists'); + await tester.enterText(find.byType(QueryaTextField).last, 'session:exists'); await tester.pumpAndSettle(); await tester.tap(find.text('Rename')); @@ -419,7 +417,7 @@ void main() { expect(find.text('Edit Item [0]'), findsOneWidget); final field = find.descendant( of: find.byType(QueryaModalDialog), - matching: find.byType(shadcn.TextField), + matching: find.byType(QueryaTextField), ); await tester.enterText(field, 'task1_updated'); await tester.pumpAndSettle(); @@ -503,7 +501,7 @@ void main() { final field = find.descendant( of: find.byType(QueryaModalDialog), - matching: find.byType(shadcn.TextField), + matching: find.byType(QueryaTextField), ); await tester.enterText(field, 'task1_my_edit'); await tester.pumpAndSettle(); diff --git a/test/features/redis/redis_key_tree_engine_test.dart b/test/features/redis/redis_key_tree_engine_test.dart index 7519f8c2..80752b1e 100644 --- a/test/features/redis/redis_key_tree_engine_test.dart +++ b/test/features/redis/redis_key_tree_engine_test.dart @@ -122,5 +122,87 @@ void main() { expect(tree.rootFolders.first.name, 'trailing'); expect(tree.rootFolders.first.leaves.first.name, '(empty)'); }); + + test('empty segments and uneven nesting keep their folders and prefixes (#1523)', + () { + final tree = RedisKeyTreeEngine.buildTree( + keys: [ + makeKey('user:100:profile'), + makeKey('user:100:Avatar'), + makeKey('user::x'), + makeKey(':root'), + makeKey('trail:'), + makeKey('plain'), + ], + delimiter: ':', + ); + + expect(tree.rootLeaves.map((l) => l.name), ['plain']); + // '' sorts before 'trail' and 'user'. + expect(tree.rootFolders.map((f) => f.name), ['', 'trail', 'user']); + expect(tree.rootFolders.map((f) => f.fullPrefix), [':', 'trail:', 'user:']); + + final empty = tree.rootFolders[0]; + expect(empty.leaves.map((l) => l.name), ['root']); + + final trail = tree.rootFolders[1]; + expect(trail.leaves.map((l) => l.name), ['(empty)']); + + final user = tree.rootFolders[2]; + expect(user.folders.map((f) => f.name), ['', '100']); + expect(user.folders.map((f) => f.fullPrefix), ['user::', 'user:100:']); + expect(user.folders[0].leaves.map((l) => l.name), ['x']); + expect( + user.folders[1].leaves.map((l) => l.name), + ['Avatar', 'profile'], + ); + expect(tree.totalKeyCount, 6); + }); + + test('a multi-character delimiter cuts at every occurrence (#1523)', () { + final tree = RedisKeyTreeEngine.buildTree( + keys: [makeKey('a::b::c'), makeKey('a::d')], + delimiter: '::', + ); + expect(tree.rootFolders.map((f) => f.name), ['a']); + final a = tree.rootFolders.single; + expect(a.fullPrefix, 'a::'); + expect(a.folders.single.name, 'b'); + expect(a.folders.single.fullPrefix, 'a::b::'); + expect(a.folders.single.leaves.map((l) => l.name), ['c']); + expect(a.leaves.map((l) => l.name), ['d']); + }); + + test('names are ordered case-insensitively, ties keep their input order (#1551)', + () { + final flat = RedisKeyTreeEngine.buildTree( + keys: [ + makeKey('b'), + makeKey('B'), + makeKey('a'), + makeKey('A'), + makeKey('c'), + ], + delimiter: '', + ); + expect(flat.rootLeaves.map((l) => l.name), ['a', 'A', 'b', 'B', 'c']); + + final nested = RedisKeyTreeEngine.buildTree( + keys: [ + makeKey('f:Zeta'), + makeKey('f:alpha'), + makeKey('f:Beta'), + makeKey('F:x'), + makeKey('e:y'), + ], + delimiter: ':', + ); + // 'e' < 'f' / 'F' (equal lower-case: input order, 'f' came first). + expect(nested.rootFolders.map((f) => f.name), ['e', 'f', 'F']); + expect( + nested.rootFolders[1].leaves.map((l) => l.name), + ['alpha', 'Beta', 'Zeta'], + ); + }); }); } diff --git a/test/features/redis/redis_keys_view_test.dart b/test/features/redis/redis_keys_view_test.dart index 62f78eba..2a2518b9 100644 --- a/test/features/redis/redis_keys_view_test.dart +++ b/test/features/redis/redis_keys_view_test.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; -import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/features/redis/redis_keys_view.dart'; import '../../support/querya_theme_test_shell.dart'; +import '../../support/redis_connection_test_fake.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/test/features/redis/redis_type_editors_test.dart b/test/features/redis/redis_type_editors_test.dart index 9a115067..9e6248cb 100644 --- a/test/features/redis/redis_type_editors_test.dart +++ b/test/features/redis/redis_type_editors_test.dart @@ -2,14 +2,15 @@ import 'dart:typed_data'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/features/redis/editors/redis_hash_editor.dart'; import 'package:querya_desktop/features/redis/editors/redis_list_editor.dart'; import 'package:querya_desktop/features/redis/editors/redis_set_editor.dart'; import 'package:querya_desktop/features/redis/editors/redis_string_editor.dart'; import 'package:querya_desktop/features/redis/editors/redis_zset_editor.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide TextExtension; import '../../support/querya_theme_test_shell.dart'; @@ -19,7 +20,7 @@ final _binary = RedisBulkValue.fromReply(Uint8List.fromList([0xff, 0xfe, 0x01])) Future _pump( WidgetTester tester, - material.Widget Function(shadcn.ColorScheme cs) editor, + material.Widget Function(QueryaUiColors cs) editor, ) async { await tester.pumpWidget( queryaThemeTestShell( @@ -29,7 +30,7 @@ Future _pump( height: 600, child: material.Builder( builder: (context) => - editor(shadcn.Theme.of(context).colorScheme), + editor(context.colors), ), ), ), @@ -39,8 +40,7 @@ Future _pump( } Finder _field(String placeholder) => find.byWidgetPredicate( - (w) => w is shadcn.TextField && w.placeholder is shadcn.Text && - (w.placeholder! as shadcn.Text).data == placeholder, + (w) => w is QueryaTextField && w.placeholder == placeholder, ); void main() { @@ -57,7 +57,7 @@ void main() { controller: controller, isReadOnly: false, onSave: () => saved++, - colorScheme: cs, + colors: cs, ), ); @@ -79,7 +79,7 @@ void main() { controller: controller, isReadOnly: false, onSave: () {}, - colorScheme: cs, + colors: cs, ), ); await tester.enterText(find.byType(material.TextField), 'changed'); @@ -98,7 +98,7 @@ void main() { controller: controller, isReadOnly: true, onSave: () {}, - colorScheme: cs, + colors: cs, ), ); @@ -121,7 +121,7 @@ void main() { controller: controller, isReadOnly: false, onSave: () {}, - colorScheme: cs, + colors: cs, ), ); @@ -155,7 +155,7 @@ void main() { isReadOnly: readOnly, onSet: onSet ?? (_, __) {}, onDelete: onDelete ?? (_) {}, - colorScheme: cs, + colors: cs, shadcnCs: cs, footer: footer, ), @@ -253,7 +253,7 @@ void main() { onPush: onPush ?? (_) {}, onSet: onSet ?? (_, __, {expectedCurrent}) async {}, onRemove: onRemove ?? (_, __) {}, - colorScheme: cs, + colors: cs, shadcnCs: cs, ), ); @@ -306,7 +306,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Edit Item [1]'), findsOneWidget); - await tester.enterText(find.byType(shadcn.TextField).last, 'changed'); + await tester.enterText(find.byType(QueryaTextField).last, 'changed'); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); @@ -388,7 +388,7 @@ void main() { isReadOnly: readOnly, onAdd: onAdd ?? (_) {}, onRemove: onRemove ?? (_) {}, - colorScheme: cs, + colors: cs, shadcnCs: cs, ), ); @@ -456,7 +456,7 @@ void main() { isReadOnly: readOnly, onAdd: onAdd ?? (_, __) {}, onRemove: onRemove ?? (_) {}, - colorScheme: cs, + colors: cs, shadcnCs: cs, ), ); diff --git a/test/features/results/charts/chart_data_parse_time_test.dart b/test/features/results/charts/chart_data_parse_time_test.dart new file mode 100644 index 00000000..5854f427 --- /dev/null +++ b/test/features/results/charts/chart_data_parse_time_test.dart @@ -0,0 +1,123 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/results/charts/chart_data.dart'; + +/// `ChartData.parseTime` before #1514 (regex, replaceFirst, substrings): the +/// reference the code-unit version has to agree with. +final _isoTime = RegExp( + r'^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?(Z|[+-]\d{2}:?\d{2})?$', +); +final _hasZone = RegExp(r'(Z|[+-]\d{2}:?\d{2})$'); + +DateTime? _reference(String raw) { + final text = raw.trim(); + if (!_isoTime.hasMatch(text)) return null; + final parsed = DateTime.tryParse(text.replaceFirst(' ', 'T')); + if (parsed == null) return null; + if (parsed.year != int.parse(text.substring(0, 4)) || + parsed.month != int.parse(text.substring(5, 7)) || + parsed.day != int.parse(text.substring(8, 10))) { + return null; + } + if (text.length >= 16 && + (parsed.hour != int.parse(text.substring(11, 13)) || + parsed.minute != int.parse(text.substring(14, 16)))) { + return null; + } + if (_hasZone.hasMatch(text)) return parsed.toUtc(); + return DateTime.utc(parsed.year, parsed.month, parsed.day, parsed.hour, + parsed.minute, parsed.second, parsed.millisecond, parsed.microsecond); +} + +void main() { + group('ChartData.parseTime scanner (#1514)', () { + test('agrees with the previous implementation on listed values', () { + const cases = [ + '', + ' ', + '2026-01-31', + ' 2026-01-31 ', + '2026-01-31 10:30', + '2026-01-31T10:30', + '2026-01-31T10:30:15', + '2026-01-31T10:30:15.123', + '2026-01-31T10:30:15.123456', + '2026-01-31T10:30:15.', + '2026-01-31T10:30:1', + '2026-01-31T10:3', + '2026-01-31T10', + '2026-01-31T10:30Z', + '2026-01-31T10:30:00Z', + '2026-01-31 10:30:00+02:00', + '2026-01-31 10:30:00-0530', + '2026-01-31 10:30:00+02', + '2026-01-31 10:30:00 +02:00', + '2026-01-31Z', + '2026-01-31+01:00', + '2026-01-31+0100', + '2026-13-45', + '2026-02-30', + '2026-02-29', + '2024-02-29', + '2026-01-31T25:00', + '2026-01-31T10:61', + '2026-01-31T24:00', + '2026-1-31', + '26-01-31', + '31.01.2026', + '2026/01/31', + '2026-01-31x', + '2026-01-31T10:30:00zZ', + '2026-01-31t10:30', + '2026-01-31 10:30', + '0000-01-01', + '9999-12-31T23:59:59.999Z', + '2026-01-31', + ]; + for (final raw in cases) { + expect(ChartData.parseTime(raw), _reference(raw), reason: '"$raw"'); + } + }); + + test('agrees with the previous implementation on random strings', () { + const pieces = [ + '2026', '2024', '0000', '99', '01', '12', '13', '31', '45', '10', '24', + '60', '-', ':', '.', ' ', 'T', 'Z', '+', 'z', 't', '5', 'x', + ]; + final random = Random(1514); + for (var n = 0; n < 6000; n++) { + // Mostly shaped like a date, with random pieces dropped and swapped in. + final parts = [ + '2026', '-', '01', '-', '31', + if (random.nextBool()) ...[ + random.nextBool() ? 'T' : ' ', + '10', ':', '30', + if (random.nextBool()) ...[':', '15'], + if (random.nextBool()) ...['.', '123'], + ], + if (random.nextBool()) ...[ + random.nextBool() ? 'Z' : '+', + '02', if (random.nextBool()) ':', '00', + ], + ]; + for (var k = random.nextInt(3); k > 0; k--) { + parts[random.nextInt(parts.length)] = + pieces[random.nextInt(pieces.length)]; + } + final raw = parts.join(); + expect(ChartData.parseTime(raw), _reference(raw), reason: '"$raw"'); + } + }); + + test('a 100,000 row column is checked in one pass', () { + final rows = [ + for (var i = 0; i < 100000; i++) + ['2026-01-${(i % 28 + 1).toString().padLeft(2, '0')} 10:30:00'], + ]; + expect(ChartData.isTimeColumn(rows, 0), isTrue); + rows.add(['not a date']); + expect(ChartData.isTimeColumn(rows, 0), isFalse); + }); + }); +} diff --git a/test/features/results/charts/quick_chart_view_test.dart b/test/features/results/charts/quick_chart_view_test.dart index f07f89d1..9ba10062 100644 --- a/test/features/results/charts/quick_chart_view_test.dart +++ b/test/features/results/charts/quick_chart_view_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/theme/parser/querya_theme_from_vscode.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/results/charts/chart_data.dart'; import 'package:querya_desktop/features/results/charts/quick_chart_view.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -205,7 +206,7 @@ void main() { tooltip.getTooltipItem(group, 0, group.barRods.first, 0)?.text, contains('1,234,567'), ); - final popover = Theme.of(t.element(find.byType(BarChart))).colorScheme.popover; + final popover = t.element(find.byType(BarChart)).colors.popover; expect(tooltip.getTooltipColor(group), popover); }); @@ -289,7 +290,7 @@ void main() { final background = tooltip.getTooltipColor(group); final text = tooltip.getTooltipItem(group, 0, group.barRods.first, 0)!.textStyle.color!; - final cs = Theme.of(t.element(find.byType(BarChart))).colorScheme; + final cs = t.element(find.byType(BarChart)).colors; expect(background, cs.popover); expect(text, cs.popoverForeground); // WCAG AA for normal text. diff --git a/test/features/settings/interface_scale_slider_test.dart b/test/features/settings/interface_scale_slider_test.dart index ee651abe..b66140ac 100644 --- a/test/features/settings/interface_scale_slider_test.dart +++ b/test/features/settings/interface_scale_slider_test.dart @@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; @@ -20,7 +19,7 @@ void main() { await tester.pump(); expect(find.text('100%'), findsOneWidget); - expect(find.byType(Slider), findsOneWidget); + expect(find.byType(material.Slider), findsOneWidget); }); test('UiScaleController.normalize snaps to presets unless fine mode', () { diff --git a/test/features/settings/preferences_appearance_section_test.dart b/test/features/settings/preferences_appearance_section_test.dart index c03b7600..951b172b 100644 --- a/test/features/settings/preferences_appearance_section_test.dart +++ b/test/features/settings/preferences_appearance_section_test.dart @@ -170,5 +170,18 @@ void main() { expect(ThemeController.instance.themeAnimationEnabled, isTrue); }); + + testWidgets('does not wrap body in inner SingleChildScrollView (#1650)', + (tester) async { + await pumpSection(tester); + + expect( + find.descendant( + of: find.byType(PreferencesAppearanceSection), + matching: find.byType(material.SingleChildScrollView), + ), + findsNothing, + ); + }); }); } diff --git a/test/features/settings/preferences_extensions_section_test.dart b/test/features/settings/preferences_extensions_section_test.dart new file mode 100644 index 00000000..93c152c1 --- /dev/null +++ b/test/features/settings/preferences_extensions_section_test.dart @@ -0,0 +1,169 @@ +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/local_extension_installer.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/platform/macos_sandbox.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/features/settings/preferences_extensions_section.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +class _FakeLocalExtensionInstaller extends LocalExtensionInstaller { + _FakeLocalExtensionInstaller({ + this.manifestToReturn, + this.exceptionToThrow, + }); + + final ExtensionManifest? manifestToReturn; + final Exception? exceptionToThrow; + + @override + Future installFromPath( + String path, { + String? expectedSha256, + void Function(double progress)? onProgress, + }) async { + if (exceptionToThrow != null) throw exceptionToThrow!; + return manifestToReturn!; + } +} + +void main() { + group('PreferencesExtensionsSection', () { + testWidgets('renders section title, hint and action buttons', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: PreferencesExtensionsSection(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Extensions'), findsOneWidget); + expect(find.text('Install from file…'), findsOneWidget); + expect(find.text('Open extensions folder'), findsOneWidget); + }); + + testWidgets('says extensions cannot run in the macOS sandbox (#1593)', + (tester) async { + MacOsSandbox.debugOverride = true; + addTearDown(() => MacOsSandbox.debugOverride = null); + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold(body: PreferencesExtensionsSection()), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const material.ValueKey('extensions_sandbox_notice')), + findsOneWidget, + ); + }); + + testWidgets('outside the sandbox there is no notice', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold(body: PreferencesExtensionsSection()), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const material.ValueKey('extensions_sandbox_notice')), + findsNothing, + ); + }); + + testWidgets('renders installation error with wb.destructive and error icon (#1651)', + (tester) async { + final installer = _FakeLocalExtensionInstaller( + exceptionToThrow: MarketplaceException('Corrupt zip archive.'), + ); + + await tester.pumpWidget( + queryaThemeTestShell( + data: QueryaTheme.darkDefault, + child: material.Scaffold( + body: PreferencesExtensionsSection( + installer: installer, + filePicker: () async => XFile('sample.zip'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Install from file…')); + await tester.pumpAndSettle(); + + expect(find.text('Corrupt zip archive.'), findsOneWidget); + final errorText = tester.widget(find.text('Corrupt zip archive.')); + expect( + errorText.style?.color, + QueryaTheme.darkDefault.workbench.destructive, + ); + + final errorIcon = tester.widget( + find.byIcon(material.Icons.error_outline_rounded), + ); + expect( + errorIcon.color, + QueryaTheme.darkDefault.workbench.destructive, + ); + }); + + testWidgets( + 'renders installation success with wb.success and checkmark icon (#1651)', + (tester) async { + const manifest = ExtensionManifest( + id: 'corp.clickhouse', + name: 'ClickHouse Driver', + version: '1.2.0', + publisher: 'Corp', + type: ExtensionType.databaseDriver, + engines: {'querya': '^1.0.0'}, + ); + final installer = _FakeLocalExtensionInstaller( + manifestToReturn: manifest, + ); + + await tester.pumpWidget( + queryaThemeTestShell( + data: QueryaTheme.lightDefault, + child: material.Scaffold( + body: PreferencesExtensionsSection( + installer: installer, + filePicker: () async => XFile('clickhouse.qext'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Install from file…')); + await tester.pumpAndSettle(); + + const expectedMsg = 'Installed "ClickHouse Driver" (corp.clickhouse) v1.2.0.'; + expect(find.text(expectedMsg), findsOneWidget); + + final successText = tester.widget(find.text(expectedMsg)); + expect( + successText.style?.color, + QueryaTheme.lightDefault.workbench.success, + ); + + final successIcon = tester.widget( + find.byIcon(material.Icons.check_circle_outline_rounded), + ); + expect( + successIcon.color, + QueryaTheme.lightDefault.workbench.success, + ); + }); + }); +} diff --git a/test/features/settings/preferences_master_detail_test.dart b/test/features/settings/preferences_master_detail_test.dart index 9390d002..67825ac0 100644 --- a/test/features/settings/preferences_master_detail_test.dart +++ b/test/features/settings/preferences_master_detail_test.dart @@ -5,7 +5,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; +import 'package:querya_desktop/core/updater/installers/update_install_context.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -47,10 +50,10 @@ void main() { Widget buildTestApp({ PreferencesCategory? initialCategory, }) { - return ShadcnApp( - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, + return MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, home: material.Scaffold( body: material.Builder( builder: (context) { @@ -126,6 +129,79 @@ void main() { expect(find.text('PostgreSQL timeout'), findsOneWidget); }); + testWidgets('Data Grid has no staging switch bound to the SQL confirmation', + (tester) async { + // It wrote confirm_destructive_operations: turning it off in Data Grid + // silently turned off the DROP / TRUNCATE / DELETE confirmation. + await tester.pumpWidget( + buildTestApp(initialCategory: PreferencesCategory.dataGrid), + ); + await openDialog(tester); + + expect(find.text('Max rows in results'), findsOneWidget); + expect(find.text('Confirm DML staging applications'), findsNothing); + expect(find.byType(material.Switch), findsNothing); + }); + + testWidgets('each pane keeps its own scroll position', (tester) async { + await tester.pumpWidget( + buildTestApp(initialCategory: PreferencesCategory.shortcuts), + ); + await openDialog(tester); + + material.ScrollPosition position(PreferencesCategory cat) => tester + .state(find + .descendant( + of: find + .byKey(material.ValueKey('preferences_pane_${cat.name}')), + matching: find.byType(material.Scrollable), + skipOffstage: false, + ) + .first) + .position; + + final shortcuts = position(PreferencesCategory.shortcuts); + expect(shortcuts.maxScrollExtent, greaterThan(100)); + shortcuts.jumpTo(100); + await tester.pump(); + + await tester.tap(find.text('About & Storage')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(position(PreferencesCategory.about).pixels, 0); + + await tester.tap(find.text('Shortcuts')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(position(PreferencesCategory.shortcuts).pixels, 100); + }); + + testWidgets('the release channel is usable on a portable install (#1497)', + (tester) async { + await tester.pumpWidget(buildTestApp()); + await tester.tap(find.text('Open Preferences')); + await tester.pumpAndSettle(); + + expect(find.byKey(const material.ValueKey('release_channel_row')), + findsOneWidget); + expect(find.textContaining('Updates come from your package manager'), + findsNothing); + }); + + testWidgets('on a package install the channel row says why it does nothing (#1497)', + (tester) async { + UpdateInstallContext.debugPackageManagerInstall = true; + addTearDown(() => UpdateInstallContext.debugPackageManagerInstall = null); + + await tester.pumpWidget(buildTestApp()); + await tester.tap(find.text('Open Preferences')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Updates come from your package manager'), + findsOneWidget); + expect(find.text('Stable (Recommended)'), findsOneWidget); + }); + testWidgets('search bar filters preferences and updates matches', (tester) async { await tester.pumpWidget(buildTestApp()); @@ -148,8 +224,8 @@ void main() { var currentValue = false; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: material.Scaffold( body: StatefulBuilder( builder: (context, setState) { @@ -179,5 +255,27 @@ void main() { expect(currentValue, isTrue); }); + + testWidgets('adapts category rail width to UI scale (#1653)', (tester) async { + await tester.pumpWidget( + QueryaUiScaleScope( + scale: 1.5, + child: buildTestApp(), + ), + ); + await openDialog(tester); + + final railScrollView = find.ancestor( + of: find.text('General').first, + matching: find.byType(material.SingleChildScrollView), + ); + final railContainer = find.ancestor( + of: railScrollView, + matching: find.byType(material.Container), + ).first; + + final containerWidget = tester.widget(railContainer); + expect(containerWidget.constraints?.maxWidth, 184 * 1.5); + }); }); } diff --git a/test/features/settings/preferences_mcp_section_test.dart b/test/features/settings/preferences_mcp_section_test.dart index e07200e2..bfff36cb 100644 --- a/test/features/settings/preferences_mcp_section_test.dart +++ b/test/features/settings/preferences_mcp_section_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart' as material; @@ -12,7 +13,8 @@ import 'package:querya_desktop/features/settings/preferences_mcp_section.dart'; import '../../support/querya_theme_test_shell.dart'; class _Controller extends McpServerController { - _Controller() : super(endpointFile: File('/nonexistent/mcp.json'), version: 't'); + _Controller() + : super(endpointFile: File('/nonexistent/mcp.json'), version: 't'); var enabled = false; final enabledCalls = []; @@ -97,7 +99,8 @@ void main() { loadConnections: () async => [ _row(1, 'postgresql', 'Orders DB', prod: true), _row(2, 'sqlite', 'Local file'), - _row(3, 'mongodb', 'Mongo'), // not SQL: hidden + _row(3, 'mongodb', 'Mongo'), // MongoDB tools: listed too + _row(4, 'redis', 'Cache'), // nothing MCP can read: hidden ], loadActivity: () async => activity, clearActivity: () async => activity = [], @@ -108,17 +111,25 @@ void main() { await tester.pump(); } - testWidgets('lists SQL connections with their shared state', (tester) async { + testWidgets('lists SQL and MongoDB connections with their shared state', + (tester) async { await pump(tester); expect(find.text('Orders DB'), findsOneWidget); expect(find.text('Local file'), findsOneWidget); - expect(find.text('Mongo'), findsNothing); + expect(find.text('Mongo'), findsOneWidget); + expect(find.text('Cache'), findsNothing); expect(find.text('PROD'), findsOneWidget); + // Type badges use the product names, not the stored type ids. + expect(find.text('PostgreSQL'), findsOneWidget); + expect(find.text('SQLite'), findsOneWidget); + expect(find.text('MongoDB'), findsOneWidget); + expect(find.text('postgresql'), findsNothing); - material.Switch sw(int id) => tester.widget(find.descendant( - of: find.byKey(material.ValueKey('mcp_share_$id')), - matching: find.byType(material.Switch))); + material.Switch sw(int id) => + tester.widget(find.descendant( + of: find.byKey(material.ValueKey('mcp_share_$id')), + matching: find.byType(material.Switch))); expect(sw(1).value, isTrue); expect(sw(2).value, isFalse); @@ -149,7 +160,8 @@ void main() { (tester) async { await pump(tester); - await tester.tap(find.byKey(const material.ValueKey('mcp_copy_claudeDesktop'))); + await tester + .tap(find.byKey(const material.ValueKey('mcp_copy_claudeDesktop'))); await tester.pump(); await tester.tap(find.byKey(const material.ValueKey('mcp_copy_vsCode'))); await tester.pump(); @@ -193,7 +205,7 @@ void main() { expect(find.text('1 row(s) · 12 ms'), findsOneWidget); expect(find.text('Data-modifying statements are not allowed over MCP.'), findsOneWidget); - expect(find.text('refused by data_modifying'), findsOneWidget); + expect(find.text('refused: data_modifying'), findsOneWidget); await tester.tap(find.byKey(const material.ValueKey('mcp_clear_log'))); await tester.pump(); @@ -216,6 +228,48 @@ void main() { )); await tester.pump(); await tester.pump(); - expect(find.text('No SQL connections'), findsOneWidget); + expect(find.text('No connections to share'), findsOneWidget); + }); + + testWidgets('nothing that depends on stored state is drawn before it loads', + (tester) async { + final connections = Completer>(); + controller.enabled = true; + await tester.pumpWidget(queryaThemeTestShell( + child: material.SingleChildScrollView( + child: PreferencesMcpSection( + controller: controller, + access: access, + shimPath: '/opt/querya-desktop/querya-mcp', + loadConnections: () => connections.future, + loadActivity: () async => [], + ), + ), + )); + await tester.pump(); + // Still loading: no "off" switch, no "no connections", no "no calls". + expect(find.byKey(const material.ValueKey('mcp_enabled')), findsNothing); + expect(find.text('No connections to share'), findsNothing); + expect(find.text('No calls yet'), findsNothing); + + connections.complete([_row(1, 'postgresql', 'Orders DB')]); + await tester.pump(); + await tester.pump(); + final sw = tester.widget(find.descendant( + of: find.byKey(const material.ValueKey('mcp_enabled')), + matching: find.byType(material.Switch))); + expect(sw.value, isTrue); + expect(find.text('Orders DB'), findsOneWidget); + }); + + testWidgets('the querya-mcp path is shown on its own line', (tester) async { + await pump(tester); + expect( + find.descendant( + of: find.byKey(const material.ValueKey('mcp_shim_path')), + matching: find.text('/opt/querya-desktop/querya-mcp'), + ), + findsOneWidget, + ); }); } diff --git a/test/features/settings/preferences_shortcuts_section_test.dart b/test/features/settings/preferences_shortcuts_section_test.dart index 063abba2..4cf0f942 100644 --- a/test/features/settings/preferences_shortcuts_section_test.dart +++ b/test/features/settings/preferences_shortcuts_section_test.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/settings/preferences_shortcuts_section.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +import '../../support/querya_theme_test_shell.dart'; + void main() { Widget buildTestWidget({String searchQuery = ''}) { return ShadcnApp( @@ -104,5 +108,36 @@ void main() { expect(find.text('No shortcuts match "xyzunknown"'), findsOneWidget); expect(find.text('Execute query / selection'), findsNothing); }); + + testWidgets('uses mono font fallback and does not overflow under narrow constraints (#1652)', + (tester) async { + tester.view.physicalSize = const material.Size(320, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + queryaThemeTestShell( + data: QueryaTheme.darkDefault, + child: const material.Scaffold( + body: material.SingleChildScrollView( + child: PreferencesShortcutsSection(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // No RenderFlex overflow should occur. + expect(tester.takeException(), isNull); + + // Verify mono font and fallback stack on keycaps. + final f5Key = tester.widget(find.text('F5').first); + expect(f5Key.style?.fontFamily, QueryaTypography.mono); + expect( + f5Key.style?.fontFamilyFallback, + QueryaTypography.monoFontFamilyFallback, + ); + }); }); } diff --git a/test/features/settings/theme_editor_dialog_test.dart b/test/features/settings/theme_editor_dialog_test.dart index 28688753..4dec0733 100644 --- a/test/features/settings/theme_editor_dialog_test.dart +++ b/test/features/settings/theme_editor_dialog_test.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_editor_draft.dart'; import 'package:querya_desktop/core/theme/theme_registry_service.dart'; import 'package:querya_desktop/features/settings/theme_editor_dialog.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; import '../../support/querya_theme_test_shell.dart'; @@ -102,11 +103,50 @@ void main() { expect(find.text(field.label), findsWidgets); } + // Verify hex label uses mono font and fallback + final hexTextFinder = find.byWidgetPredicate((widget) { + return widget is material.Text && + widget.style?.fontFamily == 'Cascadia Code' && + widget.style?.fontFamilyFallback?.contains('Consolas') == true; + }); + expect(hexTextFinder, findsWidgets); + // Close modal await tester.tap(find.text('Done')); await tester.pumpAndSettle(); expect(find.text('Theme Studio'), findsNothing); }); + + testWidgets('applies WindowLayout.dialogConstraints to Theme Studio modal (#1653)', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(1280, 900)); + await tester.pumpWidget( + queryaThemeTestShell( + uiScale: 1.25, + child: material.Scaffold( + body: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showThemeEditorDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + final modalFinder = find.byType(QueryaModalDialog); + expect(modalFinder, findsOneWidget); + final modalWidget = tester.widget(modalFinder); + expect(modalWidget.constraints.maxWidth, 580 * 1.25); + expect(modalWidget.constraints.maxHeight, 600 * 1.25); + + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + }); }); } diff --git a/test/features/settings/theme_import_flow_test.dart b/test/features/settings/theme_import_flow_test.dart index 05bb13fb..9e73f496 100644 --- a/test/features/settings/theme_import_flow_test.dart +++ b/test/features/settings/theme_import_flow_test.dart @@ -123,7 +123,7 @@ void main() { expect(c.selectedThemeId, 'fixture-custom-dark'); expect(c.selectedThemeLoadError, isNull); expect( - c.activeTheme.colorScheme.primary, + c.activeTheme.colors.primary, parseQueryaThemeColor('#38BDF8'), ); expect( @@ -142,7 +142,7 @@ void main() { expect(c.selectedThemeId, 'fixture-custom-dark'); expect(c.selectedThemeLoadError, isNull); expect( - c.activeTheme.colorScheme.primary, + c.activeTheme.colors.primary, parseQueryaThemeColor('#38BDF8'), ); expect( diff --git a/test/features/settings/theme_preview_card_test.dart b/test/features/settings/theme_preview_card_test.dart index 9cf26532..9ede5510 100644 --- a/test/features/settings/theme_preview_card_test.dart +++ b/test/features/settings/theme_preview_card_test.dart @@ -40,6 +40,25 @@ void main() { expect(find.text('Surface'), findsOneWidget); expect(find.text('Primary'), findsOneWidget); expect(find.text('Accent'), findsOneWidget); + + // Verify swatch border uses theme border token with alpha + final containers = tester + .widgetList(find.byType(material.Container)) + .where((c) { + final dec = c.decoration; + return dec is material.BoxDecoration && dec.border is material.Border; + }).toList(); + + final swatchBox = containers.firstWhere((c) { + final dec = c.decoration as material.BoxDecoration; + return dec.borderRadius == material.BorderRadius.circular(4); + }); + final border = + (swatchBox.decoration as material.BoxDecoration).border as material.Border; + expect( + border.top.color, + QueryaTheme.darkDefault.colors.border.withValues(alpha: 0.6), + ); }); testWidgets('shows loading state', (tester) async { diff --git a/test/features/sqlite/sqlite_sql_tx_guard_test.dart b/test/features/sqlite/sqlite_sql_tx_guard_test.dart index c624eaab..21591fae 100644 --- a/test/features/sqlite/sqlite_sql_tx_guard_test.dart +++ b/test/features/sqlite/sqlite_sql_tx_guard_test.dart @@ -1,10 +1,10 @@ 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:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart' show SqliteObjectKind; import 'package:querya_desktop/features/sqlite/sqlite_sql_tx_guard.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; void main() { test('sqliteObjectOpensTableBrowser covers table and view', () { @@ -15,8 +15,8 @@ void main() { testWidgets('Stay keeps the caller on the SQL session', (tester) async { var left = false; await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: material.Builder( builder: (context) { return material.TextButton( diff --git a/test/features/updater/update_available_badge_test.dart b/test/features/updater/update_available_badge_test.dart index 6f61d05a..0a02bb64 100644 --- a/test/features/updater/update_available_badge_test.dart +++ b/test/features/updater/update_available_badge_test.dart @@ -121,4 +121,53 @@ void main() { Duration(microseconds: kUpdateBadgePulsePeriod.inMicroseconds ~/ 2), ); }); + + testWidgets('pulse is finite: settles and stays idle (#1554)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: material.Scaffold( + body: UpdateAvailableBadge(controller: controller), + ), + ), + ), + ); + await tester.pump(); + + const manifest = UpdateManifest( + version: '3.0.0', + changelog: '', + assets: [], + ); + controller.setPendingUpdate(manifest); + await tester.pump(); + + final state = tester.state( + find.byType(UpdateAvailableBadge), + ); + expect(state.isPulseAnimating, isTrue); + + await tester.pump( + kUpdateBadgePulsePeriod * kUpdateBadgePulseLegs + + const Duration(milliseconds: 100), + ); + await tester.pump(); + expect(state.isPulseAnimating, isFalse); + expect(find.textContaining('v3.0.0 available'), findsOneWidget); + + // Another sync (controller notification, dependency change) must not + // start the pulse again for the same pending update. + controller.setPendingUpdate(manifest); + await tester.pump(); + expect(state.isPulseAnimating, isFalse); + + // A later update pulses again once the badge went away in between. + controller.setPendingUpdate(null); + await tester.pump(); + controller.setPendingUpdate(manifest); + await tester.pump(); + expect(state.isPulseAnimating, isTrue); + }); } diff --git a/test/features/updater/update_dialog_test.dart b/test/features/updater/update_dialog_test.dart index a49a4c0b..11135e41 100644 --- a/test/features/updater/update_dialog_test.dart +++ b/test/features/updater/update_dialog_test.dart @@ -184,6 +184,82 @@ void main() { expect(downloaded, isFalse); }); + Future pumpInstallable( + WidgetTester tester, { + required Future Function(String, String) backup, + required Future Function(File) install, + }) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: SizedBox( + width: 800, + height: 700, + child: UpdateDialogContent( + initialManifest: _manifest, + isInstallBlocked: false, + hasUnsavedWork: () => false, + selectAsset: (_) => _asset, + download: (_, __) async => File('/tmp/querya-update.zip'), + backupLocalData: backup, + install: install, + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Download')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(find.text('Restart & Update Now')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + testWidgets('the local data is copied before the update is installed (#1497)', + (tester) async { + final order = []; + String? to; + await pumpInstallable( + tester, + backup: (from, target) async { + order.add('backup'); + to = target; + }, + install: (_) async => order.add('install'), + ); + + expect(order, ['backup', 'install']); + expect(to, '0.5.1'); + }); + + testWidgets('a failed copy asks, and Cancel keeps the app as it is (#1497)', + (tester) async { + var installed = false; + await pumpInstallable( + tester, + backup: (_, __) async => throw const FileSystemException('disk full'), + install: (_) async => installed = true, + ); + + expect(find.text('Could not back up local data'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(installed, isFalse); + }); + + testWidgets('a failed copy can be overridden (#1497)', (tester) async { + var installed = false; + await pumpInstallable( + tester, + backup: (_, __) async => throw const FileSystemException('disk full'), + install: (_) async => installed = true, + ); + + await tester.tap(find.text('Install without a copy')); + await tester.pumpAndSettle(); + expect(installed, isTrue); + }); + testWidgets('dirty restart cancel does not install', (tester) async { var installed = false; await tester.pumpWidget( diff --git a/test/features/workspace/context_menu_mode_switch_test.dart b/test/features/workspace/context_menu_mode_switch_test.dart index efc2805b..764057c6 100644 --- a/test/features/workspace/context_menu_mode_switch_test.dart +++ b/test/features/workspace/context_menu_mode_switch_test.dart @@ -1,15 +1,12 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' as material; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/theme/querya_theme.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:querya_desktop/shared/widgets/querya_context_menu.dart'; material.Widget _testShell({required material.Widget child}) { - final td = QueryaTheme.darkDefault - .toShadcnThemeData() - .copyWith(platform: () => material.TargetPlatform.linux); - return ShadcnApp( - theme: td, + return material.MaterialApp( + theme: material.ThemeData.dark(), home: material.Scaffold( body: child, ), @@ -33,23 +30,23 @@ class _DynamicContextMenuTestHost extends material.StatefulWidget { this.initialItemsBuilder, }); - final List? initialItems; - final List Function(material.BuildContext)? initialItemsBuilder; + final List? initialItems; + final List Function(material.BuildContext)? initialItemsBuilder; @override - State<_DynamicContextMenuTestHost> createState() => + material.State<_DynamicContextMenuTestHost> createState() => _DynamicContextMenuTestHostState(); } class _DynamicContextMenuTestHostState extends material.State<_DynamicContextMenuTestHost> { - late List? items = widget.initialItems; - late List Function(material.BuildContext)? itemsBuilder = + late List? items = widget.initialItems; + late List Function(material.BuildContext)? itemsBuilder = widget.initialItemsBuilder; void update({ - List? newItems, - List Function(material.BuildContext)? newItemsBuilder, + List? newItems, + List Function(material.BuildContext)? newItemsBuilder, }) { setState(() { items = newItems; @@ -60,7 +57,7 @@ class _DynamicContextMenuTestHostState @override material.Widget build(material.BuildContext context) { return material.Center( - child: ContextMenu( + child: QueryaContextMenu( items: items, itemsBuilder: itemsBuilder, child: const material.Text('Target Box'), @@ -70,7 +67,7 @@ class _DynamicContextMenuTestHostState } void main() { - group('ContextMenu mode switching (#1011)', () { + group('QueryaContextMenu mode switching (#1011)', () { testWidgets( 'switches from itemsBuilder to items at runtime without throwing', (tester) async { @@ -83,7 +80,7 @@ void main() { key: hostKey, initialItems: null, initialItemsBuilder: (context) => [ - const MenuButton(child: Text('Lazy Item 1')), + const QueryaMenuItem(child: Text('Lazy Item 1')), ], ), ), @@ -93,14 +90,14 @@ void main() { // Switch to eager items without itemsBuilder hostKey.currentState!.update( newItems: [ - const MenuButton(child: Text('Eager Item 1')), + const QueryaMenuItem(child: Text('Eager Item 1')), ], newItemsBuilder: null, ); await tester.pumpAndSettle(); // Right-clicking the target box must resolve eager items and open the menu - // without throwing "Null check operator used on a null value". + // without throwing. await _secondaryClick(tester, find.text('Target Box')); await tester.pumpAndSettle(); @@ -119,7 +116,7 @@ void main() { child: _DynamicContextMenuTestHost( key: hostKey, initialItems: const [ - MenuButton(child: Text('Old Eager Item')), + QueryaMenuItem(child: Text('Old Eager Item')), ], initialItemsBuilder: null, ), @@ -135,7 +132,7 @@ void main() { newItemsBuilder: (context) { builderCallCount++; return [ - const MenuButton(child: Text('New Lazy Item')), + const QueryaMenuItem(child: Text('New Lazy Item')), ]; }, ); @@ -161,7 +158,7 @@ void main() { child: _DynamicContextMenuTestHost( key: hostKey, initialItems: const [ - MenuButton(child: Text('Version 1 Item')), + QueryaMenuItem(child: Text('Version 1 Item')), ], initialItemsBuilder: null, ), @@ -171,7 +168,7 @@ void main() { hostKey.currentState!.update( newItems: const [ - MenuButton(child: Text('Version 2 Item')), + QueryaMenuItem(child: Text('Version 2 Item')), ], newItemsBuilder: null, ); diff --git a/test/features/workspace/data_grid_calc_bar_test.dart b/test/features/workspace/data_grid_calc_bar_test.dart new file mode 100644 index 00000000..b6bd0c4a --- /dev/null +++ b/test/features/workspace/data_grid_calc_bar_test.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/features/workspace/data_grid_calc_bar.dart'; +import 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + String? clipboardContent; + + setUp(() { + clipboardContent = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (message) async { + if (message.method == 'Clipboard.setData') { + clipboardContent = (message.arguments as Map)['text'] as String?; + return null; + } + if (message.method == 'Clipboard.getData') { + return {'text': clipboardContent}; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + material.Widget buildBar(GridCalcStats stats) { + return queryaThemeTestShell( + child: material.Scaffold( + body: material.Column( + children: [ + const material.Spacer(), + DataGridCalcBar(stats: stats), + ], + ), + ), + ); + } + + group('DataGridCalcBar', () { + const populatedStats = GridCalcStats( + totalCount: 5, + distinctCount: 4, + numericCount: 4, + nullCount: 1, + sum: 100.0, + average: 25.0, + median: 20.0, + min: 10.0, + max: 40.0, + ); + + testWidgets('renders all stat metrics and mono typography', (tester) async { + await tester.pumpWidget(buildBar(populatedStats)); + await tester.pumpAndSettle(); + + expect(find.text('Count: '), findsOneWidget); + expect(find.text('5'), findsOneWidget); + expect(find.text('Distinct: '), findsOneWidget); + expect(find.text('4'), findsOneWidget); + expect(find.text('NULLs: '), findsOneWidget); + expect(find.text('1'), findsOneWidget); + expect(find.text('Sum: '), findsOneWidget); + expect(find.text('Avg: '), findsOneWidget); + expect(find.text('Median: '), findsOneWidget); + expect(find.text('Min: '), findsOneWidget); + expect(find.text('Max: '), findsOneWidget); + expect(find.text('Copy Stats'), findsOneWidget); + + final valText = tester.widget(find.text('5')); + expect(valText.style?.fontFamily, QueryaTypography.mono); + expect( + valText.style?.fontFamilyFallback, + QueryaTypography.monoFontFamilyFallback, + ); + }); + + testWidgets( + 'collapsing stats does not throw RenderFlex overflow during animation (#1641)', + (tester) async { + final statsNotifier = material.ValueNotifier(populatedStats); + addTearDown(statsNotifier.dispose); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.Column( + children: [ + const material.Spacer(), + material.ValueListenableBuilder( + valueListenable: statsNotifier, + builder: (_, s, __) => DataGridCalcBar(stats: s), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Verify currently expanded + expect(find.text('Count: '), findsOneWidget); + + // Collapse to empty stats + statsNotifier.value = GridCalcStats.empty; + await tester.pump(); + + // Pump partial frames during collapse animation (e.g. at 40ms, 80ms, 120ms, 160ms) + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.takeException(), isNull); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.takeException(), isNull); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.takeException(), isNull); + + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + // Now collapsed + expect(find.text('Count: '), findsNothing); + }); + + testWidgets('copy button copies full stats summary to clipboard', + (tester) async { + await tester.pumpWidget(buildBar(populatedStats)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Copy Stats')); + await tester.pump(); + + expect(clipboardContent, populatedStats.toSummaryString()); + }); + }); +} diff --git a/test/features/workspace/data_grid_context_menu_test.dart b/test/features/workspace/data_grid_context_menu_test.dart index d48bbe77..60cc9451 100644 --- a/test/features/workspace/data_grid_context_menu_test.dart +++ b/test/features/workspace/data_grid_context_menu_test.dart @@ -7,13 +7,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; material.Widget _testShell({required material.Widget child}) { - final td = QueryaTheme.darkDefault - .toShadcnThemeData() - .copyWith(platform: () => TargetPlatform.linux); - return ShadcnApp( + final td = materialThemeFromQuerya(QueryaTheme.darkDefault.colors) + .copyWith(platform: material.TargetPlatform.linux); + return material.MaterialApp( theme: td, home: material.Scaffold( body: child, diff --git a/test/features/workspace/data_grid_engines_test.dart b/test/features/workspace/data_grid_engines_test.dart index dd64ac11..002d34ee 100644 --- a/test/features/workspace/data_grid_engines_test.dart +++ b/test/features/workspace/data_grid_engines_test.dart @@ -371,6 +371,24 @@ void main() { expect(activeAvg.aggValue, equals(200.0)); }); + test('aggregates numbers written with thousands separators', () { + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: [0], + rows: const [ + ['a', '1,234.5'], + ['a', ' 2,000 '], + ['a', '1,2,3'], // commas count for nothing: 123 + ['a', 'n/a, see note'], // not a number: skipped + ['a', '10'], + ], + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.sum, + targetColIndex: 1, + ), + ); + expect(groups.single.aggValue, closeTo(1234.5 + 2000 + 123 + 10, 1e-9)); + }); + test('sorts groups by key or aggregate', () { final keySorted = GridGroupingsEngine.buildGroups( groupColIndices: [1], diff --git a/test/features/workspace/destructive_query_dialog_test.dart b/test/features/workspace/destructive_query_dialog_test.dart index 890fa390..a33b6b10 100644 --- a/test/features/workspace/destructive_query_dialog_test.dart +++ b/test/features/workspace/destructive_query_dialog_test.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; +import 'package:querya_desktop/shared/widgets/querya_dialog_card.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; @@ -275,5 +277,90 @@ void main() { await tester.pumpAndSettle(); expect(confirmed, isTrue); }); + + testWidgets( + 'SQL preview uses workbench editorBackground and foreground (#1644)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + final inspection = + DestructiveSqlDetector.inspect('DROP TABLE legacy_users;'); + + material.Widget buildDialog(QueryaTheme theme) => queryaThemeTestShell( + data: theme, + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showDestructiveQueryDialog( + context: context, + result: inspection, + sql: 'DROP TABLE legacy_users;', + ), + child: const material.Text('Open'), + ), + ), + ); + + // Light theme verification + await tester.pumpWidget(buildDialog(QueryaTheme.lightDefault)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + final selectableTextFinder = find.widgetWithText( + material.SelectableText, + 'DROP TABLE legacy_users;', + ); + expect(selectableTextFinder, findsOneWidget); + + final container = tester.widget( + find + .ancestor( + of: selectableTextFinder, + matching: find.byType(material.Container), + ) + .first, + ); + final deco = container.decoration! as material.BoxDecoration; + expect( + deco.color, + QueryaTheme.lightDefault.workbench.editorBackground, + ); + + final selectableText = + tester.widget(selectableTextFinder); + expect( + selectableText.style?.color, + QueryaTheme.lightDefault.colors.foreground, + ); + }); + + testWidgets('fits a small window instead of running off it (#1642)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(520, 420)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showDestructiveQueryDialog( + context: context, + result: DestructiveSqlDetector.inspect('DROP TABLE legacy_users;'), + sql: 'DROP TABLE legacy_users;', + ), + child: const material.Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + final card = tester.getRect(find.byType(QueryaDialogCard)); + expect(card.left, greaterThanOrEqualTo(0)); + expect(card.top, greaterThanOrEqualTo(0)); + expect(card.right, lessThanOrEqualTo(520)); + expect(card.bottom, lessThanOrEqualTo(420)); + expect(find.text('Execute Destructive Statement'), findsOneWidget); + }); }); } diff --git a/test/features/workspace/dml_preview_dialog_test.dart b/test/features/workspace/dml_preview_dialog_test.dart index 4b70f425..2d2cc284 100644 --- a/test/features/workspace/dml_preview_dialog_test.dart +++ b/test/features/workspace/dml_preview_dialog_test.dart @@ -1,18 +1,24 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/workspace/dml_preview_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; void main() { - material.Widget buildTestDialog({required TableMutationPlan plan, void Function(bool?)? onResult}) { + material.Widget buildTestDialog({ + required TableMutationPlan plan, + void Function(bool?)? onResult, + QueryaTheme data = QueryaTheme.darkDefault, + }) { return queryaThemeTestShell( + data: data, child: material.Material( child: material.Builder( builder: (context) { - return OutlineButton( + return material.ElevatedButton( onPressed: () async { final res = await showDmlPreviewDialog(context: context, plan: plan); onResult?.call(res); @@ -123,5 +129,86 @@ void main() { expect(find.textContaining('No Primary Key detected'), findsOneWidget); }); + + testWidgets( + 'SQL preview uses workbench editorBackground and foreground (#1644)', + (tester) async { + await tester.pumpWidget(buildTestDialog( + plan: samplePlan, + data: QueryaTheme.lightDefault, + )); + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + final selectableTextFinder = find.byType(material.SelectableText); + expect(selectableTextFinder, findsOneWidget); + + final container = tester.widget( + find + .ancestor( + of: selectableTextFinder, + matching: find.byType(material.Container), + ) + .first, + ); + final deco = container.decoration! as material.BoxDecoration; + expect( + deco.color, + QueryaTheme.lightDefault.workbench.editorBackground, + ); + + final selectableText = + tester.widget(selectableTextFinder); + expect( + selectableText.style?.color, + QueryaTheme.lightDefault.colors.foreground, + ); + }); + + testWidgets( + 'breakdown pills and warning banner use workbench status colors (#1643)', + (tester) async { + await tester.pumpWidget(buildTestDialog( + plan: samplePlan, + data: QueryaTheme.darkDefault, + )); + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + final updateText = tester.widget(find.text('1 UPDATE')); + expect(updateText.style?.color, QueryaTheme.darkDefault.workbench.warning); + + final insertText = tester.widget(find.text('1 INSERT')); + expect(insertText.style?.color, QueryaTheme.darkDefault.workbench.success); + + final deleteText = tester.widget(find.text('1 DELETE')); + expect(deleteText.style?.color, QueryaTheme.darkDefault.workbench.destructive); + }); + + testWidgets('a long schema.table name is cut instead of overflowing (#1642)', + (tester) async { + await tester.binding.setSurfaceSize(const Size(640, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final longName = 'very_long_table_name_' * 8; + final plan = TableMutationPlan( + dialect: SqlDialect.postgres, + tableName: longName, + schema: 'reporting_archive_schema', + statements: const [ + TableMutationStatement( + type: MutationType.delete, + sql: 'DELETE FROM t WHERE "id" = 3', + description: 'Delete row 3', + ), + ], + ); + + await tester.pumpWidget(buildTestDialog(plan: plan)); + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('reporting_archive_schema.$longName'), findsOneWidget); + }); }); } diff --git a/test/features/workspace/generic_sql_workspace_error_caret_test.dart b/test/features/workspace/generic_sql_workspace_error_caret_test.dart new file mode 100644 index 00000000..5cf307b6 --- /dev/null +++ b/test/features/workspace/generic_sql_workspace_error_caret_test.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/workspace/generic_sql_workspace.dart'; + +import '../../support/fake_sql_execution_delegate.dart'; +import '../../support/local_db_test_support.dart'; +import '../../support/querya_theme_test_shell.dart'; + + +/// A hung test must fail in a minute instead of blocking CI for ten. +const _timeout = Timeout(Duration(seconds: 60)); + + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late ConnectionRow connection; + + setUpAll(() async { + tempDir = await initTestLocalDb('generic_ws_error_caret_'); + // History is written with a foreign key to `connections`, so the workspace + // needs a connection that really exists. + const row = ConnectionRow( + type: 'sqlite', + name: 'Synthetic', + host: '/tmp/synthetic.db', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + connection = row.copyWith(id: id); + }); + tearDownAll(() => disposeTestLocalDb(tempDir)); + + tearDown(() { + SqlEditorCommandBridge.instance.unregister(connectionId: connection.id); + }); + + Future pumpWorkspace( + WidgetTester tester, + FakeSqlExecutionDelegate delegate, { + String? initialSql, + }) async { + await tester.binding.setSurfaceSize(const material.Size(1200, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox.expand( + child: GenericSqlWorkspace( + connectionRow: connection, + delegate: delegate, + dialect: SqlDialect.sqlite, + initialSql: initialSql, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return tester.state( + find.byType(GenericSqlWorkspace), + ); + } + + /// What SQLite throws for a bad column in a query the app wrapped (#1144), + /// with [at] counting bytes from the start of the wrapper. + Object sqliteError(String sql, int at) => Exception( + 'SqliteFfiException(sqlite_error: 1, , SqliteException(1): while ' + 'preparing statement, no such column: nope, SQL logic error (code 1)\n' + ' Causing statement (at position $at): SELECT * FROM (\n$sql\n)\n' + 'LIMIT 5000})', + ); + + Future run( + WidgetTester tester, + GenericSqlWorkspaceState state, + ) async { + // Settings are read from the real SQLite file: start the query in the test + // zone, then let real time pass. + unawaited(state.execute()); + for (var i = 0; i < 4; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 150)), + ); + await tester.pump(); + } + } + + testWidgets('a failed query puts the caret where the driver says', + timeout: _timeout, (tester) async { + const sql = 'SELECT a,\n nope\nFROM t'; + final delegate = FakeSqlExecutionDelegate(onExecute: (s) { + throw sqliteError(s, 16 + sql.indexOf('nope')); + }); + final state = await pumpWorkspace(tester, delegate, initialSql: sql); + + await run(tester, state); + + final session = state.activeSession; + expect(session.error, contains('no such column: nope')); + expect(session.controller.selection.isCollapsed, isTrue); + expect(session.controller.selection.baseOffset, sql.indexOf('nope')); + }); + + testWidgets('leading blank lines in the editor are counted', + timeout: _timeout, (tester) async { + const sql = 'SELECT nope FROM t'; + const padded = '\n\n $sql'; + final delegate = FakeSqlExecutionDelegate(onExecute: (s) { + throw sqliteError(s, 16 + sql.indexOf('nope')); + }); + final state = await pumpWorkspace(tester, delegate, initialSql: padded); + + await run(tester, state); + + expect(state.activeSession.controller.selection.baseOffset, + padded.indexOf('nope')); + }); + + testWidgets('an error without a position leaves the caret alone', + timeout: _timeout, (tester) async { + final delegate = FakeSqlExecutionDelegate( + onExecute: (s) => + throw Exception('MySQLServerException [1064]: You have an error'), + ); + final state = await pumpWorkspace(tester, delegate, initialSql: 'SELEC 1'); + state.activeSession.controller.selection = + const material.TextSelection.collapsed(offset: 2); + + await run(tester, state); + + expect(state.activeSession.error, contains('1064')); + expect(state.activeSession.controller.selection.baseOffset, 2); + }); +} diff --git a/test/features/workspace/generic_sql_workspace_sessions_test.dart b/test/features/workspace/generic_sql_workspace_sessions_test.dart index d832d6e8..812e635f 100644 --- a/test/features/workspace/generic_sql_workspace_sessions_test.dart +++ b/test/features/workspace/generic_sql_workspace_sessions_test.dart @@ -6,7 +6,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/workspace/generic_sql_workspace.dart'; +import 'package:querya_desktop/features/workspace/sql_workspace_toolbar.dart'; import '../../support/fake_sql_execution_delegate.dart'; import '../../support/local_db_test_support.dart'; @@ -254,4 +256,58 @@ void main() { () => Future.delayed(const Duration(milliseconds: 100)), ); }); + + testWidgets( + 'updates toolbar background when theme switches between dark and light (#1577)', + timeout: _timeout, (tester) async { + final delegate = FakeSqlExecutionDelegate(); + await tester.binding.setSurfaceSize(const material.Size(1200, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + material.Widget buildWithTheme(QueryaTheme theme) { + return queryaThemeTestShell( + data: theme, + child: material.SizedBox.expand( + child: GenericSqlWorkspace( + connectionRow: connection, + delegate: delegate, + dialect: SqlDialect.sqlite, + ), + ), + ); + } + + // 1. Initial dark theme mount + await tester.pumpWidget(buildWithTheme(QueryaTheme.darkDefault)); + await tester.pumpAndSettle(); + + final toolbarFinder = find.byType(SqlWorkspaceToolbar); + expect(toolbarFinder, findsOneWidget); + + material.BoxDecoration getToolbarDecoration() { + final container = tester.widget( + find + .ancestor( + of: toolbarFinder, + matching: find.byType(material.Container), + ) + .first, + ); + return container.decoration! as material.BoxDecoration; + } + + expect( + getToolbarDecoration().color, + QueryaTheme.darkDefault.workbench.surface, + ); + + // 2. Switch to light theme + await tester.pumpWidget(buildWithTheme(QueryaTheme.lightDefault)); + await tester.pumpAndSettle(); + + expect( + getToolbarDecoration().color, + QueryaTheme.lightDefault.workbench.surface, + ); + }); } diff --git a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart index fcce2173..a60feb5b 100644 --- a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart +++ b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart @@ -367,20 +367,30 @@ void main() { expect(tester.getCenter(session).dy, closeTo(tester.getCenter(run).dy, 1)); }); - testWidgets('below the breakpoint the labels hide and the icons stay', + testWidgets('the row gives up its labels one control at a time (#1558)', timeout: _timeout, (tester) async { await pumpWorkspace(tester, FakeSqlExecutionDelegate()); expect(find.text('Execute (F5)'), findsOneWidget); expect(find.text('Explain'), findsOneWidget); + expect(find.text('Session · auto-commit'), findsOneWidget); + // Narrower: Explain and Session become icons, the run button keeps its + // label. await tester.binding.setSurfaceSize(const material.Size(800, 700)); await tester.pumpAndSettle(); + expect(find.text('Execute (F5)'), findsOneWidget); + expect(find.text('Explain'), findsNothing); + expect(find.text('Session · auto-commit'), findsNothing); + expect(find.byKey(const material.ValueKey('explain_query')), findsOneWidget); + expect(find.byTooltip('Show the query plan'), findsOneWidget); + expect(find.byTooltip('Session: auto-commit'), findsOneWidget); + // Narrowest: the run button is an icon too, and every control stays. + await tester.binding.setSurfaceSize(const material.Size(560, 700)); + await tester.pumpAndSettle(); expect(find.text('Execute (F5)'), findsNothing); - expect(find.text('Explain'), findsNothing); expect(find.byKey(const material.ValueKey('run_script')), findsOneWidget); expect(find.byKey(const material.ValueKey('explain_query')), findsOneWidget); - expect(find.byTooltip('Show the query plan'), findsOneWidget); expect(find.byTooltip('Execute (F5)'), findsOneWidget); }); diff --git a/test/features/workspace/generic_table_view_filter_calc_test.dart b/test/features/workspace/generic_table_view_filter_calc_test.dart index 2c06122b..b9056c86 100644 --- a/test/features/workspace/generic_table_view_filter_calc_test.dart +++ b/test/features/workspace/generic_table_view_filter_calc_test.dart @@ -152,14 +152,26 @@ void main() { }); group('calc bar', () { - testWidgets('shows aggregates for the selected cell', (tester) async { + testWidgets('shows aggregates for a numeric cell', (tester) async { await pumpGenericTableView(tester, FakeTableDataDelegate()); - await tester.tap(find.text('Alice')); + await tester.tap(find.text('1').first); await tester.pump(const Duration(milliseconds: 500)); await tester.pumpAndSettle(); expect(find.text('Count: '), findsOneWidget); }); + + testWidgets('stays collapsed for a single text cell', (tester) async { + await pumpGenericTableView(tester, FakeTableDataDelegate()); + + await tester.tap(find.text('Alice')); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + // One non-numeric value has nothing to aggregate: the bar is collapsed + // and, since #1674, its content is not built at all. + expect(find.text('Count: '), findsNothing); + }); }); } diff --git a/test/features/workspace/generic_table_view_relations_test.dart b/test/features/workspace/generic_table_view_relations_test.dart index 2e935946..ae5d654c 100644 --- a/test/features/workspace/generic_table_view_relations_test.dart +++ b/test/features/workspace/generic_table_view_relations_test.dart @@ -23,8 +23,9 @@ TableDataPage _catalog(String sql) { ['orgs', 'region_id', 'regions', 'id'], ]); } - if (sql == ErdCatalog.columnsSql(d) || - sql == ErdCatalog.columnsSql(d, schema: 'public')) { + final columns = _unfiltered(sql); + if (columns == ErdCatalog.columnsSql(d) || + columns == ErdCatalog.columnsSql(d, schema: 'public')) { return const TableDataPage(columns: ['t', 'c', 'ty', 'pk'], rows: [ ['users', 'id', 'integer', '1'], ['users', 'org_id', 'integer', '0'], @@ -55,6 +56,10 @@ Iterable _opacityOf(WidgetTester t, String table) => matching: find.byType(material.Opacity), )).map((o) => o.opacity); +/// [sql] without the table-name filter `columnsSql(tables: ...)` adds (#1572). +String _unfiltered(String sql) => sql.replaceAll( + RegExp(r"\n AND (c\.relname|c\.table_name|m\.name) IN \([^)]*\)"), ''); + void main() { setUp(() => TableViewCommandBridge.instance.resetForTest()); diff --git a/test/features/workspace/grid_cell_editor_test.dart b/test/features/workspace/grid_cell_editor_test.dart index 2bd5cc2e..ad96c01e 100644 --- a/test/features/workspace/grid_cell_editor_test.dart +++ b/test/features/workspace/grid_cell_editor_test.dart @@ -4,7 +4,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/grid_cell_editor.dart'; import 'package:querya_desktop/features/workspace/grid_cell_popover_inspector.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; +import 'package:querya_desktop/shared/widgets/querya_spinner.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; @@ -143,6 +145,13 @@ void main() { await tester.pumpAndSettle(); expect(find.byIcon(material.Icons.error_outline_rounded), findsOneWidget); + final errorIcon = tester.widget( + find.byIcon(material.Icons.error_outline_rounded), + ); + expect( + errorIcon.color, + QueryaTheme.darkDefault.colors.destructive, + ); }); }); @@ -188,6 +197,89 @@ void main() { expect(result!.contains('"name": "querya"'), isTrue); }); + group('text past the isolate threshold', () { + final saved = inspectorIsolateThreshold; + setUp(() => inspectorIsolateThreshold = 64); + tearDown(() => inspectorIsolateThreshold = saved); + + // `compute` runs on a real isolate, which the fake-async test zone does + // not wait for: give it real time between pumps. + Future waitUntil( + WidgetTester tester, + bool Function() done, + ) async { + for (var i = 0; i < 100 && !done(); i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pump(); + } + } + + testWidgets('JSON is detected and formatted off the UI thread', + (tester) async { + final big = + '[${List.generate(12, (i) => '{"id":$i,"v":"xxxxxxxxxx"}').join(',')}]'; + expect(big.length, greaterThan(inspectorIsolateThreshold)); + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'metadata', + initialValue: big, + rowIndex: 0, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + // The check runs on the worker; the button shows when it answers. + await waitUntil(tester, () => find.text('Format JSON').evaluate().isNotEmpty); + expect(find.text('Format JSON'), findsOneWidget); + + await tester.tap(find.text('Format JSON')); + await tester.pump(); + await waitUntil(tester, () => find.byType(QueryaSpinner).evaluate().isEmpty); + + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + expect(result, isNotNull); + expect(result!.contains('\n'), isTrue); + expect(result!.contains('"id": 11'), isTrue); + }); + + testWidgets('plain large text does not get JSON buttons', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showGridCellInspectorDialog( + context: context, + columnName: 'notes', + initialValue: 'x' * 200, + ), + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + expect(find.text('Format JSON'), findsNothing); + expect(find.text('Format XML'), findsNothing); + }); + }); + testWidgets('allows setting value to NULL in dialog', (tester) async { String? result; diff --git a/test/features/workspace/grid_filter_predicate_test.dart b/test/features/workspace/grid_filter_predicate_test.dart new file mode 100644 index 00000000..fe035b1e --- /dev/null +++ b/test/features/workspace/grid_filter_predicate_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/grid_filter_engine.dart'; + +void main() { + // A predicate resolves its operator, target, IN set and BETWEEN bounds once + // when it is built (#1550); these pin the per-row results. + group('GridFilterEngine predicates', () { + const columns = ['id', 'name', 'price', 'note']; + const rows = [ + ['1', 'Anna', '10', 'x'], + ['2', 'Andy', '20.0', 'NULL'], + ['3', 'Bob', '30', ''], + ['4', 'Notebook', '40', 'y'], + ['5', 'Android', '50', 'z'], + ]; + + List run(String filter) => GridFilterEngine.filterRowIndices( + filterText: filter, + columns: columns, + rows: rows, + ); + + test('IN compares trimmed, case-insensitive values', () { + expect(run("name IN (' ANNA ', bob)"), [0, 2]); + expect(run('name IN (anDY, "NOTEBOOK")'), [1, 3]); + expect(run("name NOT IN (' anna ', BOB)"), [1, 3, 4]); + expect(run('price IN (10, 30)'), [0, 2]); // "20.0" is not in the list + }); + + test('BETWEEN is numeric when all three sides are numbers', () { + expect(run('price BETWEEN 15 AND 40'), [1, 2, 3]); + expect(run('price BETWEEN 20 AND 20'), [1]); + }); + + test('BETWEEN falls back to a case-sensitive string range', () { + expect(run("name BETWEEN 'Anna' AND 'Bob'"), [0, 2]); + }); + + test('numeric comparison needs both sides to parse', () { + expect(run('price = 20'), [1]); // "20.0" equals 20 + expect(run('price > 25'), [2, 3, 4]); + expect(run('price <= 20'), [0, 1]); + expect(run('price = abc'), isEmpty); + }); + + test('string comparison and contains ignore case', () { + expect(run('name = ANNA'), [0]); + expect(run('name : ND'), [1, 4]); + expect(run('name != bob'), [0, 1, 3, 4]); + expect(run('name > Bob'), [3]); // "notebook" > "bob" + }); + + test('a number target compares numerically, a text target as text', () { + expect(run('id = 3'), [2]); + expect(run('price : 20'), [1]); // `:` is equality between two numbers + expect(run('price >= 30'), [2, 3, 4]); + expect(run('name = 10'), isEmpty); // text cells against a number + expect(run('price = abc'), isEmpty); // number cells against text + }); + + test('free text is case-insensitive in every column', () { + expect(run('ANDROID'), [4]); + expect(run('NOTEBOOK x'), isEmpty); // both terms must hit some column + expect(run('anna X'), [0]); + }); + + test('IS NULL treats NULL, null and empty as null', () { + expect(run('note IS NULL'), [1, 2]); + expect(run('note IS NOT NULL'), [0, 3, 4]); + }); + }); +} diff --git a/test/features/workspace/grid_filter_tokenizer_test.dart b/test/features/workspace/grid_filter_tokenizer_test.dart new file mode 100644 index 00000000..ae4d903c --- /dev/null +++ b/test/features/workspace/grid_filter_tokenizer_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/grid_filter_engine.dart'; + +void main() { + // The lexer reads the input by offset with precompiled patterns (#1527, + // #1512); these pin the keyword boundaries and operators it has to keep. + group('GridFilterEngine tokenizer', () { + const columns = ['id', 'name', 'price', 'note']; + const rows = [ + ['1', 'Anna', '10', 'x'], + ['2', 'Andy', '20', 'NULL'], + ['3', 'Bob', '30', ''], + ['4', 'Notebook', '40', 'y'], + ['5', 'Android', '50', 'z'], + ]; + + List run(String filter) => GridFilterEngine.filterRowIndices( + filterText: filter, + columns: columns, + rows: rows, + ); + + test('AND / OR / NOT are keywords only on a word boundary', () { + expect(run('android'), [4]); // not AND + roid + expect(run('note'), [3]); // not NOT + e + expect(run('name = Bob AND price > 10'), [2]); + expect(run('Bob and price > 10'), [2]); + expect(run('price < 20 or name = Bob'), [0, 2]); + }); + + test('symbolic operators && || ! work without spaces around names', () { + expect(run('price>=30 && name:o'), [2, 3, 4]); + expect(run('price<20 || name = Bob'), [0, 2]); + expect(run('!name = Bob'), [0, 1, 3, 4]); + expect(run('NOT name = Bob'), [0, 1, 3, 4]); + }); + + test('comparison operators without spaces', () { + expect(run('price>=30'), [2, 3, 4]); + expect(run('price<>30'), [0, 1, 3, 4]); + expect(run('price!=30'), [0, 1, 3, 4]); + expect(run('name:an'), [0, 1, 4]); + }); + + test('IS NULL / IS NOT NULL in any case', () { + expect(run('note IS NULL'), [1, 2]); + expect(run('note is not null'), [0, 3, 4]); + expect(run('name IS NULL'), isEmpty); + }); + + test('IN, NOT IN and BETWEEN', () { + expect(run("name IN ('anna', \"BOB\")"), [0, 2]); + expect(run('name NOT IN (Anna, Bob)'), [1, 3, 4]); + expect(run('name IN(Anna)'), [0]); + expect(run('price BETWEEN 15 AND 40'), [1, 2, 3]); + expect(run('price between 15 and 40'), [1, 2, 3]); + }); + + test('LIKE, ILIKE and NOT LIKE', () { + expect(run("name LIKE 'An%'"), [0, 1, 4]); + expect(run("name ILIKE 'an%'"), [0, 1, 4]); + expect(run("name LIKE 'an%'"), isEmpty); + expect(run("name NOT LIKE 'An%'"), [2, 3]); + }); + + test('parentheses group predicates', () { + expect(run('(price<20 OR price>40) AND note != y'), [0, 4]); + }); + }); +} diff --git a/test/features/workspace/grid_selection_calc_format_test.dart b/test/features/workspace/grid_selection_calc_format_test.dart new file mode 100644 index 00000000..d119a500 --- /dev/null +++ b/test/features/workspace/grid_selection_calc_format_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart'; + +void main() { + group('GridSelectionCalcEngine.formatNum (#1531)', () { + test('trims trailing zeros and a dangling dot', () { + expect(GridSelectionCalcEngine.formatNum(null), '-'); + expect(GridSelectionCalcEngine.formatNum(3.0), '3'); + expect(GridSelectionCalcEngine.formatNum(3.14), '3.14'); + expect(GridSelectionCalcEngine.formatNum(0.5), '0.5'); + expect(GridSelectionCalcEngine.formatNum(-2.5), '-2.5'); + expect(GridSelectionCalcEngine.formatNum(1 / 3), '0.3333'); + expect(GridSelectionCalcEngine.formatNum(0.1 + 0.2), '0.3'); + expect(GridSelectionCalcEngine.formatNum(1234.5678), '1234.5678'); + }); + + test('values that round to a whole number keep their integer zeros', () { + expect(GridSelectionCalcEngine.formatNum(2.5000001), '2.5'); + expect(GridSelectionCalcEngine.formatNum(10.00001), '10'); + expect(GridSelectionCalcEngine.formatNum(100.00004), '100'); + expect(GridSelectionCalcEngine.formatNum(0.00001), '0'); + }); + }); + + group('GridSelectionCalcEngine.compute with thousands separators (#1520)', () { + test('commas are stripped only for parsing, distinct keeps the text', () { + final stats = GridSelectionCalcEngine.compute( + ['1,000', ' 2,500.5 ', 'NULL', '', 'abc', '3', '3'], + ); + expect(stats.totalCount, 7); + expect(stats.nullCount, 2); + expect(stats.distinctCount, 4); // '1,000', '2,500.5', 'abc', '3' + expect(stats.numericCount, 4); + expect(stats.sum, 3506.5); + expect(stats.min, 3); + expect(stats.max, 2500.5); + expect(stats.median, closeTo(501.5, 1e-9)); // (3 + 1000) / 2 + }); + }); + + group('GridSelectionCalcEngine.parseThousandsSeparated (#1520)', () { + // What it replaces: drop every comma, then double.tryParse. + double? copy(String s) => double.tryParse(s.replaceAll(',', '')); + + test('reads the same numbers as dropping the commas', () { + for (final s in [ + '1,000', + '2,500.5', + '-1,234.56', + '+3,000', + '0,0', + '0.001,5', + '1,0e-3', + '1e,6', + ',-327', + '5,.', + ',.5', + '12,345,678,901,234,567,890', + '9,007,199,254,740,993', + '1,e22', + '1,e23', + '1,e-400', + '1,e400', + ]) { + final expected = copy(s); + final actual = GridSelectionCalcEngine.parseThousandsSeparated(s); + expect(actual, expected, reason: s); + } + }); + + test('-0 keeps its sign', () { + expect( + GridSelectionCalcEngine.parseThousandsSeparated('-0,0')!.isNegative, + isTrue, + ); + }); + + test('text with commas is not a number', () { + for (final s in ['Doe, John', ',', '1,,e', '1, 000', '1.2,3.4', 'e,5']) { + expect( + GridSelectionCalcEngine.parseThousandsSeparated(s), + copy(s), + reason: s, + ); + } + }); + }); +} diff --git a/test/features/workspace/grid_selection_export_test.dart b/test/features/workspace/grid_selection_export_test.dart new file mode 100644 index 00000000..d8b2af1f --- /dev/null +++ b/test/features/workspace/grid_selection_export_test.dart @@ -0,0 +1,153 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/grid/grid_selection_manager.dart'; + +void main() { + group('ResultGridSelection clipboard export (#1524)', () { + const columns = ['x', 'y', 'z']; + const rows = [ + ['a', 'b c', 'd,e'], + ['1', '', '"q"'], + ]; + const all = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 2, + ); + + test('CSV: header, quoting and row order', () { + expect( + all.toCsv(rows, columns: columns), + 'x,y,z\na,b c,"d,e"\n1,,"""q"""', + ); + expect(all.toCsv(rows), 'a,b c,"d,e"\n1,,"""q"""'); + }); + + test('TSV: header, quoting and row order', () { + expect( + all.toTsv(rows, columns: columns), + 'x\ty\tz\na\tb c\td,e\n1\t\t"""q"""', + ); + expect(all.toTsv(rows), 'a\tb c\td,e\n1\t\t"""q"""'); + }); + + test('a column sub-range and rows past the end', () { + const sub = ResultGridSelection( + startRow: 1, + startColumn: 1, + endRow: 5, + endColumn: 3, + ); + // Column 3 does not exist: an empty cell; row 5 does not exist: skipped. + expect(sub.toCsv(rows, columns: columns), 'y,z,\n,"""q""",'); + expect(sub.toTsv(rows), '\t"""q"""'); + }); + + test('trailing empty cells of the last row are trimmed in TSV only', () { + const one = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 0, + endColumn: 1, + ); + const data = [ + ['a', ''], + ]; + expect(one.toTsv(data), 'a'); + expect(one.toCsv(data), 'a,'); + }); + + test('empty input gives an empty string', () { + expect(all.toCsv(const []), ''); + expect(all.toTsv(const []), ''); + }); + + test('JSON keeps leading zeros, nulls and booleans', () { + const sel = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 0, + endColumn: 9, + ); + final json = sel.toJson( + ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], + const [ + ['007', '12', '1.5', 'NULL', 'true', 'FALSE', 'x', ' 007 ', '0', '0.5'], + ], + ); + expect(jsonDecode(json), { + 'a': '007', + 'b': 12, + 'c': 1.5, + 'd': null, + 'e': true, + 'f': false, + 'g': 'x', + 'h': ' 007 ', + 'i': 0, + 'j': 0.5, + }); + }); + + test('JSON: text that only looks like a number or a boolean stays text', + () { + const sel = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 0, + endColumn: 6, + ); + final json = sel.toJson( + ['a', 'b', 'c', 'd', 'e', 'f', 'g'], + const [ + ['Infinity', 'NaN', 'tRuE', ' 5', '-3', '.5', 'yes'], + ], + ); + expect(jsonDecode(json), { + 'a': 'Infinity', + 'b': 'NaN', + 'c': true, + 'd': 5.0, + 'e': -3, + 'f': 0.5, + 'g': 'yes', + }); + }); + + test('CSV and TSV quote only what needs it', () { + const sel = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 0, + endColumn: 3, + ); + const data = [ + ['plain', 'a\tb', 'c,d', 'line\nbreak'], + ]; + expect(sel.toCsv(data), 'plain,a\tb,"c,d","line\nbreak"'); + expect(sel.toTsv(data), 'plain\t"a\tb"\tc,d\t"line\nbreak"'); + }); + + test('JSON of several rows is an array', () { + const sel = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 0, + ); + final json = sel.toJson( + ['n'], + const [ + ['1'], + ['2'], + ], + ); + expect(jsonDecode(json), [ + {'n': 1}, + {'n': 2}, + ]); + }); + }); +} diff --git a/test/features/workspace/grid_sort_dates_test.dart b/test/features/workspace/grid_sort_dates_test.dart new file mode 100644 index 00000000..867e7a44 --- /dev/null +++ b/test/features/workspace/grid_sort_dates_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/grid/grid_sorting.dart'; + +/// #1533: date keys are read without DateTime.parse's local constructor per +/// row, with the same instants and therefore the same order. +void main() { + int? reference(String s) => DateTime.tryParse(s)?.microsecondsSinceEpoch; + int? fast(String s, Map cache) => + isoMicrosSinceEpoch(s, localOffsets: cache) ?? reference(s); + + const shapes = [ + '2026-01-02', + '2026-01-02T03:04', + '2026-01-02T03:04:05', + '2026-01-02 03:04:05.123', + '2026-01-02T03:04:05.1234567', + '2026-01-02T03:04:05Z', + '2026-01-02t03:04:05z', + '2026-01-02T03:04:05.5+03:00', + '2026-01-02T03:04:05-0530', + '2026-01-02T03:04:05+02', + '2026-02-30 12:00', + '2026-01-02T25:61:61', + // Daylight-saving changes in Europe and the US, and the hours around them. + '2026-03-29T01:59:59', '2026-03-29T02:30', '2026-03-29T03:00', + '2026-10-25T01:30', '2026-10-25T02:30', '2026-10-25T03:00', + '2026-03-08T02:30', '2026-11-01T01:30', + // Shapes left to DateTime.tryParse. + '20260102', '2026-01-02T10', '2026-01-02 Z', '+2026-01-02', 'x', '', + ]; + + test('the same instant as DateTime.parse for every shape', () { + final cache = {}; + for (final s in shapes) { + expect(fast(s, cache), reference(s), reason: s); + } + // A second pass reads the cached offsets. + for (final s in shapes) { + expect(fast(s, cache), reference(s), reason: 'cached: $s'); + } + }); + + test('shapes it does not read are left to DateTime.tryParse', () { + for (final s in [ + '20260102', + '2026-01-02T10', + '2026-01-02 Z', + '2026-01-02t03:04:05', + 'x', + '', + ]) { + expect(isoMicrosSinceEpoch(s), isNull, reason: s); + } + }); + + test('a timestamp column sorts by time, NULL and empty last', () { + final rows = [ + ['2026-01-02T10:00:00'], + ['NULL'], + ['2025-12-31 23:59:59.999'], + [''], + ['2026-01-02T09:00:00Z'], + ['2026-01-01'], + ]; + final asc = sortResultGridRowsWithIndices( + rows: rows, + columnIndex: 0, + order: ResultGridSortOrder.ascending, + ); + final instants = [ + for (final i in asc.sortedToModelIndices.take(4)) + DateTime.parse(rows[i][0]).microsecondsSinceEpoch, + ]; + expect(instants, [...instants]..sort()); + expect(asc.sortedToModelIndices.skip(4).toSet(), {1, 3}); + }); +} diff --git a/test/features/workspace/result_grid_cell_ux_test.dart b/test/features/workspace/result_grid_cell_ux_test.dart index b57e9f97..5c23352a 100644 --- a/test/features/workspace/result_grid_cell_ux_test.dart +++ b/test/features/workspace/result_grid_cell_ux_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/grid_cell_editor.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:querya_desktop/shared/widgets/querya_context_menu.dart'; import '../../support/querya_theme_test_shell.dart'; @@ -250,7 +250,7 @@ void main() { expect( find.descendant( of: find.byKey(const material.ValueKey('result-row-0')), - matching: find.byType(ContextMenu), + matching: find.byType(QueryaContextMenu), ), findsNothing, ); diff --git a/test/features/workspace/result_grid_lazy_context_menu_test.dart b/test/features/workspace/result_grid_lazy_context_menu_test.dart index f46508e8..e23d7372 100644 --- a/test/features/workspace/result_grid_lazy_context_menu_test.dart +++ b/test/features/workspace/result_grid_lazy_context_menu_test.dart @@ -3,13 +3,12 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; material.Widget _testShell({required material.Widget child}) { - final td = QueryaTheme.darkDefault - .toShadcnThemeData() - .copyWith(platform: () => material.TargetPlatform.linux); - return ShadcnApp( + final td = materialThemeFromQuerya(QueryaTheme.darkDefault.colors) + .copyWith(platform: material.TargetPlatform.linux); + return material.MaterialApp( theme: td, home: material.Scaffold( body: child, @@ -65,7 +64,7 @@ void main() { // Right-clicking a cell opens its context menu, which is exactly when // the (now lazy) item list must be built. - await _secondaryClick(tester, find.text('Admin').first); + await _secondaryClick(tester, find.text('Admin').at(3)); expect(gridCellContextMenuItemsBuiltCount, greaterThan(before)); }); diff --git a/test/features/workspace/result_grid_listener_leak_test.dart b/test/features/workspace/result_grid_listener_leak_test.dart new file mode 100644 index 00000000..9f43a0c3 --- /dev/null +++ b/test/features/workspace/result_grid_listener_leak_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/security/pii_masking_controller.dart'; +import 'package:querya_desktop/features/workspace/result_grid_view.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +material.Widget _shell({required material.Widget child}) { + return queryaThemeTestShell(child: material.Scaffold(body: child)); +} + +void main() { + group('VirtualResultGrid listeners (#1555)', () { + testWidgets('disposing a grid removes its listener from PiiMaskingController', + (tester) async { + // ignore: invalid_use_of_protected_member + final before = PiiMaskingController.instance.hasListeners; + + await tester.pumpWidget( + _shell( + child: const material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + // ignore: invalid_use_of_protected_member + expect(PiiMaskingController.instance.hasListeners, isTrue); + + await tester.pumpWidget(_shell(child: const material.SizedBox())); + await tester.pumpAndSettle(); + + // ignore: invalid_use_of_protected_member + expect(PiiMaskingController.instance.hasListeners, before); + }); + }); +} diff --git a/test/features/workspace/result_grid_sorting_model_test.dart b/test/features/workspace/result_grid_sorting_model_test.dart index e1efd25e..db5780bf 100644 --- a/test/features/workspace/result_grid_sorting_model_test.dart +++ b/test/features/workspace/result_grid_sorting_model_test.dart @@ -5,13 +5,13 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; material.Widget _testShell({required material.Widget child}) { - final td = QueryaTheme.darkDefault - .toShadcnThemeData() - .copyWith(platform: () => TargetPlatform.linux); - return ShadcnApp( + final td = materialThemeFromQuerya(QueryaTheme.darkDefault.colors) + .copyWith(platform: material.TargetPlatform.linux); + return material.MaterialApp( theme: td, home: material.Scaffold( body: child, diff --git a/test/features/workspace/results_tab_test.dart b/test/features/workspace/results_tab_test.dart index 69615aa8..d13841ca 100644 --- a/test/features/workspace/results_tab_test.dart +++ b/test/features/workspace/results_tab_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:flutter/services.dart' + show LogicalKeyboardKey, MethodCall, SystemChannels; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; @@ -778,6 +779,63 @@ void main() { expect(find.text('3'), findsOneWidget); }); + group('the mode bar says only what the status bar does not (#1558)', () { + Future pumpBar( + WidgetTester tester, { + String? statusLine, + int? affectedRows, + }) async { + await tester.binding.setSurfaceSize(const material.Size(900, 500)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + resultsShell( + child: material.Scaffold( + body: ResultsTab( + columns: const ['id', 'name'], + rows: const [ + ['1', 'a'], + ['2', 'b'], + ['3', 'c'], + ], + statusLine: statusLine, + affectedRows: affectedRows, + ), + ), + ), + ); + await tester.pump(); + } + + testWidgets('a plain row count is left to the status bar', (tester) async { + await pumpBar(tester, statusLine: '3 row(s).'); + expect(find.text('3 row(s).'), findsNothing); + expect(find.text('3 rows'), findsNothing); + }); + + testWidgets('a truncation note is kept', (tester) async { + await pumpBar( + tester, statusLine: 'Showing first 3 row(s) (result capped).'); + expect(find.text('Showing first 3 row(s) (result capped).'), + findsOneWidget); + }); + + testWidgets('a count with a hint is kept', (tester) async { + await pumpBar(tester, statusLine: '3 row(s). Cannot edit: no key.'); + expect(find.text('3 row(s). Cannot edit: no key.'), findsOneWidget); + }); + + testWidgets('without a status line the count is shown, as before', + (tester) async { + await pumpBar(tester); + expect(find.text('3 rows'), findsOneWidget); + }); + + testWidgets('affected rows are kept', (tester) async { + await pumpBar(tester, affectedRows: 4); + expect(find.text('Rows affected: 4'), findsOneWidget); + }); + }); + testWidgets('shows status and affected empty modes', (tester) async { await tester.pumpWidget( resultsShell( @@ -1457,4 +1515,84 @@ void main() { } }); }); + + group('ResultsTab query errors (#1557)', () { + const raw = + 'SqliteFfiException(sqlite_error: 1, , SqliteException(1): while preparing ' + 'statement, no such column: u.name, SQL logic error (code 1)\n' + ' Causing statement (at position 29): SELECT * FROM (\n' + 'SELECT o.id, u.name FROM orders o JOIN users u ON u.id = o.user_id\n' + ')\n' + 'LIMIT 5000})'; + + Future pumpError(WidgetTester tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab(isLoading: false, errorMessage: raw), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('the headline is the driver message; details are folded', + (tester) async { + await pumpError(tester); + + expect(find.text('no such column: u.name'), findsOneWidget); + expect(find.textContaining('SqliteFfiException'), findsNothing); + expect(find.textContaining('SELECT * FROM ('), findsNothing); + + await tester.tap(find.text('Show details')); + await tester.pumpAndSettle(); + + expect(find.textContaining('SqliteFfiException'), findsWidgets); + expect(find.textContaining('SELECT * FROM ('), findsNothing); + expect(find.textContaining('LIMIT 5000'), findsNothing); + expect(find.text('Hide details'), findsOneWidget); + }); + + testWidgets('Copy error puts the unwrapped text on the clipboard', + (tester) async { + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + addTearDown(() => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null)); + + await pumpError(tester); + await tester.tap(find.text('Copy error')); + await tester.pump(); + + expect(copied, contains('no such column: u.name')); + expect(copied, isNot(contains('SELECT * FROM ('))); + expect(find.text('Copied'), findsOneWidget); + + await tester.pump(const Duration(seconds: 3)); + expect(find.text('Copy error'), findsOneWidget); + }); + + testWidgets('a one-line error has no details to show', (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: ResultsTab(isLoading: false, errorMessage: 'connection refused'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('connection refused'), findsOneWidget); + expect(find.text('Show details'), findsNothing); + expect(find.text('Copy error'), findsNothing); + }); + }); } diff --git a/test/features/workspace/sql_editor_chrome_test.dart b/test/features/workspace/sql_editor_chrome_test.dart index 316a4b12..68f69b7d 100644 --- a/test/features/workspace/sql_editor_chrome_test.dart +++ b/test/features/workspace/sql_editor_chrome_test.dart @@ -11,7 +11,7 @@ import '../../support/querya_theme_test_shell.dart'; void main() { group('SqlEditorChrome.inlineFieldDecoration', () { - test('uses editor background and workbench accent glow', () { + test('uses editor background and workbench accent glow (dialog fields)', () { const editor = QueryaEditorTheme( background: Color(0xFF111111), foreground: Color(0xFFFFFFFF), @@ -128,15 +128,129 @@ void main() { const Color(0xFF112233).withValues(alpha: 0.5), ); - final outerGlow = containers + // No glow: one framed card (#1558). + final shadows = containers .map((c) => c.decoration) .whereType() - .expand((d) => d.boxShadow ?? const []) - .map((s) => s.color) - .whereType() - .firstWhere( - (c) => c == const Color(0xFF00FFEE).withValues(alpha: 0.1)); - expect(outerGlow, const Color(0xFF00FFEE).withValues(alpha: 0.1)); + .expand((d) => d.boxShadow ?? const []); + expect(shadows, isEmpty); + }); + + testWidgets('the border takes the accent while the editor has the keyboard', + (tester) async { + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget( + queryaThemeTestShell( + data: QueryaTheme.darkDefault, + child: material.SizedBox( + width: 320, + height: 200, + child: SqlEditorChrome( + child: Focus(focusNode: focus, child: const material.SizedBox.expand()), + ), + ), + ), + ); + + Color borderColor() { + final c = tester.widgetList(find.descendant( + of: find.byType(SqlEditorChrome), + matching: find.byType(material.Container), + )).firstWhere((c) => c.decoration is material.BoxDecoration && + (c.decoration! as material.BoxDecoration).border != null); + return ((c.decoration! as material.BoxDecoration).border! as Border) + .top + .color; + } + + final idle = borderColor(); + focus.requestFocus(); + await tester.pump(); + final focused = borderColor(); + expect(focused, + QueryaTheme.darkDefault.workbench.accent.withValues(alpha: 0.6)); + expect(idle, isNot(focused)); + }); + + testWidgets('a card with nothing to type in keeps its border', (tester) async { + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget( + queryaThemeTestShell( + data: QueryaTheme.darkDefault, + child: material.SizedBox( + width: 320, + height: 200, + child: SqlEditorChrome( + showFocus: false, + background: const Color(0xFF123456), + child: Focus(focusNode: focus, child: const material.SizedBox.expand()), + ), + ), + ), + ); + focus.requestFocus(); + await tester.pump(); + final c = tester.widgetList(find.descendant( + of: find.byType(SqlEditorChrome), + matching: find.byType(material.Container), + )).firstWhere((c) => c.decoration is material.BoxDecoration && + (c.decoration! as material.BoxDecoration).border != null); + final d = c.decoration! as material.BoxDecoration; + expect(d.color, const Color(0xFF123456)); + expect( + (d.border! as Border).top.color, + isNot(QueryaTheme.darkDefault.workbench.accent.withValues(alpha: 0.6)), + ); + }); + }); + + group('SqlEditorChrome.sqlToolbarDecoration', () { + testWidgets('uses solid surface and subtle border in dark theme', + (tester) async { + late material.BoxDecoration deco; + await tester.pumpWidget( + queryaThemeTestShell( + data: QueryaTheme.darkDefault, + child: material.Builder( + builder: (context) { + deco = SqlEditorChrome.sqlToolbarDecoration(context); + return const material.SizedBox.shrink(); + }, + ), + ), + ); + expect(deco.color, QueryaTheme.darkDefault.workbench.surface); + final border = deco.border as Border?; + expect( + border?.bottom.color, + QueryaTheme.darkDefault.workbench.borderSubtle + .withValues(alpha: 0.35), + ); + }); + + testWidgets('uses solid surface and subtle border in light theme', + (tester) async { + late material.BoxDecoration deco; + await tester.pumpWidget( + queryaThemeTestShell( + data: QueryaTheme.lightDefault, + child: material.Builder( + builder: (context) { + deco = SqlEditorChrome.sqlToolbarDecoration(context); + return const material.SizedBox.shrink(); + }, + ), + ), + ); + expect(deco.color, QueryaTheme.lightDefault.workbench.surface); + final border = deco.border as Border?; + expect( + border?.bottom.color, + QueryaTheme.lightDefault.workbench.borderSubtle + .withValues(alpha: 0.35), + ); }); }); } diff --git a/test/features/workspace/sql_query_history_preview_test.dart b/test/features/workspace/sql_query_history_preview_test.dart new file mode 100644 index 00000000..ce57adc4 --- /dev/null +++ b/test/features/workspace/sql_query_history_preview_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/sql_query_history_dialog.dart'; + +/// The implementation before #1545: collapse the whole text, then cut. +String _reference(String sql) { + final collapsed = sql.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (collapsed.length <= 96) return collapsed; + return '${collapsed.substring(0, 93)}…'; +} + +void main() { + group('previewSqlOneLine (#1545)', () { + test('matches the old collapse-then-cut result', () { + final inputs = [ + '', + ' ', + ' \n\t ', + 'SELECT 1', + ' SELECT 1 \n FROM\tt ', + 'SELECT a, b FROM t', + 'a' * 96, + 'a' * 97, + '${'a' * 96} ', + '${'a' * 96} b', + '${'a' * 95} b', + '${'a' * 94} b c', + ('select x from t\n' * 20), + '\n\n\n${'x ' * 200}', + 'a' * 5000, + 'word ' * 30000, + ]; + for (final sql in inputs) { + expect(previewSqlOneLine(sql), _reference(sql), + reason: 'input of length ${sql.length}'); + } + }); + + test('long text is cut to 93 characters and an ellipsis', () { + final out = previewSqlOneLine('SELECT ${'column_name, ' * 100}FROM t'); + expect(out.length, 94); + expect(out.endsWith('…'), isTrue); + }); + }); +} diff --git a/test/features/workspace/sql_query_tab_session_format_test.dart b/test/features/workspace/sql_query_tab_session_format_test.dart new file mode 100644 index 00000000..4d7a986e --- /dev/null +++ b/test/features/workspace/sql_query_tab_session_format_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; +import 'package:querya_desktop/features/workspace/sql_query_tab_session.dart'; + +void main() { + tearDown(UnsavedWorkRegistry.instance.resetForTest); + + test('Format SQL keeps the caret and the selection (#1566)', () { + final session = SqlQueryTabSession( + id: '1', + title: 'Q', + customController: TextEditingController(), + ); + addTearDown(session.dispose); + + session.controller.value = const TextEditingValue( + text: "select a from t where b = 'or'", + selection: TextSelection(baseOffset: 7, extentOffset: 8), + ); + session.formatSql(); + + expect(session.controller.text, "SELECT a FROM t WHERE b = 'or'"); + expect( + session.controller.selection, + const TextSelection(baseOffset: 7, extentOffset: 8), + ); + }); +} diff --git a/test/features/workspace/table_view_sorting_test.dart b/test/features/workspace/table_view_sorting_test.dart index eb2ac56b..ce88433e 100644 --- a/test/features/workspace/table_view_sorting_test.dart +++ b/test/features/workspace/table_view_sorting_test.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/database/sqlite_connection.dart'; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -160,12 +162,12 @@ void main() { bool activeSortAscending = true; await tester.pumpWidget( - ShadcnApp( - theme: QueryaTheme.darkDefault.toShadcnThemeData(), + material.MaterialApp( + theme: materialThemeFromQuerya(QueryaTheme.darkDefault.colors), home: material.Scaffold( body: material.StatefulBuilder( builder: (context, setState) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final isSorted = activeSortColumn == 'username'; return material.SizedBox( width: 200, diff --git a/test/features/workspace/table_view_staging_test.dart b/test/features/workspace/table_view_staging_test.dart index 75a0324b..5778387e 100644 --- a/test/features/workspace/table_view_staging_test.dart +++ b/test/features/workspace/table_view_staging_test.dart @@ -4,10 +4,10 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; -import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:flutter/material.dart' show MaterialApp; +import '../../support/querya_theme_test_shell.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/table_view_staging.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; void main() { group('tableViewEditingEnabled', () { @@ -288,8 +288,8 @@ void main() { group('applyTableViewStagedChanges', () { testWidgets('returns noop when the buffer is clean', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: const material.Scaffold(body: material.SizedBox()), ), ); @@ -319,8 +319,8 @@ void main() { testWidgets('returns failed when dirty but no primary key', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: const material.Scaffold(body: material.SizedBox()), ), ); @@ -353,8 +353,8 @@ void main() { 'applied save yields a baseline without deleted rows or NULL sentinels', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: const material.Scaffold(body: material.SizedBox()), ), ); @@ -392,8 +392,8 @@ void main() { testWidgets('0-row DML is failed and the staging buffer stays dirty', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: const material.Scaffold(body: material.SizedBox()), ), ); @@ -461,8 +461,8 @@ void main() { group('confirmDiscardTableEditsIfDirty', () { testWidgets('returns true immediately when clean', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: const material.Scaffold(body: material.SizedBox()), ), ); @@ -486,8 +486,8 @@ void main() { testWidgets('shows dialog and returns false when dirty and Cancel pressed', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: const material.Scaffold(body: material.SizedBox()), ), ); @@ -529,8 +529,8 @@ void main() { testWidgets('shows dialog and returns true when dirty and Discard pressed', (tester) async { await tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: const material.Scaffold(body: material.SizedBox()), ), ); @@ -565,8 +565,8 @@ void main() { group('TableEditModeButton', () { Future pump(WidgetTester tester, material.Widget child) => tester.pumpWidget( - ShadcnApp( - theme: AppTheme.dark, + MaterialApp( + theme: queryaDarkMaterialTheme, home: material.Scaffold(body: material.Center(child: child)), ), ); diff --git a/test/guards/colors_through_context_test.dart b/test/guards/colors_through_context_test.dart new file mode 100644 index 00000000..fe74ff62 --- /dev/null +++ b/test/guards/colors_through_context_test.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Epic #1465, #1731: colours are read through `context.colors` +/// (`QueryaUiColors`), not through a theme's `colorScheme`. `Theme.of` itself +/// stays allowed for what is not a colour (brightness, radius) until #1735. +/// +/// The guard covers all of `lib/` except `lib/core/theme/`, which bridges to +/// the shadcn shell and leaves with #1465 step 5. +void main() { + final banned = RegExp(r'\bcolorScheme\b|\bColorScheme\b'); + + test('lib/ reads colours through context.colors (outside core/theme)', () { + final offenders = []; + for (final e in Directory('lib').listSync(recursive: true)) { + if (e is! File || !e.path.endsWith('.dart')) continue; + final normalized = e.path.replaceAll(r'\', '/'); + if (normalized.startsWith('lib/core/theme/')) continue; + + final lines = e.readAsLinesSync(); + for (var i = 0; i < lines.length; i++) { + final line = lines[i]; + if (line.trimLeft().startsWith('//')) continue; + if (banned.hasMatch(line)) { + offenders.add('$normalized:${i + 1}: ${line.trim()}'); + } + } + } + expect(offenders, isEmpty, + reason: 'Use context.colors (QueryaUiColors) instead.'); + }); +} diff --git a/test/guards/devcontainer_flutter_version_test.dart b/test/guards/devcontainer_flutter_version_test.dart new file mode 100644 index 00000000..c80bf503 --- /dev/null +++ b/test/guards/devcontainer_flutter_version_test.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// #1487: the dev container has to build with the Flutter version CI uses, +/// otherwise "works in the container" and "passes in CI" drift apart. +void main() { + test('the dev container Dockerfile pins the Flutter version of ci.yml', () { + final dockerfile = File('.devcontainer/Dockerfile').readAsStringSync(); + final arg = RegExp(r'^ARG FLUTTER_VERSION=(\S+)$', multiLine: true) + .firstMatch(dockerfile); + expect(arg, isNotNull, reason: 'ARG FLUTTER_VERSION= not found'); + + final ci = File('.github/workflows/ci.yml').readAsStringSync(); + final versions = RegExp(r"flutter-version:\s*'([^']+)'") + .allMatches(ci) + .map((m) => m.group(1)!) + .toSet(); + + expect(versions, isNotEmpty, reason: 'no flutter-version in ci.yml'); + expect( + versions, + {arg!.group(1)}, + reason: 'ci.yml uses $versions, .devcontainer/Dockerfile pins ' + '${arg.group(1)}: change them together', + ); + }); +} diff --git a/test/guards/no_extension_method_listeners_test.dart b/test/guards/no_extension_method_listeners_test.dart new file mode 100644 index 00000000..70eb109b --- /dev/null +++ b/test/guards/no_extension_method_listeners_test.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// #1555: an extension method torn off twice gives two different closures, so +/// `removeListener(_onX)` never removes what `addListener(_onX)` stored and the +/// listener keeps its owner alive. Handlers passed to `addListener` / +/// `removeListener` must be class members or a stored closure. +void main() { + test('addListener / removeListener never take an extension method', () { + final extensionBody = RegExp( + r'^extension\s+\w+(?:<[^>]*>)?\s+on\s+[^{]+\{', + multiLine: true, + ); + final methodDecl = RegExp( + r'^ (?:[\w<>?,\s]+\s)?(_?\w+)\s*\([^)]*\)\s*(?:async\s*)?(?:\{|=>)', + multiLine: true, + ); + final listenerArg = RegExp(r'\b(?:add|remove)Listener\(\s*(_?\w+)\s*[,)]'); + + final extensionMethods = {}; + final files = []; + for (final f in Directory('lib').listSync(recursive: true)) { + if (f is! File || !f.path.endsWith('.dart')) continue; + files.add(f); + final src = f.readAsStringSync(); + for (final m in extensionBody.allMatches(src)) { + // Extensions are declared at the top level; their members are indented + // by two spaces up to the first closing brace in column zero. + final end = src.indexOf('\n}', m.end); + final body = src.substring(m.end, end < 0 ? src.length : end); + for (final d in methodDecl.allMatches(body)) { + extensionMethods.add(d.group(1)!); + } + } + } + + // Comments may quote the pattern they warn about. + final commentLine = RegExp(r'^\s*//.*$', multiLine: true); + + final offenders = []; + for (final f in files) { + final code = f.readAsStringSync().replaceAll(commentLine, ''); + for (final m in listenerArg.allMatches(code)) { + final name = m.group(1)!; + if (extensionMethods.contains(name)) offenders.add('${f.path}: $name'); + } + } + + expect( + offenders, + isEmpty, + reason: 'Extension methods passed to addListener / removeListener: ' + '$offenders. Declare them in the class or keep one closure in a ' + 'field.', + ); + }); +} diff --git a/test/guards/shadcn_imports_ratchet_test.dart b/test/guards/shadcn_imports_ratchet_test.dart new file mode 100644 index 00000000..51ac945c --- /dev/null +++ b/test/guards/shadcn_imports_ratchet_test.dart @@ -0,0 +1,91 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Epic #1465: `package:shadcn_flutter` is leaving `lib/`. A ratchet: the files +/// below still import it and may; no other file may start to, and a file +/// that drops the import leaves the list in the same change. +/// +/// `Gap` is the `gap` package's own (#1729): the barrel exports it directly +/// and a file that imports shadcn without a prefix hides shadcn's copy. +void main() { + const allowed = { + 'lib/core/editor/querya_code_editor.dart', + 'lib/features/mongodb/mongo_document_tree_view.dart', + 'lib/features/mongodb/mongo_documents_json_view.dart', + 'lib/features/mongodb/mongo_documents_table_view.dart', + 'lib/features/settings/preferences_controls.dart', + 'lib/features/workspace/data_grid_calc_bar.dart', + 'lib/features/workspace/data_grid_filter_bar.dart', + 'lib/features/workspace/data_grid_value_panel.dart', + 'lib/features/workspace/grid_cell_editor.dart', + 'lib/features/workspace/result_grid_view.dart', + 'lib/features/workspace/sql_editor_chrome.dart', + 'lib/shared/widgets/widgets.dart', + }; + + final importsShadcn = RegExp('package:shadcn_flutter/'); + + Map sources() => { + for (final e in Directory('lib').listSync(recursive: true)) + if (e is File && e.path.endsWith('.dart')) + e.path.replaceAll(r'\', '/'): e.readAsStringSync(), + }; + + test('no new file imports shadcn_flutter', () { + final importing = { + for (final MapEntry(:key, :value) in sources().entries) + if (importsShadcn.hasMatch(value)) key, + }; + expect(importing.difference(allowed), isEmpty, + reason: 'New shadcn imports: use the kit or Flutter (#1465).'); + expect(allowed.difference(importing), isEmpty, + reason: 'These no longer import shadcn: take them off the list.'); + }); + + test('Gap comes from the gap package, not through shadcn', () { + final shadcnImport = RegExp( + r"import 'package:shadcn_flutter/shadcn_flutter\.dart'([^;]*);"); + final offenders = []; + for (final MapEntry(:key, :value) in sources().entries) { + final m = shadcnImport.firstMatch(value); + if (m == null) continue; + final spec = m.group(1)!; + // A prefixed import or a `show` list brings no unprefixed Gap. + if (spec.contains(' as ')) continue; + if (RegExp(r'\bshow\b').hasMatch(spec) && + !RegExp(r'\bGap\b').hasMatch(spec)) { + continue; + } + if (!RegExp(r'hide [^;]*\bGap\b').hasMatch(spec)) offenders.add(key); + } + expect(offenders, isEmpty, + reason: 'Import shadcn with `hide Gap` and take Gap from package:gap.'); + }); + + test('text inputs are QueryaTextField, not shadcn TextField (#1733)', () { + final shadcnField = RegExp(r'(?[ + for (final MapEntry(:key, :value) in sources().entries) + if (key != 'lib/shared/widgets/querya_text_field.dart' && + shadcnField.hasMatch(value)) + key, + ]; + expect(offenders, isEmpty, + reason: 'Use QueryaTextField (the barrel hides shadcn TextField).'); + }); + + test('no hand-built material TextField outside the kit (#1733)', () { + const kit = { + 'lib/shared/widgets/querya_text_field.dart', + 'lib/shared/widgets/querya_search_field.dart', + }; + final offenders = [ + for (final MapEntry(:key, :value) in sources().entries) + if (!kit.contains(key) && value.contains('material.TextField(')) + key, + ]; + expect(offenders, isEmpty, + reason: 'Use QueryaTextField (framed: false inside a frame of your own).'); + }); +} diff --git a/test/guards/sql_workspace_chrome_guards_test.dart b/test/guards/sql_workspace_chrome_guards_test.dart new file mode 100644 index 00000000..585db10d --- /dev/null +++ b/test/guards/sql_workspace_chrome_guards_test.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// The bars around the SQL editor are one visual system (#1558): they take +/// their colours from the workbench tokens, and no two controls of the row +/// share an icon. +void main() { + const chrome = [ + 'lib/features/workspace/sql_query_tab_bar.dart', + 'lib/features/workspace/sql_workspace_toolbar.dart', + 'lib/features/workspace/sql_editor_chrome.dart', + ]; + + String code(String path) => File(path) + .readAsLinesSync() + .where((l) => !l.trimLeft().startsWith('//')) + .join('\n'); + + test('the SQL pane chrome uses workbench tokens, not the shadcn scheme', () { + for (final path in chrome) { + final source = code(path); + expect(source.contains('colorScheme'), isFalse, + reason: '$path reads Theme.colors: use context.workbench.'); + expect(RegExp(r'Color\(0x').hasMatch(source), isFalse, + reason: '$path has a raw colour: use a theme token.'); + } + }); + + test('Explain and the Diagram button do not share an icon', () { + final toolbar = code('lib/features/workspace/sql_workspace_toolbar.dart'); + final workspace = code('lib/features/workspace/generic_sql_workspace.dart'); + final explain = RegExp( + r"ValueKey\('explain_query'\)[\s\S]*?icon: material\.Icons\.(\w+)", + ).firstMatch(toolbar)?.group(1); + final diagram = RegExp( + r"ValueKey\('open_diagram_tab'\)[\s\S]*?icon: material\.Icons\.(\w+)", + ).firstMatch(workspace)?.group(1); + + expect(explain, isNotNull); + expect(diagram, isNotNull); + expect(explain, isNot(diagram)); + }); + + test('the tab strip and the toolbar share one surface decoration', () { + expect(code('lib/features/workspace/sql_query_tab_bar.dart'), + contains('SqlEditorChrome.sqlToolbarDecoration')); + expect(code('lib/features/workspace/generic_sql_workspace.dart'), + contains('SqlEditorChrome.sqlToolbarDecoration')); + }); +} diff --git a/test/packaging/macos_entitlements_test.dart b/test/packaging/macos_entitlements_test.dart new file mode 100644 index 00000000..bc1c5b17 --- /dev/null +++ b/test/packaging/macos_entitlements_test.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// #1592: what ships for macOS (the Xcode Release configuration when there are +/// no signing secrets, ReleaseSigned when there are) has no App Sandbox. +void main() { + const sandbox = 'com.apple.security.app-sandbox'; + + test('the Xcode Release configuration uses ReleaseUnsigned.entitlements', + () { + final project = + File('macos/Runner.xcodeproj/project.pbxproj').readAsStringSync(); + final release = RegExp( + r'/\* Release \*/ = \{\s*isa = XCBuildConfiguration;' + r'[^}]*?CODE_SIGN_ENTITLEMENTS = ([^;]+);', + ).allMatches(project).map((m) => m[1]).toList(); + expect(release, ['Runner/ReleaseUnsigned.entitlements']); + }); + + for (final name in ['ReleaseUnsigned', 'ReleaseSigned']) { + test('$name.entitlements has no App Sandbox', () { + final text = + File('macos/Runner/$name.entitlements').readAsStringSync(); + expect(text, isNot(contains(sandbox))); + }); + } +} diff --git a/test/packaging/package_version_test.dart b/test/packaging/package_version_test.dart new file mode 100644 index 00000000..954dd28d --- /dev/null +++ b/test/packaging/package_version_test.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// `.deb` / `.rpm` versions sort a beta before its final release (#1494). +void main() { + final skipNonPosix = Platform.isWindows ? 'needs bash' : null; + + String packageVersion(String version) { + final r = Process.runSync( + 'bash', + ['scripts/linux/package_version.sh', version], + ); + expect(r.exitCode, 0, reason: '${r.stderr}'); + return (r.stdout as String).trim(); + } + + bool has(String tool) => + Process.runSync('sh', ['-c', 'command -v $tool']).exitCode == 0; + + test('a pre-release gets ~ before its label; a final version is unchanged', + () { + expect(packageVersion('0.5.0'), '0.5.0'); + expect(packageVersion('0.5.0-beta.1'), '0.5.0~beta.1'); + expect(packageVersion('0.5.0-rc-2'), '0.5.0~rc.2'); + }, skip: skipNonPosix); + + // Pairs that must sort as given: older first. + final ordered = [ + ('0.4.19', '0.5.0-beta.1'), + ('0.5.0-beta.1', '0.5.0-beta.2'), + ('0.5.0-beta.2', '0.5.0-beta.10'), + ('0.5.0-beta.2', '0.5.0'), + ('0.5.0', '0.5.1'), + ]; + + test('dpkg upgrades a beta to the final release', () { + for (final (older, newer) in ordered) { + final r = Process.runSync('dpkg', [ + '--compare-versions', + packageVersion(older), + 'lt', + packageVersion(newer), + ]); + expect(r.exitCode, 0, reason: '$older should sort before $newer'); + } + }, skip: skipNonPosix ?? (has('dpkg') ? null : 'dpkg is not installed')); + + test('rpm upgrades a beta to the final release', () { + for (final (older, newer) in ordered) { + final a = packageVersion(older), b = packageVersion(newer); + final r = Process.runSync( + 'rpm', + ['--eval', '%{lua: print(rpm.vercmp("$a", "$b"))}'], + ); + expect((r.stdout as String).trim(), '-1', + reason: '$older should sort before $newer'); + } + }, skip: skipNonPosix ?? (has('rpm') ? null : 'rpm is not installed')); +} diff --git a/test/packaging/release_scripts_test.dart b/test/packaging/release_scripts_test.dart new file mode 100644 index 00000000..af7ae9d5 --- /dev/null +++ b/test/packaging/release_scripts_test.dart @@ -0,0 +1,119 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +/// scripts/release/check_release.sh and prepare_beta.sh (#1498). +void main() { + // The scripts are bash; the release runs them on Linux runners. + if (Platform.isWindows) return; + late Directory root; + + void write(String version, String changelog) { + File(p.join(root.path, 'pubspec.yaml')) + .writeAsStringSync('name: querya_desktop\nversion: $version\n'); + File(p.join(root.path, 'CHANGELOG.md')).writeAsStringSync(changelog); + } + + ProcessResult run(String script, List args) => Process.runSync( + 'bash', + ['scripts/release/$script', ...args], + environment: {'QUERYA_RELEASE_ROOT': root.path}, + ); + + const changelog = '# Changelog\n\n## [Unreleased]\n\n### Added\n- Beta thing\n\n' + '## [0.4.22] - 2026-10-01\n\n- Old\n'; + + setUp(() { + root = Directory.systemTemp.createTempSync('querya_release_'); + Process.runSync('git', ['init', '-q', root.path]); + }); + tearDown(() => root.deleteSync(recursive: true)); + + group('check_release.sh', () { + test('a prepared beta passes and is announced as a pre-release', () { + write('0.5.0-beta.1+6', '# Changelog\n\n## [0.5.0-beta.1] - 2026-10-18\n\n- x\n'); + final r = run('check_release.sh', ['0.5.0-beta.1']); + expect(r.exitCode, 0, reason: '${r.stdout}'); + expect(r.stdout, contains('pre-release')); + expect(r.stdout, contains('AUR and the APT / DNF repositories are skipped')); + }); + + test('a missing changelog section is named', () { + write('0.5.0-beta.1+6', changelog); + final r = run('check_release.sh', ['0.5.0-beta.1']); + expect(r.exitCode, 1); + expect(r.stdout, contains("no '## [0.5.0-beta.1]' section")); + }); + + test('a pubspec that does not match the tag fails', () { + write('0.4.22+5', '## [0.5.0]\n'); + final r = run('check_release.sh', ['v0.5.0']); + expect(r.exitCode, 1); + expect(r.stdout, contains('pubspec says 0.4.22+5')); + }); + + test('a beta needs a build number', () { + write('0.5.0-beta.1', '## [0.5.0-beta.1]\n'); + final r = run('check_release.sh', ['0.5.0-beta.1']); + expect(r.exitCode, 1); + expect(r.stdout, contains('needs a build number')); + }); + + test('a tag that is not SemVer fails', () { + write('0.5.0+1', '## [0.5.0]\n'); + expect(run('check_release.sh', ['0.5']).exitCode, 1); + expect(run('check_release.sh', ['0.5.0-beta_1']).exitCode, 1); + }); + + test('an existing tag fails unless --no-tag-check', () { + write('0.5.0+1', '## [0.5.0]\n'); + Process.runSync('git', ['-C', root.path, 'add', '.']); + Process.runSync('git', [ + '-C', root.path, '-c', 'user.email=t@t', '-c', 'user.name=t', + 'commit', '-qm', 'x', + ]); + Process.runSync('git', ['-C', root.path, 'tag', '0.5.0']); + + final r = run('check_release.sh', ['0.5.0']); + expect(r.exitCode, 1); + expect(r.stdout, contains('already exists')); + expect(run('check_release.sh', ['0.5.0', '--no-tag-check']).exitCode, 0); + }); + + test('a stable tag is announced as latest', () { + write('0.5.0+9', '## [0.5.0] - 2026-11-01\n'); + final r = run('check_release.sh', ['0.5.0']); + expect(r.exitCode, 0, reason: '${r.stdout}'); + expect(r.stdout, contains("make it 'latest'")); + }); + }); + + group('prepare_beta.sh', () { + test('sets the version, turns Unreleased into the beta section, then checks out', + () { + write('0.4.22+5', changelog); + final r = run('prepare_beta.sh', ['0.5.0', '1']); + expect(r.exitCode, 0, reason: '${r.stderr}'); + + expect(File(p.join(root.path, 'pubspec.yaml')).readAsStringSync(), + contains('version: 0.5.0-beta.1+6')); + final notes = File(p.join(root.path, 'CHANGELOG.md')).readAsStringSync(); + final unreleased = notes.indexOf('## [Unreleased]'); + final beta = notes.indexOf('## [0.5.0-beta.1]'); + expect(unreleased, isNonNegative); + expect(beta, greaterThan(unreleased)); + expect(notes.indexOf('- Beta thing'), greaterThan(beta)); + expect(r.stdout, contains('git tag -a 0.5.0-beta.1')); + + expect(run('check_release.sh', ['0.5.0-beta.1']).exitCode, 0); + }); + + test('refuses a section that already exists and bad arguments', () { + write('0.5.0-beta.1+6', '## [Unreleased]\n\n## [0.5.0-beta.1]\n'); + expect(run('prepare_beta.sh', ['0.5.0', '1']).exitCode, 1); + expect(run('prepare_beta.sh', ['0.5', '1']).exitCode, 2); + expect(run('prepare_beta.sh', ['0.5.0', '0']).exitCode, 2); + }); + }); +} diff --git a/test/shared/app_dialog_test.dart b/test/shared/app_dialog_test.dart index 576e01fd..49fde364 100644 --- a/test/shared/app_dialog_test.dart +++ b/test/shared/app_dialog_test.dart @@ -4,6 +4,7 @@ import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/shared/widgets/app_dialog.dart'; @@ -180,7 +181,11 @@ void main() { final filter = tester.widget(find.byType(BackdropFilter)); expect(filter.filter, fullFilter); - expect(tintOf(tester), fullTint); + // The tint is the theme's shadow colour (#1509), at full alpha here. + expect( + tintOf(tester), + ctx.workbenchOrDefault.shadow.withValues(alpha: fullTint.a), + ); expect( find.ancestor( of: find.byType(BackdropFilter), diff --git a/test/shared/app_toast_test.dart b/test/shared/app_toast_test.dart index cbad963a..8cef4bd7 100644 --- a/test/shared/app_toast_test.dart +++ b/test/shared/app_toast_test.dart @@ -115,4 +115,50 @@ void main() { await tester.pump(const Duration(seconds: 6)); await tester.pumpAndSettle(); }); + + testWidgets('closing fades the toast out before it is removed (#1645)', + (tester) async { + await tester.pumpWidget( + _app( + onShow: (context) => showAppToast(context: context, message: 'Leaving'), + ), + ); + await tester.tap(find.text('Show')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + await tester.tap(find.byIcon(material.Icons.close_rounded)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + expect(find.text('Leaving'), findsOneWidget); + final opacity = tester.widget( + find + .ancestor( + of: find.text('Leaving'), + matching: find.byType(material.Opacity), + ) + .first, + ); + expect(opacity.opacity, lessThan(1)); + + await tester.pumpAndSettle(); + expect(find.text('Leaving'), findsNothing); + }); + + testWidgets('under reduced motion a closed toast goes at once', (tester) async { + await tester.pumpWidget( + _app( + disableAnimations: true, + onShow: (context) => showAppToast(context: context, message: 'Quick'), + ), + ); + await tester.tap(find.text('Show')); + await tester.pump(); + + await tester.tap(find.byIcon(material.Icons.close_rounded)); + await tester.pump(); + + expect(find.text('Quick'), findsNothing); + }); } diff --git a/test/shared/data_export_service_test.dart b/test/shared/data_export_service_test.dart index aaacc30e..f018f382 100644 --- a/test/shared/data_export_service_test.dart +++ b/test/shared/data_export_service_test.dart @@ -133,4 +133,60 @@ void main() { expect(md, DataExportService.formatMarkdownTable(columns, rows)); }); }); + + group('formatAsync in chunks (#1537)', () { + const columns = ['id', 'name | pipe', 'note']; + List> table(int n) => [ + for (var r = 0; r < n; r++) + [ + '$r', + r % 7 == 0 ? 'NULL' : 'n"a,me\n$r', + if (r % 5 != 0) "it's | $r", + ], + ]; + + String whole(DataExportFormat format, List> rows) => + switch (format) { + DataExportFormat.csv => DataExportService.formatCsv(columns, rows), + DataExportFormat.json => DataExportService.formatJson(columns, rows), + DataExportFormat.markdown => + DataExportService.formatMarkdownTable(columns, rows), + DataExportFormat.sqlDump => + DataExportService.formatSqlInsertDump('t', columns, rows), + }; + + for (final format in DataExportFormat.values) { + test('${format.name}: chunks joined give the whole document', () { + final rows = table(23); + for (final size in [1, 3, 7, 22, 23]) { + final parts = []; + for (var start = 0; start < rows.length; start += size) { + final end = + start + size < rows.length ? start + size : rows.length; + parts.add(DataExportService.formatChunk( + format, + columns, + rows.sublist(start, end), + 't', + first: start == 0, + last: end == rows.length, + )); + } + expect(parts.join(), whole(format, rows), reason: 'size $size'); + } + }); + + test('${format.name}: formatAsync over the hand-off size', () async { + final rows = + table(DataExportService.handOffCells ~/ columns.length * 2 + 5); + final text = await DataExportService.formatAsync( + format, + columns: columns, + rows: rows, + tableName: 't', + ); + expect(text, whole(format, rows)); + }); + } + }); } diff --git a/test/shared/widgets/query_error_view_test.dart b/test/shared/widgets/query_error_view_test.dart new file mode 100644 index 00000000..1be1bfc2 --- /dev/null +++ b/test/shared/widgets/query_error_view_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; +import 'package:querya_desktop/shared/widgets/query_error_view.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + Future pump(WidgetTester t, String raw, {bool inline = false}) async { + await t.binding.setSurfaceSize(const material.Size(800, 600)); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(queryaThemeTestShell( + child: material.SizedBox.expand( + child: material.Center( + child: QueryErrorView(error: QueryErrorText.parse(raw), inline: inline), + ), + ), + )); + } + + const raw = 'MongoDartError: Could not run the command\n' + 'at db.users.find()\nserver said: unauthorized'; + + testWidgets('the headline first, the rest behind Show details', (t) async { + await pump(t, raw); + expect(find.text('Query Error'), findsOneWidget); + expect(find.text('MongoDartError: Could not run the command'), + findsOneWidget); + expect(find.textContaining('unauthorized'), findsNothing); + + await t.tap(find.text('Show details')); + await t.pump(); + expect(find.textContaining('unauthorized'), findsWidgets); + await t.tap(find.text('Hide details')); + await t.pump(); + expect(find.textContaining('unauthorized'), findsNothing); + }); + + testWidgets('Copy error puts the whole text on the clipboard', (t) async { + String? copied; + t.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + addTearDown(() => t.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null)); + await pump(t, raw); + + await t.tap(find.text('Copy error')); + await t.pump(); + expect(copied, raw); + expect(find.text('Copied'), findsOneWidget); + await t.pump(const Duration(seconds: 3)); + expect(find.text('Copy error'), findsOneWidget); + }); + + testWidgets('inline has no title of its own: the box around it has one', + (t) async { + await pump(t, raw, inline: true); + expect(find.text('Query Error'), findsNothing); + expect(find.text('MongoDartError: Could not run the command'), + findsOneWidget); + }); + + testWidgets('a one-line error has no details or copy', (t) async { + await pump(t, 'collection not found'); + expect(find.text('collection not found'), findsOneWidget); + expect(find.text('Show details'), findsNothing); + }); + + testWidgets('a new error closes the details', (t) async { + await pump(t, raw); + await t.tap(find.text('Show details')); + await t.pump(); + await pump(t, '$raw\nsecond'); + expect(find.text('Show details'), findsOneWidget); + }); +} diff --git a/test/shared/widgets/querya_button_test.dart b/test/shared/widgets/querya_button_test.dart index ff130e7b..305d283c 100644 --- a/test/shared/widgets/querya_button_test.dart +++ b/test/shared/widgets/querya_button_test.dart @@ -277,4 +277,76 @@ void main() { isFalse, ); }); + + group('QueryaButton truncated label (#1689)', () { + testWidgets('a button inside an intrinsic-width layout still lays out', + (tester) async { + await tester.pumpWidget(_shell(material.IntrinsicWidth( + child: QueryaButton.secondary(label: 'Fits', onPressed: () {}), + ))); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.text('Fits'), findsOneWidget); + }); + + testWidgets('widening the space removes the tooltip again', (tester) async { + Future at(double width) async { + await tester.pumpWidget(_shell(material.SizedBox( + width: width, + child: QueryaButton.secondary( + label: 'Export the current result set as CSV', + onPressed: () {}, + ), + ))); + await tester.pump(); + } + + await at(90); + expect(find.byType(material.Tooltip), findsOneWidget); + await at(600); + expect(find.byType(material.Tooltip), findsNothing); + }); + + const long = 'Export the current result set as CSV'; + Finder tooltipWith(String message) => find.byWidgetPredicate( + (w) => w is material.Tooltip && w.message == message, + ); + + testWidgets('a label cut short shows its full text as a tooltip', + (tester) async { + await tester.pumpWidget(_shell(material.SizedBox( + width: 90, + child: QueryaButton.secondary(label: long, onPressed: () {}), + ))); + await tester.pump(); // the cut is reported after the first frame + + expect(tooltipWith(long), findsOneWidget); + }); + + testWidgets('a label that fits gets no tooltip', (tester) async { + await tester.pumpWidget(_shell(material.SizedBox( + width: 240, + child: QueryaButton.secondary(label: 'Save', onPressed: () {}), + ))); + await tester.pump(); + + expect(find.byType(material.Tooltip), findsNothing); + }); + + testWidgets('an explicit tooltip wins over the cut label', (tester) async { + await tester.pumpWidget(_shell(material.SizedBox( + width: 90, + child: QueryaButton.secondary( + label: long, + tooltip: 'Export as CSV', + onPressed: () {}, + ), + ))); + await tester.pump(); + + expect(tooltipWith(long), findsNothing); + expect(tooltipWith('Export as CSV'), findsOneWidget); + }); + }); } diff --git a/test/shared/widgets/querya_color_picker_test.dart b/test/shared/widgets/querya_color_picker_test.dart new file mode 100644 index 00000000..7dc7df1d --- /dev/null +++ b/test/shared/widgets/querya_color_picker_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_color_picker.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + test('hex round trip, with and without alpha', () { + const c = material.Color(0x80FF8800); + expect(QueryaColorPicker.toHex(c), '#80FF8800'); + expect(QueryaColorPicker.toHex(c, alpha: false), '#FF8800'); + expect(QueryaColorPicker.parseHex('#80ff8800'), c); + expect(QueryaColorPicker.parseHex('ff8800'), const material.Color(0xFFFF8800)); + expect(QueryaColorPicker.parseHex('#ff88'), isNull); + expect(QueryaColorPicker.parseHex('nothex'), isNull); + }); + + testWidgets('typing a hex reports that colour', (t) async { + material.Color? seen; + await t.pumpWidget(queryaThemeTestShell( + child: material.Material( + child: QueryaColorPicker( + value: const material.Color(0xFF000000), + onChanged: (c) => seen = c, + ), + ), + )); + await t.enterText(find.byType(material.TextField), '#FF8800'); + expect(seen, const material.Color(0xFFFF8800)); + }); +} diff --git a/test/shared/widgets/querya_context_menu_test.dart b/test/shared/widgets/querya_context_menu_test.dart new file mode 100644 index 00000000..0607d5c0 --- /dev/null +++ b/test/shared/widgets/querya_context_menu_test.dart @@ -0,0 +1,243 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_context_menu.dart'; +import 'package:querya_desktop/shared/widgets/querya_menu_bar.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +material.Widget _shell(material.Widget child) => queryaThemeTestShell( + child: material.Scaffold( + body: material.Center(child: child), + ), + ); + +Future _secondaryClick(WidgetTester tester, Finder finder) async { + final gesture = await tester.startGesture( + tester.getCenter(finder), + buttons: kSecondaryMouseButton, + ); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); +} + +void main() { + group('QueryaContextMenu', () { + testWidgets('opens on secondary click and fires onPressed', + (tester) async { + var item1Pressed = false; + var item2Pressed = false; + + await tester.pumpWidget( + _shell( + QueryaContextMenu( + items: [ + QueryaMenuItem( + onPressed: (_) => item1Pressed = true, + child: const material.Text('Item 1'), + ), + QueryaMenuItem( + onTap: () => item2Pressed = true, + child: const material.Text('Item 2'), + ), + const QueryaMenuDivider(), + QueryaMenuItem( + enabled: false, + onTap: () {}, + child: const material.Text('Disabled Item'), + ), + ], + child: const material.Text('Target Box'), + ), + ), + ); + + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 2'), findsNothing); + + await _secondaryClick(tester, find.text('Target Box')); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + expect(find.text('Disabled Item'), findsOneWidget); + + await tester.tap(find.text('Item 1')); + await tester.pumpAndSettle(); + + expect(item1Pressed, isTrue); + expect(item2Pressed, isFalse); + expect(find.text('Item 1'), findsNothing); + }); + + testWidgets('evaluates itemsBuilder lazily only when opened', + (tester) async { + var builds = 0; + + await tester.pumpWidget( + _shell( + QueryaContextMenu( + itemsBuilder: (context) { + builds++; + return [ + QueryaMenuItem( + onTap: () {}, + child: const material.Text('Lazy Item'), + ), + ]; + }, + child: const material.Text('Lazy Target'), + ), + ), + ); + + expect(builds, 0); + + await _secondaryClick(tester, find.text('Lazy Target')); + expect(builds, 1); + expect(find.text('Lazy Item'), findsOneWidget); + + await tester.tap(find.text('Lazy Item')); + await tester.pumpAndSettle(); + expect(find.text('Lazy Item'), findsNothing); + }); + + testWidgets('supports submenu expansion', (tester) async { + var subItemPressed = false; + + await tester.pumpWidget( + _shell( + QueryaContextMenu( + items: [ + QueryaMenuItem( + subMenu: [ + QueryaMenuItem( + onTap: () => subItemPressed = true, + child: const material.Text('Nested Item'), + ), + ], + child: const material.Text('Parent Menu'), + ), + ], + child: const material.Text('Submenu Target'), + ), + ), + ); + + await _secondaryClick(tester, find.text('Submenu Target')); + expect(find.text('Parent Menu'), findsOneWidget); + expect(find.text('Nested Item'), findsNothing); + + await tester.tap(find.text('Parent Menu')); + await tester.pumpAndSettle(); + expect(find.text('Nested Item'), findsOneWidget); + + await tester.tap(find.text('Nested Item')); + await tester.pumpAndSettle(); + expect(subItemPressed, isTrue); + expect(find.text('Nested Item'), findsNothing); + }); + + testWidgets('opens via contextMenu key shortcut', (tester) async { + final focusNode = material.FocusNode(); + + await tester.pumpWidget( + _shell( + QueryaContextMenu( + items: [ + QueryaMenuItem( + onTap: () {}, + child: const material.Text('Shortcut Item'), + ), + ], + child: material.Focus( + focusNode: focusNode, + autofocus: true, + child: const material.Text('Shortcut Target'), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('Shortcut Item'), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.contextMenu); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('Shortcut Item'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('Shortcut Item'), findsNothing); + }); + }); + + group('QueryaMenuBar', () { + testWidgets('opens dropdown and toggles on repeated tap', (tester) async { + var newFilePressed = false; + + await tester.pumpWidget( + _shell( + material.Column( + children: [ + QueryaMenuBar( + children: [ + QueryaMenuItem( + subMenu: [ + QueryaMenuItem( + onTap: () => newFilePressed = true, + child: const material.Text('New File'), + ), + QueryaMenuItem( + onTap: () {}, + child: const material.Text('Save File'), + ), + ], + child: const material.Text('File'), + ), + QueryaMenuItem( + subMenu: [ + QueryaMenuItem( + onTap: () {}, + child: const material.Text('Undo Action'), + ), + ], + child: const material.Text('Edit'), + ), + ], + ), + ], + ), + ), + ); + + expect(find.text('New File'), findsNothing); + expect(find.text('Undo Action'), findsNothing); + + // Tap 'File' to open + await tester.tap(find.text('File')); + await tester.pumpAndSettle(); + + expect(find.text('New File'), findsOneWidget); + expect(find.text('Save File'), findsOneWidget); + + // Tap 'File' again to toggle close + await tester.tap(find.text('File')); + await tester.pumpAndSettle(); + + expect(find.text('New File'), findsNothing); + + // Open again and select an item + await tester.tap(find.text('File')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('New File')); + await tester.pumpAndSettle(); + + expect(newFilePressed, isTrue); + expect(find.text('New File'), findsNothing); + }); + }); +} diff --git a/test/shared/widgets/querya_modal_dialog_test.dart b/test/shared/widgets/querya_modal_dialog_test.dart index 1c77a6c2..6fd3dce0 100644 --- a/test/shared/widgets/querya_modal_dialog_test.dart +++ b/test/shared/widgets/querya_modal_dialog_test.dart @@ -17,7 +17,7 @@ void main() { icon: const material.Icon(material.Icons.info_outline), content: const Text('Dialog Content Body'), actions: [ - OutlineButton( + material.ElevatedButton( onPressed: () => actionClicked = true, child: const Text('Action Button'), ), diff --git a/test/shared/widgets/querya_text_field_test.dart b/test/shared/widgets/querya_text_field_test.dart new file mode 100644 index 00000000..a484e849 --- /dev/null +++ b/test/shared/widgets/querya_text_field_test.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/shared/widgets/querya_button.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + Future pump(WidgetTester t, material.Widget field, + {QueryaControlSize? scope}) async { + await t.pumpWidget(queryaThemeTestShell( + child: material.Center( + child: material.SizedBox( + width: 320, + child: scope == null + ? field + : QueryaControlScope(size: scope, child: field), + ), + ), + )); + } + + double heightOf(WidgetTester t) => t.getSize(find.byType(QueryaTextField)).height; + + group('size', () { + testWidgets('a single line has the control height, md by default', + (t) async { + await pump(t, const QueryaTextField()); + expect(heightOf(t), QueryaControlSize.md.height); + }); + + testWidgets('it follows the control scope and an explicit size', + (t) async { + await pump(t, const QueryaTextField(), scope: QueryaControlSize.lg); + expect(heightOf(t), QueryaControlSize.lg.height); + + await pump(t, const QueryaTextField(size: QueryaControlSize.sm)); + expect(heightOf(t), QueryaControlSize.sm.height); + }); + + testWidgets('a field and a button of one size in a row are one height', + (t) async { + await t.pumpWidget(queryaThemeTestShell( + child: const material.Center( + child: QueryaControlScope( + size: QueryaControlSize.md, + child: material.Row(children: [ + material.SizedBox(width: 200, child: QueryaTextField()), + QueryaButton.secondary(label: 'Browse…'), + ]), + ), + ), + )); + expect(t.getSize(find.byType(QueryaTextField)).height, + t.getSize(find.byType(QueryaButton)).height); + }); + + testWidgets('a multi-line field grows past one line', (t) async { + await pump(t, const QueryaTextField(minLines: 4, maxLines: 4)); + expect(heightOf(t), greaterThan(QueryaControlSize.md.height * 2)); + }); + }); + + group('text', () { + testWidgets('typing reaches the controller and onChanged', (t) async { + final c = material.TextEditingController(); + addTearDown(c.dispose); + final seen = []; + await pump(t, QueryaTextField(controller: c, onChanged: seen.add)); + await t.enterText(find.byType(QueryaTextField), 'abc'); + expect(c.text, 'abc'); + expect(seen, ['abc']); + }); + + testWidgets('the placeholder shows while the field is empty', + (t) async { + final c = material.TextEditingController(); + addTearDown(c.dispose); + await pump(t, QueryaTextField(controller: c, placeholder: 'host')); + expect(find.text('host'), findsOneWidget); + // Typing hides the hint (the decorator fades it out). + await t.enterText(find.byType(QueryaTextField), 'x'); + await t.pumpAndSettle(); + final hint = t.widget(find.ancestor( + of: find.text('host'), matching: find.byType(material.AnimatedOpacity))); + expect(hint.opacity, 0); + }); + + testWidgets('submit calls onSubmitted', (t) async { + String? submitted; + await pump(t, QueryaTextField(onSubmitted: (v) => submitted = v)); + await t.enterText(find.byType(QueryaTextField), 'go'); + await t.testTextInput.receiveAction(material.TextInputAction.done); + expect(submitted, 'go'); + }); + + testWidgets('disabled and read-only fields refuse typing', (t) async { + final c = material.TextEditingController(text: 'fixed'); + addTearDown(c.dispose); + await pump(t, QueryaTextField(controller: c, readOnly: true)); + final editable = t.widget( + find.byType(material.EditableText)); + expect(editable.readOnly, isTrue); + + await pump(t, QueryaTextField(controller: c, enabled: false)); + expect( + t.widget(find.byType(material.TextField)).enabled, + isFalse); + }); + }); + + group('obscure', () { + testWidgets('the text is hidden; the button shows and hides it', + (t) async { + final c = material.TextEditingController(text: 'secret'); + addTearDown(c.dispose); + await pump(t, QueryaTextField(controller: c, obscureText: true)); + bool obscured() => + t.widget(find.byType(material.EditableText)) + .obscureText; + expect(obscured(), isTrue); + + await t.tap(find.byType(QueryaButton)); + await t.pump(); + expect(obscured(), isFalse); + await t.tap(find.byType(QueryaButton)); + await t.pump(); + expect(obscured(), isTrue); + }); + + testWidgets('a trailing widget of the caller replaces the button', + (t) async { + await pump( + t, + const QueryaTextField( + obscureText: true, trailing: material.Icon(material.Icons.key))); + expect(find.byIcon(material.Icons.key), findsOneWidget); + expect(find.byType(QueryaButton), findsNothing); + }); + }); + + group('slots and states', () { + testWidgets('leading and trailing are shown', (t) async { + await pump( + t, + const QueryaTextField( + leading: material.Icon(material.Icons.search_rounded), + trailing: material.Icon(material.Icons.close_rounded), + )); + expect(find.byIcon(material.Icons.search_rounded), findsOneWidget); + expect(find.byIcon(material.Icons.close_rounded), findsOneWidget); + }); + + testWidgets('an error text is shown under the field', (t) async { + await pump(t, const QueryaTextField(errorText: 'Port must be a number')); + expect(find.text('Port must be a number'), findsOneWidget); + }); + + testWidgets('the border follows the focus', (t) async { + final node = material.FocusNode(); + addTearDown(node.dispose); + await pump(t, QueryaTextField(focusNode: node)); + + Object border() => (t + .widget(find + .descendant( + of: find.byType(QueryaTextField), + matching: find.byType(material.Container)) + .first) + .decoration! as material.BoxDecoration) + .border!; + final idle = border(); + node.requestFocus(); + await t.pump(); + expect(border(), isNot(idle)); + }); + + testWidgets('unframed, it draws no box of its own', (t) async { + await pump(t, const QueryaTextField(framed: false)); + expect( + find.descendant( + of: find.byType(QueryaTextField), + matching: find.byType(material.DecoratedBox)), + findsNothing); + }); + }); +} diff --git a/test/shared/widgets/querya_ui_kit_theme_switch_test.dart b/test/shared/widgets/querya_ui_kit_theme_switch_test.dart index 7bfa07d4..0dba513f 100644 --- a/test/shared/widgets/querya_ui_kit_theme_switch_test.dart +++ b/test/shared/widgets/querya_ui_kit_theme_switch_test.dart @@ -34,7 +34,7 @@ void main() { captured = _Captured( context.workbench, context.semanticPalette, - Theme.of(context).colorScheme.foreground, + context.colors.foreground, ); return kit; }, diff --git a/test/support/querya_theme_test_shell.dart b/test/support/querya_theme_test_shell.dart index 16604034..bec2d174 100644 --- a/test/support/querya_theme_test_shell.dart +++ b/test/support/querya_theme_test_shell.dart @@ -1,29 +1,37 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/widgets.dart'; import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; -/// Wraps [child] with [ShadcnApp] and [QueryaThemeScope] for widget tests. +/// The default dark theme as the app hands it to [material.MaterialApp]. +material.ThemeData get queryaDarkMaterialTheme => + materialThemeFromQuerya(QueryaTheme.darkDefault.colors); + +/// Wraps [child] with [material.MaterialApp] and [QueryaThemeScope] for widget +/// tests. Widget queryaThemeTestShell({ required Widget child, QueryaTheme data = QueryaTheme.darkDefault, - ThemeData? theme, + material.ThemeData? theme, + double uiScale = 1.0, }) { - final td = theme ?? data.toShadcnThemeData(); - return ShadcnApp( + final td = theme ?? materialThemeFromQuerya(data.colors); + return material.MaterialApp( theme: td, darkTheme: td, - themeMode: ThemeMode.dark, - // As in the app: the default is a sheet on the mobile platform the test - // binding reports, and a sheet needs a DrawerOverlay the app does not have. - menuHandler: const PopoverOverlayHandler(), + themeMode: material.ThemeMode.dark, builder: (context, appChild) => QueryaUiScaleScope( - scale: 1.0, + scale: uiScale, child: QueryaThemeScope( data: data, child: appChild ?? const SizedBox.shrink(), ), ), - home: child, + // ShadcnApp gave the page a surface; a bare MaterialApp page has none, and + // the kit's text fields need a Material ancestor. + home: material.Material( + type: material.MaterialType.transparency, child: child), ); } diff --git a/test/support/redis_connection_test_fake.dart b/test/support/redis_connection_test_fake.dart new file mode 100644 index 00000000..d9197290 --- /dev/null +++ b/test/support/redis_connection_test_fake.dart @@ -0,0 +1,277 @@ + +import 'package:querya_desktop/core/database/redis_connection.dart'; + +/// In-memory stub for widget tests (no socket). Handles SELECT, DBSIZE, SCAN, +/// TYPE, TTL used by [RedisKeysView]. +class RedisConnectionTestFake extends RedisConnection { + RedisConnectionTestFake({ + this.firstScanKeys = const ['alpha', 'beta'], + this.secondScanKeys = const [], + this.dbSizeResult = 2, + this.getResult, + List? listItems, + this.llenResult, + this.hashFirstPage = const {}, + this.hashSecondPage = const {}, + this.hlenResult, + this.setFirstPage = const [], + this.setSecondPage = const [], + this.scardResult, + this.zsetItems = const <(String, double)>[], + this.zcardResult, + this.typeResult = 'string', + this.failType = false, + this.getBytesResult, + this.binaryScanKeys = const >[], + this.unlinkUnsupported = false, + }) : listItems = + listItems != null ? List.from(listItems) : [], + super( + id: -1, + name: 'test-fake', + host: 'localhost', + port: 6379, + ); + + final List firstScanKeys; + final List secondScanKeys; + final int dbSizeResult; + final String? getResult; + final List listItems; + final int? llenResult; + final Map hashFirstPage; + final Map hashSecondPage; + final int? hlenResult; + final List setFirstPage; + final List setSecondPage; + final int? scardResult; + final List<(String, double)> zsetItems; + final int? zcardResult; + final String typeResult; + final bool failType; + final List? getBytesResult; + final List> binaryScanKeys; + + /// Answers `UNLINK` like a server before Redis 4.0. + final bool unlinkUnsupported; + final List sentCommands = []; + + var _up = false; + bool _firstScanDone = false; + bool _firstHscanDone = false; + bool _firstSscanDone = false; + + @override + bool get isConnected => _up; + + @override + Future connect() async { + _up = true; + } + + @override + Future disconnect() async { + _up = false; + } + + @override + Future sendCommand(List args) async { + if (!_up) { + throw StateError('Not connected to Redis'); + } + final op = args.first.toString().toUpperCase(); + sentCommands.add(op); + switch (op) { + case 'SELECT': + return 'OK'; + case 'DBSIZE': + return dbSizeResult; + case 'SCAN': + final cursor = int.tryParse(args[1].toString()) ?? 0; + List keysFor(List named) => + [...named, ...binaryScanKeys]; + if (cursor == 0 && !_firstScanDone) { + _firstScanDone = true; + final next = secondScanKeys.isNotEmpty ? 1 : 0; + return [next, keysFor(firstScanKeys)]; + } + if (cursor == 1 && secondScanKeys.isNotEmpty) { + return [0, keysFor(secondScanKeys)]; + } + return [0, []]; + case 'TYPE': + if (failType) { + throw StateError('TYPE failed'); + } + return typeResult; + case 'TTL': + return -1; + case 'GET': + if (getBytesResult != null) return getBytesResult; + if (typeResult != 'string') { + throw StateError( + 'WRONGTYPE Operation against a key holding the wrong kind of value', + ); + } + return getResult; + case 'SET': + if (typeResult != 'string') { + throw StateError('SET refused: key is $typeResult'); + } + return 'OK'; + case 'HGETALL': + case 'SMEMBERS': + throw StateError('$op is unbounded; use a paged command'); + case 'HLEN': + return hlenResult ?? hashFirstPage.length + hashSecondPage.length; + case 'LLEN': + return llenResult ?? listItems.length; + case 'SCARD': + return scardResult ?? setFirstPage.length + setSecondPage.length; + case 'ZCARD': + return zcardResult ?? zsetItems.length; + case 'LRANGE': + return _sliceList(listItems, args); + case 'ZRANGE': + final stop = int.tryParse(args[3].toString()) ?? -1; + if (stop < 0) { + throw StateError('unbounded ZRANGE'); + } + final start = int.tryParse(args[2].toString()) ?? 0; + if (start >= zsetItems.length || start > stop) return []; + final end = (stop + 1).clamp(0, zsetItems.length); + final out = []; + for (final (member, score) in zsetItems.sublist(start, end)) { + out.add(member); + out.add(score); + } + return out; + case 'HSCAN': + return _pagedPairs( + cursor: int.tryParse(args[2].toString()) ?? 0, + first: hashFirstPage, + second: hashSecondPage, + firstDone: _firstHscanDone, + markFirst: () => _firstHscanDone = true, + ); + case 'SSCAN': + final cursor = int.tryParse(args[2].toString()) ?? 0; + if (cursor == 0 && !_firstSscanDone) { + _firstSscanDone = true; + final next = setSecondPage.isNotEmpty ? 1 : 0; + return [next, setFirstPage]; + } + if (cursor == 1 && setSecondPage.isNotEmpty) { + return [0, setSecondPage]; + } + return [0, []]; + case 'READONLY': + case 'READWRITE': + return 'OK'; + case 'EXISTS': + final target = args[1].toString(); + final allKeys = [...firstScanKeys, ...secondScanKeys]; + return allKeys.contains(target) ? 1 : 0; + case 'RENAME': + return 'OK'; + case 'RENAMENX': + final target = args[2].toString(); + final allKeys = [...firstScanKeys, ...secondScanKeys]; + return allKeys.contains(target) ? 0 : 1; + case 'RPUSH': + listItems.add(args[2].toString()); + return 'OK'; + case 'LINDEX': + final lindexIdx = int.tryParse(args[2].toString()) ?? 0; + if (lindexIdx >= 0 && lindexIdx < listItems.length) { + return listItems[lindexIdx]; + } + return null; + case 'LSET': + final idx = int.tryParse(args[2].toString()) ?? 0; + final val = args[3].toString(); + if (idx >= 0 && idx < listItems.length) { + listItems[idx] = val; + } + return 'OK'; + case 'LREM': + final count = int.tryParse(args[2].toString()) ?? 0; + final val = args[3].toString(); + var removed = 0; + if (count == 0) { + removed = listItems.where((e) => e == val).length; + listItems.removeWhere((e) => e == val); + } else if (count > 0) { + for (var i = 0; i < count; i++) { + final idx = listItems.indexOf(val); + if (idx != -1) { + listItems.removeAt(idx); + removed++; + } + } + } else { + for (var i = 0; i < count.abs(); i++) { + final idx = listItems.lastIndexOf(val); + if (idx != -1) { + listItems.removeAt(idx); + removed++; + } + } + } + return removed; + case 'UNLINK' when unlinkUnsupported: + throw Exception("ERR unknown command 'UNLINK'"); + case 'DEL' || 'UNLINK': + final allKeys = [...firstScanKeys, ...secondScanKeys]; + var count = 0; + for (var i = 1; i < args.length; i++) { + final target = args[i].toString(); + if (allKeys.contains(target)) { + count++; + } + } + return count; + default: + return null; + } + } + + List _sliceList(List items, List args) { + if (items.isEmpty) return const []; + final start = int.tryParse(args[2].toString()) ?? 0; + var stop = int.tryParse(args[3].toString()) ?? -1; + if (stop < 0) { + throw StateError('unbounded LRANGE'); + } + if (start >= items.length || start > stop) return const []; + final end = (stop + 1).clamp(0, items.length); + return items.sublist(start, end); + } + + List _pagedPairs({ + required int cursor, + required Map first, + required Map second, + required bool firstDone, + required void Function() markFirst, + }) { + List flatten(Map map) { + final out = []; + map.forEach((k, v) { + out.add(k); + out.add(v); + }); + return out; + } + + if (cursor == 0 && !firstDone) { + markFirst(); + final next = second.isNotEmpty ? 1 : 0; + return [next, flatten(first)]; + } + if (cursor == 1 && second.isNotEmpty) { + return [0, flatten(second)]; + } + return [0, []]; + } +} diff --git a/third_party/refresh_rate/CHANGELOG.md b/third_party/refresh_rate/CHANGELOG.md deleted file mode 100644 index 6f9b880f..00000000 --- a/third_party/refresh_rate/CHANGELOG.md +++ /dev/null @@ -1,60 +0,0 @@ -# Changelog - -## 1.0.2 - -- Added Swift Package Manager support. - -## 1.0.1 - -### Web support -- Added web platform implementation using `requestAnimationFrame` interval - timing to detect the display's current refresh rate (same technique as TestUFO). -- Control methods are graceful no-ops (browsers own vsync scheduling). -- FPS overlay and benchmark sessions work unchanged on web. - -### Documentation -- Added comprehensive Dartdoc comments to all public APIs. - -### Platform fixes -- Restructured `Package.swift` into `ios/refresh_rate/` and `macos/refresh_rate/` - for correct Swift Package Manager module resolution. -- Resolved minor lint warnings from test suites. - -## 1.0.0 - -Initial stable release — unlock, query, overlay, and benchmark display refresh rates across all Flutter platforms. - -### Control - -- `RefreshRate.enable()` — one-line unlock of peak display refresh rate -- `RefreshRate.preferMax()` / `preferDefault()` — explicit rate preference -- `RefreshRate.matchContent(fps)` — sync display cadence to content frame rate (fixes 24 fps video judder) -- `RefreshRate.boost(duration)` — temporary max-rate spike for gesture-driven animations -- `RefreshRate.category(RateCategory)` — Android 15 semantic rate category -- `RefreshRate.setTouchBoost(bool)` — Android 15 touch-driven rate boost - -### Diagnostics - -- `RefreshRate.info` — synchronous cached `RefreshRateInfo` snapshot (current rate, max, min, supported rates, VRR, API level) -- `RefreshRate.onChanged` — stream fires on rate change, Low Power Mode toggle, or thermal state change -- `RefreshRate.isLowPowerMode` / `thermalState` / `isProMotionReady` - -### Debug overlay - -- `RefreshRate.showOverlay()` — full diagnostic HUD with live FPS, build/raster timings, frame budget -- `RefreshRate.showFPS()` / `showHz()` — individual overlay badges -- FPS colour is relative to the device's actual target rate, not a hard-coded 60 Hz baseline - -### Benchmark sessions - -- `RefreshRate.startSession(name)` returns `RefreshRateSession` -- `session.end()` returns `SessionReport` with verdict, bottleneck, avgFps, 1% low FPS, missed-frame %, and JSON export -- Sessions auto-exclude backgrounded periods, resume warmup, LPM changes, and thermal changes - -### Platform coverage - -- **Android 6+** (API 23): `SurfaceControl.Transaction.setFrameRate()` (API 34+), `preferredRefreshRate` + `preferredDisplayModeId` (API 30–33), legacy `preferredDisplayModeId` fallback (API 23–29) -- **iOS 15+**: `CADisplayLink.preferredFrameRateRange` with runtime `Info.plist` ProMotion validation -- **macOS 14+**: `CADisplayLink`-based control via `NSView.displayLink` -- **macOS < 14 / Windows / Linux**: query-only (reports real monitor refresh rate) -- All platform bridges use [pigeon](https://pub.dev/packages/pigeon) — fully typed, zero codec overhead diff --git a/third_party/refresh_rate/LICENSE b/third_party/refresh_rate/LICENSE deleted file mode 100644 index 5bdae8a2..00000000 --- a/third_party/refresh_rate/LICENSE +++ /dev/null @@ -1,24 +0,0 @@ -Copyright 2026 Qoder. All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. -* Neither the name of Qoder nor the names of its contributors may be - used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/refresh_rate/README.md b/third_party/refresh_rate/README.md deleted file mode 100644 index 1d07f6a9..00000000 --- a/third_party/refresh_rate/README.md +++ /dev/null @@ -1,230 +0,0 @@ -# refresh_rate - -[![pub package](https://img.shields.io/pub/v/refresh_rate.svg)](https://pub.dev/packages/refresh_rate) -[![pub points](https://img.shields.io/pub/points/refresh_rate)](https://pub.dev/packages/refresh_rate/score) -[![likes](https://img.shields.io/pub/likes/refresh_rate)](https://pub.dev/packages/refresh_rate/score) -[![popularity](https://img.shields.io/pub/popularity/refresh_rate)](https://pub.dev/packages/refresh_rate/score) -[![License: BSD-3](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://github.com/qoder-official/refresh_rate/blob/main/LICENSE) - -**Unlock your device's full refresh rate in one line of Flutter.** - -Your Flutter app runs at 60 Hz on a 120 Hz phone right now. The engine never tells the OS compositor it can handle more. `refresh_rate` fixes that — and gives you diagnostics, benchmarks, and a live overlay to prove it. - -```dart -void main() { - RefreshRate.enable(); // that's it — 120 Hz on a 120 Hz device - runApp(const MyApp()); -} -``` - -> **Why does this happen?** Flutter's engine never calls Android's `Surface.setFrameRate()` and the default iOS template is missing the `CADisableMinimumFrameDurationOnPhone` plist key. See [Flutter #160952](https://github.com/flutter/flutter/issues/160952). This package makes those calls for you. - -Built on [pigeon](https://pub.dev/packages/pigeon) — fully typed end-to-end, zero `MethodChannel` codec overhead. - ---- - -## Platform support - -| Platform | Unlock | Query | Overlay | Benchmark | -|:---------|:------:|:-----:|:-------:|:---------:| -| **Android** 6+ (API 23) | ✅ | ✅ | ✅ | ✅ | -| **iOS** 15+ (ProMotion) | ✅ \* | ✅ | ✅ | ✅ | -| **macOS** 14+ (Sonoma) | ✅ | ✅ | ✅ | ✅ | -| **macOS** < 14 | — | ✅ | ✅ | ✅ | -| **Windows** | — | ✅ | ✅ | ✅ | -| **Linux** | — | ✅ | ✅ | ✅ | - -\* iOS requires `Info.plist` key — see [iOS setup](#ios-setup). - ---- - -## Installation - -```yaml -dependencies: - refresh_rate: ^1.0.0 -``` - -### iOS setup - -Add to `ios/Runner/Info.plist` — required for > 60 Hz on iPhones with ProMotion: - -```xml -CADisableMinimumFrameDurationOnPhone - -``` - -Without this key, iOS caps your app at 60 Hz even on 120 Hz hardware. The plugin detects this at runtime and prints a console warning if missing. iPad Pro does **not** need this key. - ---- - -## Quick start - -```dart -import 'package:refresh_rate/refresh_rate.dart'; - -void main() { - WidgetsFlutterBinding.ensureInitialized(); - RefreshRate.enable(); // unlocks peak rate on every supported device - runApp(const MyApp()); -} -``` - -One call in `main()`. On a 120 Hz device your app now renders at 120 Hz. On a 60 Hz device nothing changes — the OS stays in control. - ---- - -## Diagnostics - -```dart -final info = RefreshRate.info; // synchronous cached snapshot - -print(info.currentRate); // 120.0 -print(info.maxRate); // 120.0 -print(info.supportedRates); // [60.0, 90.0, 120.0] -print(info.isVariableRefreshRate); // true (LTPO panel) -print(info.androidApiLevel); // 34 (Android only) -print(info.displayServer); // "wayland" (Linux only) - -print(RefreshRate.isLowPowerMode); // false -print(RefreshRate.thermalState); // ThermalState.nominal -print(RefreshRate.isProMotionReady); // true — plist key + hardware both present - -await RefreshRate.refresh(); // reload platform cache - -RefreshRate.onChanged.listen((info) { // rate, Low Power Mode, or thermal change - print('Now running at ${info.currentRate} Hz'); -}); -``` - ---- - -## Advanced control - -```dart -RefreshRate.preferMax(); // highest available rate -RefreshRate.preferDefault(); // return to OS default -RefreshRate.matchContent(24.0); // sync to 24 fps video (fixes judder) -RefreshRate.boost(const Duration(seconds: 2)); // temporary spike for gestures - -// Android 15+ -RefreshRate.category(RateCategory.high); // semantic rate category -RefreshRate.setTouchBoost(true); // OS-managed touch boost -``` - ---- - -## Debug overlay - -Drop a live performance HUD into any debug build: - -```dart -if (kDebugMode) RefreshRate.showOverlay(); -``` - -The overlay is **refresh-rate-aware**: FPS is colour-coded against the device's *actual* target Hz, not a hard-coded 60 Hz baseline. It also shows per-frame build/raster timings, frame budget, Low Power Mode, and thermal state. - -```dart -RefreshRate.showFPS(); // just the FPS counter -RefreshRate.showHz(); // just the Hz badge -RefreshRate.showOverlay(); // full diagnostic panel -RefreshRate.hideOverlay(); // dismiss -``` - ---- - -## Benchmark sessions - -Record a named performance window and get a structured report: - -```dart -final session = RefreshRate.startSession('home_scroll'); - -// ... user interacts ... - -final report = await session.end(); - -print(report.verdict); // Verdict.good / degraded / poor -print(report.likelyBottleneck); // Bottleneck.rasterBound / buildBound / none -print(report.avgFps); // 108.4 -print(report.onePercentLowFps); // 87.2 -print(report.missedFramePercent); // 3.2% - -final json = report.toJson(); // export for CI / QA dashboards -``` - -Sessions automatically exclude app backgrounding, resume warmup, Low Power Mode toggles, and thermal state changes — so numbers reflect real rendering performance. - ---- - -## How it works - -### Android - -| API Level | What the plugin calls | -|:---------:|:----------------------| -| **34+** | `SurfaceControl.Transaction.setFrameRate()` — direct SurfaceFlinger vote | -| **30–33** | `preferredRefreshRate` + `preferredDisplayModeId` — dual hint | -| **23–29** | `preferredDisplayModeId` with resolution-match guard — legacy fallback | - -Flutter never calls `Surface.setFrameRate()`. That single missing call is why 120 Hz phones render Flutter at 60 Hz. - -### iOS - -Sets `CADisplayLink.preferredFrameRateRange` with the device max. Validates the `CADisableMinimumFrameDurationOnPhone` plist key at runtime and warns loudly if missing. - -### macOS - -`NSView.displayLink` with `preferredFrameRateRange` on macOS 14+. Falls back to `NSScreen.maximumFramesPerSecond` / `CGDisplayCopyDisplayMode` for query. - -### Windows & Linux - -Query-only via `QueryDisplayConfig` (Win) and `gdk_monitor_get_refresh_rate` (Linux). Control depends on Flutter's desktop embedder evolution — tracked at [#93058](https://github.com/flutter/flutter/issues/93058) and [#183703](https://github.com/flutter/flutter/issues/183703). - ---- - -## API reference - -| Method | What it does | -|:-------|:-------------| -| `enable()` | Unlock peak rate — call once in `main()` | -| `disable()` | Stop overriding, return to OS default | -| `preferMax()` | Request highest available rate | -| `preferDefault()` | Clear rate override | -| `matchContent(fps)` | Sync display cadence to content frame rate | -| `boost(duration)` | Temporary max-rate spike | -| `category(cat)` | Android 15 semantic rate category | -| `setTouchBoost(bool)` | Android 15 touch-driven boost | -| `refresh()` | Reload platform info cache | -| `info` | Cached `RefreshRateInfo` snapshot | -| `isLowPowerMode` | Battery Saver / Low Power Mode active | -| `thermalState` | `ThermalState` enum | -| `isProMotionReady` | iOS plist key + ProMotion hardware | -| `onChanged` | `Stream` | -| `showFPS()` | Live FPS counter overlay | -| `showHz()` | Live Hz badge overlay | -| `showOverlay()` | Full diagnostic overlay | -| `hideOverlay()` | Dismiss overlay | -| `startSession(name)` | Start benchmark session | - ---- - -## Why this exists - -Flutter's engine (Impeller since 3.24) *can* render at 120 Hz. But it never *tells the OS*. On Android, `Surface.setFrameRate()` returns zero search results across the entire engine codebase. On iOS, the engine code is correct but the default template omits the plist key. - -This has been open since January 2023 ([#119268](https://github.com/flutter/flutter/issues/119268)), currently tracked at [#160952](https://github.com/flutter/flutter/issues/160952) (P2, unassigned). - -`refresh_rate` fixes this today on shipping apps, while collecting real-device evidence for an eventual engine-level fix. - ---- - -## License - -BSD 3-Clause © 2026 [Qoder](https://qoder.in) - ---- - -

- Made with care by Qoder · More packages -

diff --git a/third_party/refresh_rate/analysis_options.yaml b/third_party/refresh_rate/analysis_options.yaml deleted file mode 100644 index b0f3c0af..00000000 --- a/third_party/refresh_rate/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml -linter: - rules: - - public_member_api_docs diff --git a/third_party/refresh_rate/android/build.gradle b/third_party/refresh_rate/android/build.gradle deleted file mode 100644 index 484f253e..00000000 --- a/third_party/refresh_rate/android/build.gradle +++ /dev/null @@ -1,51 +0,0 @@ -group = "in.qoder.refresh_rate" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "1.9.22" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.1.0") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - namespace = "in.qoder.refresh_rate" - compileSdk = 35 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = "1.8" - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - } - - defaultConfig { - minSdk = 21 - } -} - -dependencies { - implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version") -} diff --git a/third_party/refresh_rate/android/settings.gradle b/third_party/refresh_rate/android/settings.gradle deleted file mode 100644 index f27361d7..00000000 --- a/third_party/refresh_rate/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "refresh_rate" diff --git a/third_party/refresh_rate/android/src/main/AndroidManifest.xml b/third_party/refresh_rate/android/src/main/AndroidManifest.xml deleted file mode 100644 index 1e375ae6..00000000 --- a/third_party/refresh_rate/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/RefreshRatePlugin.kt b/third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/RefreshRatePlugin.kt deleted file mode 100644 index 596430dd..00000000 --- a/third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/RefreshRatePlugin.kt +++ /dev/null @@ -1,248 +0,0 @@ -package `in`.qoder.refresh_rate - -import android.app.Activity -import android.content.Context -import android.hardware.display.DisplayManager -import android.os.Build -import android.os.PowerManager -import android.view.Display -import android.view.Surface -import android.view.View -import android.view.WindowManager -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.embedding.engine.plugins.activity.ActivityAware -import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding -import `in`.qoder.refresh_rate.generated.DisplayInfoMessage -import `in`.qoder.refresh_rate.generated.RefreshRateFlutterApi -import `in`.qoder.refresh_rate.generated.RefreshRateHostApi - -class RefreshRatePlugin : FlutterPlugin, ActivityAware, RefreshRateHostApi { - - private var activity: Activity? = null - private var context: Context? = null - private var flutterApi: RefreshRateFlutterApi? = null - private var displayListener: DisplayManager.DisplayListener? = null - - // ─── FlutterPlugin ────────────────────────────────────────── - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - context = binding.applicationContext - RefreshRateHostApi.setUp(binding.binaryMessenger, this) - flutterApi = RefreshRateFlutterApi(binding.binaryMessenger) - registerDisplayListener() - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - RefreshRateHostApi.setUp(binding.binaryMessenger, null) - unregisterDisplayListener() - flutterApi = null - } - - // ─── ActivityAware ────────────────────────────────────────── - - override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity } - override fun onDetachedFromActivityForConfigChanges() { activity = null } - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { activity = binding.activity } - override fun onDetachedFromActivity() { activity = null } - - // ─── RefreshRateHostApi ───────────────────────────────────── - - override fun getDisplayInfo(): DisplayInfoMessage { - val display = getDisplay() - val currentRate = display?.refreshRate?.toDouble() ?: 60.0 - val modes = display?.supportedModes ?: emptyArray() - val supportedRates = modes.map { it.refreshRate.toDouble() }.distinct().sorted() - val maxRate = supportedRates.maxOrNull() ?: 60.0 - val minRate = supportedRates.minOrNull() ?: 60.0 - val isVRR = (maxRate - minRate > 30) && modes.size <= 4 - val pm = context?.getSystemService(Context.POWER_SERVICE) as? PowerManager - val thermalIndex: Long? = if (Build.VERSION.SDK_INT >= 29) { - when (pm?.currentThermalStatus) { - PowerManager.THERMAL_STATUS_NONE -> 0L - PowerManager.THERMAL_STATUS_LIGHT, PowerManager.THERMAL_STATUS_MODERATE -> 1L - PowerManager.THERMAL_STATUS_SEVERE -> 2L - PowerManager.THERMAL_STATUS_CRITICAL, - PowerManager.THERMAL_STATUS_EMERGENCY, - PowerManager.THERMAL_STATUS_SHUTDOWN -> 3L - else -> null - } - } else null - - // hasArrSupport() is API 36+ — fall back to VRR heuristic for now - val hasArr = isVRR - - return DisplayInfoMessage( - currentRate = currentRate, - maxRate = maxRate, - minRate = minRate, - supportedRates = supportedRates, - isVariableRefreshRate = isVRR, - engineTargetRate = currentRate, - androidApiLevel = Build.VERSION.SDK_INT.toLong(), - isLowPowerMode = pm?.isPowerSaveMode, - thermalStateIndex = thermalIndex, - hasAdaptiveRefreshRate = hasArr, - iosProMotionEnabled = null, - displayServer = null, - monitorCount = null, - ) - } - - override fun enable() = setDeviceDefault() - override fun disable() = resetToDefault() - override fun preferMax() = setDeviceDefault() - override fun preferDefault() = resetToDefault() - - override fun matchContent(fps: Double) { - if (Build.VERSION.SDK_INT >= 30) { - setSurfaceFrameRate(fps.toFloat(), Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, forceAlways = true) - } else if (Build.VERSION.SDK_INT >= 23) { - setPreferredDisplayMode(fps.toFloat()) - } - } - - override fun boost(durationMs: Long) { - val display = getDisplay() ?: return - val maxRate = display.supportedModes.maxByOrNull { it.refreshRate }?.refreshRate ?: 60f - if (Build.VERSION.SDK_INT >= 35) { - try { activity?.window?.decorView?.setRequestedFrameRate(View.REQUESTED_FRAME_RATE_CATEGORY_HIGH.toFloat()) } catch (_: Exception) {} - } - setSurfaceFrameRate(maxRate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT, forceAlways = true) - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ resetToDefault() }, durationMs) - } - - override fun setCategory(categoryIndex: Long) { - if (Build.VERSION.SDK_INT < 35) { - when (categoryIndex.toInt()) { - 3 -> setDeviceDefault() - 0, 1 -> resetToDefault() - else -> {} - } - return - } - try { - val categoryFloat = when (categoryIndex.toInt()) { - 0 -> 0f - 1 -> View.REQUESTED_FRAME_RATE_CATEGORY_LOW.toFloat() - 2 -> View.REQUESTED_FRAME_RATE_CATEGORY_NORMAL.toFloat() - 3 -> View.REQUESTED_FRAME_RATE_CATEGORY_HIGH.toFloat() - else -> View.REQUESTED_FRAME_RATE_CATEGORY_HIGH.toFloat() - } - activity?.window?.decorView?.setRequestedFrameRate(categoryFloat) - } catch (_: Exception) {} - } - - override fun setTouchBoost(enabled: Boolean) { - if (Build.VERSION.SDK_INT >= 35) { - try { activity?.window?.setFrameRateBoostOnTouchEnabled(enabled) } catch (_: Exception) {} - } - } - - override fun isSupported(): Boolean = Build.VERSION.SDK_INT >= 23 - - // ─── Private helpers ──────────────────────────────────────── - - private fun setDeviceDefault() { - val display = getDisplay() ?: return - val maxRate = display.supportedModes.maxByOrNull { it.refreshRate }?.refreshRate ?: 60f - if (Build.VERSION.SDK_INT >= 35) { - try { - val window = activity?.window - window?.decorView?.setRequestedFrameRate(View.REQUESTED_FRAME_RATE_CATEGORY_HIGH.toFloat()) - window?.setFrameRateBoostOnTouchEnabled(true) - } catch (_: Exception) {} - setSurfaceFrameRate(maxRate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT) - } else if (Build.VERSION.SDK_INT >= 30) { - setSurfaceFrameRate(maxRate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT) - } else if (Build.VERSION.SDK_INT >= 23) { - setPreferredDisplayMode(maxRate) - } - } - - private fun resetToDefault() { - if (Build.VERSION.SDK_INT >= 35) { - try { - val window = activity?.window - window?.decorView?.setRequestedFrameRate(0f) - window?.setFrameRateBoostOnTouchEnabled(false) - } catch (_: Exception) {} - setSurfaceFrameRate(0f, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT) - } else if (Build.VERSION.SDK_INT >= 30) { - setSurfaceFrameRate(0f, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT) - } else if (Build.VERSION.SDK_INT >= 23) { - try { - val params = activity?.window?.attributes ?: return - params.preferredDisplayModeId = 0 - activity?.window?.attributes = params - } catch (_: Exception) {} - } - } - - private fun setSurfaceFrameRate(frameRate: Float, compatibility: Int, forceAlways: Boolean = false): Boolean { - if (Build.VERSION.SDK_INT < 30) return false - return try { - val window = activity?.window ?: return false - val strategy = if (Build.VERSION.SDK_INT >= 31) { - if (forceAlways) Surface.CHANGE_FRAME_RATE_ALWAYS - else Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS - } else -1 - val params = window.attributes - params.preferredRefreshRate = frameRate - val display = getDisplay() - if (display != null && frameRate > 0) { - val targetMode = display.supportedModes - .filter { it.refreshRate >= frameRate - 1f } - .minByOrNull { Math.abs(it.refreshRate - frameRate) } - if (targetMode != null) params.preferredDisplayModeId = targetMode.modeId - } else if (frameRate == 0f) { - params.preferredDisplayModeId = 0 - params.preferredRefreshRate = 0f - } - window.attributes = params - true - } catch (e: Exception) { false } - } - - private fun setPreferredDisplayMode(targetRate: Float): Boolean { - if (Build.VERSION.SDK_INT < 23) return false - return try { - val window = activity?.window ?: return false - val display = getDisplay() ?: return false - val currentMode = display.mode - val targetMode = display.supportedModes - .filter { it.physicalWidth == currentMode.physicalWidth && it.physicalHeight == currentMode.physicalHeight } - .minByOrNull { Math.abs(it.refreshRate - targetRate) } - if (targetMode != null) { - val params = window.attributes - params.preferredDisplayModeId = targetMode.modeId - window.attributes = params - true - } else false - } catch (e: Exception) { false } - } - - private fun registerDisplayListener() { - val dm = context?.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager ?: return - displayListener = object : DisplayManager.DisplayListener { - override fun onDisplayChanged(displayId: Int) { - activity?.runOnUiThread { flutterApi?.onDisplayInfoChanged(getDisplayInfo()) {} } - } - override fun onDisplayAdded(displayId: Int) {} - override fun onDisplayRemoved(displayId: Int) {} - } - dm.registerDisplayListener(displayListener, null) - } - - private fun unregisterDisplayListener() { - val dm = context?.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager ?: return - displayListener?.let { dm.unregisterDisplayListener(it) } - displayListener = null - } - - @Suppress("DEPRECATION") - private fun getDisplay(): Display? = if (Build.VERSION.SDK_INT >= 30) { - activity?.display - } else { - (context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)?.defaultDisplay - } -} diff --git a/third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/generated/RefreshRateApi.kt b/third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/generated/RefreshRateApi.kt deleted file mode 100644 index b72597dc..00000000 --- a/third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/generated/RefreshRateApi.kt +++ /dev/null @@ -1,345 +0,0 @@ -// Copyright 2026 Qoder (qoder.in). All rights reserved. -// Use of this source code is governed by a BSD-style license. -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon -@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") - -package `in`.qoder.refresh_rate.generated - -import android.util.Log -import io.flutter.plugin.common.BasicMessageChannel -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MessageCodec -import io.flutter.plugin.common.StandardMethodCodec -import io.flutter.plugin.common.StandardMessageCodec -import java.io.ByteArrayOutputStream -import java.nio.ByteBuffer - -private fun wrapResult(result: Any?): List { - return listOf(result) -} - -private fun wrapError(exception: Throwable): List { - return if (exception is FlutterError) { - listOf( - exception.code, - exception.message, - exception.details - ) - } else { - listOf( - exception.javaClass.simpleName, - exception.toString(), - "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) - ) - } -} - -private fun createConnectionError(channelName: String): FlutterError { - return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "")} - -/** - * Error class for passing custom error details to Flutter via a thrown PlatformException. - * @property code The error code. - * @property message The error message. - * @property details The error details. Must be a datatype supported by the api codec. - */ -class FlutterError ( - val code: String, - override val message: String? = null, - val details: Any? = null -) : Throwable() - -/** Generated class from Pigeon that represents data sent in messages. */ -data class DisplayInfoMessage ( - val currentRate: Double? = null, - val maxRate: Double? = null, - val minRate: Double? = null, - val supportedRates: List? = null, - val isVariableRefreshRate: Boolean? = null, - val engineTargetRate: Double? = null, - val iosProMotionEnabled: Boolean? = null, - val androidApiLevel: Long? = null, - val isLowPowerMode: Boolean? = null, - val thermalStateIndex: Long? = null, - val hasAdaptiveRefreshRate: Boolean? = null, - val displayServer: String? = null, - val monitorCount: Long? = null -) - { - companion object { - fun fromList(pigeonVar_list: List): DisplayInfoMessage { - val currentRate = pigeonVar_list[0] as Double? - val maxRate = pigeonVar_list[1] as Double? - val minRate = pigeonVar_list[2] as Double? - val supportedRates = pigeonVar_list[3] as List? - val isVariableRefreshRate = pigeonVar_list[4] as Boolean? - val engineTargetRate = pigeonVar_list[5] as Double? - val iosProMotionEnabled = pigeonVar_list[6] as Boolean? - val androidApiLevel = pigeonVar_list[7] as Long? - val isLowPowerMode = pigeonVar_list[8] as Boolean? - val thermalStateIndex = pigeonVar_list[9] as Long? - val hasAdaptiveRefreshRate = pigeonVar_list[10] as Boolean? - val displayServer = pigeonVar_list[11] as String? - val monitorCount = pigeonVar_list[12] as Long? - return DisplayInfoMessage(currentRate, maxRate, minRate, supportedRates, isVariableRefreshRate, engineTargetRate, iosProMotionEnabled, androidApiLevel, isLowPowerMode, thermalStateIndex, hasAdaptiveRefreshRate, displayServer, monitorCount) - } - } - fun toList(): List { - return listOf( - currentRate, - maxRate, - minRate, - supportedRates, - isVariableRefreshRate, - engineTargetRate, - iosProMotionEnabled, - androidApiLevel, - isLowPowerMode, - thermalStateIndex, - hasAdaptiveRefreshRate, - displayServer, - monitorCount, - ) - } -} -private open class RefreshRateApiPigeonCodec : StandardMessageCodec() { - override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { - return when (type) { - 129.toByte() -> { - return (readValue(buffer) as? List)?.let { - DisplayInfoMessage.fromList(it) - } - } - else -> super.readValueOfType(type, buffer) - } - } - override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { - when (value) { - is DisplayInfoMessage -> { - stream.write(129) - writeValue(stream, value.toList()) - } - else -> super.writeValue(stream, value) - } - } -} - -/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ -interface RefreshRateHostApi { - fun getDisplayInfo(): DisplayInfoMessage - fun enable() - fun disable() - fun preferMax() - fun preferDefault() - fun matchContent(fps: Double) - fun boost(durationMs: Long) - fun setCategory(categoryIndex: Long) - fun setTouchBoost(enabled: Boolean) - fun isSupported(): Boolean - - companion object { - /** The codec used by RefreshRateHostApi. */ - val codec: MessageCodec by lazy { - RefreshRateApiPigeonCodec() - } - /** Sets up an instance of `RefreshRateHostApi` to handle messages through the `binaryMessenger`. */ - @JvmOverloads - fun setUp(binaryMessenger: BinaryMessenger, api: RefreshRateHostApi?, messageChannelSuffix: String = "") { - val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { _, reply -> - val wrapped: List = try { - listOf(api.getDisplayInfo()) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { _, reply -> - val wrapped: List = try { - api.enable() - listOf(null) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { _, reply -> - val wrapped: List = try { - api.disable() - listOf(null) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { _, reply -> - val wrapped: List = try { - api.preferMax() - listOf(null) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { _, reply -> - val wrapped: List = try { - api.preferDefault() - listOf(null) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val fpsArg = args[0] as Double - val wrapped: List = try { - api.matchContent(fpsArg) - listOf(null) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val durationMsArg = args[0] as Long - val wrapped: List = try { - api.boost(durationMsArg) - listOf(null) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val categoryIndexArg = args[0] as Long - val wrapped: List = try { - api.setCategory(categoryIndexArg) - listOf(null) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val enabledArg = args[0] as Boolean - val wrapped: List = try { - api.setTouchBoost(enabledArg) - listOf(null) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { _, reply -> - val wrapped: List = try { - listOf(api.isSupported()) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - } - } -} -/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ -class RefreshRateFlutterApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { - companion object { - /** The codec used by RefreshRateFlutterApi. */ - val codec: MessageCodec by lazy { - RefreshRateApiPigeonCodec() - } - } - fun onDisplayInfoChanged(infoArg: DisplayInfoMessage, callback: (Result) -> Unit) -{ - val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" - val channelName = "dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged$separatedMessageChannelSuffix" - val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(infoArg)) { - if (it is List<*>) { - if (it.size > 1) { - callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) - } else { - callback(Result.success(Unit)) - } - } else { - callback(Result.failure(createConnectionError(channelName))) - } - } - } -} diff --git a/third_party/refresh_rate/example/README.md b/third_party/refresh_rate/example/README.md deleted file mode 100644 index 2a77dc4a..00000000 --- a/third_party/refresh_rate/example/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# refresh_rate_example - -A new Flutter project. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. diff --git a/third_party/refresh_rate/example/analysis_options.yaml b/third_party/refresh_rate/example/analysis_options.yaml deleted file mode 100644 index 0d290213..00000000 --- a/third_party/refresh_rate/example/analysis_options.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/third_party/refresh_rate/example/android/app/build.gradle.kts b/third_party/refresh_rate/example/android/app/build.gradle.kts deleted file mode 100644 index ea59d5c0..00000000 --- a/third_party/refresh_rate/example/android/app/build.gradle.kts +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "in.qoder.refresh_rate_example" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "in.qoder.refresh_rate_example" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } - } -} - -flutter { - source = "../.." -} diff --git a/third_party/refresh_rate/example/android/app/src/debug/AndroidManifest.xml b/third_party/refresh_rate/example/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/third_party/refresh_rate/example/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/third_party/refresh_rate/example/android/app/src/main/AndroidManifest.xml b/third_party/refresh_rate/example/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 9d5b4340..00000000 --- a/third_party/refresh_rate/example/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/third_party/refresh_rate/example/android/app/src/main/kotlin/in/qoder/refresh_rate_example/MainActivity.kt b/third_party/refresh_rate/example/android/app/src/main/kotlin/in/qoder/refresh_rate_example/MainActivity.kt deleted file mode 100644 index 5b5dd1d0..00000000 --- a/third_party/refresh_rate/example/android/app/src/main/kotlin/in/qoder/refresh_rate_example/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package `in`.qoder.refresh_rate_example - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() diff --git a/third_party/refresh_rate/example/android/app/src/main/res/drawable-v21/launch_background.xml b/third_party/refresh_rate/example/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f3..00000000 --- a/third_party/refresh_rate/example/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/third_party/refresh_rate/example/android/app/src/main/res/drawable/launch_background.xml b/third_party/refresh_rate/example/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f8..00000000 --- a/third_party/refresh_rate/example/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index db77bb4b..00000000 Binary files a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 17987b79..00000000 Binary files a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 09d43914..00000000 Binary files a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index d5f1c8d3..00000000 Binary files a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 4d6372ee..00000000 Binary files a/third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/third_party/refresh_rate/example/android/app/src/main/res/values-night/styles.xml b/third_party/refresh_rate/example/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be7..00000000 --- a/third_party/refresh_rate/example/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/third_party/refresh_rate/example/android/app/src/main/res/values/styles.xml b/third_party/refresh_rate/example/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef880..00000000 --- a/third_party/refresh_rate/example/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/third_party/refresh_rate/example/android/app/src/profile/AndroidManifest.xml b/third_party/refresh_rate/example/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/third_party/refresh_rate/example/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/third_party/refresh_rate/example/android/build.gradle.kts b/third_party/refresh_rate/example/android/build.gradle.kts deleted file mode 100644 index dbee657b..00000000 --- a/third_party/refresh_rate/example/android/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/third_party/refresh_rate/example/android/gradle.properties b/third_party/refresh_rate/example/android/gradle.properties deleted file mode 100644 index fbee1d8c..00000000 --- a/third_party/refresh_rate/example/android/gradle.properties +++ /dev/null @@ -1,2 +0,0 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true diff --git a/third_party/refresh_rate/example/android/gradle/wrapper/gradle-wrapper.properties b/third_party/refresh_rate/example/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index e4ef43fb..00000000 --- a/third_party/refresh_rate/example/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/third_party/refresh_rate/example/android/settings.gradle.kts b/third_party/refresh_rate/example/android/settings.gradle.kts deleted file mode 100644 index ca7fe065..00000000 --- a/third_party/refresh_rate/example/android/settings.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - val flutterSdkPath = - run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.11.1" apply false - id("org.jetbrains.kotlin.android") version "2.2.20" apply false -} - -include(":app") diff --git a/third_party/refresh_rate/example/integration_test/screenshot_test.dart b/third_party/refresh_rate/example/integration_test/screenshot_test.dart deleted file mode 100644 index 610c8c09..00000000 --- a/third_party/refresh_rate/example/integration_test/screenshot_test.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:refresh_rate/refresh_rate.dart'; - -import 'package:refresh_rate_example/main.dart'; - -/// Waits for [finder] to match at least one widget, polling every 100 ms. -Future _waitFor( - WidgetTester tester, - Finder finder, { - Duration timeout = const Duration(seconds: 5), -}) async { - final deadline = DateTime.now().add(timeout); - while (!finder.evaluate().isNotEmpty) { - if (DateTime.now().isAfter(deadline)) return; - await tester.pump(const Duration(milliseconds: 100)); - } - await tester.pump(const Duration(milliseconds: 200)); -} - -/// Converts the Flutter surface to an image, pumps one frame, and -/// takes a named screenshot via the integration test binding. -Future _captureScreenshot( - IntegrationTestWidgetsFlutterBinding binding, - WidgetTester tester, - String name, -) async { - await binding.convertFlutterSurfaceToImage(); - await tester.pump(const Duration(milliseconds: 200)); - await binding.takeScreenshot(name); -} - -// ── Mock overlay widgets ───────────────────────────────────────────────────── -// The real FPS overlay shows "0 FPS" during integration tests because -// pump() doesn't generate real frame timings. These mock widgets render -// hardcoded values that represent a realistic 120Hz device scenario. - -/// Mock FPS badge — identical styling to FpsOverlayWidget (showFPS) with -/// a hardcoded value showing a healthy 120 FPS readout. -Widget _mockFpsOverlay() { - return Positioned( - top: 0, - right: 8, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.only(top: 4), - child: IgnorePointer( - child: Material( - type: MaterialType.transparency, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xDD000000), - borderRadius: BorderRadius.circular(6), - ), - child: const Text( - '120 FPS', - style: TextStyle( - color: Color(0xFF4CAF50), // green — hitting target - fontSize: 13, - fontWeight: FontWeight.bold, - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - ), - ), - ), - ), - ), - ); -} - -/// Mock Hz badge — identical styling to HzOverlayWidget. -Widget _mockHzOverlay() { - return Positioned( - top: 0, - right: 8, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.only(top: 4), - child: IgnorePointer( - child: Material( - type: MaterialType.transparency, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xDD000000), - borderRadius: BorderRadius.circular(6), - ), - child: const Text( - '120Hz', - style: TextStyle( - color: Color(0xFF64B5F6), - fontSize: 13, - fontWeight: FontWeight.bold, - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - ), - ), - ), - ), - ), - ); -} - -/// Wraps the example app with a mock overlay [widget] on top. -Widget _appWithOverlay(Widget overlay) { - return Directionality( - textDirection: TextDirection.ltr, - child: Stack( - children: [ - const RefreshRateExampleApp(), - overlay, - ], - ), - ); -} - -void main() { - final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - // ── 1. overlay — FPS counter showing 120 FPS in green ──────────────────── - testWidgets('screenshot: fps', (tester) async { - await tester.pumpWidget(_appWithOverlay(_mockFpsOverlay())); - await tester.pump(const Duration(seconds: 1)); - - await _waitFor(tester, find.text('Diagnostic Console')); - - // Enable high refresh so the hero panel shows real 120 Hz data. - RefreshRate.enable(); - await tester.pump(const Duration(seconds: 2)); - - await _captureScreenshot(binding, tester, 'fps'); - }); - - // ── 2. before_after — Hz badge showing 120Hz ──────────────────────────── - testWidgets('screenshot: hz', (tester) async { - await tester.pumpWidget(_appWithOverlay(_mockHzOverlay())); - await tester.pump(const Duration(seconds: 1)); - - await _waitFor(tester, find.text('Diagnostic Console')); - - RefreshRate.enable(); - await tester.pump(const Duration(seconds: 2)); - - await _captureScreenshot(binding, tester, 'hz'); - }); -} diff --git a/third_party/refresh_rate/example/ios/Flutter/AppFrameworkInfo.plist b/third_party/refresh_rate/example/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 391a902b..00000000 --- a/third_party/refresh_rate/example/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - - diff --git a/third_party/refresh_rate/example/ios/Flutter/Debug.xcconfig b/third_party/refresh_rate/example/ios/Flutter/Debug.xcconfig deleted file mode 100644 index ec97fc6f..00000000 --- a/third_party/refresh_rate/example/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "Generated.xcconfig" diff --git a/third_party/refresh_rate/example/ios/Flutter/Release.xcconfig b/third_party/refresh_rate/example/ios/Flutter/Release.xcconfig deleted file mode 100644 index c4855bfe..00000000 --- a/third_party/refresh_rate/example/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "Generated.xcconfig" diff --git a/third_party/refresh_rate/example/ios/Podfile b/third_party/refresh_rate/example/ios/Podfile deleted file mode 100644 index 620e46eb..00000000 --- a/third_party/refresh_rate/example/ios/Podfile +++ /dev/null @@ -1,43 +0,0 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '13.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/third_party/refresh_rate/example/ios/Podfile.lock b/third_party/refresh_rate/example/ios/Podfile.lock deleted file mode 100644 index 7b48a767..00000000 --- a/third_party/refresh_rate/example/ios/Podfile.lock +++ /dev/null @@ -1,28 +0,0 @@ -PODS: - - Flutter (1.0.0) - - integration_test (0.0.1): - - Flutter - - refresh_rate (1.0.2): - - Flutter - -DEPENDENCIES: - - Flutter (from `Flutter`) - - integration_test (from `.symlinks/plugins/integration_test/ios`) - - refresh_rate (from `.symlinks/plugins/refresh_rate/ios`) - -EXTERNAL SOURCES: - Flutter: - :path: Flutter - integration_test: - :path: ".symlinks/plugins/integration_test/ios" - refresh_rate: - :path: ".symlinks/plugins/refresh_rate/ios" - -SPEC CHECKSUMS: - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 - refresh_rate: 7b70fe15ba35859a04c4ed7145ef5373073c9498 - -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e - -COCOAPODS: 1.16.2 diff --git a/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.pbxproj b/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index a5d8e17e..00000000 --- a/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,738 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 408442DF8837659AF5CB83DB /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7106237642409D042B52BB66 /* Pods_RunnerTests.framework */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - F287BBF60D9D2B9168604931 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6D93A38BCF1D017DD70781D7 /* Pods_Runner.framework */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 1E1F0922F0E206C0A75DF6D6 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 6D93A38BCF1D017DD70781D7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 6DC24DB3096513F8AA853A09 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 7106237642409D042B52BB66 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 72D8257624B60955295C8C97 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 8FD1DB725A5CEDEB0388717B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - F05CCA270615CA628AE3ACDF /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - F6EABD99D945E14E0AD67068 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 213E13E82D28B3952759D378 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 408442DF8837659AF5CB83DB /* Pods_RunnerTests.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - F287BBF60D9D2B9168604931 /* Pods_Runner.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 8B93923519CFCE8A9F0A16A1 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 6D93A38BCF1D017DD70781D7 /* Pods_Runner.framework */, - 7106237642409D042B52BB66 /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - D39C39D29B0F3A68A4AAA3D7 /* Pods */, - 8B93923519CFCE8A9F0A16A1 /* Frameworks */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; - D39C39D29B0F3A68A4AAA3D7 /* Pods */ = { - isa = PBXGroup; - children = ( - 8FD1DB725A5CEDEB0388717B /* Pods-Runner.debug.xcconfig */, - 1E1F0922F0E206C0A75DF6D6 /* Pods-Runner.release.xcconfig */, - 72D8257624B60955295C8C97 /* Pods-Runner.profile.xcconfig */, - F6EABD99D945E14E0AD67068 /* Pods-RunnerTests.debug.xcconfig */, - 6DC24DB3096513F8AA853A09 /* Pods-RunnerTests.release.xcconfig */, - F05CCA270615CA628AE3ACDF /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C8080294A63A400263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 1598A1C76FC46748D8686F89 /* [CP] Check Pods Manifest.lock */, - 331C807D294A63A400263BE5 /* Sources */, - 331C807F294A63A400263BE5 /* Resources */, - 213E13E82D28B3952759D378 /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - 331C8086294A63A400263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 81F4AE0F9E3D19C5B6DFDAD1 /* [CP] Check Pods Manifest.lock */, - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 91C2B0F87759B0619DD56467 /* [CP] Embed Pods Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C8080294A63A400263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 97C146ED1CF9000F007C117D; - }; - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - 331C8080294A63A400263BE5 /* RunnerTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C807F294A63A400263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 1598A1C76FC46748D8686F89 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 81F4AE0F9E3D19C5B6DFDAD1 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 91C2B0F87759B0619DD56467 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C807D294A63A400263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ALF7HY2G9Q; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 331C8088294A63A400263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = F6EABD99D945E14E0AD67068 /* Pods-RunnerTests.debug.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Debug; - }; - 331C8089294A63A400263BE5 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 6DC24DB3096513F8AA853A09 /* Pods-RunnerTests.release.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Release; - }; - 331C808A294A63A400263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = F05CCA270615CA628AE3ACDF /* Pods-RunnerTests.profile.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ALF7HY2G9Q; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ALF7HY2G9Q; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6..00000000 --- a/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/third_party/refresh_rate/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/third_party/refresh_rate/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index e3773d42..00000000 --- a/third_party/refresh_rate/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/third_party/refresh_rate/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/third_party/refresh_rate/example/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 21a3cc14..00000000 --- a/third_party/refresh_rate/example/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/third_party/refresh_rate/example/ios/Runner/AppDelegate.swift b/third_party/refresh_rate/example/ios/Runner/AppDelegate.swift deleted file mode 100644 index c30b367e..00000000 --- a/third_party/refresh_rate/example/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Flutter -import UIKit - -@main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - } -} diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fab..00000000 --- a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada47..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 7353c41e..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 797d452e..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 6ed2d933..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cd7b009..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index fe730945..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 321773cd..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 797d452e..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 502f463a..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 0ec30343..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 0ec30343..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index e9f5fea2..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 84ac32ae..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 8953cba0..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 0467bf12..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2f..00000000 --- a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b..00000000 --- a/third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/third_party/refresh_rate/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/third_party/refresh_rate/example/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c7..00000000 --- a/third_party/refresh_rate/example/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/third_party/refresh_rate/example/ios/Runner/Base.lproj/Main.storyboard b/third_party/refresh_rate/example/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c28516..00000000 --- a/third_party/refresh_rate/example/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/third_party/refresh_rate/example/ios/Runner/DebugProfile.entitlements b/third_party/refresh_rate/example/ios/Runner/DebugProfile.entitlements deleted file mode 100644 index 23c52e48..00000000 --- a/third_party/refresh_rate/example/ios/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - get-task-allow - - - diff --git a/third_party/refresh_rate/example/ios/Runner/Info.plist b/third_party/refresh_rate/example/ios/Runner/Info.plist deleted file mode 100644 index 1711d1be..00000000 --- a/third_party/refresh_rate/example/ios/Runner/Info.plist +++ /dev/null @@ -1,70 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Refresh Rate Example - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - refresh_rate_example - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/third_party/refresh_rate/example/ios/Runner/Release.entitlements b/third_party/refresh_rate/example/ios/Runner/Release.entitlements deleted file mode 100644 index e89b7f32..00000000 --- a/third_party/refresh_rate/example/ios/Runner/Release.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.app-sandbox - - - diff --git a/third_party/refresh_rate/example/ios/Runner/Runner-Bridging-Header.h b/third_party/refresh_rate/example/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a56..00000000 --- a/third_party/refresh_rate/example/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/third_party/refresh_rate/example/ios/Runner/SceneDelegate.swift b/third_party/refresh_rate/example/ios/Runner/SceneDelegate.swift deleted file mode 100644 index b9ce8ea2..00000000 --- a/third_party/refresh_rate/example/ios/Runner/SceneDelegate.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Flutter -import UIKit - -class SceneDelegate: FlutterSceneDelegate { - -} diff --git a/third_party/refresh_rate/example/ios/RunnerTests/RunnerTests.swift b/third_party/refresh_rate/example/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 86a7c3b1..00000000 --- a/third_party/refresh_rate/example/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Flutter -import UIKit -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/third_party/refresh_rate/example/lib/main.dart b/third_party/refresh_rate/example/lib/main.dart deleted file mode 100644 index f23c8c97..00000000 --- a/third_party/refresh_rate/example/lib/main.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:refresh_rate/refresh_rate.dart'; - -import 'src/example_app.dart'; -export 'src/example_app.dart'; - -void main() { - WidgetsFlutterBinding.ensureInitialized(); - RefreshRate.enable(); - runApp(const RefreshRateExampleApp()); -} diff --git a/third_party/refresh_rate/example/lib/src/example_app.dart b/third_party/refresh_rate/example/lib/src/example_app.dart deleted file mode 100644 index 06fb3ba0..00000000 --- a/third_party/refresh_rate/example/lib/src/example_app.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'example_home.dart'; -import 'example_theme.dart'; - -class RefreshRateExampleApp extends StatelessWidget { - const RefreshRateExampleApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'refresh_rate example', - debugShowCheckedModeBanner: false, - theme: buildExampleTheme(), - home: const ExampleHome(), - ); - } -} diff --git a/third_party/refresh_rate/example/lib/src/example_home.dart b/third_party/refresh_rate/example/lib/src/example_home.dart deleted file mode 100644 index 7b9fe61c..00000000 --- a/third_party/refresh_rate/example/lib/src/example_home.dart +++ /dev/null @@ -1,422 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:refresh_rate/refresh_rate.dart'; - -import 'models/action_spec.dart'; -import 'models/info_item.dart'; -import 'sections/action_panel.dart'; -import 'sections/benchmark_panel.dart'; -import 'sections/display_info_panel.dart'; -import 'sections/hero_panel.dart'; -import 'sections/scroll_test_panel.dart'; -import 'sections/status_panel.dart'; - -class ExampleHome extends StatefulWidget { - const ExampleHome({super.key}); - - @override - State createState() => _ExampleHomeState(); -} - -class _ExampleHomeState extends State { - RefreshRateSession? _session; - SessionReport? _lastReport; - String _status = 'System nominal'; - StreamSubscription? _infoSubscription; - - @override - void initState() { - super.initState(); - _infoSubscription = RefreshRate.onChanged.listen((_) { - if (mounted) setState(() {}); - }); - RefreshRate.refresh().then((_) { - if (mounted) setState(() {}); - }); - } - - @override - void dispose() { - _infoSubscription?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final info = RefreshRate.info; - final width = MediaQuery.sizeOf(context).width; - final wideLayout = width >= 1040; - final largeHero = width >= 760; - final frameBudgetMs = info.currentRate > 0 ? 1000 / info.currentRate : 0.0; - final maxBudgetMs = info.maxRate > 0 ? 1000 / info.maxRate : 0.0; - final supportedRatesText = _supportedRatesText(info); - final liveStateColor = _liveStateColor(context, info); - - final displayInfoItems = [ - InfoItem('Current rate', '${info.currentRate.toStringAsFixed(1)} Hz'), - InfoItem('Max rate', '${info.maxRate.toStringAsFixed(1)} Hz'), - InfoItem('Min rate', '${info.minRate.toStringAsFixed(1)} Hz'), - InfoItem('Supported rates', supportedRatesText), - InfoItem('VRR / LTPO', info.isVariableRefreshRate ? 'Yes' : 'No'), - InfoItem( - 'ProMotion ready', - RefreshRate.isProMotionReady ? 'Ready' : 'Unavailable', - ), - InfoItem( - 'Low power mode', - RefreshRate.isLowPowerMode ? 'Enabled' : 'Off', - ), - InfoItem('Thermal state', RefreshRate.thermalState.name), - InfoItem('Android API', '${info.androidApiLevel ?? 'n/a'}'), - InfoItem('Display server', info.displayServer ?? 'n/a'), - InfoItem('Monitor count', '${info.monitorCount}'), - InfoItem( - 'Engine target', '${info.engineTargetRate.toStringAsFixed(1)} Hz'), - ]; - - return Scaffold( - body: DecoratedBox( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF151515), Color(0xFF101010), Color(0xFF131313)], - ), - ), - child: SafeArea( - child: SingleChildScrollView( - padding: EdgeInsets.fromLTRB(16, 12, 16, width >= 760 ? 24 : 16), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1320), - child: wideLayout - ? Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 7, - child: Column( - children: [ - HeroPanel( - info: info, - status: _status, - frameBudgetMs: frameBudgetMs, - maxBudgetMs: maxBudgetMs, - liveStateColor: liveStateColor, - largeHero: largeHero, - supportedRatesText: supportedRatesText, - ), - const SizedBox(height: 16), - DisplayInfoPanel(items: displayInfoItems), - const SizedBox(height: 16), - ActionPanel( - eyebrow: 'Control', - title: 'Rate Requests', - subtitle: - 'Exercise the plugin APIs directly and verify device behavior against the live telemetry above.', - actions: _controlActions(), - ), - const SizedBox(height: 16), - ScrollTestPanel(info: info), - ], - ), - ), - const SizedBox(width: 16), - Expanded( - flex: 4, - child: Column( - children: [ - ActionPanel( - eyebrow: 'Overlay', - title: 'Verification HUD', - subtitle: - 'Toggle lightweight or full in-app overlays to validate frame pacing without leaving the example app.', - actions: _overlayActions(), - footer: _overlayFooter(), - ), - const SizedBox(height: 16), - BenchmarkPanel( - actions: _benchmarkActions(), - reportItems: _lastReport == null - ? null - : _reportItems(_lastReport!), - ), - const SizedBox(height: 16), - StatusPanel(status: _status), - ], - ), - ), - ], - ) - : Column( - children: [ - HeroPanel( - info: info, - status: _status, - frameBudgetMs: frameBudgetMs, - maxBudgetMs: maxBudgetMs, - liveStateColor: liveStateColor, - largeHero: largeHero, - supportedRatesText: supportedRatesText, - ), - const SizedBox(height: 16), - DisplayInfoPanel(items: displayInfoItems), - const SizedBox(height: 16), - ActionPanel( - eyebrow: 'Control', - title: 'Rate Requests', - subtitle: - 'Exercise the plugin APIs directly and verify device behavior against the live telemetry above.', - actions: _controlActions(), - ), - const SizedBox(height: 16), - ActionPanel( - eyebrow: 'Overlay', - title: 'Verification HUD', - subtitle: - 'Toggle lightweight or full in-app overlays to validate frame pacing without leaving the example app.', - actions: _overlayActions(), - footer: _overlayFooter(), - ), - const SizedBox(height: 16), - BenchmarkPanel( - actions: _benchmarkActions(), - reportItems: _lastReport == null - ? null - : _reportItems(_lastReport!), - ), - const SizedBox(height: 16), - ScrollTestPanel(info: info), - const SizedBox(height: 16), - StatusPanel(status: _status), - ], - ), - ), - ), - ), - ), - ), - ); - } - - List _controlActions() { - return [ - ActionSpec( - label: 'ENABLE', - accent: const Color(0xFF36FF8B), - outlined: false, - onTap: () { - RefreshRate.enable(); - _setStatus('Peak mode requested'); - }, - ), - ActionSpec( - label: 'DISABLE', - accent: const Color(0xFFFFBA20), - outlined: true, - onTap: () { - RefreshRate.disable(); - _setStatus('Requests cleared'); - }, - ), - ActionSpec( - label: 'PREFER MAX', - accent: const Color(0xFF00F0FF), - outlined: true, - onTap: () { - RefreshRate.preferMax(); - _setStatus('Maximum refresh preferred'); - }, - ), - ActionSpec( - label: 'DEFAULT', - accent: const Color(0xFFB9CACB), - outlined: true, - onTap: () { - RefreshRate.preferDefault(); - _setStatus('OS-managed default restored'); - }, - ), - ActionSpec( - label: 'MATCH 24FPS', - accent: const Color(0xFF00F0FF), - outlined: false, - onTap: () { - RefreshRate.matchContent(24.0); - _setStatus('Matched content to 24fps cadence'); - }, - ), - ActionSpec( - label: 'BOOST 3S', - accent: const Color(0xFFFFBA20), - outlined: false, - onTap: () { - RefreshRate.boost(const Duration(seconds: 3)); - _setStatus('Temporary boost active for 3 seconds'); - }, - ), - ActionSpec( - label: 'CATEGORY HIGH', - accent: const Color(0xFF36FF8B), - outlined: true, - onTap: () { - RefreshRate.category(RateCategory.high); - _setStatus('Android category set to high'); - }, - ), - ActionSpec( - label: 'TOUCH BOOST', - accent: const Color(0xFF00F0FF), - outlined: true, - onTap: () { - RefreshRate.setTouchBoost(true); - _setStatus('Touch boost enabled'); - }, - ), - ]; - } - - List _overlayActions() { - return [ - ActionSpec( - label: 'SHOW FPS', - accent: const Color(0xFF00F0FF), - outlined: true, - onTap: () { - RefreshRate.showFPS(); - _setStatus('FPS overlay shown'); - }, - ), - ActionSpec( - label: 'SHOW HZ', - accent: const Color(0xFF36FF8B), - outlined: true, - onTap: () { - RefreshRate.showHz(); - _setStatus('Hz overlay shown'); - }, - ), - ActionSpec( - label: 'FULL OVERLAY', - accent: const Color(0xFFFFBA20), - outlined: false, - onTap: () { - RefreshRate.showOverlay(); - _setStatus('Full overlay mounted'); - }, - ), - ActionSpec( - label: 'HIDE', - accent: const Color(0xFFB9CACB), - outlined: true, - onTap: () { - RefreshRate.hideOverlay(); - _setStatus('Overlay hidden'); - }, - ), - ]; - } - - List _benchmarkActions() { - return [ - ActionSpec( - label: _session == null ? 'START SESSION' : 'SESSION ACTIVE', - accent: const Color(0xFF36FF8B), - outlined: _session != null, - onTap: _session == null - ? () { - setState(() { - _session = RefreshRate.startSession('example_scroll'); - _status = 'Benchmark session running'; - }); - } - : null, - ), - ActionSpec( - label: 'END SESSION', - accent: const Color(0xFFFFBA20), - outlined: true, - onTap: _session == null - ? null - : () async { - final activeSession = _session; - if (activeSession == null) return; - final report = await activeSession.end(); - if (!mounted) return; - setState(() { - _lastReport = report; - _session = null; - _status = 'Session ended: ${report.verdict.name}'; - }); - }, - ), - ]; - } - - Widget _overlayFooter() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - decoration: BoxDecoration( - color: const Color(0xFF0E0E0E), - borderRadius: BorderRadius.circular(14), - ), - child: Row( - children: [ - Container( - width: 9, - height: 9, - decoration: BoxDecoration( - color: RefreshRate.isOverlayVisible - ? const Color(0xFF36FF8B) - : const Color(0xFF3B494B), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 10), - Text( - RefreshRate.isOverlayVisible - ? 'Overlay currently visible' - : 'Overlay currently hidden', - style: const TextStyle( - color: Color(0xFFB9CACB), - ), - ), - ], - ), - ); - } - - List _reportItems(SessionReport report) { - return [ - InfoItem('Verdict', report.verdict.name), - InfoItem('Bottleneck', report.likelyBottleneck.name), - InfoItem('Avg FPS', report.avgFps.toStringAsFixed(1)), - InfoItem('1% Low', report.onePercentLowFps.toStringAsFixed(1)), - InfoItem( - 'Missed frames', '${report.missedFramePercent.toStringAsFixed(1)}%'), - InfoItem('Valid duration', '${report.validDuration.inMilliseconds} ms'), - InfoItem('Excluded', '${report.excludedDuration.inMilliseconds} ms'), - ]; - } - - void _setStatus(String value) => setState(() => _status = value); - - String _supportedRatesText(DisplayInfo info) { - if (info.supportedRates.isEmpty) return 'n/a'; - return info.supportedRates - .map((rate) => '${rate.toStringAsFixed(0)}Hz') - .join(' • '); - } - - Color _liveStateColor(BuildContext context, DisplayInfo info) { - if (RefreshRate.isLowPowerMode) return const Color(0xFFFFBA20); - if (RefreshRate.thermalState.name != 'nominal') { - return const Color(0xFFFFBA20); - } - if (info.currentRate >= info.maxRate && info.maxRate > 0) { - return const Color(0xFF36FF8B); - } - return Theme.of(context).colorScheme.primary; - } -} diff --git a/third_party/refresh_rate/example/lib/src/example_theme.dart b/third_party/refresh_rate/example/lib/src/example_theme.dart deleted file mode 100644 index 709d0b45..00000000 --- a/third_party/refresh_rate/example/lib/src/example_theme.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/material.dart'; - -ThemeData buildExampleTheme() { - const base = Color(0xFF131313); - const panelHigh = Color(0xFF2A2A2A); - const cyan = Color(0xFF00F0FF); - const emerald = Color(0xFF36FF8B); - const amber = Color(0xFFFFBA20); - const text = Color(0xFFE5E2E1); - const muted = Color(0xFFB9CACB); - - final scheme = const ColorScheme.dark( - brightness: Brightness.dark, - surface: base, - primary: cyan, - secondary: emerald, - tertiary: amber, - onSurface: text, - onPrimary: Color(0xFF00363A), - onSecondary: Color(0xFF003919), - onTertiary: Color(0xFF412D00), - outline: Color(0xFF3B494B), - ); - - return ThemeData( - useMaterial3: true, - colorScheme: scheme, - scaffoldBackgroundColor: base, - canvasColor: base, - textTheme: ThemeData.dark().textTheme.apply( - bodyColor: text, - displayColor: text, - ), - chipTheme: ChipThemeData( - backgroundColor: panelHigh, - selectedColor: cyan.withValues(alpha: 0.16), - side: BorderSide.none, - labelStyle: const TextStyle( - color: text, - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - filledButtonTheme: FilledButtonThemeData( - style: FilledButton.styleFrom( - backgroundColor: panelHigh, - foregroundColor: text, - elevation: 0, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - textStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - letterSpacing: 0.8, - ), - ), - ), - outlinedButtonTheme: OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - foregroundColor: text, - side: BorderSide(color: cyan.withValues(alpha: 0.38)), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - textStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - letterSpacing: 0.8, - ), - ), - ), - dividerColor: muted.withValues(alpha: 0.08), - ); -} diff --git a/third_party/refresh_rate/example/lib/src/models/action_spec.dart b/third_party/refresh_rate/example/lib/src/models/action_spec.dart deleted file mode 100644 index a467a437..00000000 --- a/third_party/refresh_rate/example/lib/src/models/action_spec.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/material.dart'; - -class ActionSpec { - const ActionSpec({ - required this.label, - required this.accent, - required this.outlined, - required this.onTap, - }); - - final String label; - final Color accent; - final bool outlined; - final VoidCallback? onTap; -} diff --git a/third_party/refresh_rate/example/lib/src/models/info_item.dart b/third_party/refresh_rate/example/lib/src/models/info_item.dart deleted file mode 100644 index ada745bb..00000000 --- a/third_party/refresh_rate/example/lib/src/models/info_item.dart +++ /dev/null @@ -1,6 +0,0 @@ -class InfoItem { - const InfoItem(this.label, this.value); - - final String label; - final String value; -} diff --git a/third_party/refresh_rate/example/lib/src/sections/action_panel.dart b/third_party/refresh_rate/example/lib/src/sections/action_panel.dart deleted file mode 100644 index 5661a85b..00000000 --- a/third_party/refresh_rate/example/lib/src/sections/action_panel.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../models/action_spec.dart'; -import '../ui/panel_widgets.dart'; - -class ActionPanel extends StatelessWidget { - const ActionPanel({ - super.key, - required this.eyebrow, - required this.title, - required this.subtitle, - required this.actions, - this.footer, - }); - - final String eyebrow; - final String title; - final String subtitle; - final List actions; - final Widget? footer; - - @override - Widget build(BuildContext context) { - return SurfacePanel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PanelHeader( - eyebrow: eyebrow, - title: title, - subtitle: subtitle, - ), - const SizedBox(height: 14), - Wrap( - spacing: 10, - runSpacing: 10, - children: - actions.map((action) => ActionButton(spec: action)).toList(), - ), - if (footer != null) ...[ - const SizedBox(height: 14), - footer!, - ], - ], - ), - ); - } -} diff --git a/third_party/refresh_rate/example/lib/src/sections/benchmark_panel.dart b/third_party/refresh_rate/example/lib/src/sections/benchmark_panel.dart deleted file mode 100644 index fc188045..00000000 --- a/third_party/refresh_rate/example/lib/src/sections/benchmark_panel.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../models/action_spec.dart'; -import '../models/info_item.dart'; -import '../ui/panel_widgets.dart'; -import 'action_panel.dart'; - -class BenchmarkPanel extends StatelessWidget { - const BenchmarkPanel({ - super.key, - required this.actions, - required this.reportItems, - }); - - final List actions; - final List? reportItems; - - @override - Widget build(BuildContext context) { - return ActionPanel( - eyebrow: 'Benchmark', - title: 'Session Capture', - subtitle: - 'Run a named session, exercise scrolling or animations, then inspect verdict and missed-frame behavior.', - actions: actions, - footer: reportItems == null - ? Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFF0E0E0E), - borderRadius: BorderRadius.circular(14), - ), - child: const Text( - 'No report captured yet. Start a session, interact with the scroll test, then end the session to inspect the results.', - style: TextStyle( - color: Color(0xFFB9CACB), - fontSize: 12, - height: 1.45, - ), - ), - ) - : ReportSummary(items: reportItems!), - ); - } -} diff --git a/third_party/refresh_rate/example/lib/src/sections/display_info_panel.dart b/third_party/refresh_rate/example/lib/src/sections/display_info_panel.dart deleted file mode 100644 index 5c8216da..00000000 --- a/third_party/refresh_rate/example/lib/src/sections/display_info_panel.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../models/info_item.dart'; -import '../ui/panel_widgets.dart'; - -class DisplayInfoPanel extends StatelessWidget { - const DisplayInfoPanel({ - super.key, - required this.items, - }); - - final List items; - - @override - Widget build(BuildContext context) { - return SurfacePanel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const PanelHeader( - eyebrow: 'Telemetry', - title: 'Display Info', - subtitle: - 'Hardware capability, operating state, and platform-level diagnostics.', - ), - const SizedBox(height: 14), - LayoutBuilder( - builder: (context, constraints) { - final columns = constraints.maxWidth >= 900 - ? 3 - : constraints.maxWidth >= 560 - ? 2 - : 1; - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: items.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - mainAxisExtent: 76, - ), - itemBuilder: (context, index) => InfoTile(item: items[index]), - ); - }, - ), - ], - ), - ); - } -} diff --git a/third_party/refresh_rate/example/lib/src/sections/hero_panel.dart b/third_party/refresh_rate/example/lib/src/sections/hero_panel.dart deleted file mode 100644 index 76e40526..00000000 --- a/third_party/refresh_rate/example/lib/src/sections/hero_panel.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:refresh_rate/refresh_rate.dart'; - -import '../ui/panel_widgets.dart'; - -class HeroPanel extends StatelessWidget { - const HeroPanel({ - super.key, - required this.info, - required this.status, - required this.frameBudgetMs, - required this.maxBudgetMs, - required this.liveStateColor, - required this.largeHero, - required this.supportedRatesText, - }); - - final DisplayInfo info; - final String status; - final double frameBudgetMs; - final double maxBudgetMs; - final Color liveStateColor; - final bool largeHero; - final String supportedRatesText; - - @override - Widget build(BuildContext context) { - return SurfacePanel( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Expanded( - child: PanelHeader( - eyebrow: 'refresh_rate v1.0', - title: 'Diagnostic Console', - subtitle: - 'Live instrumentation for display state, frame budget, control requests, and benchmark sessions.', - ), - ), - SignalBadge( - label: status.toUpperCase(), - color: liveStateColor, - ), - ], - ), - const SizedBox(height: 18), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - HeroMetric( - label: 'Current Rate', - value: '${info.currentRate.toStringAsFixed(1)} Hz', - caption: 'Observed display target', - accent: const Color(0xFF00F0FF), - wide: largeHero, - ), - HeroMetric( - label: 'Peak Capability', - value: '${info.maxRate.toStringAsFixed(1)} Hz', - caption: 'Device-reported maximum', - accent: const Color(0xFF36FF8B), - wide: largeHero, - ), - HeroMetric( - label: 'Frame Budget', - value: '${frameBudgetMs.toStringAsFixed(2)} ms', - caption: frameBudgetMs > 0 - ? 'Target at ${info.currentRate.toStringAsFixed(1)} Hz' - : 'Waiting for rate data', - accent: const Color(0xFFFFBA20), - wide: largeHero, - ), - ], - ), - const SizedBox(height: 18), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - TelemetryChip(label: 'Supported', value: supportedRatesText), - TelemetryChip( - label: 'VRR', - value: info.isVariableRefreshRate ? 'Enabled' : 'Fixed', - ), - TelemetryChip( - label: 'Low Power', - value: RefreshRate.isLowPowerMode ? 'On' : 'Off', - ), - TelemetryChip( - label: 'Thermal', - value: RefreshRate.thermalState.name, - ), - TelemetryChip( - label: 'Peak Budget', - value: maxBudgetMs > 0 - ? '${maxBudgetMs.toStringAsFixed(2)} ms' - : 'n/a', - ), - ], - ), - ], - ), - ); - } -} diff --git a/third_party/refresh_rate/example/lib/src/sections/scroll_test_panel.dart b/third_party/refresh_rate/example/lib/src/sections/scroll_test_panel.dart deleted file mode 100644 index 70f32338..00000000 --- a/third_party/refresh_rate/example/lib/src/sections/scroll_test_panel.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:refresh_rate/refresh_rate.dart'; - -import '../ui/panel_widgets.dart'; - -class ScrollTestPanel extends StatelessWidget { - const ScrollTestPanel({ - super.key, - required this.info, - }); - - final DisplayInfo info; - - @override - Widget build(BuildContext context) { - return SurfacePanel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const PanelHeader( - eyebrow: 'Exercise', - title: 'Scroll Test', - subtitle: - 'Use this list to generate sustained motion and compare the on-screen experience against the live frame budget.', - ), - const SizedBox(height: 12), - Container( - height: 340, - decoration: BoxDecoration( - color: const Color(0xFF0E0E0E), - borderRadius: BorderRadius.circular(16), - ), - child: ListView.separated( - padding: const EdgeInsets.all(10), - itemCount: 30, - separatorBuilder: (_, __) => const SizedBox(height: 6), - itemBuilder: (context, index) { - final laneColor = index.isEven - ? const Color(0xFF00F0FF) - : const Color(0xFF36FF8B); - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - decoration: BoxDecoration( - color: const Color(0xFF171717), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: laneColor.withValues(alpha: 0.16), - borderRadius: BorderRadius.circular(10), - ), - alignment: Alignment.center, - child: Text( - '${index + 1}', - style: TextStyle( - color: laneColor, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Trace sample ${index + 1}', - style: const TextStyle( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - 'Target ${(info.maxRate > 0 ? info.maxRate : info.currentRate).toStringAsFixed(0)} Hz • verify pacing under continuous scroll.', - style: const TextStyle( - color: Color(0xFFB9CACB), - fontSize: 12, - ), - ), - ], - ), - ), - Text( - '${(index + 1) * 8} ms', - style: const TextStyle( - color: Color(0xFFB9CACB), - fontSize: 12, - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - ], - ), - ); - }, - ), - ), - ], - ), - ); - } -} diff --git a/third_party/refresh_rate/example/lib/src/sections/status_panel.dart b/third_party/refresh_rate/example/lib/src/sections/status_panel.dart deleted file mode 100644 index 52070079..00000000 --- a/third_party/refresh_rate/example/lib/src/sections/status_panel.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../ui/panel_widgets.dart'; - -class StatusPanel extends StatelessWidget { - const StatusPanel({ - super.key, - required this.status, - }); - - final String status; - - @override - Widget build(BuildContext context) { - return SurfacePanel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const PanelHeader( - eyebrow: 'System', - title: 'Run Status', - subtitle: - 'Recent command state and what the console is currently signaling to the platform.', - ), - const SizedBox(height: 14), - Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFF0E0E0E), - borderRadius: BorderRadius.circular(14), - ), - child: Text( - status, - style: const TextStyle( - color: Color(0xFFE5E2E1), - fontSize: 13, - fontWeight: FontWeight.w600, - height: 1.4, - ), - ), - ), - ], - ), - ); - } -} diff --git a/third_party/refresh_rate/example/lib/src/ui/panel_widgets.dart b/third_party/refresh_rate/example/lib/src/ui/panel_widgets.dart deleted file mode 100644 index 0f2d077b..00000000 --- a/third_party/refresh_rate/example/lib/src/ui/panel_widgets.dart +++ /dev/null @@ -1,377 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../models/action_spec.dart'; -import '../models/info_item.dart'; - -class SurfacePanel extends StatelessWidget { - const SurfacePanel({ - super.key, - required this.child, - this.padding = const EdgeInsets.all(16), - }); - - final Widget child; - final EdgeInsetsGeometry padding; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: const Color(0xFF1C1B1B), - borderRadius: BorderRadius.circular(22), - border: Border.all( - color: const Color(0xFF00F0FF).withValues(alpha: 0.08), - ), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF1F1E1E), Color(0xFF181818)], - ), - ), - child: Padding( - padding: padding, - child: child, - ), - ); - } -} - -class PanelHeader extends StatelessWidget { - const PanelHeader({ - super.key, - required this.eyebrow, - required this.title, - required this.subtitle, - }); - - final String eyebrow; - final String title; - final String subtitle; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - eyebrow.toUpperCase(), - style: const TextStyle( - color: Color(0xFFB9CACB), - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - ), - ), - const SizedBox(height: 6), - Text( - title, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w800, - letterSpacing: -0.4, - ), - ), - const SizedBox(height: 6), - Text( - subtitle, - style: const TextStyle( - color: Color(0xFFB9CACB), - fontSize: 12, - height: 1.5, - ), - ), - ], - ); - } -} - -class HeroMetric extends StatelessWidget { - const HeroMetric({ - super.key, - required this.label, - required this.value, - required this.caption, - required this.accent, - required this.wide, - }); - - final String label; - final String value; - final String caption; - final Color accent; - final bool wide; - - @override - Widget build(BuildContext context) { - return Container( - width: wide ? 240 : double.infinity, - constraints: const BoxConstraints(minHeight: 132, minWidth: 210), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF0E0E0E), - borderRadius: BorderRadius.circular(18), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label.toUpperCase(), - style: const TextStyle( - color: Color(0xFFB9CACB), - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 1.0, - ), - ), - const SizedBox(height: 22), - Text( - value, - style: TextStyle( - color: accent, - fontSize: 28, - fontWeight: FontWeight.w800, - letterSpacing: -1.0, - ), - ), - const SizedBox(height: 6), - Text( - caption, - style: const TextStyle( - color: Color(0xFFB9CACB), - fontSize: 12, - height: 1.4, - ), - ), - ], - ), - ); - } -} - -class TelemetryChip extends StatelessWidget { - const TelemetryChip({ - super.key, - required this.label, - required this.value, - }); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFF2A2A2A), - borderRadius: BorderRadius.circular(10), - ), - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: '${label.toUpperCase()}: ', - style: const TextStyle( - color: Color(0xFFB9CACB), - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - ), - ), - TextSpan( - text: value, - style: const TextStyle( - color: Color(0xFFE5E2E1), - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ); - } -} - -class SignalBadge extends StatelessWidget { - const SignalBadge({ - super.key, - required this.label, - required this.color, - }); - - final String label; - final Color color; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(14), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text( - label, - style: TextStyle( - color: color, - fontSize: 11, - fontWeight: FontWeight.w800, - letterSpacing: 0.8, - ), - ), - ], - ), - ); - } -} - -class InfoTile extends StatelessWidget { - const InfoTile({ - super.key, - required this.item, - }); - - final InfoItem item; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFF0E0E0E), - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.label.toUpperCase(), - style: const TextStyle( - color: Color(0xFFB9CACB), - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 0.9, - ), - ), - const Spacer(), - Text( - item.value, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ); - } -} - -class ActionButton extends StatelessWidget { - const ActionButton({ - super.key, - required this.spec, - }); - - final ActionSpec spec; - - @override - Widget build(BuildContext context) { - final foreground = spec.onTap == null - ? const Color(0xFF68787A) - : (spec.outlined ? spec.accent : const Color(0xFFE5E2E1)); - - final child = Text(spec.label); - final background = spec.onTap == null - ? const Color(0xFF171717) - : spec.accent.withValues(alpha: spec.outlined ? 0.08 : 0.18); - - if (spec.outlined) { - return OutlinedButton( - onPressed: spec.onTap, - style: OutlinedButton.styleFrom( - side: BorderSide( - color: spec.onTap == null - ? const Color(0xFF2A2A2A) - : spec.accent.withValues(alpha: 0.5), - ), - backgroundColor: background, - foregroundColor: foreground, - ), - child: child, - ); - } - - return FilledButton( - onPressed: spec.onTap, - style: FilledButton.styleFrom( - backgroundColor: background, - foregroundColor: foreground, - ), - child: child, - ); - } -} - -class ReportSummary extends StatelessWidget { - const ReportSummary({ - super.key, - required this.items, - }); - - final List items; - - @override - Widget build(BuildContext context) { - return Column( - children: items - .map( - (item) => Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - decoration: BoxDecoration( - color: const Color(0xFF0E0E0E), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Expanded( - child: Text( - item.label, - style: const TextStyle( - color: Color(0xFFB9CACB), - fontSize: 12, - ), - ), - ), - Text( - item.value, - style: const TextStyle( - color: Color(0xFFE5E2E1), - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ) - .toList(), - ); - } -} diff --git a/third_party/refresh_rate/example/linux/CMakeLists.txt b/third_party/refresh_rate/example/linux/CMakeLists.txt deleted file mode 100644 index 2cb7e278..00000000 --- a/third_party/refresh_rate/example/linux/CMakeLists.txt +++ /dev/null @@ -1,128 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "refresh_rate_example") -# The unique GTK application identifier for this application. See: -# https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "in.qoder.refresh_rate_example") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(SET CMP0063 NEW) - -# Load bundled libraries from the lib/ directory relative to the binary. -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Root filesystem for cross-building. -if(FLUTTER_TARGET_PLATFORM_SYSROOT) - set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -endif() - -# Define build configuration options. -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") -endif() - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) - -# Only the install-generated bundle's copy of the executable will launch -# correctly, since the resources must in the right relative locations. To avoid -# people trying to run the unbundled copy, put it in a subdirectory instead of -# the default top-level location. -set_target_properties(${BINARY_NAME} - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" -) - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. -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() - -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) - install(FILES "${bundled_library}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endforeach(bundled_library) - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") - install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() diff --git a/third_party/refresh_rate/example/linux/flutter/CMakeLists.txt b/third_party/refresh_rate/example/linux/flutter/CMakeLists.txt deleted file mode 100644 index d5bd0164..00000000 --- a/third_party/refresh_rate/example/linux/flutter/CMakeLists.txt +++ /dev/null @@ -1,88 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.10) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. - -# Serves the same purpose as list(TRANSFORM ... PREPEND ...), -# which isn't available in 3.10. -function(list_prepend LIST_NAME PREFIX) - set(NEW_LIST "") - foreach(element ${${LIST_NAME}}) - list(APPEND NEW_LIST "${PREFIX}${element}") - endforeach(element) - set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) -endfunction() - -# === Flutter Library === -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) -pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) - -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "fl_basic_message_channel.h" - "fl_binary_codec.h" - "fl_binary_messenger.h" - "fl_dart_project.h" - "fl_engine.h" - "fl_json_message_codec.h" - "fl_json_method_codec.h" - "fl_message_codec.h" - "fl_method_call.h" - "fl_method_channel.h" - "fl_method_codec.h" - "fl_method_response.h" - "fl_plugin_registrar.h" - "fl_plugin_registry.h" - "fl_standard_message_codec.h" - "fl_standard_method_codec.h" - "fl_string_codec.h" - "fl_value.h" - "fl_view.h" - "flutter_linux.h" -) -list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") -target_link_libraries(flutter INTERFACE - PkgConfig::GTK - PkgConfig::GLIB - PkgConfig::GIO -) -add_dependencies(flutter flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CMAKE_CURRENT_BINARY_DIR}/_phony_ - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} -) diff --git a/third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.cc b/third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 080e8e35..00000000 --- a/third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) refresh_rate_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "RefreshRatePlugin"); - refresh_rate_plugin_register_with_registrar(refresh_rate_registrar); -} diff --git a/third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.h b/third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47b..00000000 --- a/third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/third_party/refresh_rate/example/linux/flutter/generated_plugins.cmake b/third_party/refresh_rate/example/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 0d162423..00000000 --- a/third_party/refresh_rate/example/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,24 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - refresh_rate -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/third_party/refresh_rate/example/linux/runner/CMakeLists.txt b/third_party/refresh_rate/example/linux/runner/CMakeLists.txt deleted file mode 100644 index e97dabc7..00000000 --- a/third_party/refresh_rate/example/linux/runner/CMakeLists.txt +++ /dev/null @@ -1,26 +0,0 @@ -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the application ID. -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Add dependency libraries. Add any application-specific dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) - -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/third_party/refresh_rate/example/linux/runner/main.cc b/third_party/refresh_rate/example/linux/runner/main.cc deleted file mode 100644 index e7c5c543..00000000 --- a/third_party/refresh_rate/example/linux/runner/main.cc +++ /dev/null @@ -1,6 +0,0 @@ -#include "my_application.h" - -int main(int argc, char** argv) { - g_autoptr(MyApplication) app = my_application_new(); - return g_application_run(G_APPLICATION(app), argc, argv); -} diff --git a/third_party/refresh_rate/example/linux/runner/my_application.cc b/third_party/refresh_rate/example/linux/runner/my_application.cc deleted file mode 100644 index 02c797bc..00000000 --- a/third_party/refresh_rate/example/linux/runner/my_application.cc +++ /dev/null @@ -1,148 +0,0 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -// Called when first Flutter frame received. -static void first_frame_cb(MyApplication* self, FlView* view) { - gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); -} - -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "refresh_rate_example"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "refresh_rate_example"); - } - - gtk_window_set_default_size(window, 1280, 720); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments( - project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - GdkRGBA background_color; - // Background defaults to black, override it here if necessary, e.g. #00000000 - // for transparent. - gdk_rgba_parse(&background_color, "#000000"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - // Show the window when Flutter renders. - // Requires the view to be realized so we can start rendering. - g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), - self); - gtk_widget_realize(GTK_WIDGET(view)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - gtk_widget_grab_focus(GTK_WIDGET(view)); -} - -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, - gchar*** arguments, - int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -// Implements GApplication::startup. -static void my_application_startup(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application startup. - - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); -} - -// Implements GApplication::shutdown. -static void my_application_shutdown(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application shutdown. - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); -} - -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = - my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) {} - -MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. - g_set_prgname(APPLICATION_ID); - - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, "flags", - G_APPLICATION_NON_UNIQUE, nullptr)); -} diff --git a/third_party/refresh_rate/example/linux/runner/my_application.h b/third_party/refresh_rate/example/linux/runner/my_application.h deleted file mode 100644 index db16367a..00000000 --- a/third_party/refresh_rate/example/linux/runner/my_application.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -G_DECLARE_FINAL_TYPE(MyApplication, - my_application, - MY, - APPLICATION, - GtkApplication) - -/** - * my_application_new: - * - * Creates a new Flutter-based application. - * - * Returns: a new #MyApplication. - */ -MyApplication* my_application_new(); - -#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/third_party/refresh_rate/example/macos/Flutter/Flutter-Debug.xcconfig b/third_party/refresh_rate/example/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index 4b81f9b2..00000000 --- a/third_party/refresh_rate/example/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/third_party/refresh_rate/example/macos/Flutter/Flutter-Release.xcconfig b/third_party/refresh_rate/example/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index 5caa9d15..00000000 --- a/third_party/refresh_rate/example/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/third_party/refresh_rate/example/macos/Podfile b/third_party/refresh_rate/example/macos/Podfile deleted file mode 100644 index ff5ddb3b..00000000 --- a/third_party/refresh_rate/example/macos/Podfile +++ /dev/null @@ -1,42 +0,0 @@ -platform :osx, '10.15' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_macos_podfile_setup - -target 'Runner' do - use_frameworks! - - flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_macos_build_settings(target) - end -end diff --git a/third_party/refresh_rate/example/macos/Podfile.lock b/third_party/refresh_rate/example/macos/Podfile.lock deleted file mode 100644 index 776dd6e8..00000000 --- a/third_party/refresh_rate/example/macos/Podfile.lock +++ /dev/null @@ -1,22 +0,0 @@ -PODS: - - FlutterMacOS (1.0.0) - - refresh_rate (0.1.0): - - FlutterMacOS - -DEPENDENCIES: - - FlutterMacOS (from `Flutter/ephemeral`) - - refresh_rate (from `Flutter/ephemeral/.symlinks/plugins/refresh_rate/macos`) - -EXTERNAL SOURCES: - FlutterMacOS: - :path: Flutter/ephemeral - refresh_rate: - :path: Flutter/ephemeral/.symlinks/plugins/refresh_rate/macos - -SPEC CHECKSUMS: - FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - refresh_rate: 75ff2e747acb2d546aa80e0f521a91607a73388b - -PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 - -COCOAPODS: 1.16.2 diff --git a/third_party/refresh_rate/example/macos/Runner.xcodeproj/project.pbxproj b/third_party/refresh_rate/example/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 7e9b5438..00000000 --- a/third_party/refresh_rate/example/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,801 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 910248FF45164247AED47927 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 48DEDB687F544FA88A70B539 /* Pods_RunnerTests.framework */; }; - CAE0E1D6B3E065E67EACBD01 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78E82EFBCFE9D8FF45D965F6 /* Pods_Runner.framework */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC10EC2044A3C60003C045; - remoteInfo = Runner; - }; - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* refresh_rate_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = refresh_rate_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 48DEDB687F544FA88A70B539 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 78E82EFBCFE9D8FF45D965F6 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - A5943E0CD58AC7DB09939013 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - AB95905B211F4A9F4CF45C3D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - B36A800253185A385429601E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - B949B21E5690992FF2267767 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - CD862853BFB36B0C3DDAA1F5 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - D2BF29F912559E7212913015 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 331C80D2294CF70F00263BE5 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 910248FF45164247AED47927 /* Pods_RunnerTests.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - CAE0E1D6B3E065E67EACBD01 /* Pods_Runner.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C80D6294CF71000263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C80D7294CF71000263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 331C80D6294CF71000263BE5 /* RunnerTests */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - C10F1240AAD4C5F3329E39AA /* Pods */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* refresh_rate_example.app */, - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - C10F1240AAD4C5F3329E39AA /* Pods */ = { - isa = PBXGroup; - children = ( - B949B21E5690992FF2267767 /* Pods-Runner.debug.xcconfig */, - D2BF29F912559E7212913015 /* Pods-Runner.release.xcconfig */, - AB95905B211F4A9F4CF45C3D /* Pods-Runner.profile.xcconfig */, - CD862853BFB36B0C3DDAA1F5 /* Pods-RunnerTests.debug.xcconfig */, - B36A800253185A385429601E /* Pods-RunnerTests.release.xcconfig */, - A5943E0CD58AC7DB09939013 /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 78E82EFBCFE9D8FF45D965F6 /* Pods_Runner.framework */, - 48DEDB687F544FA88A70B539 /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C80D4294CF70F00263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 99F025F5D1F12E172730F152 /* [CP] Check Pods Manifest.lock */, - 331C80D1294CF70F00263BE5 /* Sources */, - 331C80D2294CF70F00263BE5 /* Frameworks */, - 331C80D3294CF70F00263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C80DA294CF71000263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - EB0C5EE370B257EB4486DA5D /* [CP] Check Pods Manifest.lock */, - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - 3399D490228B24CF009A79C7 /* ShellScript */, - FBE83B029BB64CA0A7DD0B6F /* [CP] Embed Pods Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* refresh_rate_example.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C80D4294CF70F00263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 33CC10EC2044A3C60003C045; - }; - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 331C80D4294CF70F00263BE5 /* RunnerTests */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C80D3294CF70F00263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; - 99F025F5D1F12E172730F152 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - EB0C5EE370B257EB4486DA5D /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - FBE83B029BB64CA0A7DD0B6F /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C80D1294CF70F00263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC10EC2044A3C60003C045 /* Runner */; - targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; - }; - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 331C80DB294CF71000263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = CD862853BFB36B0C3DDAA1F5 /* Pods-RunnerTests.debug.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/refresh_rate_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/refresh_rate_example"; - }; - name = Debug; - }; - 331C80DC294CF71000263BE5 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = B36A800253185A385429601E /* Pods-RunnerTests.release.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/refresh_rate_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/refresh_rate_example"; - }; - name = Release; - }; - 331C80DD294CF71000263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = A5943E0CD58AC7DB09939013 /* Pods-RunnerTests.profile.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/refresh_rate_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/refresh_rate_example"; - }; - name = Profile; - }; - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C80DB294CF71000263BE5 /* Debug */, - 331C80DC294CF71000263BE5 /* Release */, - 331C80DD294CF71000263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/third_party/refresh_rate/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/third_party/refresh_rate/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/third_party/refresh_rate/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/third_party/refresh_rate/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/third_party/refresh_rate/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index e27790b3..00000000 --- a/third_party/refresh_rate/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/third_party/refresh_rate/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/third_party/refresh_rate/example/macos/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 21a3cc14..00000000 --- a/third_party/refresh_rate/example/macos/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/third_party/refresh_rate/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/third_party/refresh_rate/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/third_party/refresh_rate/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/third_party/refresh_rate/example/macos/Runner/AppDelegate.swift b/third_party/refresh_rate/example/macos/Runner/AppDelegate.swift deleted file mode 100644 index b3c17614..00000000 --- a/third_party/refresh_rate/example/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Cocoa -import FlutterMacOS - -@main -class AppDelegate: FlutterAppDelegate { - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true - } -} diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a2ec33f1..00000000 --- a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" - }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 82b6f9d9..00000000 Binary files a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index 13b35eba..00000000 Binary files a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index 0a3f5fa4..00000000 Binary files a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index bdb57226..00000000 Binary files a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index f083318e..00000000 Binary files a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index 326c0e72..00000000 Binary files a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index 2f1632cf..00000000 Binary files a/third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/third_party/refresh_rate/example/macos/Runner/Base.lproj/MainMenu.xib b/third_party/refresh_rate/example/macos/Runner/Base.lproj/MainMenu.xib deleted file mode 100644 index 80e867a4..00000000 --- a/third_party/refresh_rate/example/macos/Runner/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/third_party/refresh_rate/example/macos/Runner/Configs/AppInfo.xcconfig b/third_party/refresh_rate/example/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index ac08879f..00000000 --- a/third_party/refresh_rate/example/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = refresh_rate_example - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = in.qoder.refreshRateExample - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2026 in.qoder. All rights reserved. diff --git a/third_party/refresh_rate/example/macos/Runner/Configs/Debug.xcconfig b/third_party/refresh_rate/example/macos/Runner/Configs/Debug.xcconfig deleted file mode 100644 index 36b0fd94..00000000 --- a/third_party/refresh_rate/example/macos/Runner/Configs/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Debug.xcconfig" -#include "Warnings.xcconfig" diff --git a/third_party/refresh_rate/example/macos/Runner/Configs/Release.xcconfig b/third_party/refresh_rate/example/macos/Runner/Configs/Release.xcconfig deleted file mode 100644 index dff4f495..00000000 --- a/third_party/refresh_rate/example/macos/Runner/Configs/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Release.xcconfig" -#include "Warnings.xcconfig" diff --git a/third_party/refresh_rate/example/macos/Runner/Configs/Warnings.xcconfig b/third_party/refresh_rate/example/macos/Runner/Configs/Warnings.xcconfig deleted file mode 100644 index 42bcbf47..00000000 --- a/third_party/refresh_rate/example/macos/Runner/Configs/Warnings.xcconfig +++ /dev/null @@ -1,13 +0,0 @@ -WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings -GCC_WARN_UNDECLARED_SELECTOR = YES -CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES -CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE -CLANG_WARN__DUPLICATE_METHOD_MATCH = YES -CLANG_WARN_PRAGMA_PACK = YES -CLANG_WARN_STRICT_PROTOTYPES = YES -CLANG_WARN_COMMA = YES -GCC_WARN_STRICT_SELECTOR_MATCH = YES -CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES -CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES -GCC_WARN_SHADOW = YES -CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/third_party/refresh_rate/example/macos/Runner/DebugProfile.entitlements b/third_party/refresh_rate/example/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index dddb8a30..00000000 --- a/third_party/refresh_rate/example/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - - diff --git a/third_party/refresh_rate/example/macos/Runner/Info.plist b/third_party/refresh_rate/example/macos/Runner/Info.plist deleted file mode 100644 index 4789daa6..00000000 --- a/third_party/refresh_rate/example/macos/Runner/Info.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - - diff --git a/third_party/refresh_rate/example/macos/Runner/MainFlutterWindow.swift b/third_party/refresh_rate/example/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 3cc05eb2..00000000 --- a/third_party/refresh_rate/example/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Cocoa -import FlutterMacOS - -class MainFlutterWindow: NSWindow { - override func awakeFromNib() { - let flutterViewController = FlutterViewController() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/third_party/refresh_rate/example/macos/Runner/Release.entitlements b/third_party/refresh_rate/example/macos/Runner/Release.entitlements deleted file mode 100644 index 852fa1a4..00000000 --- a/third_party/refresh_rate/example/macos/Runner/Release.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.app-sandbox - - - diff --git a/third_party/refresh_rate/example/macos/RunnerTests/RunnerTests.swift b/third_party/refresh_rate/example/macos/RunnerTests/RunnerTests.swift deleted file mode 100644 index 61f3bd1f..00000000 --- a/third_party/refresh_rate/example/macos/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Cocoa -import FlutterMacOS -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/third_party/refresh_rate/example/pubspec.yaml b/third_party/refresh_rate/example/pubspec.yaml deleted file mode 100644 index 83af76af..00000000 --- a/third_party/refresh_rate/example/pubspec.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: refresh_rate_example -description: Example app demonstrating the refresh_rate plugin. -publish_to: 'none' -version: 1.0.0+1 - -environment: - sdk: ^3.5.0 - -dependencies: - flutter: - sdk: flutter - refresh_rate: - path: ../ - -dev_dependencies: - flutter_test: - sdk: flutter - integration_test: - sdk: flutter - flutter_lints: ^5.0.0 - -flutter: - uses-material-design: true diff --git a/third_party/refresh_rate/example/test/widget_test.dart b/third_party/refresh_rate/example/test/widget_test.dart deleted file mode 100644 index 7512d156..00000000 --- a/third_party/refresh_rate/example/test/widget_test.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:refresh_rate_example/main.dart'; - -void main() { - testWidgets('App smoke test', (WidgetTester tester) async { - await tester.pumpWidget(const RefreshRateExampleApp()); - expect(find.text('Diagnostic Console'), findsOneWidget); - expect(find.text('Display Info'), findsOneWidget); - }); -} diff --git a/third_party/refresh_rate/example/test_driver/integration_test.dart b/third_party/refresh_rate/example/test_driver/integration_test.dart deleted file mode 100644 index 79c2b403..00000000 --- a/third_party/refresh_rate/example/test_driver/integration_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'dart:io'; - -import 'package:integration_test/integration_test_driver_extended.dart'; - -/// Flutter Drive driver for screenshot capture. -/// -/// Receives screenshot bytes from the device and writes them to the package -/// root `screenshots/` directory (one level up from `example/`). -/// -/// Run from the package root via `scripts/take_screenshots.sh`, or manually: -/// -/// cd example -/// flutter drive \ -/// --driver=test_driver/integration_test.dart \ -/// --target=integration_test/screenshot_test.dart \ -/// -d [device-id] -Future main() => integrationDriver( - onScreenshot: ( - String name, - List screenshotBytes, [ - Map? args, - ]) async { - final dir = Directory('../screenshots'); - if (!dir.existsSync()) dir.createSync(recursive: true); - final file = File('../screenshots/$name.png'); - await file.writeAsBytes(screenshotBytes); - stdout.writeln(' screenshot saved → screenshots/$name.png'); - return true; - }, - ); diff --git a/third_party/refresh_rate/example/web/favicon.png b/third_party/refresh_rate/example/web/favicon.png deleted file mode 100644 index 8aaa46ac..00000000 Binary files a/third_party/refresh_rate/example/web/favicon.png and /dev/null differ diff --git a/third_party/refresh_rate/example/web/icons/Icon-192.png b/third_party/refresh_rate/example/web/icons/Icon-192.png deleted file mode 100644 index b749bfef..00000000 Binary files a/third_party/refresh_rate/example/web/icons/Icon-192.png and /dev/null differ diff --git a/third_party/refresh_rate/example/web/icons/Icon-512.png b/third_party/refresh_rate/example/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48d..00000000 Binary files a/third_party/refresh_rate/example/web/icons/Icon-512.png and /dev/null differ diff --git a/third_party/refresh_rate/example/web/icons/Icon-maskable-192.png b/third_party/refresh_rate/example/web/icons/Icon-maskable-192.png deleted file mode 100644 index eb9b4d76..00000000 Binary files a/third_party/refresh_rate/example/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/third_party/refresh_rate/example/web/icons/Icon-maskable-512.png b/third_party/refresh_rate/example/web/icons/Icon-maskable-512.png deleted file mode 100644 index d69c5669..00000000 Binary files a/third_party/refresh_rate/example/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/third_party/refresh_rate/example/web/index.html b/third_party/refresh_rate/example/web/index.html deleted file mode 100644 index 2b81a3a0..00000000 --- a/third_party/refresh_rate/example/web/index.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - refresh_rate_example - - - - - - - diff --git a/third_party/refresh_rate/example/web/manifest.json b/third_party/refresh_rate/example/web/manifest.json deleted file mode 100644 index dca8856b..00000000 --- a/third_party/refresh_rate/example/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "refresh_rate_example", - "short_name": "refresh_rate_example", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/third_party/refresh_rate/example/windows/CMakeLists.txt b/third_party/refresh_rate/example/windows/CMakeLists.txt deleted file mode 100644 index 6130394e..00000000 --- a/third_party/refresh_rate/example/windows/CMakeLists.txt +++ /dev/null @@ -1,108 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.14) -project(refresh_rate_example LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "refresh_rate_example") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(VERSION 3.14...3.25) - -# Define build configuration option. -get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -if(IS_MULTICONFIG) - set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" - CACHE STRING "" FORCE) -else() - if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") - endif() -endif() -# Define settings for the Profile build mode. -set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") -set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") -set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") -set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") - -# Use Unicode for all projects. -add_definitions(-DUNICODE -D_UNICODE) - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") - target_compile_options(${TARGET} PRIVATE /EHsc) - target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") - target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# Support files are copied into place next to the executable, so that it can -# run in place. This is done instead of making a separate bundle (as on Linux) -# so that building and running from within Visual Studio will work. -set(BUILD_BUNDLE_DIR "$") -# Make the "install" step default, as it's required to run. -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - CONFIGURATIONS Profile;Release - COMPONENT Runtime) diff --git a/third_party/refresh_rate/example/windows/flutter/CMakeLists.txt b/third_party/refresh_rate/example/windows/flutter/CMakeLists.txt deleted file mode 100644 index 903f4899..00000000 --- a/third_party/refresh_rate/example/windows/flutter/CMakeLists.txt +++ /dev/null @@ -1,109 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. -set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") - -# Set fallback configurations for older versions of the flutter tool. -if (NOT DEFINED FLUTTER_TARGET_PLATFORM) - set(FLUTTER_TARGET_PLATFORM "windows-x64") -endif() - -# === Flutter Library === -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - ${FLUTTER_TARGET_PLATFORM} $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) diff --git a/third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.cc b/third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 31208abc..00000000 --- a/third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,14 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - RefreshRatePluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("RefreshRatePluginCApi")); -} diff --git a/third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.h b/third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d85..00000000 --- a/third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/third_party/refresh_rate/example/windows/flutter/generated_plugins.cmake b/third_party/refresh_rate/example/windows/flutter/generated_plugins.cmake deleted file mode 100644 index c7644834..00000000 --- a/third_party/refresh_rate/example/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,24 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - refresh_rate -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/third_party/refresh_rate/example/windows/runner/CMakeLists.txt b/third_party/refresh_rate/example/windows/runner/CMakeLists.txt deleted file mode 100644 index 394917c0..00000000 --- a/third_party/refresh_rate/example/windows/runner/CMakeLists.txt +++ /dev/null @@ -1,40 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} WIN32 - "flutter_window.cpp" - "main.cpp" - "utils.cpp" - "win32_window.cpp" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" - "Runner.rc" - "runner.exe.manifest" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the build version. -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") - -# Disable Windows macros that collide with C++ standard library functions. -target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") - -# Add dependency libraries and include directories. Add any application-specific -# dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) -target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/third_party/refresh_rate/example/windows/runner/Runner.rc b/third_party/refresh_rate/example/windows/runner/Runner.rc deleted file mode 100644 index cba01e6a..00000000 --- a/third_party/refresh_rate/example/windows/runner/Runner.rc +++ /dev/null @@ -1,121 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) -#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD -#else -#define VERSION_AS_NUMBER 1,0,0,0 -#endif - -#if defined(FLUTTER_VERSION) -#define VERSION_AS_STRING FLUTTER_VERSION -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "in.qoder" "\0" - VALUE "FileDescription", "refresh_rate_example" "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "refresh_rate_example" "\0" - VALUE "LegalCopyright", "Copyright (C) 2026 in.qoder. All rights reserved." "\0" - VALUE "OriginalFilename", "refresh_rate_example.exe" "\0" - VALUE "ProductName", "refresh_rate_example" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/third_party/refresh_rate/example/windows/runner/flutter_window.cpp b/third_party/refresh_rate/example/windows/runner/flutter_window.cpp deleted file mode 100644 index 955ee303..00000000 --- a/third_party/refresh_rate/example/windows/runner/flutter_window.cpp +++ /dev/null @@ -1,71 +0,0 @@ -#include "flutter_window.h" - -#include - -#include "flutter/generated_plugin_registrant.h" - -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} - -FlutterWindow::~FlutterWindow() {} - -bool FlutterWindow::OnCreate() { - if (!Win32Window::OnCreate()) { - return false; - } - - RECT frame = GetClientArea(); - - // The size here must match the window dimensions to avoid unnecessary surface - // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); - // Ensure that basic setup of the controller was successful. - if (!flutter_controller_->engine() || !flutter_controller_->view()) { - return false; - } - RegisterPlugins(flutter_controller_->engine()); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); - - flutter_controller_->engine()->SetNextFrameCallback([&]() { - this->Show(); - }); - - // Flutter can complete the first frame before the "show window" callback is - // registered. The following call ensures a frame is pending to ensure the - // window is shown. It is a no-op if the first frame hasn't completed yet. - flutter_controller_->ForceRedraw(); - - return true; -} - -void FlutterWindow::OnDestroy() { - if (flutter_controller_) { - flutter_controller_ = nullptr; - } - - Win32Window::OnDestroy(); -} - -LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - // Give Flutter, including plugins, an opportunity to handle window messages. - if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); - if (result) { - return *result; - } - } - - switch (message) { - case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); - break; - } - - return Win32Window::MessageHandler(hwnd, message, wparam, lparam); -} diff --git a/third_party/refresh_rate/example/windows/runner/flutter_window.h b/third_party/refresh_rate/example/windows/runner/flutter_window.h deleted file mode 100644 index 6da0652f..00000000 --- a/third_party/refresh_rate/example/windows/runner/flutter_window.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef RUNNER_FLUTTER_WINDOW_H_ -#define RUNNER_FLUTTER_WINDOW_H_ - -#include -#include - -#include - -#include "win32_window.h" - -// A window that does nothing but host a Flutter view. -class FlutterWindow : public Win32Window { - public: - // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); - virtual ~FlutterWindow(); - - protected: - // Win32Window: - bool OnCreate() override; - void OnDestroy() override; - LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, - LPARAM const lparam) noexcept override; - - private: - // The project to run. - flutter::DartProject project_; - - // The Flutter instance hosted by this window. - std::unique_ptr flutter_controller_; -}; - -#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/third_party/refresh_rate/example/windows/runner/main.cpp b/third_party/refresh_rate/example/windows/runner/main.cpp deleted file mode 100644 index 9f77364f..00000000 --- a/third_party/refresh_rate/example/windows/runner/main.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include -#include -#include - -#include "flutter_window.h" -#include "utils.h" - -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { - CreateAndAttachConsole(); - } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. - ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - - flutter::DartProject project(L"data"); - - std::vector command_line_arguments = - GetCommandLineArguments(); - - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - - FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.Create(L"refresh_rate_example", origin, size)) { - return EXIT_FAILURE; - } - window.SetQuitOnClose(true); - - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { - ::TranslateMessage(&msg); - ::DispatchMessage(&msg); - } - - ::CoUninitialize(); - return EXIT_SUCCESS; -} diff --git a/third_party/refresh_rate/example/windows/runner/resource.h b/third_party/refresh_rate/example/windows/runner/resource.h deleted file mode 100644 index 66a65d1e..00000000 --- a/third_party/refresh_rate/example/windows/runner/resource.h +++ /dev/null @@ -1,16 +0,0 @@ -//{{NO_DEPENDENCIES}} -// Microsoft Visual C++ generated include file. -// Used by Runner.rc -// -#define IDI_APP_ICON 101 - -// Next default values for new objects -// -#ifdef APSTUDIO_INVOKED -#ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 102 -#define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 101 -#endif -#endif diff --git a/third_party/refresh_rate/example/windows/runner/resources/app_icon.ico b/third_party/refresh_rate/example/windows/runner/resources/app_icon.ico deleted file mode 100644 index c04e20ca..00000000 Binary files a/third_party/refresh_rate/example/windows/runner/resources/app_icon.ico and /dev/null differ diff --git a/third_party/refresh_rate/example/windows/runner/runner.exe.manifest b/third_party/refresh_rate/example/windows/runner/runner.exe.manifest deleted file mode 100644 index 153653e8..00000000 --- a/third_party/refresh_rate/example/windows/runner/runner.exe.manifest +++ /dev/null @@ -1,14 +0,0 @@ - - - - - PerMonitorV2 - - - - - - - - - diff --git a/third_party/refresh_rate/example/windows/runner/utils.cpp b/third_party/refresh_rate/example/windows/runner/utils.cpp deleted file mode 100644 index 3a0b4651..00000000 --- a/third_party/refresh_rate/example/windows/runner/utils.cpp +++ /dev/null @@ -1,65 +0,0 @@ -#include "utils.h" - -#include -#include -#include -#include - -#include - -void CreateAndAttachConsole() { - if (::AllocConsole()) { - FILE *unused; - if (freopen_s(&unused, "CONOUT$", "w", stdout)) { - _dup2(_fileno(stdout), 1); - } - if (freopen_s(&unused, "CONOUT$", "w", stderr)) { - _dup2(_fileno(stdout), 2); - } - std::ios::sync_with_stdio(); - FlutterDesktopResyncOutputStreams(); - } -} - -std::vector GetCommandLineArguments() { - // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. - int argc; - wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); - if (argv == nullptr) { - return std::vector(); - } - - std::vector command_line_arguments; - - // Skip the first argument as it's the binary name. - for (int i = 1; i < argc; i++) { - command_line_arguments.push_back(Utf8FromUtf16(argv[i])); - } - - ::LocalFree(argv); - - return command_line_arguments; -} - -std::string Utf8FromUtf16(const wchar_t* utf16_string) { - if (utf16_string == nullptr) { - return std::string(); - } - unsigned int target_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, nullptr, 0, nullptr, nullptr) - -1; // remove the trailing null character - int input_length = (int)wcslen(utf16_string); - std::string utf8_string; - if (target_length == 0 || target_length > utf8_string.max_size()) { - return utf8_string; - } - utf8_string.resize(target_length); - int converted_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, utf8_string.data(), target_length, nullptr, nullptr); - if (converted_length == 0) { - return std::string(); - } - return utf8_string; -} diff --git a/third_party/refresh_rate/example/windows/runner/utils.h b/third_party/refresh_rate/example/windows/runner/utils.h deleted file mode 100644 index 3879d547..00000000 --- a/third_party/refresh_rate/example/windows/runner/utils.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef RUNNER_UTILS_H_ -#define RUNNER_UTILS_H_ - -#include -#include - -// Creates a console for the process, and redirects stdout and stderr to -// it for both the runner and the Flutter library. -void CreateAndAttachConsole(); - -// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string -// encoded in UTF-8. Returns an empty std::string on failure. -std::string Utf8FromUtf16(const wchar_t* utf16_string); - -// Gets the command line arguments passed in as a std::vector, -// encoded in UTF-8. Returns an empty std::vector on failure. -std::vector GetCommandLineArguments(); - -#endif // RUNNER_UTILS_H_ diff --git a/third_party/refresh_rate/example/windows/runner/win32_window.cpp b/third_party/refresh_rate/example/windows/runner/win32_window.cpp deleted file mode 100644 index 60608d0f..00000000 --- a/third_party/refresh_rate/example/windows/runner/win32_window.cpp +++ /dev/null @@ -1,288 +0,0 @@ -#include "win32_window.h" - -#include -#include - -#include "resource.h" - -namespace { - -/// Window attribute that enables dark mode window decorations. -/// -/// Redefined in case the developer's machine has a Windows SDK older than -/// version 10.0.22000.0. -/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute -#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE -#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 -#endif - -constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - -/// Registry key for app theme preference. -/// -/// A value of 0 indicates apps should use dark mode. A non-zero or missing -/// value indicates apps should use light mode. -constexpr const wchar_t kGetPreferredBrightnessRegKey[] = - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; -constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; - -// The number of Win32Window objects that currently exist. -static int g_active_window_count = 0; - -using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); - -// Scale helper to convert logical scaler values to physical using passed in -// scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} - -// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. -// This API is only needed for PerMonitor V1 awareness mode. -void EnableFullDpiSupportIfAvailable(HWND hwnd) { - HMODULE user32_module = LoadLibraryA("User32.dll"); - if (!user32_module) { - return; - } - auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); - if (enable_non_client_dpi_scaling != nullptr) { - enable_non_client_dpi_scaling(hwnd); - } - FreeLibrary(user32_module); -} - -} // namespace - -// Manages the Win32Window's window class registration. -class WindowClassRegistrar { - public: - ~WindowClassRegistrar() = default; - - // Returns the singleton registrar instance. - static WindowClassRegistrar* GetInstance() { - if (!instance_) { - instance_ = new WindowClassRegistrar(); - } - return instance_; - } - - // Returns the name of the window class, registering the class if it hasn't - // previously been registered. - const wchar_t* GetWindowClass(); - - // Unregisters the window class. Should only be called if there are no - // instances of the window. - void UnregisterWindowClass(); - - private: - WindowClassRegistrar() = default; - - static WindowClassRegistrar* instance_; - - bool class_registered_ = false; -}; - -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; - -const wchar_t* WindowClassRegistrar::GetWindowClass() { - if (!class_registered_) { - WNDCLASS window_class{}; - window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); - window_class.lpszClassName = kWindowClassName; - window_class.style = CS_HREDRAW | CS_VREDRAW; - window_class.cbClsExtra = 0; - window_class.cbWndExtra = 0; - window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); - window_class.hbrBackground = 0; - window_class.lpszMenuName = nullptr; - window_class.lpfnWndProc = Win32Window::WndProc; - RegisterClass(&window_class); - class_registered_ = true; - } - return kWindowClassName; -} - -void WindowClassRegistrar::UnregisterWindowClass() { - UnregisterClass(kWindowClassName, nullptr); - class_registered_ = false; -} - -Win32Window::Win32Window() { - ++g_active_window_count; -} - -Win32Window::~Win32Window() { - --g_active_window_count; - Destroy(); -} - -bool Win32Window::Create(const std::wstring& title, - const Point& origin, - const Size& size) { - Destroy(); - - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); - - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; - HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); - UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); - double scale_factor = dpi / 96.0; - - HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); - - if (!window) { - return false; - } - - UpdateTheme(window); - - return OnCreate(); -} - -bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); -} - -// static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); - - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); - } - - return DefWindowProc(window, message, wparam, lparam); -} - -LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; - } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; - } - - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; - - case WM_DWMCOLORIZATIONCOLORCHANGED: - UpdateTheme(hwnd); - return 0; - } - - return DefWindowProc(window_handle_, message, wparam, lparam); -} - -void Win32Window::Destroy() { - OnDestroy(); - - if (window_handle_) { - DestroyWindow(window_handle_); - window_handle_ = nullptr; - } - if (g_active_window_count == 0) { - WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); - } -} - -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); -} - -void Win32Window::SetChildContent(HWND content) { - child_content_ = content; - SetParent(content, window_handle_); - RECT frame = GetClientArea(); - - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); - - SetFocus(child_content_); -} - -RECT Win32Window::GetClientArea() { - RECT frame; - GetClientRect(window_handle_, &frame); - return frame; -} - -HWND Win32Window::GetHandle() { - return window_handle_; -} - -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} - -bool Win32Window::OnCreate() { - // No-op; provided for subclasses. - return true; -} - -void Win32Window::OnDestroy() { - // No-op; provided for subclasses. -} - -void Win32Window::UpdateTheme(HWND const window) { - DWORD light_mode; - DWORD light_mode_size = sizeof(light_mode); - LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, - kGetPreferredBrightnessRegValue, - RRF_RT_REG_DWORD, nullptr, &light_mode, - &light_mode_size); - - if (result == ERROR_SUCCESS) { - BOOL enable_dark_mode = light_mode == 0; - DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, - &enable_dark_mode, sizeof(enable_dark_mode)); - } -} diff --git a/third_party/refresh_rate/example/windows/runner/win32_window.h b/third_party/refresh_rate/example/windows/runner/win32_window.h deleted file mode 100644 index e901dde6..00000000 --- a/third_party/refresh_rate/example/windows/runner/win32_window.h +++ /dev/null @@ -1,102 +0,0 @@ -#ifndef RUNNER_WIN32_WINDOW_H_ -#define RUNNER_WIN32_WINDOW_H_ - -#include - -#include -#include -#include - -// A class abstraction for a high DPI-aware Win32 Window. Intended to be -// inherited from by classes that wish to specialize with custom -// rendering and input handling -class Win32Window { - public: - struct Point { - unsigned int x; - unsigned int y; - Point(unsigned int x, unsigned int y) : x(x), y(y) {} - }; - - struct Size { - unsigned int width; - unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} - }; - - Win32Window(); - virtual ~Win32Window(); - - // Creates a win32 window with |title| that is positioned and sized using - // |origin| and |size|. New windows are created on the default monitor. Window - // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size this function will scale the inputted width and height as - // as appropriate for the default monitor. The window is invisible until - // |Show| is called. Returns true if the window was created successfully. - bool Create(const std::wstring& title, const Point& origin, const Size& size); - - // Show the current window. Returns true if the window was successfully shown. - bool Show(); - - // Release OS resources associated with window. - void Destroy(); - - // Inserts |content| into the window tree. - void SetChildContent(HWND content); - - // Returns the backing Window handle to enable clients to set icon and other - // window properties. Returns nullptr if the window has been destroyed. - HWND GetHandle(); - - // If true, closing this window will quit the application. - void SetQuitOnClose(bool quit_on_close); - - // Return a RECT representing the bounds of the current client area. - RECT GetClientArea(); - - protected: - // Processes and route salient window messages for mouse handling, - // size change and DPI. Delegates handling of these to member overloads that - // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Called when CreateAndShow is called, allowing subclass window-related - // setup. Subclasses should return false if setup fails. - virtual bool OnCreate(); - - // Called when Destroy is called. - virtual void OnDestroy(); - - private: - friend class WindowClassRegistrar; - - // OS callback called by message pump. Handles the WM_NCCREATE message which - // is passed when the non-client area is being created and enables automatic - // non-client DPI scaling so that the non-client area automatically - // responds to changes in DPI. All other messages are handled by - // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Retrieves a class instance pointer for |window| - static Win32Window* GetThisFromHandle(HWND const window) noexcept; - - // Update the window frame's theme to match the system theme. - static void UpdateTheme(HWND const window); - - bool quit_on_close_ = false; - - // window handle for top level window. - HWND window_handle_ = nullptr; - - // window handle for hosted content. - HWND child_content_ = nullptr; -}; - -#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.h b/third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.h deleted file mode 100644 index c3fcdbdb..00000000 --- a/third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.h +++ /dev/null @@ -1,14 +0,0 @@ -#import - -/// Set the max frame rate cap for all display links. Pass 0 to disable. -void RRSetOverrideMaxRate(float rate); - -/// Get the current override max rate. 0 means no override. -float RRGetOverrideMaxRate(void); - -/// Tag a display link so the swizzle bypasses it (for monitoring/boost links). -void RRBypassDisplayLink(CADisplayLink * _Nonnull link); - -/// Apply the current override to all tracked (non-bypassed) display links. -/// Call this after changing the override rate. -void RRApplyOverrideToTrackedLinks(void); diff --git a/third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.m b/third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.m deleted file mode 100644 index 255cfcf4..00000000 --- a/third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.m +++ /dev/null @@ -1,186 +0,0 @@ -#import -#import - -// Key for tagging display links that should bypass the override. -static const void *RRBypassKey = &RRBypassKey; -// Key for storing the original (uncapped) frame rate range. -static const void *RROriginalRangeKey = &RROriginalRangeKey; - -/// Global override cap. 0 means no override (pass through). -static float _rr_overrideMaxRate = 0; - -/// Storage for tracked display links (weak-ish — we check validity). -static NSPointerArray *_rr_trackedLinks = nil; -static NSLock *_rr_lock = nil; - -// Store original IMPs — declared early so swizzled functions can reference them. -static IMP _rr_origAddToRunLoop = NULL; -static IMP _rr_origSetRange = NULL; - -#pragma mark - Public C interface (called from Swift) - -/// Set the max frame rate cap. Pass 0 to disable. -void RRSetOverrideMaxRate(float rate) { - _rr_overrideMaxRate = rate; -} - -float RRGetOverrideMaxRate(void) { - return _rr_overrideMaxRate; -} - -/// Tag a display link so the swizzle bypasses it. -void RRBypassDisplayLink(CADisplayLink *link) { - objc_setAssociatedObject(link, RRBypassKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -/// Apply the current override to all tracked display links. -void RRApplyOverrideToTrackedLinks(void) { - if (!_rr_lock) return; - - [_rr_lock lock]; - - // Compact nil refs - [_rr_trackedLinks compact]; - - NSUInteger count = [_rr_trackedLinks count]; - for (NSUInteger i = 0; i < count; i++) { - CADisplayLink *link = [_rr_trackedLinks pointerAtIndex:i]; - if (!link) continue; - - // Skip bypassed links - if (objc_getAssociatedObject(link, RRBypassKey)) continue; - - if (@available(iOS 15.0, *)) { - // Read stored original range - NSDictionary *stored = objc_getAssociatedObject(link, RROriginalRangeKey); - - float cap = _rr_overrideMaxRate; - - if (cap > 0 && stored) { - float origMin = [stored[@"min"] floatValue]; - float origMax = [stored[@"max"] floatValue]; - float origPref = [stored[@"preferred"] floatValue]; - CAFrameRateRange capped = CAFrameRateRangeMake( - fminf(origMin, cap), - fminf(origMax, cap), - fminf(origPref, cap) - ); - // Use original IMP directly to avoid re-triggering our swizzle - // (which would overwrite the stored original with the capped value) - SEL sel = NSSelectorFromString(@"setPreferredFrameRateRange:"); - if (_rr_origSetRange) { - ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(link, sel, capped); - } - } else if (cap <= 0 && stored) { - // Restore original - float origMin = [stored[@"min"] floatValue]; - float origMax = [stored[@"max"] floatValue]; - float origPref = [stored[@"preferred"] floatValue]; - SEL sel = NSSelectorFromString(@"setPreferredFrameRateRange:"); - if (_rr_origSetRange) { - ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(link, sel, CAFrameRateRangeMake(origMin, origMax, origPref)); - } - } - } - } - - [_rr_lock unlock]; -} - -#pragma mark - Swizzled implementations - -/// Swizzled addToRunLoop:forMode: — tracks every display link. -static void rr_addToRunLoop(CADisplayLink *self, SEL _cmd, NSRunLoop *runloop, NSRunLoopMode mode) { - // Track this link - [_rr_lock lock]; - if (_rr_trackedLinks) { - // Check not already tracked - BOOL found = NO; - [_rr_trackedLinks compact]; - for (NSUInteger i = 0; i < [_rr_trackedLinks count]; i++) { - if ([_rr_trackedLinks pointerAtIndex:i] == (__bridge void *)self) { - found = YES; - break; - } - } - if (!found) { - [_rr_trackedLinks addPointer:(__bridge void *)self]; - } - } - [_rr_lock unlock]; - - // Call original — the IMP was saved during swizzle - ((void (*)(id, SEL, NSRunLoop *, NSRunLoopMode))_rr_origAddToRunLoop)(self, _cmd, runloop, mode); -} - -/// Swizzled setPreferredFrameRateRange: — stores original range and applies cap. -static void rr_setPreferredFrameRateRange(CADisplayLink *self, SEL _cmd, CAFrameRateRange range) { - // Bypass tagged links - if (objc_getAssociatedObject(self, RRBypassKey)) { - ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, range); - return; - } - - // Store original range - if (@available(iOS 15.0, *)) { - NSDictionary *stored = @{ - @"min": @(range.minimum), - @"max": @(range.maximum), - @"preferred": @(range.preferred), - }; - objc_setAssociatedObject(self, RROriginalRangeKey, stored, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - - // Apply cap if active - float cap = _rr_overrideMaxRate; - if (cap > 0) { - if (@available(iOS 15.0, *)) { - CAFrameRateRange capped = CAFrameRateRangeMake( - fminf(range.minimum, cap), - fminf(range.maximum, cap), - fminf(range.preferred, cap) - ); - ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, capped); - } else { - ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, range); - } - } else { - ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, range); - } -} - -#pragma mark - +load (runs before main, before Flutter engine starts) - -@interface RRDisplayLinkSwizzle : NSObject -@end - -@implementation RRDisplayLinkSwizzle - -+ (void)load { - _rr_trackedLinks = [NSPointerArray weakObjectsPointerArray]; - _rr_lock = [[NSLock alloc] init]; - - Class cls = [CADisplayLink class]; - - // Swizzle addToRunLoop:forMode: - { - SEL sel = @selector(addToRunLoop:forMode:); - Method method = class_getInstanceMethod(cls, sel); - if (method) { - _rr_origAddToRunLoop = method_getImplementation(method); - method_setImplementation(method, (IMP)rr_addToRunLoop); - } - } - - // Swizzle setPreferredFrameRateRange: (iOS 15+) - if (@available(iOS 15.0, *)) { - SEL sel = NSSelectorFromString(@"setPreferredFrameRateRange:"); - Method method = class_getInstanceMethod(cls, sel); - if (method) { - _rr_origSetRange = method_getImplementation(method); - method_setImplementation(method, (IMP)rr_setPreferredFrameRateRange); - } - } -} - -@end diff --git a/third_party/refresh_rate/ios/Classes/RefreshRatePlugin.swift b/third_party/refresh_rate/ios/Classes/RefreshRatePlugin.swift deleted file mode 100644 index 1e0d806c..00000000 --- a/third_party/refresh_rate/ios/Classes/RefreshRatePlugin.swift +++ /dev/null @@ -1,210 +0,0 @@ -import Flutter -import UIKit -import QuartzCore - -public class RefreshRatePlugin: NSObject, FlutterPlugin, RefreshRateHostApi { - - private var flutterApi: RefreshRateFlutterApi? - private var displayLink: CADisplayLink? - private var boostDisplayLink: CADisplayLink? - private var lastReportedRate: Double = 0 - private var powerObserver: NSObjectProtocol? - private var thermalObserver: NSObjectProtocol? - - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = RefreshRatePlugin() - RefreshRateHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) - instance.flutterApi = RefreshRateFlutterApi(binaryMessenger: registrar.messenger()) - instance.startMonitoring() - } - - // MARK: - RefreshRateHostApi - - func getDisplayInfo() throws -> DisplayInfoMessage { - let maxRate = getMaxRefreshRate() - let currentRate = getCurrentRefreshRate() - let proMotion = isProMotionPlistKeySet() - let isLow = ProcessInfo.processInfo.isLowPowerModeEnabled - let thermal = thermalIndex() - - return DisplayInfoMessage( - currentRate: currentRate, - maxRate: maxRate, - minRate: 60.0, - supportedRates: getSupportedRefreshRates(), - isVariableRefreshRate: maxRate > 60, - engineTargetRate: currentRate, - iosProMotionEnabled: proMotion, - androidApiLevel: nil, - isLowPowerMode: isLow, - thermalStateIndex: thermal, - hasAdaptiveRefreshRate: maxRate > 60, - displayServer: nil, - monitorCount: nil - ) - } - - func enable() throws { try setToMax(forceHighest: false) } - func disable() throws { resetCap() } - func preferMax() throws { try setToMax(forceHighest: false) } - func preferDefault() throws { resetCap() } - - func matchContent(fps: Double) throws { - guard #available(iOS 15.0, *) else { return } - let maxRate = getMaxRefreshRate() - let multiple = max(1.0, (maxRate / fps).rounded(.down)) - let preferredMax = fps * multiple - RRSetOverrideMaxRate(Float(fps)) - RRApplyOverrideToTrackedLinks() - setupBoostDisplayLink(min: fps, max: preferredMax, preferred: preferredMax) - } - - func boost(durationMs: Int64) throws { - guard #available(iOS 15.0, *) else { return } - let maxRate = getMaxRefreshRate() - RRSetOverrideMaxRate(0) - RRApplyOverrideToTrackedLinks() - setupBoostDisplayLink(min: max(maxRate * 0.66, 60.0), max: maxRate, preferred: maxRate) - DispatchQueue.main.asyncAfter(deadline: .now() + Double(durationMs) / 1000.0) { - self.removeBoostDisplayLink() - RRSetOverrideMaxRate(0) - RRApplyOverrideToTrackedLinks() - } - } - - func setCategory(categoryIndex: Int64) throws { - switch categoryIndex { - case 3: try? enable() - case 0, 1: try? disable() - default: break - } - } - - func setTouchBoost(enabled: Bool) throws { - // No iOS equivalent; no-op - } - - func isSupported() throws -> Bool { - if #available(iOS 15.0, *) { return getMaxRefreshRate() > 60 } - return false - } - - // MARK: - Private control - - private func setToMax(forceHighest: Bool) throws { - let maxRate = getMaxRefreshRate() - let isPad = UIDevice.current.userInterfaceIdiom == .pad - let unlocked = isProMotionPlistKeySet() || isPad - if !unlocked { logPlistWarning(maxRate: maxRate) } - guard #available(iOS 15.0, *) else { return } - RRSetOverrideMaxRate(0) - RRApplyOverrideToTrackedLinks() - if forceHighest { - setupBoostDisplayLink(min: max(maxRate * 0.66, 60.0), max: maxRate, preferred: maxRate) - } else { - removeBoostDisplayLink() - } - } - - private func resetCap() { - RRSetOverrideMaxRate(60.0) - RRApplyOverrideToTrackedLinks() - removeBoostDisplayLink() - } - - // MARK: - Boost display link - - @available(iOS 15.0, *) - private func setupBoostDisplayLink(min: Double, max: Double, preferred: Double) { - removeBoostDisplayLink() - let link = CADisplayLink(target: self, selector: #selector(boostFired)) - RRBypassDisplayLink(link) - link.preferredFrameRateRange = CAFrameRateRange( - minimum: Float(min), maximum: Float(max), preferred: Float(preferred)) - link.add(to: .main, forMode: .common) - boostDisplayLink = link - } - - private func removeBoostDisplayLink() { - boostDisplayLink?.invalidate() - boostDisplayLink = nil - } - - @objc private func boostFired(_ link: CADisplayLink) {} - - // MARK: - Monitoring - - @objc private func monitorLinkFired(_ link: CADisplayLink) { - let rate = link.duration > 0 ? 1.0 / link.duration : 60.0 - if abs(rate - lastReportedRate) > 5.0 { - lastReportedRate = rate - let info = (try? getDisplayInfo()) ?? DisplayInfoMessage( - currentRate: rate, maxRate: rate, minRate: 60.0, - supportedRates: [60.0, rate], isVariableRefreshRate: rate > 60, - engineTargetRate: rate, iosProMotionEnabled: nil, - androidApiLevel: nil, isLowPowerMode: nil, - thermalStateIndex: nil, hasAdaptiveRefreshRate: nil, - displayServer: nil, monitorCount: nil) - flutterApi?.onDisplayInfoChanged(info: info) { _ in } - } - } - - private func startMonitoring() { - if displayLink == nil { - let link = CADisplayLink(target: self, selector: #selector(monitorLinkFired)) - RRBypassDisplayLink(link) - link.add(to: .main, forMode: .common) - displayLink = link - } - powerObserver = NotificationCenter.default.addObserver( - forName: .NSProcessInfoPowerStateDidChange, object: nil, queue: .main) { [weak self] _ in - guard let info = try? self?.getDisplayInfo() else { return } - self?.flutterApi?.onDisplayInfoChanged(info: info) { _ in } - } - thermalObserver = NotificationCenter.default.addObserver( - forName: ProcessInfo.thermalStateDidChangeNotification, object: nil, queue: .main) { [weak self] _ in - guard let info = try? self?.getDisplayInfo() else { return } - self?.flutterApi?.onDisplayInfoChanged(info: info) { _ in } - } - } - - // MARK: - Helpers - - private func getMaxRefreshRate() -> Double { Double(UIScreen.main.maximumFramesPerSecond) } - - private func getCurrentRefreshRate() -> Double { - if let link = displayLink, link.duration > 0 { return 1.0 / link.duration } - let max = getMaxRefreshRate() - let isPad = UIDevice.current.userInterfaceIdiom == .pad - return (max > 60 && (isProMotionPlistKeySet() || isPad)) ? max : 60.0 - } - - private func getSupportedRefreshRates() -> [Double] { - let max = getMaxRefreshRate() - return max > 60 ? [60.0, max] : [60.0] - } - - private func isProMotionPlistKeySet() -> Bool { - return Bundle.main.object(forInfoDictionaryKey: "CADisableMinimumFrameDurationOnPhone") as? Bool ?? false - } - - private func thermalIndex() -> Int64? { - switch ProcessInfo.processInfo.thermalState { - case .nominal: return 0 - case .fair: return 1 - case .serious: return 2 - case .critical: return 3 - @unknown default: return nil - } - } - - private func logPlistWarning(maxRate: Double) { - print(""" - ⚠️ [refresh_rate] CADisableMinimumFrameDurationOnPhone not set in Info.plist! - App is locked to 60Hz on this \(maxRate)Hz device. - Add to ios/Runner/Info.plist: - CADisableMinimumFrameDurationOnPhone - - """) - } -} diff --git a/third_party/refresh_rate/ios/Classes/generated/RefreshRateApi.swift b/third_party/refresh_rate/ios/Classes/generated/RefreshRateApi.swift deleted file mode 100644 index 70c230c8..00000000 --- a/third_party/refresh_rate/ios/Classes/generated/RefreshRateApi.swift +++ /dev/null @@ -1,369 +0,0 @@ -// Copyright 2026 Qoder (qoder.in). All rights reserved. -// Use of this source code is governed by a BSD-style license. -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -import Foundation - -#if os(iOS) - import Flutter -#elseif os(macOS) - import FlutterMacOS -#else - #error("Unsupported platform.") -#endif - -/// Error class for passing custom error details to Dart side. -final class PigeonError: Error { - let code: String - let message: String? - let details: Any? - - init(code: String, message: String?, details: Any?) { - self.code = code - self.message = message - self.details = details - } - - var localizedDescription: String { - return - "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" - } -} - -private func wrapResult(_ result: Any?) -> [Any?] { - return [result] -} - -private func wrapError(_ error: Any) -> [Any?] { - if let pigeonError = error as? PigeonError { - return [ - pigeonError.code, - pigeonError.message, - pigeonError.details, - ] - } - if let flutterError = error as? FlutterError { - return [ - flutterError.code, - flutterError.message, - flutterError.details, - ] - } - return [ - "\(error)", - "\(type(of: error))", - "Stacktrace: \(Thread.callStackSymbols)", - ] -} - -private func createConnectionError(withChannelName channelName: String) -> PigeonError { - return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") -} - -private func isNullish(_ value: Any?) -> Bool { - return value is NSNull || value == nil -} - -private func nilOrValue(_ value: Any?) -> T? { - if value is NSNull { return nil } - return value as! T? -} - -/// Generated class from Pigeon that represents data sent in messages. -struct DisplayInfoMessage { - var currentRate: Double? = nil - var maxRate: Double? = nil - var minRate: Double? = nil - var supportedRates: [Double?]? = nil - var isVariableRefreshRate: Bool? = nil - var engineTargetRate: Double? = nil - var iosProMotionEnabled: Bool? = nil - var androidApiLevel: Int64? = nil - var isLowPowerMode: Bool? = nil - var thermalStateIndex: Int64? = nil - var hasAdaptiveRefreshRate: Bool? = nil - var displayServer: String? = nil - var monitorCount: Int64? = nil - - - // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> DisplayInfoMessage? { - let currentRate: Double? = nilOrValue(pigeonVar_list[0]) - let maxRate: Double? = nilOrValue(pigeonVar_list[1]) - let minRate: Double? = nilOrValue(pigeonVar_list[2]) - let supportedRates: [Double?]? = nilOrValue(pigeonVar_list[3]) - let isVariableRefreshRate: Bool? = nilOrValue(pigeonVar_list[4]) - let engineTargetRate: Double? = nilOrValue(pigeonVar_list[5]) - let iosProMotionEnabled: Bool? = nilOrValue(pigeonVar_list[6]) - let androidApiLevel: Int64? = nilOrValue(pigeonVar_list[7]) - let isLowPowerMode: Bool? = nilOrValue(pigeonVar_list[8]) - let thermalStateIndex: Int64? = nilOrValue(pigeonVar_list[9]) - let hasAdaptiveRefreshRate: Bool? = nilOrValue(pigeonVar_list[10]) - let displayServer: String? = nilOrValue(pigeonVar_list[11]) - let monitorCount: Int64? = nilOrValue(pigeonVar_list[12]) - - return DisplayInfoMessage( - currentRate: currentRate, - maxRate: maxRate, - minRate: minRate, - supportedRates: supportedRates, - isVariableRefreshRate: isVariableRefreshRate, - engineTargetRate: engineTargetRate, - iosProMotionEnabled: iosProMotionEnabled, - androidApiLevel: androidApiLevel, - isLowPowerMode: isLowPowerMode, - thermalStateIndex: thermalStateIndex, - hasAdaptiveRefreshRate: hasAdaptiveRefreshRate, - displayServer: displayServer, - monitorCount: monitorCount - ) - } - func toList() -> [Any?] { - return [ - currentRate, - maxRate, - minRate, - supportedRates, - isVariableRefreshRate, - engineTargetRate, - iosProMotionEnabled, - androidApiLevel, - isLowPowerMode, - thermalStateIndex, - hasAdaptiveRefreshRate, - displayServer, - monitorCount, - ] - } -} - -private class RefreshRateApiPigeonCodecReader: FlutterStandardReader { - override func readValue(ofType type: UInt8) -> Any? { - switch type { - case 129: - return DisplayInfoMessage.fromList(self.readValue() as! [Any?]) - default: - return super.readValue(ofType: type) - } - } -} - -private class RefreshRateApiPigeonCodecWriter: FlutterStandardWriter { - override func writeValue(_ value: Any) { - if let value = value as? DisplayInfoMessage { - super.writeByte(129) - super.writeValue(value.toList()) - } else { - super.writeValue(value) - } - } -} - -private class RefreshRateApiPigeonCodecReaderWriter: FlutterStandardReaderWriter { - override func reader(with data: Data) -> FlutterStandardReader { - return RefreshRateApiPigeonCodecReader(data: data) - } - - override func writer(with data: NSMutableData) -> FlutterStandardWriter { - return RefreshRateApiPigeonCodecWriter(data: data) - } -} - -class RefreshRateApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { - static let shared = RefreshRateApiPigeonCodec(readerWriter: RefreshRateApiPigeonCodecReaderWriter()) -} - -/// Generated protocol from Pigeon that represents a handler of messages from Flutter. -protocol RefreshRateHostApi { - func getDisplayInfo() throws -> DisplayInfoMessage - func enable() throws - func disable() throws - func preferMax() throws - func preferDefault() throws - func matchContent(fps: Double) throws - func boost(durationMs: Int64) throws - func setCategory(categoryIndex: Int64) throws - func setTouchBoost(enabled: Bool) throws - func isSupported() throws -> Bool -} - -/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. -class RefreshRateHostApiSetup { - static var codec: FlutterStandardMessageCodec { RefreshRateApiPigeonCodec.shared } - /// Sets up an instance of `RefreshRateHostApi` to handle messages through the `binaryMessenger`. - static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RefreshRateHostApi?, messageChannelSuffix: String = "") { - let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - let getDisplayInfoChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - getDisplayInfoChannel.setMessageHandler { _, reply in - do { - let result = try api.getDisplayInfo() - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - getDisplayInfoChannel.setMessageHandler(nil) - } - let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - enableChannel.setMessageHandler { _, reply in - do { - try api.enable() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - enableChannel.setMessageHandler(nil) - } - let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - disableChannel.setMessageHandler { _, reply in - do { - try api.disable() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - disableChannel.setMessageHandler(nil) - } - let preferMaxChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - preferMaxChannel.setMessageHandler { _, reply in - do { - try api.preferMax() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - preferMaxChannel.setMessageHandler(nil) - } - let preferDefaultChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - preferDefaultChannel.setMessageHandler { _, reply in - do { - try api.preferDefault() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - preferDefaultChannel.setMessageHandler(nil) - } - let matchContentChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - matchContentChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let fpsArg = args[0] as! Double - do { - try api.matchContent(fps: fpsArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - matchContentChannel.setMessageHandler(nil) - } - let boostChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - boostChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let durationMsArg = args[0] as! Int64 - do { - try api.boost(durationMs: durationMsArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - boostChannel.setMessageHandler(nil) - } - let setCategoryChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - setCategoryChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let categoryIndexArg = args[0] as! Int64 - do { - try api.setCategory(categoryIndex: categoryIndexArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - setCategoryChannel.setMessageHandler(nil) - } - let setTouchBoostChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - setTouchBoostChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let enabledArg = args[0] as! Bool - do { - try api.setTouchBoost(enabled: enabledArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - setTouchBoostChannel.setMessageHandler(nil) - } - let isSupportedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - isSupportedChannel.setMessageHandler { _, reply in - do { - let result = try api.isSupported() - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - isSupportedChannel.setMessageHandler(nil) - } - } -} -/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. -protocol RefreshRateFlutterApiProtocol { - func onDisplayInfoChanged(info infoArg: DisplayInfoMessage, completion: @escaping (Result) -> Void) -} -class RefreshRateFlutterApi: RefreshRateFlutterApiProtocol { - private let binaryMessenger: FlutterBinaryMessenger - private let messageChannelSuffix: String - init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { - self.binaryMessenger = binaryMessenger - self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - } - var codec: RefreshRateApiPigeonCodec { - return RefreshRateApiPigeonCodec.shared - } - func onDisplayInfoChanged(info infoArg: DisplayInfoMessage, completion: @escaping (Result) -> Void) { - let channelName: String = "dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged\(messageChannelSuffix)" - let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) - channel.sendMessage([infoArg] as [Any?]) { response in - guard let listResponse = response as? [Any?] else { - completion(.failure(createConnectionError(withChannelName: channelName))) - return - } - if listResponse.count > 1 { - let code: String = listResponse[0] as! String - let message: String? = nilOrValue(listResponse[1]) - let details: String? = nilOrValue(listResponse[2]) - completion(.failure(PigeonError(code: code, message: message, details: details))) - } else { - completion(.success(Void())) - } - } - } -} diff --git a/third_party/refresh_rate/ios/refresh_rate.podspec b/third_party/refresh_rate/ios/refresh_rate.podspec deleted file mode 100644 index a6912cfb..00000000 --- a/third_party/refresh_rate/ios/refresh_rate.podspec +++ /dev/null @@ -1,22 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'refresh_rate' - s.version = '1.0.2' - s.summary = 'Control display refresh rates in Flutter.' - s.description = <<-DESC -Cross-platform Flutter plugin to query and control display refresh rates. -Unlock high refresh rates (90Hz/120Hz/144Hz) by properly communicating -with the OS compositor — something Flutter doesn't do by default. - DESC - s.homepage = 'https://qoder.in' - s.license = { :file => '../LICENSE' } - s.author = { 'Qoder' => 'dev@qoder.in' } - s.source = { :path => '.' } - s.source_files = [ - 'refresh_rate/Sources/refresh_rate/**/*.swift', - 'refresh_rate/Sources/refresh_rate_objc/**/*.{h,m}' - ] - s.public_header_files = 'refresh_rate/Sources/refresh_rate_objc/include/**/*.h' - s.dependency 'Flutter' - s.platform = :ios, '12.0' - s.swift_version = '5.0' -end diff --git a/third_party/refresh_rate/ios/refresh_rate/Package.swift b/third_party/refresh_rate/ios/refresh_rate/Package.swift deleted file mode 100644 index 08b8c9ab..00000000 --- a/third_party/refresh_rate/ios/refresh_rate/Package.swift +++ /dev/null @@ -1,32 +0,0 @@ -// swift-tools-version: 5.9 -// The swift-tools-version declares the minimum version of Swift required to build this package. -// -import PackageDescription - -let package = Package( - name: "refresh_rate", - platforms: [ - .iOS(.v12), - ], - products: [ - .library(name: "refresh-rate", targets: ["refresh_rate"]), - ], - dependencies: [ - .package(name: "FlutterFramework", path: "../FlutterFramework"), - ], - targets: [ - .target( - name: "refresh_rate_objc", - path: "Sources/refresh_rate_objc", - publicHeadersPath: "include" - ), - .target( - name: "refresh_rate", - dependencies: [ - "refresh_rate_objc", - .product(name: "FlutterFramework", package: "FlutterFramework"), - ], - path: "Sources/refresh_rate" - ), - ] -) diff --git a/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/RefreshRatePlugin.swift b/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/RefreshRatePlugin.swift deleted file mode 100644 index 6845df98..00000000 --- a/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/RefreshRatePlugin.swift +++ /dev/null @@ -1,214 +0,0 @@ -import Flutter -import UIKit -import QuartzCore - -#if SWIFT_PACKAGE -import refresh_rate_objc -#endif - -public class RefreshRatePlugin: NSObject, FlutterPlugin, RefreshRateHostApi { - - private var flutterApi: RefreshRateFlutterApi? - private var displayLink: CADisplayLink? - private var boostDisplayLink: CADisplayLink? - private var lastReportedRate: Double = 0 - private var powerObserver: NSObjectProtocol? - private var thermalObserver: NSObjectProtocol? - - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = RefreshRatePlugin() - RefreshRateHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) - instance.flutterApi = RefreshRateFlutterApi(binaryMessenger: registrar.messenger()) - instance.startMonitoring() - } - - // MARK: - RefreshRateHostApi - - func getDisplayInfo() throws -> DisplayInfoMessage { - let maxRate = getMaxRefreshRate() - let currentRate = getCurrentRefreshRate() - let proMotion = isProMotionPlistKeySet() - let isLow = ProcessInfo.processInfo.isLowPowerModeEnabled - let thermal = thermalIndex() - - return DisplayInfoMessage( - currentRate: currentRate, - maxRate: maxRate, - minRate: 60.0, - supportedRates: getSupportedRefreshRates(), - isVariableRefreshRate: maxRate > 60, - engineTargetRate: currentRate, - iosProMotionEnabled: proMotion, - androidApiLevel: nil, - isLowPowerMode: isLow, - thermalStateIndex: thermal, - hasAdaptiveRefreshRate: maxRate > 60, - displayServer: nil, - monitorCount: nil - ) - } - - func enable() throws { try setToMax(forceHighest: false) } - func disable() throws { resetCap() } - func preferMax() throws { try setToMax(forceHighest: false) } - func preferDefault() throws { resetCap() } - - func matchContent(fps: Double) throws { - guard #available(iOS 15.0, *) else { return } - let maxRate = getMaxRefreshRate() - let multiple = max(1.0, (maxRate / fps).rounded(.down)) - let preferredMax = fps * multiple - RRSetOverrideMaxRate(Float(fps)) - RRApplyOverrideToTrackedLinks() - setupBoostDisplayLink(min: fps, max: preferredMax, preferred: preferredMax) - } - - func boost(durationMs: Int64) throws { - guard #available(iOS 15.0, *) else { return } - let maxRate = getMaxRefreshRate() - RRSetOverrideMaxRate(0) - RRApplyOverrideToTrackedLinks() - setupBoostDisplayLink(min: max(maxRate * 0.66, 60.0), max: maxRate, preferred: maxRate) - DispatchQueue.main.asyncAfter(deadline: .now() + Double(durationMs) / 1000.0) { - self.removeBoostDisplayLink() - RRSetOverrideMaxRate(0) - RRApplyOverrideToTrackedLinks() - } - } - - func setCategory(categoryIndex: Int64) throws { - switch categoryIndex { - case 3: try? enable() - case 0, 1: try? disable() - default: break - } - } - - func setTouchBoost(enabled: Bool) throws { - // No iOS equivalent; no-op - } - - func isSupported() throws -> Bool { - if #available(iOS 15.0, *) { return getMaxRefreshRate() > 60 } - return false - } - - // MARK: - Private control - - private func setToMax(forceHighest: Bool) throws { - let maxRate = getMaxRefreshRate() - let isPad = UIDevice.current.userInterfaceIdiom == .pad - let unlocked = isProMotionPlistKeySet() || isPad - if !unlocked { logPlistWarning(maxRate: maxRate) } - guard #available(iOS 15.0, *) else { return } - RRSetOverrideMaxRate(0) - RRApplyOverrideToTrackedLinks() - if forceHighest { - setupBoostDisplayLink(min: max(maxRate * 0.66, 60.0), max: maxRate, preferred: maxRate) - } else { - removeBoostDisplayLink() - } - } - - private func resetCap() { - RRSetOverrideMaxRate(60.0) - RRApplyOverrideToTrackedLinks() - removeBoostDisplayLink() - } - - // MARK: - Boost display link - - @available(iOS 15.0, *) - private func setupBoostDisplayLink(min: Double, max: Double, preferred: Double) { - removeBoostDisplayLink() - let link = CADisplayLink(target: self, selector: #selector(boostFired)) - RRBypassDisplayLink(link) - link.preferredFrameRateRange = CAFrameRateRange( - minimum: Float(min), maximum: Float(max), preferred: Float(preferred)) - link.add(to: .main, forMode: .common) - boostDisplayLink = link - } - - private func removeBoostDisplayLink() { - boostDisplayLink?.invalidate() - boostDisplayLink = nil - } - - @objc private func boostFired(_ link: CADisplayLink) {} - - // MARK: - Monitoring - - @objc private func monitorLinkFired(_ link: CADisplayLink) { - let rate = link.duration > 0 ? 1.0 / link.duration : 60.0 - if abs(rate - lastReportedRate) > 5.0 { - lastReportedRate = rate - let info = (try? getDisplayInfo()) ?? DisplayInfoMessage( - currentRate: rate, maxRate: rate, minRate: 60.0, - supportedRates: [60.0, rate], isVariableRefreshRate: rate > 60, - engineTargetRate: rate, iosProMotionEnabled: nil, - androidApiLevel: nil, isLowPowerMode: nil, - thermalStateIndex: nil, hasAdaptiveRefreshRate: nil, - displayServer: nil, monitorCount: nil) - flutterApi?.onDisplayInfoChanged(info: info) { _ in } - } - } - - private func startMonitoring() { - if displayLink == nil { - let link = CADisplayLink(target: self, selector: #selector(monitorLinkFired)) - RRBypassDisplayLink(link) - link.add(to: .main, forMode: .common) - displayLink = link - } - powerObserver = NotificationCenter.default.addObserver( - forName: .NSProcessInfoPowerStateDidChange, object: nil, queue: .main) { [weak self] _ in - guard let info = try? self?.getDisplayInfo() else { return } - self?.flutterApi?.onDisplayInfoChanged(info: info) { _ in } - } - thermalObserver = NotificationCenter.default.addObserver( - forName: ProcessInfo.thermalStateDidChangeNotification, object: nil, queue: .main) { [weak self] _ in - guard let info = try? self?.getDisplayInfo() else { return } - self?.flutterApi?.onDisplayInfoChanged(info: info) { _ in } - } - } - - // MARK: - Helpers - - private func getMaxRefreshRate() -> Double { Double(UIScreen.main.maximumFramesPerSecond) } - - private func getCurrentRefreshRate() -> Double { - if let link = displayLink, link.duration > 0 { return 1.0 / link.duration } - let max = getMaxRefreshRate() - let isPad = UIDevice.current.userInterfaceIdiom == .pad - return (max > 60 && (isProMotionPlistKeySet() || isPad)) ? max : 60.0 - } - - private func getSupportedRefreshRates() -> [Double] { - let max = getMaxRefreshRate() - return max > 60 ? [60.0, max] : [60.0] - } - - private func isProMotionPlistKeySet() -> Bool { - return Bundle.main.object(forInfoDictionaryKey: "CADisableMinimumFrameDurationOnPhone") as? Bool ?? false - } - - private func thermalIndex() -> Int64? { - switch ProcessInfo.processInfo.thermalState { - case .nominal: return 0 - case .fair: return 1 - case .serious: return 2 - case .critical: return 3 - @unknown default: return nil - } - } - - private func logPlistWarning(maxRate: Double) { - print(""" - ⚠️ [refresh_rate] CADisableMinimumFrameDurationOnPhone not set in Info.plist! - App is locked to 60Hz on this \(maxRate)Hz device. - Add to ios/Runner/Info.plist: - CADisableMinimumFrameDurationOnPhone - - """) - } -} diff --git a/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/generated/RefreshRateApi.swift b/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/generated/RefreshRateApi.swift deleted file mode 100644 index 70c230c8..00000000 --- a/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/generated/RefreshRateApi.swift +++ /dev/null @@ -1,369 +0,0 @@ -// Copyright 2026 Qoder (qoder.in). All rights reserved. -// Use of this source code is governed by a BSD-style license. -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -import Foundation - -#if os(iOS) - import Flutter -#elseif os(macOS) - import FlutterMacOS -#else - #error("Unsupported platform.") -#endif - -/// Error class for passing custom error details to Dart side. -final class PigeonError: Error { - let code: String - let message: String? - let details: Any? - - init(code: String, message: String?, details: Any?) { - self.code = code - self.message = message - self.details = details - } - - var localizedDescription: String { - return - "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" - } -} - -private func wrapResult(_ result: Any?) -> [Any?] { - return [result] -} - -private func wrapError(_ error: Any) -> [Any?] { - if let pigeonError = error as? PigeonError { - return [ - pigeonError.code, - pigeonError.message, - pigeonError.details, - ] - } - if let flutterError = error as? FlutterError { - return [ - flutterError.code, - flutterError.message, - flutterError.details, - ] - } - return [ - "\(error)", - "\(type(of: error))", - "Stacktrace: \(Thread.callStackSymbols)", - ] -} - -private func createConnectionError(withChannelName channelName: String) -> PigeonError { - return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") -} - -private func isNullish(_ value: Any?) -> Bool { - return value is NSNull || value == nil -} - -private func nilOrValue(_ value: Any?) -> T? { - if value is NSNull { return nil } - return value as! T? -} - -/// Generated class from Pigeon that represents data sent in messages. -struct DisplayInfoMessage { - var currentRate: Double? = nil - var maxRate: Double? = nil - var minRate: Double? = nil - var supportedRates: [Double?]? = nil - var isVariableRefreshRate: Bool? = nil - var engineTargetRate: Double? = nil - var iosProMotionEnabled: Bool? = nil - var androidApiLevel: Int64? = nil - var isLowPowerMode: Bool? = nil - var thermalStateIndex: Int64? = nil - var hasAdaptiveRefreshRate: Bool? = nil - var displayServer: String? = nil - var monitorCount: Int64? = nil - - - // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> DisplayInfoMessage? { - let currentRate: Double? = nilOrValue(pigeonVar_list[0]) - let maxRate: Double? = nilOrValue(pigeonVar_list[1]) - let minRate: Double? = nilOrValue(pigeonVar_list[2]) - let supportedRates: [Double?]? = nilOrValue(pigeonVar_list[3]) - let isVariableRefreshRate: Bool? = nilOrValue(pigeonVar_list[4]) - let engineTargetRate: Double? = nilOrValue(pigeonVar_list[5]) - let iosProMotionEnabled: Bool? = nilOrValue(pigeonVar_list[6]) - let androidApiLevel: Int64? = nilOrValue(pigeonVar_list[7]) - let isLowPowerMode: Bool? = nilOrValue(pigeonVar_list[8]) - let thermalStateIndex: Int64? = nilOrValue(pigeonVar_list[9]) - let hasAdaptiveRefreshRate: Bool? = nilOrValue(pigeonVar_list[10]) - let displayServer: String? = nilOrValue(pigeonVar_list[11]) - let monitorCount: Int64? = nilOrValue(pigeonVar_list[12]) - - return DisplayInfoMessage( - currentRate: currentRate, - maxRate: maxRate, - minRate: minRate, - supportedRates: supportedRates, - isVariableRefreshRate: isVariableRefreshRate, - engineTargetRate: engineTargetRate, - iosProMotionEnabled: iosProMotionEnabled, - androidApiLevel: androidApiLevel, - isLowPowerMode: isLowPowerMode, - thermalStateIndex: thermalStateIndex, - hasAdaptiveRefreshRate: hasAdaptiveRefreshRate, - displayServer: displayServer, - monitorCount: monitorCount - ) - } - func toList() -> [Any?] { - return [ - currentRate, - maxRate, - minRate, - supportedRates, - isVariableRefreshRate, - engineTargetRate, - iosProMotionEnabled, - androidApiLevel, - isLowPowerMode, - thermalStateIndex, - hasAdaptiveRefreshRate, - displayServer, - monitorCount, - ] - } -} - -private class RefreshRateApiPigeonCodecReader: FlutterStandardReader { - override func readValue(ofType type: UInt8) -> Any? { - switch type { - case 129: - return DisplayInfoMessage.fromList(self.readValue() as! [Any?]) - default: - return super.readValue(ofType: type) - } - } -} - -private class RefreshRateApiPigeonCodecWriter: FlutterStandardWriter { - override func writeValue(_ value: Any) { - if let value = value as? DisplayInfoMessage { - super.writeByte(129) - super.writeValue(value.toList()) - } else { - super.writeValue(value) - } - } -} - -private class RefreshRateApiPigeonCodecReaderWriter: FlutterStandardReaderWriter { - override func reader(with data: Data) -> FlutterStandardReader { - return RefreshRateApiPigeonCodecReader(data: data) - } - - override func writer(with data: NSMutableData) -> FlutterStandardWriter { - return RefreshRateApiPigeonCodecWriter(data: data) - } -} - -class RefreshRateApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { - static let shared = RefreshRateApiPigeonCodec(readerWriter: RefreshRateApiPigeonCodecReaderWriter()) -} - -/// Generated protocol from Pigeon that represents a handler of messages from Flutter. -protocol RefreshRateHostApi { - func getDisplayInfo() throws -> DisplayInfoMessage - func enable() throws - func disable() throws - func preferMax() throws - func preferDefault() throws - func matchContent(fps: Double) throws - func boost(durationMs: Int64) throws - func setCategory(categoryIndex: Int64) throws - func setTouchBoost(enabled: Bool) throws - func isSupported() throws -> Bool -} - -/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. -class RefreshRateHostApiSetup { - static var codec: FlutterStandardMessageCodec { RefreshRateApiPigeonCodec.shared } - /// Sets up an instance of `RefreshRateHostApi` to handle messages through the `binaryMessenger`. - static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RefreshRateHostApi?, messageChannelSuffix: String = "") { - let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - let getDisplayInfoChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - getDisplayInfoChannel.setMessageHandler { _, reply in - do { - let result = try api.getDisplayInfo() - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - getDisplayInfoChannel.setMessageHandler(nil) - } - let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - enableChannel.setMessageHandler { _, reply in - do { - try api.enable() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - enableChannel.setMessageHandler(nil) - } - let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - disableChannel.setMessageHandler { _, reply in - do { - try api.disable() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - disableChannel.setMessageHandler(nil) - } - let preferMaxChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - preferMaxChannel.setMessageHandler { _, reply in - do { - try api.preferMax() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - preferMaxChannel.setMessageHandler(nil) - } - let preferDefaultChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - preferDefaultChannel.setMessageHandler { _, reply in - do { - try api.preferDefault() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - preferDefaultChannel.setMessageHandler(nil) - } - let matchContentChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - matchContentChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let fpsArg = args[0] as! Double - do { - try api.matchContent(fps: fpsArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - matchContentChannel.setMessageHandler(nil) - } - let boostChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - boostChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let durationMsArg = args[0] as! Int64 - do { - try api.boost(durationMs: durationMsArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - boostChannel.setMessageHandler(nil) - } - let setCategoryChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - setCategoryChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let categoryIndexArg = args[0] as! Int64 - do { - try api.setCategory(categoryIndex: categoryIndexArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - setCategoryChannel.setMessageHandler(nil) - } - let setTouchBoostChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - setTouchBoostChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let enabledArg = args[0] as! Bool - do { - try api.setTouchBoost(enabled: enabledArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - setTouchBoostChannel.setMessageHandler(nil) - } - let isSupportedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - isSupportedChannel.setMessageHandler { _, reply in - do { - let result = try api.isSupported() - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - isSupportedChannel.setMessageHandler(nil) - } - } -} -/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. -protocol RefreshRateFlutterApiProtocol { - func onDisplayInfoChanged(info infoArg: DisplayInfoMessage, completion: @escaping (Result) -> Void) -} -class RefreshRateFlutterApi: RefreshRateFlutterApiProtocol { - private let binaryMessenger: FlutterBinaryMessenger - private let messageChannelSuffix: String - init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { - self.binaryMessenger = binaryMessenger - self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - } - var codec: RefreshRateApiPigeonCodec { - return RefreshRateApiPigeonCodec.shared - } - func onDisplayInfoChanged(info infoArg: DisplayInfoMessage, completion: @escaping (Result) -> Void) { - let channelName: String = "dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged\(messageChannelSuffix)" - let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) - channel.sendMessage([infoArg] as [Any?]) { response in - guard let listResponse = response as? [Any?] else { - completion(.failure(createConnectionError(withChannelName: channelName))) - return - } - if listResponse.count > 1 { - let code: String = listResponse[0] as! String - let message: String? = nilOrValue(listResponse[1]) - let details: String? = nilOrValue(listResponse[2]) - completion(.failure(PigeonError(code: code, message: message, details: details))) - } else { - completion(.success(Void())) - } - } - } -} diff --git a/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/DisplayLinkSwizzle.m b/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/DisplayLinkSwizzle.m deleted file mode 100644 index 2e40973b..00000000 --- a/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/DisplayLinkSwizzle.m +++ /dev/null @@ -1,188 +0,0 @@ -#import "DisplayLinkSwizzle.h" -#import - -// Key for tagging display links that should bypass the override. -static const void *RRBypassKey = &RRBypassKey; -// Key for storing the original (uncapped) frame rate range. -static const void *RROriginalRangeKey = &RROriginalRangeKey; - -/// Global override cap. 0 means no override (pass through). -static float _rr_overrideMaxRate = 0; - -/// Storage for tracked display links (weak-ish — we check validity). -static NSPointerArray *_rr_trackedLinks = nil; -static NSLock *_rr_lock = nil; - -// Store original IMPs — declared early so swizzled functions can reference them. -static IMP _rr_origAddToRunLoop = NULL; -static IMP _rr_origSetRange = NULL; - -#pragma mark - Public C interface (called from Swift) - -/// Set the max frame rate cap. Pass 0 to disable. -void RRSetOverrideMaxRate(float rate) { - _rr_overrideMaxRate = rate; -} - -float RRGetOverrideMaxRate(void) { - return _rr_overrideMaxRate; -} - -/// Tag a display link so the swizzle bypasses it. -void RRBypassDisplayLink(CADisplayLink *link) { - objc_setAssociatedObject(link, RRBypassKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -/// Apply the current override to all tracked display links. -void RRApplyOverrideToTrackedLinks(void) { - if (!_rr_lock) return; - - [_rr_lock lock]; - - // Compact nil refs - [_rr_trackedLinks compact]; - - NSUInteger count = [_rr_trackedLinks count]; - for (NSUInteger i = 0; i < count; i++) { - CADisplayLink *link = [_rr_trackedLinks pointerAtIndex:i]; - if (!link) continue; - - // Skip bypassed links - if (objc_getAssociatedObject(link, RRBypassKey)) continue; - - if (@available(iOS 15.0, *)) { - // Read stored original range - NSDictionary *stored = objc_getAssociatedObject(link, RROriginalRangeKey); - - float cap = _rr_overrideMaxRate; - - if (cap > 0 && stored) { - float origMin = [stored[@"min"] floatValue]; - float origMax = [stored[@"max"] floatValue]; - float origPref = [stored[@"preferred"] floatValue]; - CAFrameRateRange capped = CAFrameRateRangeMake( - fminf(origMin, cap), - fminf(origMax, cap), - fminf(origPref, cap) - ); - // Use original IMP directly to avoid re-triggering our swizzle - // (which would overwrite the stored original with the capped value) - SEL sel = NSSelectorFromString(@"setPreferredFrameRateRange:"); - if (_rr_origSetRange) { - ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(link, sel, capped); - } - } else if (cap <= 0 && stored) { - // Restore original - float origMin = [stored[@"min"] floatValue]; - float origMax = [stored[@"max"] floatValue]; - float origPref = [stored[@"preferred"] floatValue]; - SEL sel = NSSelectorFromString(@"setPreferredFrameRateRange:"); - if (_rr_origSetRange) { - ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(link, sel, CAFrameRateRangeMake(origMin, origMax, origPref)); - } - } - } - } - - [_rr_lock unlock]; -} - -#pragma mark - Swizzled implementations - -/// Swizzled addToRunLoop:forMode: — tracks every display link. -static void rr_addToRunLoop(CADisplayLink *self, SEL _cmd, NSRunLoop *runloop, NSRunLoopMode mode) { - // Track this link - [_rr_lock lock]; - if (_rr_trackedLinks) { - // Check not already tracked - BOOL found = NO; - [_rr_trackedLinks compact]; - for (NSUInteger i = 0; i < [_rr_trackedLinks count]; i++) { - if ([_rr_trackedLinks pointerAtIndex:i] == (__bridge void *)self) { - found = YES; - break; - } - } - if (!found) { - [_rr_trackedLinks addPointer:(__bridge void *)self]; - } - } - [_rr_lock unlock]; - - // Call original — the IMP was saved during swizzle - ((void (*)(id, SEL, NSRunLoop *, NSRunLoopMode))_rr_origAddToRunLoop)(self, _cmd, runloop, mode); -} - -/// Swizzled setPreferredFrameRateRange: — stores original range and applies cap. -static void rr_setPreferredFrameRateRange(CADisplayLink *self, SEL _cmd, CAFrameRateRange range) API_AVAILABLE(ios(15.0)); - -static void rr_setPreferredFrameRateRange(CADisplayLink *self, SEL _cmd, CAFrameRateRange range) { - // Bypass tagged links - if (objc_getAssociatedObject(self, RRBypassKey)) { - ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, range); - return; - } - - // Store original range - if (@available(iOS 15.0, *)) { - NSDictionary *stored = @{ - @"min": @(range.minimum), - @"max": @(range.maximum), - @"preferred": @(range.preferred), - }; - objc_setAssociatedObject(self, RROriginalRangeKey, stored, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - - // Apply cap if active - float cap = _rr_overrideMaxRate; - if (cap > 0) { - if (@available(iOS 15.0, *)) { - CAFrameRateRange capped = CAFrameRateRangeMake( - fminf(range.minimum, cap), - fminf(range.maximum, cap), - fminf(range.preferred, cap) - ); - ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, capped); - } else { - ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, range); - } - } else { - ((void (*)(id, SEL, CAFrameRateRange))_rr_origSetRange)(self, _cmd, range); - } -} - -#pragma mark - +load (runs before main, before Flutter engine starts) - -@interface RRDisplayLinkSwizzle : NSObject -@end - -@implementation RRDisplayLinkSwizzle - -+ (void)load { - _rr_trackedLinks = [NSPointerArray weakObjectsPointerArray]; - _rr_lock = [[NSLock alloc] init]; - - Class cls = [CADisplayLink class]; - - // Swizzle addToRunLoop:forMode: - { - SEL sel = @selector(addToRunLoop:forMode:); - Method method = class_getInstanceMethod(cls, sel); - if (method) { - _rr_origAddToRunLoop = method_getImplementation(method); - method_setImplementation(method, (IMP)rr_addToRunLoop); - } - } - - // Swizzle setPreferredFrameRateRange: (iOS 15+) - if (@available(iOS 15.0, *)) { - SEL sel = NSSelectorFromString(@"setPreferredFrameRateRange:"); - Method method = class_getInstanceMethod(cls, sel); - if (method) { - _rr_origSetRange = method_getImplementation(method); - method_setImplementation(method, (IMP)rr_setPreferredFrameRateRange); - } - } -} - -@end diff --git a/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/include/DisplayLinkSwizzle.h b/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/include/DisplayLinkSwizzle.h deleted file mode 100644 index c3fcdbdb..00000000 --- a/third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/include/DisplayLinkSwizzle.h +++ /dev/null @@ -1,14 +0,0 @@ -#import - -/// Set the max frame rate cap for all display links. Pass 0 to disable. -void RRSetOverrideMaxRate(float rate); - -/// Get the current override max rate. 0 means no override. -float RRGetOverrideMaxRate(void); - -/// Tag a display link so the swizzle bypasses it (for monitoring/boost links). -void RRBypassDisplayLink(CADisplayLink * _Nonnull link); - -/// Apply the current override to all tracked (non-bypassed) display links. -/// Call this after changing the override rate. -void RRApplyOverrideToTrackedLinks(void); diff --git a/third_party/refresh_rate/lib/refresh_rate.dart b/third_party/refresh_rate/lib/refresh_rate.dart deleted file mode 100644 index c462fa98..00000000 --- a/third_party/refresh_rate/lib/refresh_rate.dart +++ /dev/null @@ -1,5 +0,0 @@ -export 'src/refresh_rate.dart'; -export 'src/models/display_info.dart'; -export 'src/models/enums.dart'; -export 'src/models/session_report.dart'; -export 'src/verification/refresh_rate_session.dart'; diff --git a/third_party/refresh_rate/lib/refresh_rate_web.dart b/third_party/refresh_rate/lib/refresh_rate_web.dart deleted file mode 100644 index 4c8e203c..00000000 --- a/third_party/refresh_rate/lib/refresh_rate_web.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter_web_plugins/flutter_web_plugins.dart'; - -import 'src/refresh_rate.dart'; -import 'src/web/web_refresh_rate_adapter.dart'; - -/// Web platform implementation of the RefreshRate plugin. -/// -/// Uses `requestAnimationFrame` interval timing to detect the -/// display's current refresh rate (same technique as TestUFO). -/// Control methods are graceful no-ops because browsers own their -/// vsync scheduling and expose no API to change it. -/// -/// ### What works on web -/// -/// | Feature | Status | -/// |:--------|:------:| -/// | Query current Hz | ✓ (via rAF timing) | -/// | FPS overlay / benchmark | ✓ (pure Dart, unchanged) | -/// | Unlock / control rate | ✗ (browsers own vsync) | -/// | Supported rates / max rate | ✗ (not exposed) | -/// | Low Power Mode / thermal | ✗ (no web equivalent) | -class RefreshRateWeb { - /// Registers the web plugin by swapping in [WebRefreshRateApiAdapter]. - static void registerWith(Registrar registrar) { - RefreshRate.registerAdapter(WebRefreshRateApiAdapter()); - } -} diff --git a/third_party/refresh_rate/lib/src/generated/refresh_rate_api.g.dart b/third_party/refresh_rate/lib/src/generated/refresh_rate_api.g.dart deleted file mode 100644 index 530dd070..00000000 --- a/third_party/refresh_rate/lib/src/generated/refresh_rate_api.g.dart +++ /dev/null @@ -1,446 +0,0 @@ -// Copyright 2026 Qoder (qoder.in). All rights reserved. -// Use of this source code is governed by a BSD-style license. -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon -// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers - -import 'dart:async'; -import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; - -import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; -import 'package:flutter/services.dart'; - -PlatformException _createConnectionError(String channelName) { - return PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel: "$channelName".', - ); -} - -List wrapResponse( - {Object? result, PlatformException? error, bool empty = false}) { - if (empty) { - return []; - } - if (error == null) { - return [result]; - } - return [error.code, error.message, error.details]; -} - -class DisplayInfoMessage { - DisplayInfoMessage({ - this.currentRate, - this.maxRate, - this.minRate, - this.supportedRates, - this.isVariableRefreshRate, - this.engineTargetRate, - this.iosProMotionEnabled, - this.androidApiLevel, - this.isLowPowerMode, - this.thermalStateIndex, - this.hasAdaptiveRefreshRate, - this.displayServer, - this.monitorCount, - }); - - double? currentRate; - - double? maxRate; - - double? minRate; - - List? supportedRates; - - bool? isVariableRefreshRate; - - double? engineTargetRate; - - bool? iosProMotionEnabled; - - int? androidApiLevel; - - bool? isLowPowerMode; - - int? thermalStateIndex; - - bool? hasAdaptiveRefreshRate; - - String? displayServer; - - int? monitorCount; - - Object encode() { - return [ - currentRate, - maxRate, - minRate, - supportedRates, - isVariableRefreshRate, - engineTargetRate, - iosProMotionEnabled, - androidApiLevel, - isLowPowerMode, - thermalStateIndex, - hasAdaptiveRefreshRate, - displayServer, - monitorCount, - ]; - } - - static DisplayInfoMessage decode(Object result) { - result as List; - return DisplayInfoMessage( - currentRate: result[0] as double?, - maxRate: result[1] as double?, - minRate: result[2] as double?, - supportedRates: (result[3] as List?)?.cast(), - isVariableRefreshRate: result[4] as bool?, - engineTargetRate: result[5] as double?, - iosProMotionEnabled: result[6] as bool?, - androidApiLevel: result[7] as int?, - isLowPowerMode: result[8] as bool?, - thermalStateIndex: result[9] as int?, - hasAdaptiveRefreshRate: result[10] as bool?, - displayServer: result[11] as String?, - monitorCount: result[12] as int?, - ); - } -} - -class _PigeonCodec extends StandardMessageCodec { - const _PigeonCodec(); - @override - void writeValue(WriteBuffer buffer, Object? value) { - if (value is int) { - buffer.putUint8(4); - buffer.putInt64(value); - } else if (value is DisplayInfoMessage) { - buffer.putUint8(129); - writeValue(buffer, value.encode()); - } else { - super.writeValue(buffer, value); - } - } - - @override - Object? readValueOfType(int type, ReadBuffer buffer) { - switch (type) { - case 129: - return DisplayInfoMessage.decode(readValue(buffer)!); - default: - return super.readValueOfType(type, buffer); - } - } -} - -class RefreshRateHostApi { - /// Constructor for [RefreshRateHostApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default - /// BinaryMessenger will be used which routes to the host platform. - RefreshRateHostApi( - {BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) - : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = - messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; - final BinaryMessenger? pigeonVar_binaryMessenger; - - static const MessageCodec pigeonChannelCodec = _PigeonCodec(); - - final String pigeonVar_messageChannelSuffix; - - Future getDisplayInfo() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else if (pigeonVar_replyList[0] == null) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); - } else { - return (pigeonVar_replyList[0] as DisplayInfoMessage?)!; - } - } - - Future enable() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } - } - - Future disable() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } - } - - Future preferMax() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } - } - - Future preferDefault() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } - } - - Future matchContent(double fps) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send([fps]) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } - } - - Future boost(int durationMs) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send([durationMs]) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } - } - - Future setCategory(int categoryIndex) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = await pigeonVar_channel - .send([categoryIndex]) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } - } - - Future setTouchBoost(bool enabled) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send([enabled]) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } - } - - Future isSupported() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else if (pigeonVar_replyList[0] == null) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); - } else { - return (pigeonVar_replyList[0] as bool?)!; - } - } -} - -abstract class RefreshRateFlutterApi { - static const MessageCodec pigeonChannelCodec = _PigeonCodec(); - - void onDisplayInfoChanged(DisplayInfoMessage info); - - static void setUp( - RefreshRateFlutterApi? api, { - BinaryMessenger? binaryMessenger, - String messageChannelSuffix = '', - }) { - messageChannelSuffix = - messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; - { - final BasicMessageChannel< - Object?> pigeonVar_channel = BasicMessageChannel< - Object?>( - 'dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged$messageChannelSuffix', - pigeonChannelCodec, - binaryMessenger: binaryMessenger); - if (api == null) { - pigeonVar_channel.setMessageHandler(null); - } else { - pigeonVar_channel.setMessageHandler((Object? message) async { - assert(message != null, - 'Argument for dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged was null.'); - final List args = (message as List?)!; - final DisplayInfoMessage? arg_info = (args[0] as DisplayInfoMessage?); - assert(arg_info != null, - 'Argument for dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged was null, expected non-null DisplayInfoMessage.'); - try { - api.onDisplayInfoChanged(arg_info!); - return wrapResponse(empty: true); - } on PlatformException catch (e) { - return wrapResponse(error: e); - } catch (e) { - return wrapResponse( - error: PlatformException(code: 'error', message: e.toString())); - } - }); - } - } - } -} diff --git a/third_party/refresh_rate/lib/src/models/display_info.dart b/third_party/refresh_rate/lib/src/models/display_info.dart deleted file mode 100644 index d7b8429f..00000000 --- a/third_party/refresh_rate/lib/src/models/display_info.dart +++ /dev/null @@ -1,129 +0,0 @@ -import '../generated/refresh_rate_api.g.dart'; -import 'enums.dart'; - -/// A snapshot of the current display configuration and device health. -/// -/// Retrieve a fresh snapshot via [RefreshRate.refresh] or listen to -/// [RefreshRate.onChanged] for real-time updates. -class DisplayInfo { - /// The refresh rate the display is currently running at, in Hz. - final double currentRate; - - /// The maximum refresh rate supported by this display, in Hz. - final double maxRate; - - /// The minimum refresh rate supported by this display, in Hz. - final double minRate; - - /// All refresh rates the display hardware can run at, in Hz. - final List supportedRates; - - /// Whether the display supports variable refresh rate (VRR / LTPO). - final bool isVariableRefreshRate; - - /// The frame rate the Flutter engine is currently targeting, in Hz. - final double engineTargetRate; - - /// Whether iOS ProMotion adaptive refresh is enabled for this app. - /// - /// `null` on non-iOS platforms. - final bool? iosProMotionEnabled; - - /// The Android API level of the device. - /// - /// `null` on non-Android platforms. - final int? androidApiLevel; - - /// Whether the device is in Low Power Mode. - /// - /// `null` when the platform does not expose this information. - final bool? isLowPowerMode; - - /// The current thermal state of the device. - final ThermalState thermalState; - - /// Whether the display supports an adaptive (variable) refresh rate. - /// - /// `null` when the platform does not expose this information. - final bool? hasAdaptiveRefreshRate; - - /// The name of the display server in use (Linux only, e.g. `"wayland"`). - /// - /// `null` on non-Linux platforms. - final String? displayServer; - - /// The number of monitors connected to the device (desktop platforms only). - /// - /// `null` on mobile platforms. - final int? monitorCount; - - /// Creates a new [DisplayInfo] snapshot. - const DisplayInfo({ - required this.currentRate, - required this.maxRate, - required this.minRate, - required this.supportedRates, - required this.isVariableRefreshRate, - required this.engineTargetRate, - this.iosProMotionEnabled, - this.androidApiLevel, - this.isLowPowerMode, - required this.thermalState, - this.hasAdaptiveRefreshRate, - this.displayServer, - this.monitorCount, - }); - - /// Creates a [DisplayInfo] from a platform [DisplayInfoMessage]. - factory DisplayInfo.fromMessage(DisplayInfoMessage msg) { - return DisplayInfo( - currentRate: msg.currentRate ?? 60.0, - maxRate: msg.maxRate ?? 60.0, - minRate: msg.minRate ?? 60.0, - supportedRates: - msg.supportedRates?.whereType().toList() ?? const [60.0], - isVariableRefreshRate: msg.isVariableRefreshRate ?? false, - engineTargetRate: msg.engineTargetRate ?? 60.0, - iosProMotionEnabled: msg.iosProMotionEnabled, - androidApiLevel: msg.androidApiLevel, - isLowPowerMode: msg.isLowPowerMode, - thermalState: ThermalState.fromIndex(msg.thermalStateIndex), - hasAdaptiveRefreshRate: msg.hasAdaptiveRefreshRate, - displayServer: msg.displayServer, - monitorCount: msg.monitorCount, - ); - } - - /// A safe fallback [DisplayInfo] used before the first [RefreshRate.refresh] - /// call completes. Assumes a standard 60 Hz non-VRR display. - static const DisplayInfo fallback = DisplayInfo( - currentRate: 60.0, - maxRate: 60.0, - minRate: 60.0, - supportedRates: [60.0], - isVariableRefreshRate: false, - engineTargetRate: 60.0, - thermalState: ThermalState.unknown, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is DisplayInfo && - currentRate == other.currentRate && - maxRate == other.maxRate && - minRate == other.minRate && - isVariableRefreshRate == other.isVariableRefreshRate && - engineTargetRate == other.engineTargetRate && - thermalState == other.thermalState && - isLowPowerMode == other.isLowPowerMode; - - @override - int get hashCode => Object.hash(currentRate, maxRate, minRate, - isVariableRefreshRate, engineTargetRate, thermalState, isLowPowerMode); - - @override - String toString() => - 'DisplayInfo(currentRate: ${currentRate}Hz, maxRate: ${maxRate}Hz, ' - 'thermalState: $thermalState, isLowPowerMode: $isLowPowerMode)'; -} diff --git a/third_party/refresh_rate/lib/src/models/enums.dart b/third_party/refresh_rate/lib/src/models/enums.dart deleted file mode 100644 index e9339415..00000000 --- a/third_party/refresh_rate/lib/src/models/enums.dart +++ /dev/null @@ -1,151 +0,0 @@ -/// Indicates the desired performance category for the display scheduler. -/// -/// Use [RefreshRate.category] to apply one of these hints to the platform. -enum RateCategory { - /// No refresh-rate hint — the platform decides. - none, - - /// Prefer a low refresh rate (e.g. reading, static UIs). - low, - - /// Prefer the standard refresh rate (default). - normal, - - /// Prefer the highest supported refresh rate - /// (games, animations, scrolling at high speed). - high; - - /// Returns the [RateCategory] matching [index], or [none] if unknown. - static RateCategory fromIndex(int? index) { - switch (index) { - case 0: - return none; - case 1: - return low; - case 2: - return normal; - case 3: - return high; - default: - return none; - } - } -} - -/// The thermal condition of the device, as reported by the OS. -/// -/// Higher-severity states may cause the OS to throttle the display -/// refresh rate regardless of what the app requests. -enum ThermalState { - /// The device is well within thermal limits — full performance available. - nominal, - - /// The device is slightly warm; minor throttling may apply. - fair, - - /// The device is hot; significant throttling is likely. - serious, - - /// The device is critically hot; the OS may force-reduce refresh rate. - critical, - - /// Thermal state could not be determined (unsupported platform). - unknown; - - /// Returns the [ThermalState] matching [index], or [unknown] if not found. - static ThermalState fromIndex(int? index) { - switch (index) { - case 0: - return nominal; - case 1: - return fair; - case 2: - return serious; - case 3: - return critical; - default: - return unknown; - } - } -} - -/// Lifecycle state of a [RefreshRateSession] benchmark. -enum SessionState { - /// The session has not yet started collecting data. - idle, - - /// The session is actively collecting frame timings. - running, - - /// The session is in a brief warm-up period after the app resumed. - warmup, - - /// The session was paused due to the app going to the background. - interrupted, - - /// The session has finished and a [SessionReport] is available. - completed, -} - -/// Reason why a time window was excluded from a [SessionReport]. -enum ExclusionReason { - /// App moved to the background. - appBackgrounded, - - /// App became inactive (e.g. notification tray opened). - appInactive, - - /// A brief warm-up window after the app resumed from background. - resumeWarmup, - - /// Overlay opened or the window lost focus. - overlayOrFocusLoss, - - /// The display refresh rate changed mid-session. - displayRateChanged, - - /// Low Power Mode was toggled mid-session. - lowPowerModeChanged, - - /// The thermal state changed mid-session. - thermalStateChanged, -} - -/// Overall quality verdict produced by [SessionReport]. -enum Verdict { - /// Frame rate is consistently at or near the target Hz. - excellent, - - /// Frame rate is mostly stable with minor drops. - good, - - /// Frame rate is acceptable but below optimal. - fair, - - /// Frequent frame drops — the app misses its target regularly. - poor, - - /// Not enough valid data to make a determination. - inconclusive, -} - -/// The most likely rendering bottleneck identified by [SessionReport]. -enum Bottleneck { - /// The UI/build thread is the constraint (slow widget tree rebuilds). - buildBound, - - /// The raster thread is the constraint (complex paint operations). - rasterBound, - - /// The display hardware is capping the achievable frame rate. - displayCapped, - - /// Low Power Mode or battery saver is limiting performance. - powerLimited, - - /// Thermal throttling is reducing the achievable frame rate. - thermalLimited, - - /// No significant bottleneck detected. - none, -} diff --git a/third_party/refresh_rate/lib/src/models/session_report.dart b/third_party/refresh_rate/lib/src/models/session_report.dart deleted file mode 100644 index 8ceee04c..00000000 --- a/third_party/refresh_rate/lib/src/models/session_report.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'dart:convert'; -import 'enums.dart'; - -/// A point-in-time snapshot of device conditions recorded at session start. -/// -/// Captured when a [RefreshRateSession] is created and embedded in the -/// resulting [SessionReport] so analysis can account for the environment. -class DeviceStateSnapshot { - /// Whether the device was in Low Power Mode when the session started. - /// - /// `null` when the platform does not expose this information. - final bool? isLowPowerMode; - - /// The thermal state of the device when the session started. - final ThermalState thermalState; - - /// Whether the display supports an adaptive (variable) refresh rate. - /// - /// `null` when the platform does not expose this information. - final bool? hasAdaptiveRefreshRate; - - /// The display server in use (Linux only, e.g. `"wayland"`). - /// - /// `null` on non-Linux platforms. - final String? displayServer; - - /// Number of connected monitors (desktop platforms only). - /// - /// `null` on mobile platforms. - final int? monitorCount; - - /// Creates a new [DeviceStateSnapshot]. - const DeviceStateSnapshot({ - this.isLowPowerMode, - required this.thermalState, - this.hasAdaptiveRefreshRate, - this.displayServer, - this.monitorCount, - }); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is DeviceStateSnapshot && - isLowPowerMode == other.isLowPowerMode && - thermalState == other.thermalState; - - @override - int get hashCode => Object.hash(isLowPowerMode, thermalState); - - /// Serializes this snapshot to a JSON-compatible map. - Map toMap() => { - 'isLowPowerMode': isLowPowerMode, - 'thermalState': thermalState.name, - 'hasAdaptiveRefreshRate': hasAdaptiveRefreshRate, - 'displayServer': displayServer, - 'monitorCount': monitorCount, - }; -} - -/// The result produced when a [RefreshRateSession] ends. -/// -/// Contains FPS statistics, frame timing breakdowns, a performance [verdict], -/// a likely [likelyBottleneck] hint, and excluded-window details. -class SessionReport { - /// The name given to the session when it was started. - final String sessionName; - - /// Overall quality assessment for this session. - final Verdict verdict; - - /// The rendering stage most likely responsible for any frame drops. - final Bottleneck likelyBottleneck; - - /// The target refresh rate in Hz at the time the session began. - final double targetHz; - - /// The average display refresh rate observed during the session, in Hz. - final double observedAvgHz; - - /// The frame duration budget at [targetHz] (1000 / targetHz), in ms. - final double frameBudgetMs; - - /// Average frames per second across all valid frames. - final double avgFps; - - /// The 1st-percentile FPS (worst 1% of frames). - final double onePercentLowFps; - - /// The 5th-percentile FPS (worst 5% of frames). - final double fivePercentLowFps; - - /// Average build (UI thread) time per frame, in ms. - final double avgBuildMs; - - /// Average raster thread time per frame, in ms. - final double avgRasterMs; - - /// Average total frame time (build + raster), in ms. - final double avgTotalFrameMs; - - /// Number of frames that exceeded the frame budget. - final int jankyFrameCount; - - /// Number of frames that exceeded twice the frame budget. - final int severeJankCount; - - /// Percentage of frames that were missed (janky), from 0–100. - final double missedFramePercent; - - /// Total duration of valid (non-excluded) measurement time. - final Duration validDuration; - - /// Total duration excluded from measurement (background, warmup, etc.). - final Duration excludedDuration; - - /// A breakdown of how many times each [ExclusionReason] occurred. - final Map exclusionReasons; - - /// Device state recorded at the start of the session. - final DeviceStateSnapshot deviceState; - - /// Creates a new [SessionReport]. - const SessionReport({ - required this.sessionName, - required this.verdict, - required this.likelyBottleneck, - required this.targetHz, - required this.observedAvgHz, - required this.frameBudgetMs, - required this.avgFps, - required this.onePercentLowFps, - required this.fivePercentLowFps, - required this.avgBuildMs, - required this.avgRasterMs, - required this.avgTotalFrameMs, - required this.jankyFrameCount, - required this.severeJankCount, - required this.missedFramePercent, - required this.validDuration, - required this.excludedDuration, - required this.exclusionReasons, - required this.deviceState, - }); - - /// Serializes this report to a JSON-compatible map. - Map toMap() => { - 'sessionName': sessionName, - 'verdict': verdict.name, - 'likelyBottleneck': likelyBottleneck.name, - 'targetHz': targetHz, - 'observedAvgHz': observedAvgHz, - 'frameBudgetMs': frameBudgetMs, - 'avgFps': avgFps, - 'onePercentLowFps': onePercentLowFps, - 'fivePercentLowFps': fivePercentLowFps, - 'avgBuildMs': avgBuildMs, - 'avgRasterMs': avgRasterMs, - 'avgTotalFrameMs': avgTotalFrameMs, - 'jankyFrameCount': jankyFrameCount, - 'severeJankCount': severeJankCount, - 'missedFramePercent': missedFramePercent, - 'validDurationMs': validDuration.inMilliseconds, - 'excludedDurationMs': excludedDuration.inMilliseconds, - 'exclusionReasons': exclusionReasons.map((k, v) => MapEntry(k.name, v)), - 'deviceState': deviceState.toMap(), - }; - - /// Serializes this report to a JSON string. - String toJson() => jsonEncode(toMap()); - - /// Serializes this report to a CSV string. - String toCsv() { - final m = toMap(); - String esc(dynamic v) { - final s = v is Map ? jsonEncode(v) : '$v'; - if (s.contains(',') || s.contains('"') || s.contains('\n')) { - return '"${s.replaceAll('"', '""')}"'; - } - return s; - } - - final headers = m.keys.join(','); - final values = m.values.map(esc).join(','); - return '$headers\n$values'; - } -} diff --git a/third_party/refresh_rate/lib/src/refresh_rate.dart b/third_party/refresh_rate/lib/src/refresh_rate.dart deleted file mode 100644 index ef7e1fb4..00000000 --- a/third_party/refresh_rate/lib/src/refresh_rate.dart +++ /dev/null @@ -1,244 +0,0 @@ -import 'dart:async'; -import 'package:flutter/widgets.dart'; - -import 'generated/refresh_rate_api.g.dart'; -import 'models/display_info.dart'; -import 'models/enums.dart'; -import 'refresh_rate_api_adapter.dart'; -import 'verification/overlay_controller.dart'; -import 'verification/refresh_rate_session.dart'; - -/// Primary entry-point for controlling and monitoring the display refresh rate. -/// -/// All members are static; this class cannot be instantiated. -/// -/// ### Quick-start -/// ```dart -/// // Unlock the highest supported refresh rate. -/// await RefreshRate.refresh(); -/// RefreshRate.preferMax(); -/// -/// // Show an FPS overlay for debugging. -/// RefreshRate.showFPS(); -/// ``` -class RefreshRate { - RefreshRate._(); - - static RefreshRateApiAdapter _api = PigeonRefreshRateApiAdapter(); - static DisplayInfo _cachedInfo = DisplayInfo.fallback; - static StreamController? _changedController; - static final _flutterApi = _RefreshRateFlutterApiImpl(); - - // ── Platform registration ────────────────────────────────────── - - /// Replaces the default platform API adapter. - /// - /// Called by platform-specific entrypoints (e.g. the web plugin) during - /// framework initialisation. Not intended for end-user consumption. - static void registerAdapter(RefreshRateApiAdapter adapter) { - _api = adapter; - } - - // ── Test seam ────────────────────────────────────────────────── - - /// Replaces the platform API implementation with a test fake. - /// - /// Call [clearApiForTesting] in `tearDown` to restore the real adapter. - @visibleForTesting - static void setApiForTesting(RefreshRateApiAdapter api) { - _api = api; - } - - /// Restores the real platform API and resets all internal state. - /// - /// Must be called in `tearDown` after [setApiForTesting]. - @visibleForTesting - static void clearApiForTesting() { - _api = PigeonRefreshRateApiAdapter(); - _flutterApi._onChanged = null; - RefreshRateFlutterApi.setUp(null); - _changedController?.close(); - _changedController = null; - } - - // ── Control ──────────────────────────────────────────────────── - - /// Opts the app into the platform's high-refresh-rate rendering pipeline. - /// - /// On Android this sets `preferredDisplayModeId` on the window surface. - /// On iOS/macOS this adjusts `CADisplayLink` preferred frame rate ranges. - /// Has no effect on platforms that do not support variable refresh rates. - static void enable() { - _api.enable(); - _refreshInfo(); - } - - /// Reverts the app to the platform default (typically 60 Hz). - /// - /// On iOS this allows the OS to manage the frame rate automatically. - static void disable() { - _api.disable(); - _refreshInfo(); - } - - /// Requests the maximum supported refresh rate for this display. - /// - /// Equivalent to calling [enable] then letting the platform pick the ceiling. - static void preferMax() => _api.preferMax(); - - /// Reverts to the display's default / preferred refresh rate. - static void preferDefault() => _api.preferDefault(); - - /// Requests a refresh rate that matches the given [fps] content rate. - /// - /// Useful when playing back video at a fixed frame rate (e.g. 24, 30, 60 fps) - /// so the display cadence aligns with the media cadence. - static void matchContent(double fps) => _api.matchContent(fps); - - /// Temporarily boosts the display to its maximum refresh rate for [duration]. - /// - /// Commonly used when a gesture or animation starts — the display snaps to - /// high-Hz immediately and returns to the preferred rate after the duration. - static void boost(Duration duration) => _api.boost(duration.inMilliseconds); - - /// Boosts the refresh rate for the lifetime of an [AnimationController]. - /// - /// Registers a status listener that calls [boost] whenever the controller - /// starts animating, and removes itself once the animation is done. - static void boostDuring(AnimationController controller) { - late final void Function(AnimationStatus) statusListener; - statusListener = (status) { - if (status == AnimationStatus.forward || - status == AnimationStatus.reverse) { - _api.boost(controller.duration?.inMilliseconds ?? 500); - } else if (status == AnimationStatus.completed || - status == AnimationStatus.dismissed) { - controller.removeStatusListener(statusListener); - } - }; - controller.addStatusListener(statusListener); - } - - /// Sets a named [RateCategory] hint for the display scheduler. - /// - /// Categories let you declare the performance class of your app - /// (`low`, `normal`, `high`) rather than specifying raw Hz values. - static void category(RateCategory c) => _api.setCategory(c.index); - - /// Enables or disables an automatic boost whenever the user touches the screen. - /// - /// When [enabled] is `true` the platform raises the refresh rate on every - /// touch-down event and lowers it again after a short idle period. - static void setTouchBoost(bool enabled) => _api.setTouchBoost(enabled); - - // ── Verification overlays ────────────────────────────────────── - - /// Shows a minimal live FPS counter overlay in the top-right corner. - static void showFPS() => OverlayController.instance.showFPS(); - - /// Shows a minimal live Hz readout overlay in the top-right corner. - static void showHz() => OverlayController.instance.showHz(); - - /// Shows the full diagnostic overlay (FPS + Hz + thermal state). - static void showOverlay() => OverlayController.instance.showFull(); - - /// Hides whatever verification overlay is currently visible. - static void hideOverlay() => OverlayController.instance.hide(); - - /// Whether the debug overlay is currently shown. - static bool get isOverlayVisible => OverlayController.instance.isVisible; - - // ── Diagnostics ──────────────────────────────────────────────── - - /// The most recently fetched [DisplayInfo] snapshot. - /// - /// Initialised to [DisplayInfo.fallback] (60 Hz, all optional fields null) - /// until [refresh] is awaited at least once. - static DisplayInfo get info => _cachedInfo; - - /// Fetches fresh [DisplayInfo] from the platform and caches the result. - /// - /// Resolves with the updated [DisplayInfo] on success. - static Future refresh() async { - final msg = await _api.getDisplayInfo(); - _cachedInfo = DisplayInfo.fromMessage(msg); - return _cachedInfo; - } - - /// A broadcast stream that emits a new [DisplayInfo] whenever the display - /// configuration changes (e.g. the user enables Low Power Mode, or the - /// device throttles under thermal pressure). - static Stream get onChanged { - if (_changedController == null) { - _changedController = StreamController.broadcast(); - _flutterApi._onChanged = (info) { - _cachedInfo = info; - _changedController!.add(info); - }; - RefreshRateFlutterApi.setUp(_flutterApi); - } - return _changedController!.stream; - } - - /// Whether iOS ProMotion (adaptive 120 Hz) is enabled for this app. - /// - /// Returns `false` until a successful [refresh] is completed and the device - /// is an iPhone/iPad with a ProMotion display. - static bool get isProMotionReady => _cachedInfo.iosProMotionEnabled == true; - - /// Whether the device is currently in Low Power Mode. - /// - /// Defaults to `false` when the value cannot be determined. - static bool get isLowPowerMode => _cachedInfo.isLowPowerMode ?? false; - - /// The current thermal state of the device. - /// - /// A state of [ThermalState.serious] or [ThermalState.critical] may cause - /// the OS to clamp the refresh rate regardless of your requested value. - static ThermalState get thermalState => _cachedInfo.thermalState; - - // ── Benchmark sessions ───────────────────────────────────────── - - /// Creates and starts a new FPS benchmark session named [name]. - /// - /// Call [RefreshRateSession.end] when the scenario under test completes to - /// receive a [SessionReport] with verdict, FPS stats, and bottleneck hints. - static RefreshRateSession startSession(String name) { - return RefreshRateSession.create(name, _cachedInfo); - } - - // ── Internal ─────────────────────────────────────────────────── - - static void _refreshInfo() { - void handle(DisplayInfoMessage msg) { - _cachedInfo = DisplayInfo.fromMessage(msg); - } - - void handleError(Object e) { - assert(() { - debugPrint('RefreshRate: _refreshInfo error: $e'); - return true; - }()); - } - - try { - final result = _api.getDisplayInfo(); - if (result is Future) { - result.then(handle).catchError(handleError); - } else { - handle(result); - } - } catch (e) { - handleError(e); - } - } -} - -class _RefreshRateFlutterApiImpl extends RefreshRateFlutterApi { - void Function(DisplayInfo)? _onChanged; - - @override - void onDisplayInfoChanged(DisplayInfoMessage info) { - _onChanged?.call(DisplayInfo.fromMessage(info)); - } -} diff --git a/third_party/refresh_rate/lib/src/refresh_rate_api_adapter.dart b/third_party/refresh_rate/lib/src/refresh_rate_api_adapter.dart deleted file mode 100644 index 7fce754b..00000000 --- a/third_party/refresh_rate/lib/src/refresh_rate_api_adapter.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'dart:async'; - -import 'generated/refresh_rate_api.g.dart'; - -/// Abstract interface for the host API, used as the test seam. -/// -/// The real implementation ([PigeonRefreshRateApiAdapter]) delegates to the -/// pigeon-generated [RefreshRateHostApi]. Tests implement this interface -/// directly with synchronous fakes — no await required. -abstract class RefreshRateApiAdapter { - /// Fetches the latest display configuration. - FutureOr getDisplayInfo(); - - /// Enables High Refresh Rate overrides. - FutureOr enable(); - - /// Disables High Refresh Rate overrides. - FutureOr disable(); - - /// Requests the highest possible display refresh rate. - FutureOr preferMax(); - - /// Resets to the system default refresh rate. - FutureOr preferDefault(); - - /// Attempts to set the display refresh rate to match [fps]. - FutureOr matchContent(double fps); - - /// Temporarily boosts the refresh rate for [durationMs]. - FutureOr boost(int durationMs); - - /// Sets the refresh rate based on a given category. - FutureOr setCategory(int categoryIndex); - - /// Enables or disables automatic refresh rate boost on touch interactions. - FutureOr setTouchBoost(bool enabled); - - /// Checks whether the refresh rate overrides are supported by the platform. - FutureOr isSupported(); -} - -/// Production implementation that delegates to the pigeon-generated channel. -class PigeonRefreshRateApiAdapter implements RefreshRateApiAdapter { - final RefreshRateHostApi _pigeon; - - /// Creates a new [PigeonRefreshRateApiAdapter]. - PigeonRefreshRateApiAdapter() : _pigeon = RefreshRateHostApi(); - - @override - Future getDisplayInfo() => _pigeon.getDisplayInfo(); - - @override - Future enable() => _pigeon.enable(); - - @override - Future disable() => _pigeon.disable(); - - @override - Future preferMax() => _pigeon.preferMax(); - - @override - Future preferDefault() => _pigeon.preferDefault(); - - @override - Future matchContent(double fps) => _pigeon.matchContent(fps); - - @override - Future boost(int durationMs) => _pigeon.boost(durationMs); - - @override - Future setCategory(int categoryIndex) => - _pigeon.setCategory(categoryIndex); - - @override - Future setTouchBoost(bool enabled) => _pigeon.setTouchBoost(enabled); - - @override - Future isSupported() => _pigeon.isSupported(); -} diff --git a/third_party/refresh_rate/lib/src/verification/fps_tracker.dart b/third_party/refresh_rate/lib/src/verification/fps_tracker.dart deleted file mode 100644 index ff44590d..00000000 --- a/third_party/refresh_rate/lib/src/verification/fps_tracker.dart +++ /dev/null @@ -1,145 +0,0 @@ -import 'dart:ui' show FramePhase; -import 'package:flutter/scheduler.dart'; - -/// Represents a single rendered frame's timing data. -class FrameSample { - /// Time spent in the UI build thread, in microseconds. - final int buildUs; - - /// Time spent in the raster thread, in microseconds. - final int rasterUs; - - /// Total time to render the frame, in microseconds. - final int totalUs; - - /// Vsync timestamp, in microseconds. - final int vsyncUs; - - /// System time when the sample was recorded. - final DateTime timestamp; - - /// Creates a new [FrameSample]. - FrameSample({ - required this.buildUs, - required this.rasterUs, - required this.totalUs, - required this.vsyncUs, - required this.timestamp, - }); -} - -/// Helper that records [FrameTiming] data and calculates FPS statistics. -class FpsTracker { - final List _samples = []; - - /// The number of recorded samples. - int get sampleCount => _samples.length; - - /// Adds raw frame timings from the Flutter engine. - void addTimings(List timings) { - final now = DateTime.now(); - for (final t in timings) { - _samples.add(FrameSample( - buildUs: t.buildDuration.inMicroseconds, - rasterUs: t.rasterDuration.inMicroseconds, - totalUs: t.totalSpan.inMicroseconds, - vsyncUs: t.timestampInMicroseconds(FramePhase.vsyncStart), - timestamp: now, - )); - } - // Keep a bounded window so memory doesn't grow unbounded during long sessions - if (_samples.length > 600) { - _samples.removeRange(0, _samples.length - 600); - } - } - - /// Clears all recorded samples. - void reset() => _samples.clear(); - - /// Returns an immutable list of currently recorded samples. - List get samples => List.unmodifiable(_samples); - - /// FPS over all samples — used by benchmark sessions. - double get avgFps => _fpsFromWindow(_samples); - - /// FPS over the last [n] frames — used by the live overlay. - double recentFps([int n = 60]) { - if (_samples.length < 2) return 0.0; - final window = - _samples.length <= n ? _samples : _samples.sublist(_samples.length - n); - return _fpsFromWindow(window); - } - - static double _fpsFromWindow(List s) { - if (s.length < 2) return 0.0; - final elapsedUs = s.last.vsyncUs - s.first.vsyncUs; - if (elapsedUs <= 0) return 0.0; - return (s.length - 1) * 1000000.0 / elapsedUs; - } - - /// Average duration of the UI build phase across all samples, in ms. - double get avgBuildMs { - if (_samples.isEmpty) return 0.0; - return _samples.fold(0, (s, f) => s + f.buildUs) / - _samples.length / - 1000.0; - } - - /// Average duration of the raster phase across all samples, in ms. - double get avgRasterMs { - if (_samples.isEmpty) return 0.0; - return _samples.fold(0, (s, f) => s + f.rasterUs) / - _samples.length / - 1000.0; - } - - /// Average total duration (build + raster) across all samples, in ms. - double get avgTotalMs { - if (_samples.isEmpty) return 0.0; - return _samples.fold(0, (s, f) => s + f.totalUs) / - _samples.length / - 1000.0; - } - - /// Worst 1-percentile frame rate. - double get onePercentLowFps { - if (_samples.isEmpty) return 0.0; - final sorted = _samples.map((s) => s.totalUs).toList()..sort(); - final cutoff = (sorted.length * 0.99).floor(); - final slowSamples = sorted.skip(cutoff).toList(); - if (slowSamples.isEmpty) return avgFps; - final avgSlowUs = - slowSamples.fold(0, (s, v) => s + v) / slowSamples.length; - return avgSlowUs > 0 ? 1000000 / avgSlowUs : 0.0; - } - - /// Worst 5-percentile frame rate. - double get fivePercentLowFps { - if (_samples.isEmpty) return 0.0; - final sorted = _samples.map((s) => s.totalUs).toList()..sort(); - final cutoff = (sorted.length * 0.95).floor(); - final slowSamples = sorted.skip(cutoff).toList(); - if (slowSamples.isEmpty) return avgFps; - final avgSlowUs = - slowSamples.fold(0, (s, v) => s + v) / slowSamples.length; - return avgSlowUs > 0 ? 1000000 / avgSlowUs : 0.0; - } - - /// Counts how many frames exceeded the target budget. - int jankyFrameCount(double targetHz) { - final budgetUs = (1000000 / targetHz).round(); - return _samples.where((s) => s.totalUs > budgetUs).length; - } - - /// Counts how many frames took more than twice the target budget. - int severeJankCount(double targetHz) { - final budgetUs = (1000000 / targetHz).round(); - return _samples.where((s) => s.totalUs > budgetUs * 2).length; - } - - /// Percentage of frames that were missed. - double missedFramePercent(double targetHz) { - if (_samples.isEmpty) return 0.0; - return jankyFrameCount(targetHz) / _samples.length * 100.0; - } -} diff --git a/third_party/refresh_rate/lib/src/verification/overlay_controller.dart b/third_party/refresh_rate/lib/src/verification/overlay_controller.dart deleted file mode 100644 index b79893d3..00000000 --- a/third_party/refresh_rate/lib/src/verification/overlay_controller.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'fps_tracker.dart'; -import 'overlay_widgets.dart'; - -enum _OverlayMode { none, fps, hz, full } - -/// Manages the visibility and rendering of debug overlays. -class OverlayController { - OverlayController._(); - - /// The singleton instance of [OverlayController]. - static final instance = OverlayController._(); - - OverlayEntry? _entry; - _OverlayMode _mode = _OverlayMode.none; - final FpsTracker _tracker = FpsTracker(); - TimingsCallback? _timingsCallback; - - /// Whether an overlay is currently visible. - bool get isVisible => _mode != _OverlayMode.none; - - /// Shows a compact FPS overlay. - void showFPS() => _show(_OverlayMode.fps); - - /// Shows a compact Hz (refresh rate) overlay. - void showHz() => _show(_OverlayMode.hz); - - /// Shows the full debug overlay with detailed metrics. - void showFull() => _show(_OverlayMode.full); - - /// Hides any currently visible overlay. - void hide() { - _entry?.remove(); - _entry = null; - _mode = _OverlayMode.none; - _stopTracking(); - } - - void _show(_OverlayMode mode) { - hide(); - _mode = mode; - _startTracking(); - WidgetsBinding.instance.addPostFrameCallback((_) { - final overlay = _findOverlay(); - if (overlay == null) return; - _entry = OverlayEntry(builder: (_) => _buildWidget()); - overlay.insert(_entry!); - }); - } - - // Walks DOWN the element tree to find the first OverlayState. - // Overlay.maybeOf(rootElement) fails because the Overlay is a descendant, - // not an ancestor, of the root element. - static OverlayState? _findOverlay() { - final root = WidgetsBinding.instance.rootElement; - if (root == null) return null; - OverlayState? found; - void visit(Element element) { - if (found != null) return; - if (element is StatefulElement && element.state is OverlayState) { - found = element.state as OverlayState; - return; - } - element.visitChildren(visit); - } - - root.visitChildren(visit); - return found; - } - - Widget _buildWidget() { - final inner = switch (_mode) { - _OverlayMode.fps => FpsOverlayWidget(tracker: _tracker), - _OverlayMode.hz => HzOverlayWidget(tracker: _tracker), - _OverlayMode.full => FullOverlayWidget(tracker: _tracker), - _OverlayMode.none => const SizedBox.shrink(), - }; - return inner; - } - - void _startTracking() { - if (_timingsCallback != null) return; - _timingsCallback = _tracker.addTimings; - SchedulerBinding.instance.addTimingsCallback(_timingsCallback!); - } - - void _stopTracking() { - if (_timingsCallback != null) { - SchedulerBinding.instance.removeTimingsCallback(_timingsCallback!); - _timingsCallback = null; - } - _tracker.reset(); - } -} diff --git a/third_party/refresh_rate/lib/src/verification/overlay_widgets.dart b/third_party/refresh_rate/lib/src/verification/overlay_widgets.dart deleted file mode 100644 index 273807e4..00000000 --- a/third_party/refresh_rate/lib/src/verification/overlay_widgets.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import '../models/enums.dart'; -import '../refresh_rate.dart'; -import 'fps_tracker.dart'; - -mixin _OverlayStateMixin on State { - late final Ticker _ticker; - - @override - void initState() { - super.initState(); - _ticker = Ticker((_) => setState(() {})); - _ticker.start(); - } - - @override - void dispose() { - _ticker.dispose(); - super.dispose(); - } - - Color fpsColor(double fps, double targetHz) { - if (fps >= targetHz * 0.95) return const Color(0xFF4CAF50); - if (fps >= targetHz * 0.75) return const Color(0xFFFFC107); - return const Color(0xFFF44336); - } - - Widget overlayContainer({required Widget child}) { - return Positioned( - top: MediaQueryData.fromView(View.of(context)).padding.top + 4, - right: 8, - child: IgnorePointer( - child: Material( - type: MaterialType.transparency, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xDD000000), - borderRadius: BorderRadius.circular(6), - ), - child: child, - ), - ), - ), - ); - } -} - -// ── FPS Badge ───────────────────────────────────────────────────── - -/// A small overlay widget that displays the current FPS. -class FpsOverlayWidget extends StatefulWidget { - /// The [FpsTracker] providing frame timings. - final FpsTracker tracker; - - /// Creates a [FpsOverlayWidget]. - const FpsOverlayWidget({super.key, required this.tracker}); - @override - State createState() => _FpsOverlayWidgetState(); -} - -class _FpsOverlayWidgetState extends State - with _OverlayStateMixin { - @override - Widget build(BuildContext context) { - final fps = widget.tracker.recentFps(); - return overlayContainer( - child: Text( - '${fps.toStringAsFixed(0)} FPS', - style: TextStyle( - color: fpsColor(fps, RefreshRate.info.maxRate), - fontSize: 13, - fontWeight: FontWeight.bold, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ); - } -} - -// ── Hz Badge ────────────────────────────────────────────────────── - -/// A small overlay widget that displays the current display refresh rate. -class HzOverlayWidget extends StatefulWidget { - /// The [FpsTracker] (unused but required for generic widget creation). - final FpsTracker tracker; - - /// Creates a [HzOverlayWidget]. - const HzOverlayWidget({super.key, required this.tracker}); - @override - State createState() => _HzOverlayWidgetState(); -} - -class _HzOverlayWidgetState extends State - with _OverlayStateMixin { - @override - Widget build(BuildContext context) { - final hz = RefreshRate.info.currentRate; - return overlayContainer( - child: Text( - '${hz.toStringAsFixed(0)}Hz', - style: const TextStyle( - color: Color(0xFF64B5F6), - fontSize: 13, - fontWeight: FontWeight.bold, - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - ); - } -} - -// ── Full Diagnostic Overlay ──────────────────────────────────────── - -/// A larger overlay widget that displays FPS, build/raster times, and device health. -class FullOverlayWidget extends StatefulWidget { - /// The [FpsTracker] providing frame timings. - final FpsTracker tracker; - - /// Creates a [FullOverlayWidget]. - const FullOverlayWidget({super.key, required this.tracker}); - @override - State createState() => _FullOverlayWidgetState(); -} - -class _FullOverlayWidgetState extends State - with _OverlayStateMixin { - @override - Widget build(BuildContext context) { - final fps = widget.tracker.recentFps(); - final buildMs = widget.tracker.avgBuildMs; - final rasterMs = widget.tracker.avgRasterMs; - final targetHz = RefreshRate.info.maxRate; - final budgetMs = 1000.0 / targetHz; - - return overlayContainer( - child: DefaultTextStyle( - style: const TextStyle( - color: Color(0xFFFFFFFF), - fontSize: 11, - fontFeatures: [FontFeature.tabularFigures()], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${fps.toStringAsFixed(0)} FPS', - style: TextStyle( - color: fpsColor(fps, targetHz), - fontSize: 14, - fontWeight: FontWeight.bold, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - Text( - 'build ${buildMs.toStringAsFixed(1)}ms raster ${rasterMs.toStringAsFixed(1)}ms'), - Text( - 'budget ${budgetMs.toStringAsFixed(1)}ms @ ${targetHz.toStringAsFixed(0)}Hz'), - if (RefreshRate.isLowPowerMode) - const Text( - '\u26a1 Low Power', - style: TextStyle(color: Color(0xFFFFCC02)), - ), - if (RefreshRate.thermalState != ThermalState.nominal && - RefreshRate.thermalState != ThermalState.unknown) - Text( - 'thermal: ${RefreshRate.thermalState.name}', - style: const TextStyle(color: Color(0xFFFF7043)), - ), - ], - ), - ), - ); - } -} diff --git a/third_party/refresh_rate/lib/src/verification/refresh_rate_session.dart b/third_party/refresh_rate/lib/src/verification/refresh_rate_session.dart deleted file mode 100644 index 139295c5..00000000 --- a/third_party/refresh_rate/lib/src/verification/refresh_rate_session.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart'; -import '../models/display_info.dart'; -import '../models/enums.dart'; -import '../models/session_report.dart'; -import 'fps_tracker.dart'; -import 'session_scorer.dart'; - -/// An active FPS benchmark session that records frame timings. -/// -/// Create via [RefreshRate.startSession] and call [end] to receive a -/// [SessionReport] with verdict, FPS stats, and bottleneck analysis. -/// -/// The session automatically pauses when the app goes to the background and -/// resumes (with a warm-up exclusion window) when the app returns to the -/// foreground. -class RefreshRateSession { - /// The human-readable name given to this session. - final String name; - SessionState _state; - final DateTime _startedAt; - final double _targetHz; - final DisplayInfo _initialInfo; - final FpsTracker _tracker = FpsTracker(); - Duration _excludedDuration = Duration.zero; - final Map _exclusions = {}; - TimingsCallback? _timingsCallback; - AppLifecycleListener? _lifecycleListener; - DateTime? _inactiveAt; - static const _warmupDuration = Duration(milliseconds: 500); - - RefreshRateSession._({ - required this.name, - required SessionState state, - required DateTime startedAt, - required double targetHz, - required DisplayInfo initialInfo, - }) : _state = state, - _startedAt = startedAt, - _targetHz = targetHz, - _initialInfo = initialInfo; - - /// Creates and starts a new tracking session. - static RefreshRateSession create(String name, DisplayInfo info) { - final session = RefreshRateSession._( - name: name, - state: SessionState.running, - startedAt: DateTime.now(), - targetHz: info.maxRate, - initialInfo: info, - ); - session._timingsCallback = session._tracker.addTimings; - SchedulerBinding.instance.addTimingsCallback(session._timingsCallback!); - session._registerLifecycleObserver(); - return session; - } - - /// Current lifecycle state of the session. - SessionState get state => _state; - - /// The time at which this session was created. - DateTime get startedAt => _startedAt; - - /// The target refresh rate in Hz (taken from [DisplayInfo.maxRate] at creation time). - double get targetHz => _targetHz; - - /// Stops frame timing collection and computes a [SessionReport]. - /// - /// Must be called exactly once. After this call the session is in - /// [SessionState.completed] and further calls have undefined behaviour. - Future end() async { - _stopTracking(); - _state = SessionState.completed; - return SessionScorer.compute( - sessionName: name, - tracker: _tracker, - targetHz: _targetHz, - validDuration: DateTime.now().difference(_startedAt) - _excludedDuration, - excludedDuration: _excludedDuration, - exclusionReasons: Map.unmodifiable(_exclusions), - deviceState: DeviceStateSnapshot( - isLowPowerMode: _initialInfo.isLowPowerMode, - thermalState: _initialInfo.thermalState, - hasAdaptiveRefreshRate: _initialInfo.hasAdaptiveRefreshRate, - displayServer: _initialInfo.displayServer, - monitorCount: _initialInfo.monitorCount, - ), - ); - } - - void _registerLifecycleObserver() { - _lifecycleListener = AppLifecycleListener( - onHide: _onInactive, - onInactive: _onInactive, - onPause: _onInactive, - onResume: _onResume, - ); - } - - void _onInactive() { - if (_state != SessionState.running) return; - _state = SessionState.interrupted; - _inactiveAt = DateTime.now(); - _addExclusion(ExclusionReason.appBackgrounded); - } - - void _onResume() { - if (_state != SessionState.interrupted) return; - if (_inactiveAt != null) { - _excludedDuration += DateTime.now().difference(_inactiveAt!); - _inactiveAt = null; - } - _state = SessionState.warmup; - Future.delayed(_warmupDuration, () { - if (_state == SessionState.warmup) { - _excludedDuration += _warmupDuration; - _addExclusion(ExclusionReason.resumeWarmup); - _state = SessionState.running; - } - }); - } - - void _addExclusion(ExclusionReason reason) { - _exclusions[reason] = (_exclusions[reason] ?? 0) + 1; - } - - void _stopTracking() { - if (_timingsCallback != null) { - SchedulerBinding.instance.removeTimingsCallback(_timingsCallback!); - _timingsCallback = null; - } - _lifecycleListener?.dispose(); - _lifecycleListener = null; - } -} diff --git a/third_party/refresh_rate/lib/src/verification/session_scorer.dart b/third_party/refresh_rate/lib/src/verification/session_scorer.dart deleted file mode 100644 index 3a56b00c..00000000 --- a/third_party/refresh_rate/lib/src/verification/session_scorer.dart +++ /dev/null @@ -1,97 +0,0 @@ -import '../models/enums.dart'; -import '../models/session_report.dart'; -import 'fps_tracker.dart'; - -/// Utility class that calculates the final [SessionReport] metrics. -abstract class SessionScorer { - /// Computes a [SessionReport] from the recorded tracking data. - static SessionReport compute({ - required String sessionName, - required FpsTracker tracker, - required double targetHz, - required Duration validDuration, - required Duration excludedDuration, - required Map exclusionReasons, - required DeviceStateSnapshot deviceState, - }) { - final frameBudgetMs = 1000.0 / targetHz; - final avgFps = tracker.avgFps; - final missed = tracker.missedFramePercent(targetHz); - final one = tracker.onePercentLowFps; - final five = tracker.fivePercentLowFps; - final janky = tracker.jankyFrameCount(targetHz); - final severe = tracker.severeJankCount(targetHz); - final avgBuild = tracker.avgBuildMs; - final avgRaster = tracker.avgRasterMs; - final avgTotal = tracker.avgTotalMs; - - final verdict = _computeVerdict(avgFps, targetHz, missed); - final bottleneck = _computeBottleneck( - avgBuild: avgBuild, - avgRaster: avgRaster, - avgFps: avgFps, - targetHz: targetHz, - isLowPowerMode: deviceState.isLowPowerMode, - thermalState: deviceState.thermalState, - ); - - return SessionReport( - sessionName: sessionName, - verdict: verdict, - likelyBottleneck: bottleneck, - targetHz: targetHz, - observedAvgHz: avgFps, - frameBudgetMs: frameBudgetMs, - avgFps: avgFps, - onePercentLowFps: one, - fivePercentLowFps: five, - avgBuildMs: avgBuild, - avgRasterMs: avgRaster, - avgTotalFrameMs: avgTotal, - jankyFrameCount: janky, - severeJankCount: severe, - missedFramePercent: missed, - validDuration: validDuration, - excludedDuration: excludedDuration, - exclusionReasons: exclusionReasons, - deviceState: deviceState, - ); - } - - static Verdict _computeVerdict( - double avgFps, double targetHz, double missedPct) { - if (avgFps == 0) return Verdict.inconclusive; - final hitRate = avgFps / targetHz; - if (hitRate >= 0.95 && missedPct < 2) return Verdict.excellent; - if (hitRate >= 0.85 && missedPct < 5) return Verdict.good; - if (hitRate >= 0.70 && missedPct < 15) return Verdict.fair; - return Verdict.poor; - } - - static Bottleneck _computeBottleneck({ - required double avgBuild, - required double avgRaster, - required double avgFps, - required double targetHz, - required bool? isLowPowerMode, - required ThermalState thermalState, - }) { - if (isLowPowerMode == true) return Bottleneck.powerLimited; - if (thermalState == ThermalState.serious || - thermalState == ThermalState.critical) { - return Bottleneck.thermalLimited; - } - final budgetMs = 1000.0 / targetHz; - // 85-90% of target: likely a display-side cap (LTPO floor, VSync rounding, etc.) - if (avgFps >= targetHz * 0.85 && avgFps < targetHz * 0.95) { - return Bottleneck.displayCapped; - } - // Below 85%: check for build vs raster bottleneck - if (avgFps < targetHz * 0.85) { - if (avgBuild > avgRaster && avgBuild > budgetMs * 0.5) - return Bottleneck.buildBound; - if (avgRaster > budgetMs * 0.5) return Bottleneck.rasterBound; - } - return Bottleneck.none; - } -} diff --git a/third_party/refresh_rate/lib/src/web/raf_hz_detector.dart b/third_party/refresh_rate/lib/src/web/raf_hz_detector.dart deleted file mode 100644 index ba6bfefe..00000000 --- a/third_party/refresh_rate/lib/src/web/raf_hz_detector.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'dart:async'; -import 'dart:js_interop'; - -import 'package:web/web.dart' as web; - -/// Detects the display's refresh rate by measuring -/// `requestAnimationFrame` callback intervals. -/// -/// Uses the same technique as TestUFO: collect a window of rAF -/// timestamps, compute the median inter-frame interval, and derive Hz. -class RafHzDetector { - /// Number of frames to sample before reporting a result. - static const int _kSampleCount = 120; - - /// Measures the current display refresh rate via rAF timing. - /// - /// Collects [_kSampleCount] frames, computes the median interval, - /// and returns `1000 / medianMs`. Returns `null` if measurement fails. - static Future measure() { - final completer = Completer(); - final timestamps = []; - late final JSFunction callback; - - void onFrame(JSNumber ts) { - timestamps.add(ts.toDartDouble); - - if (timestamps.length >= _kSampleCount + 1) { - // Compute intervals - final intervals = []; - for (var i = 1; i < timestamps.length; i++) { - intervals.add(timestamps[i] - timestamps[i - 1]); - } - intervals.sort(); - final median = intervals[intervals.length ~/ 2]; - - if (median > 0) { - // Round to nearest common Hz value (e.g. 60, 90, 120, 144, 165, 240) - final rawHz = 1000.0 / median; - completer.complete(_snapToCommonHz(rawHz)); - } else { - completer.complete(null); - } - } else { - web.window.requestAnimationFrame(callback); - } - } - - callback = onFrame.toJS; - web.window.requestAnimationFrame(callback); - - return completer.future; - } - - /// Snaps a raw Hz measurement to the nearest common display refresh rate - /// when the raw value is within 3% tolerance. - static double _snapToCommonHz(double raw) { - const commonRates = [ - 30.0, - 48.0, - 60.0, - 72.0, - 90.0, - 120.0, - 144.0, - 165.0, - 240.0, - 360.0 - ]; - for (final rate in commonRates) { - if ((raw - rate).abs() / rate < 0.03) return rate; - } - return double.parse(raw.toStringAsFixed(1)); - } -} diff --git a/third_party/refresh_rate/lib/src/web/web_refresh_rate_adapter.dart b/third_party/refresh_rate/lib/src/web/web_refresh_rate_adapter.dart deleted file mode 100644 index 96d0615b..00000000 --- a/third_party/refresh_rate/lib/src/web/web_refresh_rate_adapter.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'dart:async'; - -import '../generated/refresh_rate_api.g.dart'; -import '../refresh_rate_api_adapter.dart'; -import 'raf_hz_detector.dart'; - -/// Web implementation of [RefreshRateApiAdapter]. -/// -/// Uses `requestAnimationFrame` interval timing to detect the display's -/// current refresh rate. Control methods are graceful no-ops because -/// browsers own their vsync scheduling and expose no API to change it. -class WebRefreshRateApiAdapter implements RefreshRateApiAdapter { - double? _lastMeasuredRate; - - @override - Future getDisplayInfo() async { - _lastMeasuredRate = await RafHzDetector.measure(); - return DisplayInfoMessage( - currentRate: _lastMeasuredRate, - // Browsers don't expose max/min/supported rates. - maxRate: _lastMeasuredRate, - minRate: null, - supportedRates: _lastMeasuredRate != null ? [_lastMeasuredRate!] : null, - isVariableRefreshRate: null, - engineTargetRate: _lastMeasuredRate, - // Apple / Android specific — not applicable on web. - iosProMotionEnabled: null, - androidApiLevel: null, - // No battery/thermal APIs on web. - isLowPowerMode: null, - thermalStateIndex: null, - hasAdaptiveRefreshRate: null, - displayServer: 'web', - monitorCount: 1, - ); - } - - /// No-op — browsers manage their own vsync scheduling. - @override - FutureOr enable() {} - - /// No-op — browsers manage their own vsync scheduling. - @override - FutureOr disable() {} - - /// No-op — cannot request max rate on web. - @override - FutureOr preferMax() {} - - /// No-op — cannot change rate preference on web. - @override - FutureOr preferDefault() {} - - /// No-op — cannot match content frame rate on web. - @override - FutureOr matchContent(double fps) {} - - /// No-op — cannot boost refresh rate on web. - @override - FutureOr boost(int durationMs) {} - - /// No-op — rate categories are Android-specific. - @override - FutureOr setCategory(int categoryIndex) {} - - /// No-op — touch boost is Android-specific. - @override - FutureOr setTouchBoost(bool enabled) {} - - /// Web supports querying the refresh rate but not controlling it. - @override - FutureOr isSupported() => false; -} diff --git a/third_party/refresh_rate/linux/CMakeLists.txt b/third_party/refresh_rate/linux/CMakeLists.txt deleted file mode 100644 index 3f93096e..00000000 --- a/third_party/refresh_rate/linux/CMakeLists.txt +++ /dev/null @@ -1,18 +0,0 @@ -cmake_minimum_required(VERSION 3.10) -set(PROJECT_NAME "refresh_rate") -project(${PROJECT_NAME} LANGUAGES CXX) - -set(PLUGIN_NAME "${PROJECT_NAME}_plugin") - -add_library(${PLUGIN_NAME} SHARED - "refresh_rate_plugin.cc" -) - -apply_standard_settings(${PLUGIN_NAME}) -set_target_properties(${PLUGIN_NAME} PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) -target_include_directories(${PLUGIN_NAME} INTERFACE - "${CMAKE_CURRENT_SOURCE_DIR}/include") -target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) -target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) diff --git a/third_party/refresh_rate/linux/include/refresh_rate/refresh_rate_plugin.h b/third_party/refresh_rate/linux/include/refresh_rate/refresh_rate_plugin.h deleted file mode 100644 index 7d23107b..00000000 --- a/third_party/refresh_rate/linux/include/refresh_rate/refresh_rate_plugin.h +++ /dev/null @@ -1,22 +0,0 @@ -#ifndef FLUTTER_PLUGIN_REFRESH_RATE_PLUGIN_H_ -#define FLUTTER_PLUGIN_REFRESH_RATE_PLUGIN_H_ - -#include - -G_BEGIN_DECLS - -#ifdef FLUTTER_PLUGIN_IMPL -#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) -#else -#define FLUTTER_PLUGIN_EXPORT -#endif - -G_DECLARE_FINAL_TYPE(RefreshRatePlugin, refresh_rate_plugin, REFRESH_RATE, - PLUGIN, GObject) - -FLUTTER_PLUGIN_EXPORT void refresh_rate_plugin_register_with_registrar( - FlPluginRegistrar* registrar); - -G_END_DECLS - -#endif // FLUTTER_PLUGIN_REFRESH_RATE_PLUGIN_H_ diff --git a/third_party/refresh_rate/linux/refresh_rate_api.g.cc b/third_party/refresh_rate/linux/refresh_rate_api.g.cc deleted file mode 100644 index f4911e8a..00000000 --- a/third_party/refresh_rate/linux/refresh_rate_api.g.cc +++ /dev/null @@ -1,651 +0,0 @@ -// Copyright 2026 Qoder (qoder.in). All rights reserved. -// Use of this source code is governed by a BSD-style license. -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -#undef _HAS_EXCEPTIONS - -#include "refresh_rate_api.g.h" - -#include -#include -#include -#include - -#include -#include -#include - -namespace refresh_rate { -using flutter::BasicMessageChannel; -using flutter::CustomEncodableValue; -using flutter::EncodableList; -using flutter::EncodableMap; -using flutter::EncodableValue; - -FlutterError CreateConnectionError(const std::string channel_name) { - return FlutterError( - "channel-error", - "Unable to establish connection on channel: '" + channel_name + "'.", - EncodableValue("")); -} - -// DisplayInfoMessage - -DisplayInfoMessage::DisplayInfoMessage() {} - -DisplayInfoMessage::DisplayInfoMessage( - const double* current_rate, - const double* max_rate, - const double* min_rate, - const EncodableList* supported_rates, - const bool* is_variable_refresh_rate, - const double* engine_target_rate, - const bool* ios_pro_motion_enabled, - const int64_t* android_api_level, - const bool* is_low_power_mode, - const int64_t* thermal_state_index, - const bool* has_adaptive_refresh_rate, - const std::string* display_server, - const int64_t* monitor_count) - : current_rate_(current_rate ? std::optional(*current_rate) : std::nullopt), - max_rate_(max_rate ? std::optional(*max_rate) : std::nullopt), - min_rate_(min_rate ? std::optional(*min_rate) : std::nullopt), - supported_rates_(supported_rates ? std::optional(*supported_rates) : std::nullopt), - is_variable_refresh_rate_(is_variable_refresh_rate ? std::optional(*is_variable_refresh_rate) : std::nullopt), - engine_target_rate_(engine_target_rate ? std::optional(*engine_target_rate) : std::nullopt), - ios_pro_motion_enabled_(ios_pro_motion_enabled ? std::optional(*ios_pro_motion_enabled) : std::nullopt), - android_api_level_(android_api_level ? std::optional(*android_api_level) : std::nullopt), - is_low_power_mode_(is_low_power_mode ? std::optional(*is_low_power_mode) : std::nullopt), - thermal_state_index_(thermal_state_index ? std::optional(*thermal_state_index) : std::nullopt), - has_adaptive_refresh_rate_(has_adaptive_refresh_rate ? std::optional(*has_adaptive_refresh_rate) : std::nullopt), - display_server_(display_server ? std::optional(*display_server) : std::nullopt), - monitor_count_(monitor_count ? std::optional(*monitor_count) : std::nullopt) {} - -const double* DisplayInfoMessage::current_rate() const { - return current_rate_ ? &(*current_rate_) : nullptr; -} - -void DisplayInfoMessage::set_current_rate(const double* value_arg) { - current_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_current_rate(double value_arg) { - current_rate_ = value_arg; -} - - -const double* DisplayInfoMessage::max_rate() const { - return max_rate_ ? &(*max_rate_) : nullptr; -} - -void DisplayInfoMessage::set_max_rate(const double* value_arg) { - max_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_max_rate(double value_arg) { - max_rate_ = value_arg; -} - - -const double* DisplayInfoMessage::min_rate() const { - return min_rate_ ? &(*min_rate_) : nullptr; -} - -void DisplayInfoMessage::set_min_rate(const double* value_arg) { - min_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_min_rate(double value_arg) { - min_rate_ = value_arg; -} - - -const EncodableList* DisplayInfoMessage::supported_rates() const { - return supported_rates_ ? &(*supported_rates_) : nullptr; -} - -void DisplayInfoMessage::set_supported_rates(const EncodableList* value_arg) { - supported_rates_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_supported_rates(const EncodableList& value_arg) { - supported_rates_ = value_arg; -} - - -const bool* DisplayInfoMessage::is_variable_refresh_rate() const { - return is_variable_refresh_rate_ ? &(*is_variable_refresh_rate_) : nullptr; -} - -void DisplayInfoMessage::set_is_variable_refresh_rate(const bool* value_arg) { - is_variable_refresh_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_is_variable_refresh_rate(bool value_arg) { - is_variable_refresh_rate_ = value_arg; -} - - -const double* DisplayInfoMessage::engine_target_rate() const { - return engine_target_rate_ ? &(*engine_target_rate_) : nullptr; -} - -void DisplayInfoMessage::set_engine_target_rate(const double* value_arg) { - engine_target_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_engine_target_rate(double value_arg) { - engine_target_rate_ = value_arg; -} - - -const bool* DisplayInfoMessage::ios_pro_motion_enabled() const { - return ios_pro_motion_enabled_ ? &(*ios_pro_motion_enabled_) : nullptr; -} - -void DisplayInfoMessage::set_ios_pro_motion_enabled(const bool* value_arg) { - ios_pro_motion_enabled_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_ios_pro_motion_enabled(bool value_arg) { - ios_pro_motion_enabled_ = value_arg; -} - - -const int64_t* DisplayInfoMessage::android_api_level() const { - return android_api_level_ ? &(*android_api_level_) : nullptr; -} - -void DisplayInfoMessage::set_android_api_level(const int64_t* value_arg) { - android_api_level_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_android_api_level(int64_t value_arg) { - android_api_level_ = value_arg; -} - - -const bool* DisplayInfoMessage::is_low_power_mode() const { - return is_low_power_mode_ ? &(*is_low_power_mode_) : nullptr; -} - -void DisplayInfoMessage::set_is_low_power_mode(const bool* value_arg) { - is_low_power_mode_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_is_low_power_mode(bool value_arg) { - is_low_power_mode_ = value_arg; -} - - -const int64_t* DisplayInfoMessage::thermal_state_index() const { - return thermal_state_index_ ? &(*thermal_state_index_) : nullptr; -} - -void DisplayInfoMessage::set_thermal_state_index(const int64_t* value_arg) { - thermal_state_index_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_thermal_state_index(int64_t value_arg) { - thermal_state_index_ = value_arg; -} - - -const bool* DisplayInfoMessage::has_adaptive_refresh_rate() const { - return has_adaptive_refresh_rate_ ? &(*has_adaptive_refresh_rate_) : nullptr; -} - -void DisplayInfoMessage::set_has_adaptive_refresh_rate(const bool* value_arg) { - has_adaptive_refresh_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_has_adaptive_refresh_rate(bool value_arg) { - has_adaptive_refresh_rate_ = value_arg; -} - - -const std::string* DisplayInfoMessage::display_server() const { - return display_server_ ? &(*display_server_) : nullptr; -} - -void DisplayInfoMessage::set_display_server(const std::string_view* value_arg) { - display_server_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_display_server(std::string_view value_arg) { - display_server_ = value_arg; -} - - -const int64_t* DisplayInfoMessage::monitor_count() const { - return monitor_count_ ? &(*monitor_count_) : nullptr; -} - -void DisplayInfoMessage::set_monitor_count(const int64_t* value_arg) { - monitor_count_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_monitor_count(int64_t value_arg) { - monitor_count_ = value_arg; -} - - -EncodableList DisplayInfoMessage::ToEncodableList() const { - EncodableList list; - list.reserve(13); - list.push_back(current_rate_ ? EncodableValue(*current_rate_) : EncodableValue()); - list.push_back(max_rate_ ? EncodableValue(*max_rate_) : EncodableValue()); - list.push_back(min_rate_ ? EncodableValue(*min_rate_) : EncodableValue()); - list.push_back(supported_rates_ ? EncodableValue(*supported_rates_) : EncodableValue()); - list.push_back(is_variable_refresh_rate_ ? EncodableValue(*is_variable_refresh_rate_) : EncodableValue()); - list.push_back(engine_target_rate_ ? EncodableValue(*engine_target_rate_) : EncodableValue()); - list.push_back(ios_pro_motion_enabled_ ? EncodableValue(*ios_pro_motion_enabled_) : EncodableValue()); - list.push_back(android_api_level_ ? EncodableValue(*android_api_level_) : EncodableValue()); - list.push_back(is_low_power_mode_ ? EncodableValue(*is_low_power_mode_) : EncodableValue()); - list.push_back(thermal_state_index_ ? EncodableValue(*thermal_state_index_) : EncodableValue()); - list.push_back(has_adaptive_refresh_rate_ ? EncodableValue(*has_adaptive_refresh_rate_) : EncodableValue()); - list.push_back(display_server_ ? EncodableValue(*display_server_) : EncodableValue()); - list.push_back(monitor_count_ ? EncodableValue(*monitor_count_) : EncodableValue()); - return list; -} - -DisplayInfoMessage DisplayInfoMessage::FromEncodableList(const EncodableList& list) { - DisplayInfoMessage decoded; - auto& encodable_current_rate = list[0]; - if (!encodable_current_rate.IsNull()) { - decoded.set_current_rate(std::get(encodable_current_rate)); - } - auto& encodable_max_rate = list[1]; - if (!encodable_max_rate.IsNull()) { - decoded.set_max_rate(std::get(encodable_max_rate)); - } - auto& encodable_min_rate = list[2]; - if (!encodable_min_rate.IsNull()) { - decoded.set_min_rate(std::get(encodable_min_rate)); - } - auto& encodable_supported_rates = list[3]; - if (!encodable_supported_rates.IsNull()) { - decoded.set_supported_rates(std::get(encodable_supported_rates)); - } - auto& encodable_is_variable_refresh_rate = list[4]; - if (!encodable_is_variable_refresh_rate.IsNull()) { - decoded.set_is_variable_refresh_rate(std::get(encodable_is_variable_refresh_rate)); - } - auto& encodable_engine_target_rate = list[5]; - if (!encodable_engine_target_rate.IsNull()) { - decoded.set_engine_target_rate(std::get(encodable_engine_target_rate)); - } - auto& encodable_ios_pro_motion_enabled = list[6]; - if (!encodable_ios_pro_motion_enabled.IsNull()) { - decoded.set_ios_pro_motion_enabled(std::get(encodable_ios_pro_motion_enabled)); - } - auto& encodable_android_api_level = list[7]; - if (!encodable_android_api_level.IsNull()) { - decoded.set_android_api_level(std::get(encodable_android_api_level)); - } - auto& encodable_is_low_power_mode = list[8]; - if (!encodable_is_low_power_mode.IsNull()) { - decoded.set_is_low_power_mode(std::get(encodable_is_low_power_mode)); - } - auto& encodable_thermal_state_index = list[9]; - if (!encodable_thermal_state_index.IsNull()) { - decoded.set_thermal_state_index(std::get(encodable_thermal_state_index)); - } - auto& encodable_has_adaptive_refresh_rate = list[10]; - if (!encodable_has_adaptive_refresh_rate.IsNull()) { - decoded.set_has_adaptive_refresh_rate(std::get(encodable_has_adaptive_refresh_rate)); - } - auto& encodable_display_server = list[11]; - if (!encodable_display_server.IsNull()) { - decoded.set_display_server(std::get(encodable_display_server)); - } - auto& encodable_monitor_count = list[12]; - if (!encodable_monitor_count.IsNull()) { - decoded.set_monitor_count(std::get(encodable_monitor_count)); - } - return decoded; -} - - -PigeonInternalCodecSerializer::PigeonInternalCodecSerializer() {} - -EncodableValue PigeonInternalCodecSerializer::ReadValueOfType( - uint8_t type, - flutter::ByteStreamReader* stream) const { - switch (type) { - case 129: { - return CustomEncodableValue(DisplayInfoMessage::FromEncodableList(std::get(ReadValue(stream)))); - } - default: - return flutter::StandardCodecSerializer::ReadValueOfType(type, stream); - } -} - -void PigeonInternalCodecSerializer::WriteValue( - const EncodableValue& value, - flutter::ByteStreamWriter* stream) const { - if (const CustomEncodableValue* custom_value = std::get_if(&value)) { - if (custom_value->type() == typeid(DisplayInfoMessage)) { - stream->WriteByte(129); - WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); - return; - } - } - flutter::StandardCodecSerializer::WriteValue(value, stream); -} - -/// The codec used by RefreshRateHostApi. -const flutter::StandardMessageCodec& RefreshRateHostApi::GetCodec() { - return flutter::StandardMessageCodec::GetInstance(&PigeonInternalCodecSerializer::GetInstance()); -} - -// Sets up an instance of `RefreshRateHostApi` to handle messages through the `binary_messenger`. -void RefreshRateHostApi::SetUp( - flutter::BinaryMessenger* binary_messenger, - RefreshRateHostApi* api) { - RefreshRateHostApi::SetUp(binary_messenger, api, ""); -} - -void RefreshRateHostApi::SetUp( - flutter::BinaryMessenger* binary_messenger, - RefreshRateHostApi* api, - const std::string& message_channel_suffix) { - const std::string prepended_suffix = message_channel_suffix.length() > 0 ? std::string(".") + message_channel_suffix : ""; - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - ErrorOr output = api->GetDisplayInfo(); - if (output.has_error()) { - reply(WrapError(output.error())); - return; - } - EncodableList wrapped; - wrapped.push_back(CustomEncodableValue(std::move(output).TakeValue())); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - std::optional output = api->Enable(); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - std::optional output = api->Disable(); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - std::optional output = api->PreferMax(); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - std::optional output = api->PreferDefault(); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - const auto& args = std::get(message); - const auto& encodable_fps_arg = args.at(0); - if (encodable_fps_arg.IsNull()) { - reply(WrapError("fps_arg unexpectedly null.")); - return; - } - const auto& fps_arg = std::get(encodable_fps_arg); - std::optional output = api->MatchContent(fps_arg); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - const auto& args = std::get(message); - const auto& encodable_duration_ms_arg = args.at(0); - if (encodable_duration_ms_arg.IsNull()) { - reply(WrapError("duration_ms_arg unexpectedly null.")); - return; - } - const int64_t duration_ms_arg = encodable_duration_ms_arg.LongValue(); - std::optional output = api->Boost(duration_ms_arg); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - const auto& args = std::get(message); - const auto& encodable_category_index_arg = args.at(0); - if (encodable_category_index_arg.IsNull()) { - reply(WrapError("category_index_arg unexpectedly null.")); - return; - } - const int64_t category_index_arg = encodable_category_index_arg.LongValue(); - std::optional output = api->SetCategory(category_index_arg); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - const auto& args = std::get(message); - const auto& encodable_enabled_arg = args.at(0); - if (encodable_enabled_arg.IsNull()) { - reply(WrapError("enabled_arg unexpectedly null.")); - return; - } - const auto& enabled_arg = std::get(encodable_enabled_arg); - std::optional output = api->SetTouchBoost(enabled_arg); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - ErrorOr output = api->IsSupported(); - if (output.has_error()) { - reply(WrapError(output.error())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue(std::move(output).TakeValue())); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } -} - -EncodableValue RefreshRateHostApi::WrapError(std::string_view error_message) { - return EncodableValue(EncodableList{ - EncodableValue(std::string(error_message)), - EncodableValue("Error"), - EncodableValue() - }); -} - -EncodableValue RefreshRateHostApi::WrapError(const FlutterError& error) { - return EncodableValue(EncodableList{ - EncodableValue(error.code()), - EncodableValue(error.message()), - error.details() - }); -} - -// Generated class from Pigeon that represents Flutter messages that can be called from C++. -RefreshRateFlutterApi::RefreshRateFlutterApi(flutter::BinaryMessenger* binary_messenger) - : binary_messenger_(binary_messenger), - message_channel_suffix_("") {} - -RefreshRateFlutterApi::RefreshRateFlutterApi( - flutter::BinaryMessenger* binary_messenger, - const std::string& message_channel_suffix) - : binary_messenger_(binary_messenger), - message_channel_suffix_(message_channel_suffix.length() > 0 ? std::string(".") + message_channel_suffix : "") {} - -const flutter::StandardMessageCodec& RefreshRateFlutterApi::GetCodec() { - return flutter::StandardMessageCodec::GetInstance(&PigeonInternalCodecSerializer::GetInstance()); -} - -void RefreshRateFlutterApi::OnDisplayInfoChanged( - const DisplayInfoMessage& info_arg, - std::function&& on_success, - std::function&& on_error) { - const std::string channel_name = "dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged" + message_channel_suffix_; - BasicMessageChannel<> channel(binary_messenger_, channel_name, &GetCodec()); - EncodableValue encoded_api_arguments = EncodableValue(EncodableList{ - CustomEncodableValue(info_arg), - }); - channel.Send(encoded_api_arguments, [channel_name, on_success = std::move(on_success), on_error = std::move(on_error)](const uint8_t* reply, size_t reply_size) { - std::unique_ptr response = GetCodec().DecodeMessage(reply, reply_size); - const auto& encodable_return_value = *response; - const auto* list_return_value = std::get_if(&encodable_return_value); - if (list_return_value) { - if (list_return_value->size() > 1) { - on_error(FlutterError(std::get(list_return_value->at(0)), std::get(list_return_value->at(1)), list_return_value->at(2))); - } else { - on_success(); - } - } else { - on_error(CreateConnectionError(channel_name)); - } - }); -} - -} // namespace refresh_rate diff --git a/third_party/refresh_rate/linux/refresh_rate_api.g.h b/third_party/refresh_rate/linux/refresh_rate_api.g.h deleted file mode 100644 index 70ef60a0..00000000 --- a/third_party/refresh_rate/linux/refresh_rate_api.g.h +++ /dev/null @@ -1,233 +0,0 @@ -// Copyright 2026 Qoder (qoder.in). All rights reserved. -// Use of this source code is governed by a BSD-style license. -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -#ifndef PIGEON_REFRESH_RATE_API_G_H_ -#define PIGEON_REFRESH_RATE_API_G_H_ -#include -#include -#include -#include - -#include -#include -#include - -namespace refresh_rate { - - -// Generated class from Pigeon. - -class FlutterError { - public: - explicit FlutterError(const std::string& code) - : code_(code) {} - explicit FlutterError(const std::string& code, const std::string& message) - : code_(code), message_(message) {} - explicit FlutterError(const std::string& code, const std::string& message, const flutter::EncodableValue& details) - : code_(code), message_(message), details_(details) {} - - const std::string& code() const { return code_; } - const std::string& message() const { return message_; } - const flutter::EncodableValue& details() const { return details_; } - - private: - std::string code_; - std::string message_; - flutter::EncodableValue details_; -}; - -template class ErrorOr { - public: - ErrorOr(const T& rhs) : v_(rhs) {} - ErrorOr(const T&& rhs) : v_(std::move(rhs)) {} - ErrorOr(const FlutterError& rhs) : v_(rhs) {} - ErrorOr(const FlutterError&& rhs) : v_(std::move(rhs)) {} - - bool has_error() const { return std::holds_alternative(v_); } - const T& value() const { return std::get(v_); }; - const FlutterError& error() const { return std::get(v_); }; - - private: - friend class RefreshRateHostApi; - friend class RefreshRateFlutterApi; - ErrorOr() = default; - T TakeValue() && { return std::get(std::move(v_)); } - - std::variant v_; -}; - - - -// Generated class from Pigeon that represents data sent in messages. -class DisplayInfoMessage { - public: - // Constructs an object setting all non-nullable fields. - DisplayInfoMessage(); - - // Constructs an object setting all fields. - explicit DisplayInfoMessage( - const double* current_rate, - const double* max_rate, - const double* min_rate, - const flutter::EncodableList* supported_rates, - const bool* is_variable_refresh_rate, - const double* engine_target_rate, - const bool* ios_pro_motion_enabled, - const int64_t* android_api_level, - const bool* is_low_power_mode, - const int64_t* thermal_state_index, - const bool* has_adaptive_refresh_rate, - const std::string* display_server, - const int64_t* monitor_count); - - const double* current_rate() const; - void set_current_rate(const double* value_arg); - void set_current_rate(double value_arg); - - const double* max_rate() const; - void set_max_rate(const double* value_arg); - void set_max_rate(double value_arg); - - const double* min_rate() const; - void set_min_rate(const double* value_arg); - void set_min_rate(double value_arg); - - const flutter::EncodableList* supported_rates() const; - void set_supported_rates(const flutter::EncodableList* value_arg); - void set_supported_rates(const flutter::EncodableList& value_arg); - - const bool* is_variable_refresh_rate() const; - void set_is_variable_refresh_rate(const bool* value_arg); - void set_is_variable_refresh_rate(bool value_arg); - - const double* engine_target_rate() const; - void set_engine_target_rate(const double* value_arg); - void set_engine_target_rate(double value_arg); - - const bool* ios_pro_motion_enabled() const; - void set_ios_pro_motion_enabled(const bool* value_arg); - void set_ios_pro_motion_enabled(bool value_arg); - - const int64_t* android_api_level() const; - void set_android_api_level(const int64_t* value_arg); - void set_android_api_level(int64_t value_arg); - - const bool* is_low_power_mode() const; - void set_is_low_power_mode(const bool* value_arg); - void set_is_low_power_mode(bool value_arg); - - const int64_t* thermal_state_index() const; - void set_thermal_state_index(const int64_t* value_arg); - void set_thermal_state_index(int64_t value_arg); - - const bool* has_adaptive_refresh_rate() const; - void set_has_adaptive_refresh_rate(const bool* value_arg); - void set_has_adaptive_refresh_rate(bool value_arg); - - const std::string* display_server() const; - void set_display_server(const std::string_view* value_arg); - void set_display_server(std::string_view value_arg); - - const int64_t* monitor_count() const; - void set_monitor_count(const int64_t* value_arg); - void set_monitor_count(int64_t value_arg); - - - private: - static DisplayInfoMessage FromEncodableList(const flutter::EncodableList& list); - flutter::EncodableList ToEncodableList() const; - friend class RefreshRateHostApi; - friend class RefreshRateFlutterApi; - friend class PigeonInternalCodecSerializer; - std::optional current_rate_; - std::optional max_rate_; - std::optional min_rate_; - std::optional supported_rates_; - std::optional is_variable_refresh_rate_; - std::optional engine_target_rate_; - std::optional ios_pro_motion_enabled_; - std::optional android_api_level_; - std::optional is_low_power_mode_; - std::optional thermal_state_index_; - std::optional has_adaptive_refresh_rate_; - std::optional display_server_; - std::optional monitor_count_; - -}; - - -class PigeonInternalCodecSerializer : public flutter::StandardCodecSerializer { - public: - PigeonInternalCodecSerializer(); - inline static PigeonInternalCodecSerializer& GetInstance() { - static PigeonInternalCodecSerializer sInstance; - return sInstance; - } - - void WriteValue( - const flutter::EncodableValue& value, - flutter::ByteStreamWriter* stream) const override; - - protected: - flutter::EncodableValue ReadValueOfType( - uint8_t type, - flutter::ByteStreamReader* stream) const override; - -}; - -// Generated interface from Pigeon that represents a handler of messages from Flutter. -class RefreshRateHostApi { - public: - RefreshRateHostApi(const RefreshRateHostApi&) = delete; - RefreshRateHostApi& operator=(const RefreshRateHostApi&) = delete; - virtual ~RefreshRateHostApi() {} - virtual ErrorOr GetDisplayInfo() = 0; - virtual std::optional Enable() = 0; - virtual std::optional Disable() = 0; - virtual std::optional PreferMax() = 0; - virtual std::optional PreferDefault() = 0; - virtual std::optional MatchContent(double fps) = 0; - virtual std::optional Boost(int64_t duration_ms) = 0; - virtual std::optional SetCategory(int64_t category_index) = 0; - virtual std::optional SetTouchBoost(bool enabled) = 0; - virtual ErrorOr IsSupported() = 0; - - // The codec used by RefreshRateHostApi. - static const flutter::StandardMessageCodec& GetCodec(); - // Sets up an instance of `RefreshRateHostApi` to handle messages through the `binary_messenger`. - static void SetUp( - flutter::BinaryMessenger* binary_messenger, - RefreshRateHostApi* api); - static void SetUp( - flutter::BinaryMessenger* binary_messenger, - RefreshRateHostApi* api, - const std::string& message_channel_suffix); - static flutter::EncodableValue WrapError(std::string_view error_message); - static flutter::EncodableValue WrapError(const FlutterError& error); - - protected: - RefreshRateHostApi() = default; - -}; -// Generated class from Pigeon that represents Flutter messages that can be called from C++. -class RefreshRateFlutterApi { - public: - RefreshRateFlutterApi(flutter::BinaryMessenger* binary_messenger); - RefreshRateFlutterApi( - flutter::BinaryMessenger* binary_messenger, - const std::string& message_channel_suffix); - static const flutter::StandardMessageCodec& GetCodec(); - void OnDisplayInfoChanged( - const DisplayInfoMessage& info, - std::function&& on_success, - std::function&& on_error); - - private: - flutter::BinaryMessenger* binary_messenger_; - std::string message_channel_suffix_; -}; - -} // namespace refresh_rate -#endif // PIGEON_REFRESH_RATE_API_G_H_ diff --git a/third_party/refresh_rate/linux/refresh_rate_plugin.cc b/third_party/refresh_rate/linux/refresh_rate_plugin.cc deleted file mode 100644 index ca63eb64..00000000 --- a/third_party/refresh_rate/linux/refresh_rate_plugin.cc +++ /dev/null @@ -1,274 +0,0 @@ -// Linux implementation of RefreshRatePlugin. -// -// Uses GDK for query support. Implements the pigeon protocol using raw binary -// channel handlers — avoids the GObject/C++ bridge problem with BinaryMessenger. -// Control is not supported on Linux; compositor owns refresh rates. - -#include "include/refresh_rate/refresh_rate_plugin.h" - -#include -#include -#include -#include -#include - -#define REFRESH_RATE_PLUGIN(obj) \ - (G_TYPE_CHECK_INSTANCE_CAST((obj), refresh_rate_plugin_get_type(), RefreshRatePlugin)) - -#define PIGEON_CHANNEL_PREFIX "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi." - -struct _RefreshRatePlugin { - GObject parent_instance; - FlBinaryMessenger* messenger; -}; - -G_DEFINE_TYPE(RefreshRatePlugin, refresh_rate_plugin, g_object_get_type()) - -// ─── Pigeon binary encoding helpers ───────────────────────────────── - -static void pb_byte(GByteArray* b, guint8 v) { - g_byte_array_append(b, &v, 1); -} - -static void pb_varint(GByteArray* b, gsize v) { - if (v < 254) { - pb_byte(b, (guint8)v); - } else if (v < 65536) { - pb_byte(b, 254); - guint16 u = (guint16)v; - g_byte_array_append(b, (const guint8*)&u, 2); - } else { - pb_byte(b, 255); - guint32 u = (guint32)v; - g_byte_array_append(b, (const guint8*)&u, 4); - } -} - -static void pb_null(GByteArray* b) { pb_byte(b, 0); } -static void pb_bool(GByteArray* b, gboolean v) { pb_byte(b, v ? 1 : 2); } - -static void pb_double(GByteArray* b, double v) { - pb_byte(b, 6); - g_byte_array_append(b, (const guint8*)&v, 8); -} - -static void pb_int64(GByteArray* b, gint64 v) { - pb_byte(b, 4); - g_byte_array_append(b, (const guint8*)&v, 8); -} - -static void pb_string(GByteArray* b, const char* s) { - pb_byte(b, 7); - gsize len = strlen(s); - pb_varint(b, len); - g_byte_array_append(b, (const guint8*)s, len); -} - -// Build pigeon success response: outer list [result] -// For display info: result is custom type 129 (DisplayInfoMessage) -static GBytes* build_display_info_response( - double current, double max_rate, double min_rate, - double* rates, int n_rates, - gboolean is_vrr, - const char* display_server, - gint64 monitor_count) { - - GByteArray* b = g_byte_array_new(); - - // Outer success wrapper: list([result]) - pb_byte(b, 12); pb_varint(b, 1); - - // Custom type 129 (DisplayInfoMessage) - pb_byte(b, 129); - - // Inner list of 13 fields - pb_byte(b, 12); pb_varint(b, 13); - - pb_double(b, current); // 0: currentRate - pb_double(b, max_rate); // 1: maxRate - pb_double(b, min_rate); // 2: minRate - - // 3: supportedRates list - pb_byte(b, 12); pb_varint(b, n_rates); - for (int i = 0; i < n_rates; i++) pb_double(b, rates[i]); - - pb_bool(b, is_vrr); // 4: isVariableRefreshRate - pb_double(b, 60.0); // 5: engineTargetRate - pb_null(b); // 6: iosProMotionEnabled - pb_null(b); // 7: androidApiLevel - pb_null(b); // 8: isLowPowerMode - pb_null(b); // 9: thermalStateIndex - pb_bool(b, is_vrr); // 10: hasAdaptiveRefreshRate - - if (display_server) pb_string(b, display_server); - else pb_null(b); // 11: displayServer - - pb_int64(b, monitor_count); // 12: monitorCount - - return g_byte_array_free_to_bytes(b); -} - -// Pigeon empty success response: [] -static GBytes* build_empty_success() { - GByteArray* b = g_byte_array_new(); - pb_byte(b, 12); pb_varint(b, 0); - return g_byte_array_free_to_bytes(b); -} - -// Pigeon bool success response: [bool] -static GBytes* build_bool_success(gboolean v) { - GByteArray* b = g_byte_array_new(); - pb_byte(b, 12); pb_varint(b, 1); - pb_bool(b, v); - return g_byte_array_free_to_bytes(b); -} - -// ─── GDK display helpers ──────────────────────────────────────────── - -static GdkMonitor* get_primary_monitor() { - GdkDisplay* display = gdk_display_get_default(); - if (!display) return nullptr; - GdkMonitor* monitor = gdk_display_get_primary_monitor(display); - if (!monitor) monitor = gdk_display_get_monitor(display, 0); - return monitor; -} - -static double get_monitor_rate(GdkMonitor* monitor) { - if (!monitor) return 60.0; - int rate_mhz = gdk_monitor_get_refresh_rate(monitor); - return rate_mhz > 0 ? rate_mhz / 1000.0 : 60.0; -} - -static double get_primary_refresh_rate() { - return get_monitor_rate(get_primary_monitor()); -} - -static double get_max_refresh_rate() { - GdkDisplay* display = gdk_display_get_default(); - if (!display) return 60.0; - double maxRate = 0.0; - int n = gdk_display_get_n_monitors(display); - for (int i = 0; i < n; i++) { - double rate = get_monitor_rate(gdk_display_get_monitor(display, i)); - if (rate > maxRate) maxRate = rate; - } - return maxRate > 0 ? maxRate : 60.0; -} - -static const char* get_display_server_type() { - GdkDisplay* display = gdk_display_get_default(); - if (!display) return "unknown"; - const gchar* name = G_OBJECT_TYPE_NAME(display); - if (name) { - if (g_str_has_prefix(name, "GdkWayland")) return "wayland"; - if (g_str_has_prefix(name, "GdkX11")) return "x11"; - } - const char* wayland = g_getenv("WAYLAND_DISPLAY"); - if (wayland && wayland[0] != '\0') return "wayland"; - const char* x11 = g_getenv("DISPLAY"); - if (x11 && x11[0] != '\0') return "x11"; - return "unknown"; -} - -// ─── Pigeon channel handlers ──────────────────────────────────────── - -static void handle_get_display_info( - FlBinaryMessenger* messenger, - const gchar* channel, - GBytes* message, - FlBinaryMessengerResponseHandle* response_handle, - gpointer user_data) { - - double current = get_primary_refresh_rate(); - double max_r = get_max_refresh_rate(); - double min_r = current; // GDK doesn't expose min; use current - - // Collect unique rates - GdkDisplay* display = gdk_display_get_default(); - std::set seen; - if (display) { - int n = gdk_display_get_n_monitors(display); - for (int i = 0; i < n; i++) { - int rate_mhz = gdk_monitor_get_refresh_rate(gdk_display_get_monitor(display, i)); - if (rate_mhz > 0) seen.insert((rate_mhz + 500) / 1000); - } - } - if (seen.empty()) seen.insert((int)current); - - double rates[16]; - int n_rates = 0; - for (int r : seen) { - rates[n_rates++] = (double)r; - if (n_rates >= 16) break; - } - min_r = n_rates > 0 ? rates[0] : current; - - gboolean is_vrr = (max_r > current + 5.0); - const char* display_server = get_display_server_type(); - gint64 monitor_count = display ? gdk_display_get_n_monitors(display) : 1; - - g_autoptr(GBytes) response = build_display_info_response( - current, max_r, min_r, rates, n_rates, is_vrr, display_server, monitor_count); - fl_binary_messenger_send_response(messenger, response_handle, response, nullptr); -} - -static void handle_noop( - FlBinaryMessenger* messenger, - const gchar* channel, - GBytes* message, - FlBinaryMessengerResponseHandle* response_handle, - gpointer user_data) { - g_autoptr(GBytes) response = build_empty_success(); - fl_binary_messenger_send_response(messenger, response_handle, response, nullptr); -} - -static void handle_is_supported( - FlBinaryMessenger* messenger, - const gchar* channel, - GBytes* message, - FlBinaryMessengerResponseHandle* response_handle, - gpointer user_data) { - g_autoptr(GBytes) response = build_bool_success(FALSE); - fl_binary_messenger_send_response(messenger, response_handle, response, nullptr); -} - -// ─── Plugin lifecycle ──────────────────────────────────────────────── - -static void refresh_rate_plugin_dispose(GObject* object) { - G_OBJECT_CLASS(refresh_rate_plugin_parent_class)->dispose(object); -} - -static void refresh_rate_plugin_class_init(RefreshRatePluginClass* klass) { - G_OBJECT_CLASS(klass)->dispose = refresh_rate_plugin_dispose; -} - -static void refresh_rate_plugin_init(RefreshRatePlugin* self) {} - -void refresh_rate_plugin_register_with_registrar(FlPluginRegistrar* registrar) { - RefreshRatePlugin* plugin = REFRESH_RATE_PLUGIN( - g_object_new(refresh_rate_plugin_get_type(), nullptr)); - - FlBinaryMessenger* messenger = fl_plugin_registrar_get_messenger(registrar); - plugin->messenger = messenger; - - fl_binary_messenger_set_message_handler_on_channel( - messenger, PIGEON_CHANNEL_PREFIX "getDisplayInfo", - handle_get_display_info, g_object_ref(plugin), g_object_unref); - - const char* noop_channels[] = { - "enable", "disable", "preferMax", "preferDefault", - "matchContent", "boost", "setCategory", "setTouchBoost", nullptr - }; - for (int i = 0; noop_channels[i]; i++) { - gchar* name = g_strconcat(PIGEON_CHANNEL_PREFIX, noop_channels[i], nullptr); - fl_binary_messenger_set_message_handler_on_channel( - messenger, name, handle_noop, g_object_ref(plugin), g_object_unref); - g_free(name); - } - - fl_binary_messenger_set_message_handler_on_channel( - messenger, PIGEON_CHANNEL_PREFIX "isSupported", - handle_is_supported, g_object_ref(plugin), g_object_unref); - - g_object_unref(plugin); -} diff --git a/third_party/refresh_rate/macos/Classes/RefreshRatePlugin.swift b/third_party/refresh_rate/macos/Classes/RefreshRatePlugin.swift deleted file mode 100644 index 630b2c92..00000000 --- a/third_party/refresh_rate/macos/Classes/RefreshRatePlugin.swift +++ /dev/null @@ -1,167 +0,0 @@ -import Cocoa -import FlutterMacOS -import CoreVideo -import QuartzCore - -public class RefreshRatePlugin: NSObject, FlutterPlugin, RefreshRateHostApi { - - private var flutterApi: RefreshRateFlutterApi? - private var _displayLinkRef: AnyObject? // CADisplayLink on macOS 14+ - private var lastReportedRate: Double = 0 - private var displayReconfigRegistered = false - - @available(macOS 14.0, *) - private var displayLink: CADisplayLink? { - get { _displayLinkRef as? CADisplayLink } - set { _displayLinkRef = newValue } - } - - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = RefreshRatePlugin() - RefreshRateHostApiSetup.setUp(binaryMessenger: registrar.messenger, api: instance) - instance.flutterApi = RefreshRateFlutterApi(binaryMessenger: registrar.messenger) - instance.startMonitoring() - } - - // MARK: - RefreshRateHostApi - - func getDisplayInfo() throws -> DisplayInfoMessage { - let current = getCurrentRate() - let max = getMaxRate() - let rates = getSupportedRates() - let min = rates.min() ?? 60.0 - return DisplayInfoMessage( - currentRate: current, maxRate: max, minRate: min, - supportedRates: rates, isVariableRefreshRate: detectVRR(), - engineTargetRate: 60.0, - iosProMotionEnabled: nil, androidApiLevel: nil, - isLowPowerMode: nil, thermalStateIndex: nil, - hasAdaptiveRefreshRate: detectVRR(), - displayServer: nil, monitorCount: Int64(NSScreen.screens.count)) - } - - func enable() throws { - guard #available(macOS 14.0, *), getMaxRate() > 60 else { return } - let max = getMaxRate() - setupDisplayLink(minimum: 60.0, maximum: max, preferred: max) - } - - func disable() throws { - if #available(macOS 14.0, *) { displayLink?.invalidate(); displayLink = nil } - } - - func preferMax() throws { try enable() } - func preferDefault() throws { try enable() } - - func matchContent(fps: Double) throws { - guard #available(macOS 14.0, *) else { return } - let max = getMaxRate() - let multiple = Swift.max(1.0, (max / fps).rounded(.down)) - setupDisplayLink(minimum: fps, maximum: fps * multiple, preferred: fps * multiple) - } - - func boost(durationMs: Int64) throws { - guard #available(macOS 14.0, *) else { return } - let max = getMaxRate() - setupDisplayLink(minimum: Swift.max(60.0, max * 0.66), maximum: max, preferred: max) - DispatchQueue.main.asyncAfter(deadline: .now() + Double(durationMs) / 1000.0) { - try? self.enable() - } - } - - func setCategory(categoryIndex: Int64) throws { - switch categoryIndex { - case 3: try? enable() - case 0, 1: try? disable() - default: break - } - } - - func setTouchBoost(enabled: Bool) throws {} - - func isSupported() throws -> Bool { - if #available(macOS 14.0, *) { return getMaxRate() > 60 } - return false - } - - // MARK: - Private helpers - - @available(macOS 14.0, *) - private func setupDisplayLink(minimum: Double, maximum: Double, preferred: Double) { - displayLink?.invalidate() - guard let screen = NSScreen.main else { return } - let link = screen.displayLink(target: self, selector: #selector(displayLinkFired)) - link.preferredFrameRateRange = CAFrameRateRange( - minimum: Float(minimum), maximum: Float(maximum), preferred: Float(preferred)) - link.add(to: .main, forMode: .common) - displayLink = link - } - - @available(macOS 14.0, *) - @objc private func displayLinkFired(_ link: CADisplayLink) { - let rate = link.duration > 0 ? round(1.0 / link.duration) : 60.0 - if abs(rate - lastReportedRate) > 5.0 { - lastReportedRate = rate - let info = (try? getDisplayInfo()) ?? DisplayInfoMessage( - currentRate: rate, maxRate: rate, minRate: 60.0, - supportedRates: [60.0, rate], isVariableRefreshRate: rate > 60, - engineTargetRate: rate, iosProMotionEnabled: nil, - androidApiLevel: nil, isLowPowerMode: nil, - thermalStateIndex: nil, hasAdaptiveRefreshRate: nil, - displayServer: nil, monitorCount: Int64(NSScreen.screens.count)) - flutterApi?.onDisplayInfoChanged(info: info) { _ in } - } - } - - private func startMonitoring() { - guard !displayReconfigRegistered else { return } - CGDisplayRegisterReconfigurationCallback({ _, flags, userInfo in - guard let plugin = userInfo.flatMap({ - Unmanaged.fromOpaque($0).takeUnretainedValue() as RefreshRatePlugin? - }) else { return } - if flags.contains(.setModeFlag) { - guard let info = try? plugin.getDisplayInfo() else { return } - plugin.flutterApi?.onDisplayInfoChanged(info: info) { _ in } - } - }, Unmanaged.passUnretained(self).toOpaque()) - displayReconfigRegistered = true - if #available(macOS 14.0, *), displayLink == nil { - guard let screen = NSScreen.main else { return } - let link = screen.displayLink(target: self, selector: #selector(displayLinkFired)) - link.add(to: .main, forMode: .common) - displayLink = link - } - } - - private func getCurrentRate() -> Double { - if #available(macOS 14.0, *), let link = displayLink, link.duration > 0 { return round(1.0 / link.duration) } - if #available(macOS 12.0, *) { return Double(NSScreen.main?.maximumFramesPerSecond ?? 60) } - return getRefreshRateFromCGDisplay() - } - - private func getMaxRate() -> Double { - if #available(macOS 12.0, *) { return Double(NSScreen.main?.maximumFramesPerSecond ?? 60) } - return getSupportedRates().max() ?? 60.0 - } - - private func getSupportedRates() -> [Double] { - let id = (NSScreen.main?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID) ?? CGMainDisplayID() - guard let modes = CGDisplayCopyAllDisplayModes(id, nil) as? [CGDisplayMode] else { return [60.0] } - let rates = Set(modes.compactMap { $0.refreshRate > 0 ? round($0.refreshRate) : nil }) - return rates.isEmpty ? [60.0] : Array(rates).sorted() - } - - private func detectVRR() -> Bool { - if #available(macOS 12.0, *) { - guard let s = NSScreen.main else { return false } - return s.minimumRefreshInterval != s.maximumRefreshInterval - } - return getMaxRate() > 60 - } - - private func getRefreshRateFromCGDisplay() -> Double { - let id = CGMainDisplayID() - guard let mode = CGDisplayCopyDisplayMode(id) else { return 60.0 } - return mode.refreshRate > 0 ? mode.refreshRate : 60.0 - } -} diff --git a/third_party/refresh_rate/macos/Classes/generated/RefreshRateApi.swift b/third_party/refresh_rate/macos/Classes/generated/RefreshRateApi.swift deleted file mode 100644 index 70c230c8..00000000 --- a/third_party/refresh_rate/macos/Classes/generated/RefreshRateApi.swift +++ /dev/null @@ -1,369 +0,0 @@ -// Copyright 2026 Qoder (qoder.in). All rights reserved. -// Use of this source code is governed by a BSD-style license. -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -import Foundation - -#if os(iOS) - import Flutter -#elseif os(macOS) - import FlutterMacOS -#else - #error("Unsupported platform.") -#endif - -/// Error class for passing custom error details to Dart side. -final class PigeonError: Error { - let code: String - let message: String? - let details: Any? - - init(code: String, message: String?, details: Any?) { - self.code = code - self.message = message - self.details = details - } - - var localizedDescription: String { - return - "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" - } -} - -private func wrapResult(_ result: Any?) -> [Any?] { - return [result] -} - -private func wrapError(_ error: Any) -> [Any?] { - if let pigeonError = error as? PigeonError { - return [ - pigeonError.code, - pigeonError.message, - pigeonError.details, - ] - } - if let flutterError = error as? FlutterError { - return [ - flutterError.code, - flutterError.message, - flutterError.details, - ] - } - return [ - "\(error)", - "\(type(of: error))", - "Stacktrace: \(Thread.callStackSymbols)", - ] -} - -private func createConnectionError(withChannelName channelName: String) -> PigeonError { - return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") -} - -private func isNullish(_ value: Any?) -> Bool { - return value is NSNull || value == nil -} - -private func nilOrValue(_ value: Any?) -> T? { - if value is NSNull { return nil } - return value as! T? -} - -/// Generated class from Pigeon that represents data sent in messages. -struct DisplayInfoMessage { - var currentRate: Double? = nil - var maxRate: Double? = nil - var minRate: Double? = nil - var supportedRates: [Double?]? = nil - var isVariableRefreshRate: Bool? = nil - var engineTargetRate: Double? = nil - var iosProMotionEnabled: Bool? = nil - var androidApiLevel: Int64? = nil - var isLowPowerMode: Bool? = nil - var thermalStateIndex: Int64? = nil - var hasAdaptiveRefreshRate: Bool? = nil - var displayServer: String? = nil - var monitorCount: Int64? = nil - - - // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> DisplayInfoMessage? { - let currentRate: Double? = nilOrValue(pigeonVar_list[0]) - let maxRate: Double? = nilOrValue(pigeonVar_list[1]) - let minRate: Double? = nilOrValue(pigeonVar_list[2]) - let supportedRates: [Double?]? = nilOrValue(pigeonVar_list[3]) - let isVariableRefreshRate: Bool? = nilOrValue(pigeonVar_list[4]) - let engineTargetRate: Double? = nilOrValue(pigeonVar_list[5]) - let iosProMotionEnabled: Bool? = nilOrValue(pigeonVar_list[6]) - let androidApiLevel: Int64? = nilOrValue(pigeonVar_list[7]) - let isLowPowerMode: Bool? = nilOrValue(pigeonVar_list[8]) - let thermalStateIndex: Int64? = nilOrValue(pigeonVar_list[9]) - let hasAdaptiveRefreshRate: Bool? = nilOrValue(pigeonVar_list[10]) - let displayServer: String? = nilOrValue(pigeonVar_list[11]) - let monitorCount: Int64? = nilOrValue(pigeonVar_list[12]) - - return DisplayInfoMessage( - currentRate: currentRate, - maxRate: maxRate, - minRate: minRate, - supportedRates: supportedRates, - isVariableRefreshRate: isVariableRefreshRate, - engineTargetRate: engineTargetRate, - iosProMotionEnabled: iosProMotionEnabled, - androidApiLevel: androidApiLevel, - isLowPowerMode: isLowPowerMode, - thermalStateIndex: thermalStateIndex, - hasAdaptiveRefreshRate: hasAdaptiveRefreshRate, - displayServer: displayServer, - monitorCount: monitorCount - ) - } - func toList() -> [Any?] { - return [ - currentRate, - maxRate, - minRate, - supportedRates, - isVariableRefreshRate, - engineTargetRate, - iosProMotionEnabled, - androidApiLevel, - isLowPowerMode, - thermalStateIndex, - hasAdaptiveRefreshRate, - displayServer, - monitorCount, - ] - } -} - -private class RefreshRateApiPigeonCodecReader: FlutterStandardReader { - override func readValue(ofType type: UInt8) -> Any? { - switch type { - case 129: - return DisplayInfoMessage.fromList(self.readValue() as! [Any?]) - default: - return super.readValue(ofType: type) - } - } -} - -private class RefreshRateApiPigeonCodecWriter: FlutterStandardWriter { - override func writeValue(_ value: Any) { - if let value = value as? DisplayInfoMessage { - super.writeByte(129) - super.writeValue(value.toList()) - } else { - super.writeValue(value) - } - } -} - -private class RefreshRateApiPigeonCodecReaderWriter: FlutterStandardReaderWriter { - override func reader(with data: Data) -> FlutterStandardReader { - return RefreshRateApiPigeonCodecReader(data: data) - } - - override func writer(with data: NSMutableData) -> FlutterStandardWriter { - return RefreshRateApiPigeonCodecWriter(data: data) - } -} - -class RefreshRateApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { - static let shared = RefreshRateApiPigeonCodec(readerWriter: RefreshRateApiPigeonCodecReaderWriter()) -} - -/// Generated protocol from Pigeon that represents a handler of messages from Flutter. -protocol RefreshRateHostApi { - func getDisplayInfo() throws -> DisplayInfoMessage - func enable() throws - func disable() throws - func preferMax() throws - func preferDefault() throws - func matchContent(fps: Double) throws - func boost(durationMs: Int64) throws - func setCategory(categoryIndex: Int64) throws - func setTouchBoost(enabled: Bool) throws - func isSupported() throws -> Bool -} - -/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. -class RefreshRateHostApiSetup { - static var codec: FlutterStandardMessageCodec { RefreshRateApiPigeonCodec.shared } - /// Sets up an instance of `RefreshRateHostApi` to handle messages through the `binaryMessenger`. - static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RefreshRateHostApi?, messageChannelSuffix: String = "") { - let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - let getDisplayInfoChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - getDisplayInfoChannel.setMessageHandler { _, reply in - do { - let result = try api.getDisplayInfo() - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - getDisplayInfoChannel.setMessageHandler(nil) - } - let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - enableChannel.setMessageHandler { _, reply in - do { - try api.enable() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - enableChannel.setMessageHandler(nil) - } - let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - disableChannel.setMessageHandler { _, reply in - do { - try api.disable() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - disableChannel.setMessageHandler(nil) - } - let preferMaxChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - preferMaxChannel.setMessageHandler { _, reply in - do { - try api.preferMax() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - preferMaxChannel.setMessageHandler(nil) - } - let preferDefaultChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - preferDefaultChannel.setMessageHandler { _, reply in - do { - try api.preferDefault() - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - preferDefaultChannel.setMessageHandler(nil) - } - let matchContentChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - matchContentChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let fpsArg = args[0] as! Double - do { - try api.matchContent(fps: fpsArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - matchContentChannel.setMessageHandler(nil) - } - let boostChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - boostChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let durationMsArg = args[0] as! Int64 - do { - try api.boost(durationMs: durationMsArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - boostChannel.setMessageHandler(nil) - } - let setCategoryChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - setCategoryChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let categoryIndexArg = args[0] as! Int64 - do { - try api.setCategory(categoryIndex: categoryIndexArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - setCategoryChannel.setMessageHandler(nil) - } - let setTouchBoostChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - setTouchBoostChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let enabledArg = args[0] as! Bool - do { - try api.setTouchBoost(enabled: enabledArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) - } - } - } else { - setTouchBoostChannel.setMessageHandler(nil) - } - let isSupportedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - isSupportedChannel.setMessageHandler { _, reply in - do { - let result = try api.isSupported() - reply(wrapResult(result)) - } catch { - reply(wrapError(error)) - } - } - } else { - isSupportedChannel.setMessageHandler(nil) - } - } -} -/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. -protocol RefreshRateFlutterApiProtocol { - func onDisplayInfoChanged(info infoArg: DisplayInfoMessage, completion: @escaping (Result) -> Void) -} -class RefreshRateFlutterApi: RefreshRateFlutterApiProtocol { - private let binaryMessenger: FlutterBinaryMessenger - private let messageChannelSuffix: String - init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { - self.binaryMessenger = binaryMessenger - self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - } - var codec: RefreshRateApiPigeonCodec { - return RefreshRateApiPigeonCodec.shared - } - func onDisplayInfoChanged(info infoArg: DisplayInfoMessage, completion: @escaping (Result) -> Void) { - let channelName: String = "dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged\(messageChannelSuffix)" - let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) - channel.sendMessage([infoArg] as [Any?]) { response in - guard let listResponse = response as? [Any?] else { - completion(.failure(createConnectionError(withChannelName: channelName))) - return - } - if listResponse.count > 1 { - let code: String = listResponse[0] as! String - let message: String? = nilOrValue(listResponse[1]) - let details: String? = nilOrValue(listResponse[2]) - completion(.failure(PigeonError(code: code, message: message, details: details))) - } else { - completion(.success(Void())) - } - } - } -} diff --git a/third_party/refresh_rate/macos/refresh_rate.podspec b/third_party/refresh_rate/macos/refresh_rate.podspec deleted file mode 100644 index 0bc79d14..00000000 --- a/third_party/refresh_rate/macos/refresh_rate.podspec +++ /dev/null @@ -1,16 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'refresh_rate' - s.version = '1.0.2' - s.summary = 'Control display refresh rates in Flutter.' - s.description = <<-DESC -Cross-platform Flutter plugin to query and control display refresh rates. - DESC - s.homepage = 'https://qoder.in' - s.license = { :file => '../LICENSE' } - s.author = { 'Qoder' => 'dev@qoder.in' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'FlutterMacOS' - s.platform = :osx, '10.14' - s.swift_version = '5.0' -end diff --git a/third_party/refresh_rate/macos/refresh_rate/Package.swift b/third_party/refresh_rate/macos/refresh_rate/Package.swift deleted file mode 100644 index 24bee2af..00000000 --- a/third_party/refresh_rate/macos/refresh_rate/Package.swift +++ /dev/null @@ -1,35 +0,0 @@ -// swift-tools-version: 5.9 -// The swift-tools-version declares the minimum version of Swift required to build this package. -// -// This Package.swift file is consumed by Swift Package Manager when the plugin -// is used via the Flutter SPM integration (flutter build / flutter run with -// Swift Package Manager enabled). It mirrors the sources and settings declared -// in refresh_rate.podspec. - -import PackageDescription - -let package = Package( - name: "refresh_rate", - platforms: [ - .macOS(.v10_14), - ], - products: [ - .library(name: "refresh-rate", targets: ["refresh_rate"]), - ], - dependencies: [ - .package(name: "FlutterFramework", path: "../FlutterFramework"), - ], - targets: [ - .target( - name: "refresh_rate", - dependencies: [ - .product(name: "FlutterFramework", package: "FlutterFramework"), - ], - path: "../Classes", - publicHeadersPath: ".", - cSettings: [ - .headerSearchPath("../Classes"), - ] - ), - ] -) diff --git a/third_party/refresh_rate/pigeons/copyright.txt b/third_party/refresh_rate/pigeons/copyright.txt deleted file mode 100644 index f131242e..00000000 --- a/third_party/refresh_rate/pigeons/copyright.txt +++ /dev/null @@ -1,2 +0,0 @@ -Copyright 2026 Qoder (qoder.in). All rights reserved. -Use of this source code is governed by a BSD-style license. diff --git a/third_party/refresh_rate/pigeons/refresh_rate_api.dart b/third_party/refresh_rate/pigeons/refresh_rate_api.dart deleted file mode 100644 index 9b34f155..00000000 --- a/third_party/refresh_rate/pigeons/refresh_rate_api.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:pigeon/pigeon.dart'; - -@ConfigurePigeon(PigeonOptions( - dartOut: 'lib/src/generated/refresh_rate_api.g.dart', - dartOptions: DartOptions(), - kotlinOut: - 'android/src/main/kotlin/in/qoder/refresh_rate/generated/RefreshRateApi.kt', - kotlinOptions: KotlinOptions(package: 'in.qoder.refresh_rate.generated'), - swiftOut: - 'ios/refresh_rate/Sources/refresh_rate/generated/RefreshRateApi.swift', - swiftOptions: SwiftOptions(), - cppHeaderOut: 'windows/refresh_rate_api.g.h', - cppSourceOut: 'windows/refresh_rate_api.g.cpp', - cppOptions: CppOptions(namespace: 'refresh_rate'), - copyrightHeader: 'pigeons/copyright.txt', -)) - -// ─── Data Classes ───────────────────────────────────────────────── - -class DisplayInfoMessage { - double? currentRate; - double? maxRate; - double? minRate; - List? supportedRates; - bool? isVariableRefreshRate; - double? engineTargetRate; - bool? iosProMotionEnabled; - int? androidApiLevel; - bool? isLowPowerMode; - // 0=nominal, 1=fair, 2=serious, 3=critical, null=unknown - int? thermalStateIndex; - bool? hasAdaptiveRefreshRate; - String? displayServer; - int? monitorCount; -} - -// ─── Host API (platform → Dart calls these) ────────────────────── -// These are what the Dart side calls INTO the platform - -@HostApi() -abstract class RefreshRateHostApi { - DisplayInfoMessage getDisplayInfo(); - - // Control - void enable(); - void disable(); - void preferMax(); - void preferDefault(); - void matchContent(double fps); - void boost(int durationMs); - // 0=none, 1=low, 2=normal, 3=high - void setCategory(int categoryIndex); - void setTouchBoost(bool enabled); - - bool isSupported(); -} - -// ─── Flutter API (Dart → platform listens to these) ────────────── -// These are what the platform calls UP to Dart - -@FlutterApi() -abstract class RefreshRateFlutterApi { - void onDisplayInfoChanged(DisplayInfoMessage info); -} diff --git a/third_party/refresh_rate/pigeons/refresh_rate_api_linux.dart b/third_party/refresh_rate/pigeons/refresh_rate_api_linux.dart deleted file mode 100644 index e6f0cda9..00000000 --- a/third_party/refresh_rate/pigeons/refresh_rate_api_linux.dart +++ /dev/null @@ -1,63 +0,0 @@ -// Linux-specific pigeon schema. Regenerate with: -// dart run pigeon --input pigeons/refresh_rate_api_linux.dart \ -// --one_language \ -// --cpp_header_out linux/refresh_rate_api.g.h \ -// --cpp_source_out linux/refresh_rate_api.g.cc \ -// --cpp_namespace refresh_rate \ -// --copyright_header pigeons/copyright.txt -import 'package:pigeon/pigeon.dart'; - -@ConfigurePigeon(PigeonOptions( - cppHeaderOut: 'linux/refresh_rate_api.g.h', - cppSourceOut: 'linux/refresh_rate_api.g.cc', - cppOptions: CppOptions(namespace: 'refresh_rate'), - copyrightHeader: 'pigeons/copyright.txt', -)) - -// ─── Data Classes ───────────────────────────────────────────────── - -class DisplayInfoMessage { - double? currentRate; - double? maxRate; - double? minRate; - List? supportedRates; - bool? isVariableRefreshRate; - double? engineTargetRate; - bool? iosProMotionEnabled; - int? androidApiLevel; - bool? isLowPowerMode; - // 0=nominal, 1=fair, 2=serious, 3=critical, null=unknown - int? thermalStateIndex; - bool? hasAdaptiveRefreshRate; - String? displayServer; - int? monitorCount; -} - -// ─── Host API (platform → Dart calls these) ────────────────────── -// These are what the Dart side calls INTO the platform - -@HostApi() -abstract class RefreshRateHostApi { - DisplayInfoMessage getDisplayInfo(); - - // Control - void enable(); - void disable(); - void preferMax(); - void preferDefault(); - void matchContent(double fps); - void boost(int durationMs); - // 0=none, 1=low, 2=normal, 3=high - void setCategory(int categoryIndex); - void setTouchBoost(bool enabled); - - bool isSupported(); -} - -// ─── Flutter API (Dart → platform listens to these) ────────────── -// These are what the platform calls UP to Dart - -@FlutterApi() -abstract class RefreshRateFlutterApi { - void onDisplayInfoChanged(DisplayInfoMessage info); -} diff --git a/third_party/refresh_rate/pubspec.yaml b/third_party/refresh_rate/pubspec.yaml deleted file mode 100644 index 95860623..00000000 --- a/third_party/refresh_rate/pubspec.yaml +++ /dev/null @@ -1,61 +0,0 @@ -name: refresh_rate -description: >- - Unlock 90/120/144Hz on Android, iOS & desktop. Query display rates, control - refresh, benchmark FPS with live debug overlays. Uses modern platform APIs. -version: 1.0.2 -homepage: https://qoder.in/resources/refresh_rate -repository: https://github.com/qoder-official/refresh_rate -issue_tracker: https://github.com/qoder-official/refresh_rate/issues -documentation: https://pub.dev/documentation/refresh_rate/latest/ - -topics: - - display - - performance - - animation - - debug - - ui - -screenshots: - - description: "Live FPS overlay showing 120 FPS with frame budget diagnostics" - path: screenshots/fps.png - - description: "Hz badge confirming 120Hz refresh rate on a real device" - path: screenshots/hz.png - -funding: - - https://github.com/sponsors/qoder-official - -environment: - sdk: ^3.5.0 - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - flutter_web_plugins: - sdk: flutter - plugin_platform_interface: ^2.1.7 - web: ^1.1.0 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^5.0.0 - pigeon: ^22.0.0 - -flutter: - plugin: - platforms: - android: - package: in.qoder.refresh_rate - pluginClass: RefreshRatePlugin - ios: - pluginClass: RefreshRatePlugin - linux: - pluginClass: RefreshRatePlugin - macos: - pluginClass: RefreshRatePlugin - windows: - pluginClass: RefreshRatePluginCApi - web: - pluginClass: RefreshRateWeb - fileName: refresh_rate_web.dart diff --git a/third_party/refresh_rate/screenshots/fps.png b/third_party/refresh_rate/screenshots/fps.png deleted file mode 100644 index 1658d85f..00000000 Binary files a/third_party/refresh_rate/screenshots/fps.png and /dev/null differ diff --git a/third_party/refresh_rate/screenshots/hz.png b/third_party/refresh_rate/screenshots/hz.png deleted file mode 100644 index 261465ca..00000000 Binary files a/third_party/refresh_rate/screenshots/hz.png and /dev/null differ diff --git a/third_party/refresh_rate/test/fps_tracker_test.dart b/third_party/refresh_rate/test/fps_tracker_test.dart deleted file mode 100644 index 17c420c6..00000000 --- a/third_party/refresh_rate/test/fps_tracker_test.dart +++ /dev/null @@ -1,226 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:refresh_rate/src/verification/fps_tracker.dart'; -import 'package:refresh_rate/src/verification/refresh_rate_session.dart'; -import 'package:refresh_rate/src/verification/session_scorer.dart'; -import 'package:refresh_rate/src/models/display_info.dart'; -import 'package:refresh_rate/src/models/enums.dart'; -import 'package:refresh_rate/src/models/session_report.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('FpsTracker', () { - test('starts with no samples', () { - final tracker = FpsTracker(); - expect(tracker.sampleCount, 0); - expect(tracker.avgFps, 0.0); - }); - - test('addTimings accumulates samples', () { - final tracker = FpsTracker(); - tracker.addTimings(_fakeTimings(10, 8333)); - expect(tracker.sampleCount, 10); - }); - - test('avgFps is reasonable for 120fps timings', () { - final tracker = FpsTracker(); - tracker.addTimings(_fakeTimings(120, 8333)); - // 119 intervals * 1e6 / (119 * 8333µs) ≈ 120fps - expect(tracker.avgFps, closeTo(120.0, 5.0)); - }); - - test('reset clears all samples', () { - final tracker = FpsTracker(); - tracker.addTimings(_fakeTimings(10, 8333)); - tracker.reset(); - expect(tracker.sampleCount, 0); - }); - - test('onePercentLow is less than or equal to avgFps', () { - final tracker = FpsTracker(); - tracker.addTimings(_fakeTimingSequence([ - ...List.filled(100, 8333), - ...List.filled(10, 33333), - ])); - expect(tracker.onePercentLowFps, lessThanOrEqualTo(tracker.avgFps)); - }); - - test('jankyFrameCount counts frames over budget', () { - final tracker = FpsTracker(); - // At 60fps, budget = ~16667µs. Add 5 frames over budget. - tracker.addTimings(_fakeTimingSequence([ - ...List.filled(10, 8333), - ...List.filled(5, 20000), - ])); - expect(tracker.jankyFrameCount(60.0), 5); - }); - - test('missedFramePercent is 0 when no jank', () { - final tracker = FpsTracker(); - tracker.addTimings(_fakeTimings(100, 8333)); - expect(tracker.missedFramePercent(120.0), 0.0); - }); - - test('onePercentLowFps works for small sample count', () { - final tracker = FpsTracker(); - // 10 frames: 9 fast + 1 slow. With floor cutoff, 1 slow frame is captured. - tracker.addTimings(_fakeTimingSequence([ - ...List.filled(9, 8333), - ...List.filled(1, 33333), - ])); - // onePercentLow should be close to 30fps (the slow frame), not avgFps - expect(tracker.onePercentLowFps, lessThan(tracker.avgFps)); - }); - }); - - group('SessionScorer.compute', () { - test('returns inconclusive for zero-frame session', () { - final report = SessionScorer.compute( - sessionName: 'empty', - tracker: FpsTracker(), - targetHz: 120.0, - validDuration: Duration(seconds: 1), - excludedDuration: Duration.zero, - exclusionReasons: {}, - deviceState: DeviceStateSnapshot(thermalState: ThermalState.nominal), - ); - expect(report.verdict, Verdict.inconclusive); - }); - - test('returns excellent for smooth 120fps session', () { - final tracker = FpsTracker(); - tracker.addTimings(_fakeTimings(120, 8333)); - final report = SessionScorer.compute( - sessionName: 'smooth', - tracker: tracker, - targetHz: 120.0, - validDuration: Duration(seconds: 1), - excludedDuration: Duration.zero, - exclusionReasons: {}, - deviceState: DeviceStateSnapshot(thermalState: ThermalState.nominal), - ); - expect(report.verdict, Verdict.excellent); - }); - - test('returns powerLimited bottleneck when LPM is true', () { - final tracker = FpsTracker(); - tracker.addTimings(_fakeTimings(60, 8333)); - final report = SessionScorer.compute( - sessionName: 'lpm', - tracker: tracker, - targetHz: 120.0, - validDuration: Duration(seconds: 1), - excludedDuration: Duration.zero, - exclusionReasons: {}, - deviceState: DeviceStateSnapshot( - thermalState: ThermalState.nominal, - isLowPowerMode: true, - ), - ); - expect(report.likelyBottleneck, Bottleneck.powerLimited); - }); - - test('returns displayCapped for 88% fps', () { - final tracker = FpsTracker(); - // 88% of 120fps ≈ 105.6fps. Frame interval: 1_000_000 / 105.6 ≈ 9470µs - tracker.addTimings(_fakeTimings(100, 9470)); - final report = SessionScorer.compute( - sessionName: 'capped', - tracker: tracker, - targetHz: 120.0, - validDuration: Duration(seconds: 1), - excludedDuration: Duration.zero, - exclusionReasons: {}, - deviceState: DeviceStateSnapshot(thermalState: ThermalState.nominal), - ); - expect(report.likelyBottleneck, Bottleneck.displayCapped); - }); - }); - - group('RefreshRateSession', () { - test('starts in running state', () { - WidgetsFlutterBinding.ensureInitialized(); - final info = DisplayInfo( - currentRate: 120.0, - maxRate: 120.0, - minRate: 60.0, - supportedRates: [60.0, 120.0], - isVariableRefreshRate: true, - engineTargetRate: 120.0, - thermalState: ThermalState.nominal, - ); - final session = RefreshRateSession.create('test', info); - expect(session.state, SessionState.running); - expect(session.name, 'test'); - }); - - test('end() returns a SessionReport', () async { - WidgetsFlutterBinding.ensureInitialized(); - final info = DisplayInfo( - currentRate: 120.0, - maxRate: 120.0, - minRate: 60.0, - supportedRates: [60.0, 120.0], - isVariableRefreshRate: true, - engineTargetRate: 120.0, - thermalState: ThermalState.nominal, - ); - final session = RefreshRateSession.create('scroll_test', info); - final report = await session.end(); - expect(report.sessionName, 'scroll_test'); - expect(report.targetHz, 120.0); - expect(report.verdict, isA()); - }); - }); -} - -/// Builds a list of fake timings with proper sequential vsync timestamps. -/// Each frame's vsync = sum of all preceding frame durations. -List _fakeTimingSequence(List durationsUs) { - int vsync = 0; - return durationsUs.map((dur) { - final t = _MockFrameTiming(dur, vsync); - vsync += dur; - return t as FrameTiming; - }).toList(); -} - -List _fakeTimings(int count, int intervalUs) => - _fakeTimingSequence(List.filled(count, intervalUs)); - -class _MockFrameTiming implements FrameTiming { - final int _totalUs; - final int _vsyncUs; - _MockFrameTiming(this._totalUs, this._vsyncUs); - - @override - Duration get buildDuration => - Duration(microseconds: (_totalUs * 0.6).toInt()); - @override - Duration get rasterDuration => - Duration(microseconds: (_totalUs * 0.4).toInt()); - @override - Duration get totalSpan => Duration(microseconds: _totalUs); - @override - int get frameNumber => 0; - @override - int get layerCacheCount => 0; - @override - int get layerCacheBytes => 0; - @override - int get pictureCacheCount => 0; - @override - int get pictureCacheBytes => 0; - @override - int timestampInMicroseconds(FramePhase phase) { - if (phase == FramePhase.vsyncStart) return _vsyncUs; - return 0; - } - - // Forward any additional SDK-version-specific members gracefully. - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} diff --git a/third_party/refresh_rate/test/models_test.dart b/third_party/refresh_rate/test/models_test.dart deleted file mode 100644 index a9894511..00000000 --- a/third_party/refresh_rate/test/models_test.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:refresh_rate/src/models/enums.dart'; -import 'package:refresh_rate/src/models/display_info.dart'; -import 'package:refresh_rate/src/models/session_report.dart'; -import 'package:refresh_rate/src/generated/refresh_rate_api.g.dart'; - -void main() { - group('DisplayInfo.fromMessage', () { - test('maps all fields correctly', () { - final msg = DisplayInfoMessage( - currentRate: 120.0, - maxRate: 120.0, - minRate: 60.0, - supportedRates: [60.0, 120.0], - isVariableRefreshRate: true, - engineTargetRate: 120.0, - iosProMotionEnabled: true, - androidApiLevel: null, - isLowPowerMode: false, - thermalStateIndex: 0, - hasAdaptiveRefreshRate: true, - displayServer: null, - monitorCount: null, - ); - final info = DisplayInfo.fromMessage(msg); - expect(info.currentRate, 120.0); - expect(info.thermalState, ThermalState.nominal); - expect(info.isVariableRefreshRate, true); - }); - - test('handles null thermalStateIndex as unknown', () { - final msg = DisplayInfoMessage( - currentRate: 60.0, - maxRate: 60.0, - minRate: 60.0, - supportedRates: [60.0], - isVariableRefreshRate: false, - engineTargetRate: 60.0, - thermalStateIndex: null, - ); - final info = DisplayInfo.fromMessage(msg); - expect(info.thermalState, ThermalState.unknown); - }); - }); - - group('ThermalState', () { - test('fromIndex maps correctly', () { - expect(ThermalState.fromIndex(0), ThermalState.nominal); - expect(ThermalState.fromIndex(1), ThermalState.fair); - expect(ThermalState.fromIndex(2), ThermalState.serious); - expect(ThermalState.fromIndex(3), ThermalState.critical); - expect(ThermalState.fromIndex(null), ThermalState.unknown); - }); - }); - - group('SessionReport.toCsv', () { - test('escapes commas in sessionName', () { - final report = SessionReport( - sessionName: 'feed, scroll', - verdict: Verdict.good, - likelyBottleneck: Bottleneck.none, - targetHz: 120.0, - observedAvgHz: 118.0, - frameBudgetMs: 8.33, - avgFps: 118.0, - onePercentLowFps: 90.0, - fivePercentLowFps: 100.0, - avgBuildMs: 3.0, - avgRasterMs: 4.0, - avgTotalFrameMs: 7.0, - jankyFrameCount: 2, - severeJankCount: 0, - missedFramePercent: 1.5, - validDuration: Duration(seconds: 5), - excludedDuration: Duration.zero, - exclusionReasons: {}, - deviceState: DeviceStateSnapshot(thermalState: ThermalState.nominal), - ); - final csv = report.toCsv(); - final lines = csv.split('\n'); - expect(lines.length, 2); - // The escaped session name should appear in the values row - expect(lines[1], contains('"feed, scroll"')); - }); - }); -} diff --git a/third_party/refresh_rate/test/refresh_rate_api_test.dart b/third_party/refresh_rate/test/refresh_rate_api_test.dart deleted file mode 100644 index 9408ae93..00000000 --- a/third_party/refresh_rate/test/refresh_rate_api_test.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:refresh_rate/refresh_rate.dart'; -import 'package:refresh_rate/src/generated/refresh_rate_api.g.dart'; -import 'package:refresh_rate/src/refresh_rate_api_adapter.dart'; - -class _FakeHostApi implements RefreshRateApiAdapter { - final calls = []; - @override - DisplayInfoMessage getDisplayInfo() => DisplayInfoMessage( - currentRate: 120.0, - maxRate: 120.0, - minRate: 60.0, - supportedRates: [60.0, 120.0], - isVariableRefreshRate: true, - engineTargetRate: 120.0, - thermalStateIndex: 0, - ); - - @override - void enable() { - calls.add('enable'); - } - - @override - void disable() { - calls.add('disable'); - } - - @override - void preferMax() { - calls.add('preferMax'); - } - - @override - void preferDefault() { - calls.add('preferDefault'); - } - - @override - void matchContent(double fps) { - calls.add('matchContent:$fps'); - } - - @override - void boost(int durationMs) { - calls.add('boost:$durationMs'); - } - - @override - void setCategory(int c) { - calls.add('setCategory:$c'); - } - - @override - void setTouchBoost(bool e) { - calls.add('setTouchBoost:$e'); - } - - @override - bool isSupported() => true; -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - late _FakeHostApi fakeApi; - - setUp(() { - fakeApi = _FakeHostApi(); - RefreshRate.setApiForTesting(fakeApi); - }); - - tearDown(() => RefreshRate.clearApiForTesting()); - - test('enable() calls platform enable', () async { - RefreshRate.enable(); - expect(fakeApi.calls, contains('enable')); - }); - - test('preferMax() calls platform preferMax', () { - RefreshRate.preferMax(); - expect(fakeApi.calls, contains('preferMax')); - }); - - test('matchContent passes fps to platform', () { - RefreshRate.matchContent(24.0); - expect(fakeApi.calls, contains('matchContent:24.0')); - }); - - test('category(high) calls setCategory(3)', () { - RefreshRate.category(RateCategory.high); - expect(fakeApi.calls, contains('setCategory:3')); - }); - - test('info returns fallback before first fetch', () { - expect(RefreshRate.info.currentRate, isA()); - }); -} diff --git a/third_party/refresh_rate/windows/CMakeLists.txt b/third_party/refresh_rate/windows/CMakeLists.txt deleted file mode 100644 index cd093e07..00000000 --- a/third_party/refresh_rate/windows/CMakeLists.txt +++ /dev/null @@ -1,18 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -set(PROJECT_NAME "refresh_rate") -project(${PROJECT_NAME} LANGUAGES CXX) - -set(PLUGIN_NAME "refresh_rate_plugin") - -add_library(${PLUGIN_NAME} SHARED - "refresh_rate_plugin_c_api.cpp" - "refresh_rate_api.g.cpp" -) - -apply_standard_settings(${PLUGIN_NAME}) -set_target_properties(${PLUGIN_NAME} PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) -target_include_directories(${PLUGIN_NAME} INTERFACE - "${CMAKE_CURRENT_SOURCE_DIR}/include") -target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) diff --git a/third_party/refresh_rate/windows/include/refresh_rate/refresh_rate_plugin_c_api.h b/third_party/refresh_rate/windows/include/refresh_rate/refresh_rate_plugin_c_api.h deleted file mode 100644 index a4145a41..00000000 --- a/third_party/refresh_rate/windows/include/refresh_rate/refresh_rate_plugin_c_api.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifndef FLUTTER_PLUGIN_REFRESH_RATE_PLUGIN_C_API_H_ -#define FLUTTER_PLUGIN_REFRESH_RATE_PLUGIN_C_API_H_ - -#include - -#ifdef FLUTTER_PLUGIN_IMPL -#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) -#else -#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) -#endif - -#if defined(__cplusplus) -extern "C" { -#endif - -FLUTTER_PLUGIN_EXPORT void RefreshRatePluginCApiRegisterWithRegistrar( - FlutterDesktopPluginRegistrarRef registrar); - -#if defined(__cplusplus) -} -#endif - -#endif // FLUTTER_PLUGIN_REFRESH_RATE_PLUGIN_C_API_H_ diff --git a/third_party/refresh_rate/windows/refresh_rate_api.g.cpp b/third_party/refresh_rate/windows/refresh_rate_api.g.cpp deleted file mode 100644 index f4911e8a..00000000 --- a/third_party/refresh_rate/windows/refresh_rate_api.g.cpp +++ /dev/null @@ -1,651 +0,0 @@ -// Copyright 2026 Qoder (qoder.in). All rights reserved. -// Use of this source code is governed by a BSD-style license. -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -#undef _HAS_EXCEPTIONS - -#include "refresh_rate_api.g.h" - -#include -#include -#include -#include - -#include -#include -#include - -namespace refresh_rate { -using flutter::BasicMessageChannel; -using flutter::CustomEncodableValue; -using flutter::EncodableList; -using flutter::EncodableMap; -using flutter::EncodableValue; - -FlutterError CreateConnectionError(const std::string channel_name) { - return FlutterError( - "channel-error", - "Unable to establish connection on channel: '" + channel_name + "'.", - EncodableValue("")); -} - -// DisplayInfoMessage - -DisplayInfoMessage::DisplayInfoMessage() {} - -DisplayInfoMessage::DisplayInfoMessage( - const double* current_rate, - const double* max_rate, - const double* min_rate, - const EncodableList* supported_rates, - const bool* is_variable_refresh_rate, - const double* engine_target_rate, - const bool* ios_pro_motion_enabled, - const int64_t* android_api_level, - const bool* is_low_power_mode, - const int64_t* thermal_state_index, - const bool* has_adaptive_refresh_rate, - const std::string* display_server, - const int64_t* monitor_count) - : current_rate_(current_rate ? std::optional(*current_rate) : std::nullopt), - max_rate_(max_rate ? std::optional(*max_rate) : std::nullopt), - min_rate_(min_rate ? std::optional(*min_rate) : std::nullopt), - supported_rates_(supported_rates ? std::optional(*supported_rates) : std::nullopt), - is_variable_refresh_rate_(is_variable_refresh_rate ? std::optional(*is_variable_refresh_rate) : std::nullopt), - engine_target_rate_(engine_target_rate ? std::optional(*engine_target_rate) : std::nullopt), - ios_pro_motion_enabled_(ios_pro_motion_enabled ? std::optional(*ios_pro_motion_enabled) : std::nullopt), - android_api_level_(android_api_level ? std::optional(*android_api_level) : std::nullopt), - is_low_power_mode_(is_low_power_mode ? std::optional(*is_low_power_mode) : std::nullopt), - thermal_state_index_(thermal_state_index ? std::optional(*thermal_state_index) : std::nullopt), - has_adaptive_refresh_rate_(has_adaptive_refresh_rate ? std::optional(*has_adaptive_refresh_rate) : std::nullopt), - display_server_(display_server ? std::optional(*display_server) : std::nullopt), - monitor_count_(monitor_count ? std::optional(*monitor_count) : std::nullopt) {} - -const double* DisplayInfoMessage::current_rate() const { - return current_rate_ ? &(*current_rate_) : nullptr; -} - -void DisplayInfoMessage::set_current_rate(const double* value_arg) { - current_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_current_rate(double value_arg) { - current_rate_ = value_arg; -} - - -const double* DisplayInfoMessage::max_rate() const { - return max_rate_ ? &(*max_rate_) : nullptr; -} - -void DisplayInfoMessage::set_max_rate(const double* value_arg) { - max_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_max_rate(double value_arg) { - max_rate_ = value_arg; -} - - -const double* DisplayInfoMessage::min_rate() const { - return min_rate_ ? &(*min_rate_) : nullptr; -} - -void DisplayInfoMessage::set_min_rate(const double* value_arg) { - min_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_min_rate(double value_arg) { - min_rate_ = value_arg; -} - - -const EncodableList* DisplayInfoMessage::supported_rates() const { - return supported_rates_ ? &(*supported_rates_) : nullptr; -} - -void DisplayInfoMessage::set_supported_rates(const EncodableList* value_arg) { - supported_rates_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_supported_rates(const EncodableList& value_arg) { - supported_rates_ = value_arg; -} - - -const bool* DisplayInfoMessage::is_variable_refresh_rate() const { - return is_variable_refresh_rate_ ? &(*is_variable_refresh_rate_) : nullptr; -} - -void DisplayInfoMessage::set_is_variable_refresh_rate(const bool* value_arg) { - is_variable_refresh_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_is_variable_refresh_rate(bool value_arg) { - is_variable_refresh_rate_ = value_arg; -} - - -const double* DisplayInfoMessage::engine_target_rate() const { - return engine_target_rate_ ? &(*engine_target_rate_) : nullptr; -} - -void DisplayInfoMessage::set_engine_target_rate(const double* value_arg) { - engine_target_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_engine_target_rate(double value_arg) { - engine_target_rate_ = value_arg; -} - - -const bool* DisplayInfoMessage::ios_pro_motion_enabled() const { - return ios_pro_motion_enabled_ ? &(*ios_pro_motion_enabled_) : nullptr; -} - -void DisplayInfoMessage::set_ios_pro_motion_enabled(const bool* value_arg) { - ios_pro_motion_enabled_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_ios_pro_motion_enabled(bool value_arg) { - ios_pro_motion_enabled_ = value_arg; -} - - -const int64_t* DisplayInfoMessage::android_api_level() const { - return android_api_level_ ? &(*android_api_level_) : nullptr; -} - -void DisplayInfoMessage::set_android_api_level(const int64_t* value_arg) { - android_api_level_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_android_api_level(int64_t value_arg) { - android_api_level_ = value_arg; -} - - -const bool* DisplayInfoMessage::is_low_power_mode() const { - return is_low_power_mode_ ? &(*is_low_power_mode_) : nullptr; -} - -void DisplayInfoMessage::set_is_low_power_mode(const bool* value_arg) { - is_low_power_mode_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_is_low_power_mode(bool value_arg) { - is_low_power_mode_ = value_arg; -} - - -const int64_t* DisplayInfoMessage::thermal_state_index() const { - return thermal_state_index_ ? &(*thermal_state_index_) : nullptr; -} - -void DisplayInfoMessage::set_thermal_state_index(const int64_t* value_arg) { - thermal_state_index_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_thermal_state_index(int64_t value_arg) { - thermal_state_index_ = value_arg; -} - - -const bool* DisplayInfoMessage::has_adaptive_refresh_rate() const { - return has_adaptive_refresh_rate_ ? &(*has_adaptive_refresh_rate_) : nullptr; -} - -void DisplayInfoMessage::set_has_adaptive_refresh_rate(const bool* value_arg) { - has_adaptive_refresh_rate_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_has_adaptive_refresh_rate(bool value_arg) { - has_adaptive_refresh_rate_ = value_arg; -} - - -const std::string* DisplayInfoMessage::display_server() const { - return display_server_ ? &(*display_server_) : nullptr; -} - -void DisplayInfoMessage::set_display_server(const std::string_view* value_arg) { - display_server_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_display_server(std::string_view value_arg) { - display_server_ = value_arg; -} - - -const int64_t* DisplayInfoMessage::monitor_count() const { - return monitor_count_ ? &(*monitor_count_) : nullptr; -} - -void DisplayInfoMessage::set_monitor_count(const int64_t* value_arg) { - monitor_count_ = value_arg ? std::optional(*value_arg) : std::nullopt; -} - -void DisplayInfoMessage::set_monitor_count(int64_t value_arg) { - monitor_count_ = value_arg; -} - - -EncodableList DisplayInfoMessage::ToEncodableList() const { - EncodableList list; - list.reserve(13); - list.push_back(current_rate_ ? EncodableValue(*current_rate_) : EncodableValue()); - list.push_back(max_rate_ ? EncodableValue(*max_rate_) : EncodableValue()); - list.push_back(min_rate_ ? EncodableValue(*min_rate_) : EncodableValue()); - list.push_back(supported_rates_ ? EncodableValue(*supported_rates_) : EncodableValue()); - list.push_back(is_variable_refresh_rate_ ? EncodableValue(*is_variable_refresh_rate_) : EncodableValue()); - list.push_back(engine_target_rate_ ? EncodableValue(*engine_target_rate_) : EncodableValue()); - list.push_back(ios_pro_motion_enabled_ ? EncodableValue(*ios_pro_motion_enabled_) : EncodableValue()); - list.push_back(android_api_level_ ? EncodableValue(*android_api_level_) : EncodableValue()); - list.push_back(is_low_power_mode_ ? EncodableValue(*is_low_power_mode_) : EncodableValue()); - list.push_back(thermal_state_index_ ? EncodableValue(*thermal_state_index_) : EncodableValue()); - list.push_back(has_adaptive_refresh_rate_ ? EncodableValue(*has_adaptive_refresh_rate_) : EncodableValue()); - list.push_back(display_server_ ? EncodableValue(*display_server_) : EncodableValue()); - list.push_back(monitor_count_ ? EncodableValue(*monitor_count_) : EncodableValue()); - return list; -} - -DisplayInfoMessage DisplayInfoMessage::FromEncodableList(const EncodableList& list) { - DisplayInfoMessage decoded; - auto& encodable_current_rate = list[0]; - if (!encodable_current_rate.IsNull()) { - decoded.set_current_rate(std::get(encodable_current_rate)); - } - auto& encodable_max_rate = list[1]; - if (!encodable_max_rate.IsNull()) { - decoded.set_max_rate(std::get(encodable_max_rate)); - } - auto& encodable_min_rate = list[2]; - if (!encodable_min_rate.IsNull()) { - decoded.set_min_rate(std::get(encodable_min_rate)); - } - auto& encodable_supported_rates = list[3]; - if (!encodable_supported_rates.IsNull()) { - decoded.set_supported_rates(std::get(encodable_supported_rates)); - } - auto& encodable_is_variable_refresh_rate = list[4]; - if (!encodable_is_variable_refresh_rate.IsNull()) { - decoded.set_is_variable_refresh_rate(std::get(encodable_is_variable_refresh_rate)); - } - auto& encodable_engine_target_rate = list[5]; - if (!encodable_engine_target_rate.IsNull()) { - decoded.set_engine_target_rate(std::get(encodable_engine_target_rate)); - } - auto& encodable_ios_pro_motion_enabled = list[6]; - if (!encodable_ios_pro_motion_enabled.IsNull()) { - decoded.set_ios_pro_motion_enabled(std::get(encodable_ios_pro_motion_enabled)); - } - auto& encodable_android_api_level = list[7]; - if (!encodable_android_api_level.IsNull()) { - decoded.set_android_api_level(std::get(encodable_android_api_level)); - } - auto& encodable_is_low_power_mode = list[8]; - if (!encodable_is_low_power_mode.IsNull()) { - decoded.set_is_low_power_mode(std::get(encodable_is_low_power_mode)); - } - auto& encodable_thermal_state_index = list[9]; - if (!encodable_thermal_state_index.IsNull()) { - decoded.set_thermal_state_index(std::get(encodable_thermal_state_index)); - } - auto& encodable_has_adaptive_refresh_rate = list[10]; - if (!encodable_has_adaptive_refresh_rate.IsNull()) { - decoded.set_has_adaptive_refresh_rate(std::get(encodable_has_adaptive_refresh_rate)); - } - auto& encodable_display_server = list[11]; - if (!encodable_display_server.IsNull()) { - decoded.set_display_server(std::get(encodable_display_server)); - } - auto& encodable_monitor_count = list[12]; - if (!encodable_monitor_count.IsNull()) { - decoded.set_monitor_count(std::get(encodable_monitor_count)); - } - return decoded; -} - - -PigeonInternalCodecSerializer::PigeonInternalCodecSerializer() {} - -EncodableValue PigeonInternalCodecSerializer::ReadValueOfType( - uint8_t type, - flutter::ByteStreamReader* stream) const { - switch (type) { - case 129: { - return CustomEncodableValue(DisplayInfoMessage::FromEncodableList(std::get(ReadValue(stream)))); - } - default: - return flutter::StandardCodecSerializer::ReadValueOfType(type, stream); - } -} - -void PigeonInternalCodecSerializer::WriteValue( - const EncodableValue& value, - flutter::ByteStreamWriter* stream) const { - if (const CustomEncodableValue* custom_value = std::get_if(&value)) { - if (custom_value->type() == typeid(DisplayInfoMessage)) { - stream->WriteByte(129); - WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); - return; - } - } - flutter::StandardCodecSerializer::WriteValue(value, stream); -} - -/// The codec used by RefreshRateHostApi. -const flutter::StandardMessageCodec& RefreshRateHostApi::GetCodec() { - return flutter::StandardMessageCodec::GetInstance(&PigeonInternalCodecSerializer::GetInstance()); -} - -// Sets up an instance of `RefreshRateHostApi` to handle messages through the `binary_messenger`. -void RefreshRateHostApi::SetUp( - flutter::BinaryMessenger* binary_messenger, - RefreshRateHostApi* api) { - RefreshRateHostApi::SetUp(binary_messenger, api, ""); -} - -void RefreshRateHostApi::SetUp( - flutter::BinaryMessenger* binary_messenger, - RefreshRateHostApi* api, - const std::string& message_channel_suffix) { - const std::string prepended_suffix = message_channel_suffix.length() > 0 ? std::string(".") + message_channel_suffix : ""; - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.getDisplayInfo" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - ErrorOr output = api->GetDisplayInfo(); - if (output.has_error()) { - reply(WrapError(output.error())); - return; - } - EncodableList wrapped; - wrapped.push_back(CustomEncodableValue(std::move(output).TakeValue())); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.enable" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - std::optional output = api->Enable(); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.disable" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - std::optional output = api->Disable(); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferMax" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - std::optional output = api->PreferMax(); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.preferDefault" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - std::optional output = api->PreferDefault(); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.matchContent" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - const auto& args = std::get(message); - const auto& encodable_fps_arg = args.at(0); - if (encodable_fps_arg.IsNull()) { - reply(WrapError("fps_arg unexpectedly null.")); - return; - } - const auto& fps_arg = std::get(encodable_fps_arg); - std::optional output = api->MatchContent(fps_arg); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.boost" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - const auto& args = std::get(message); - const auto& encodable_duration_ms_arg = args.at(0); - if (encodable_duration_ms_arg.IsNull()) { - reply(WrapError("duration_ms_arg unexpectedly null.")); - return; - } - const int64_t duration_ms_arg = encodable_duration_ms_arg.LongValue(); - std::optional output = api->Boost(duration_ms_arg); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setCategory" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - const auto& args = std::get(message); - const auto& encodable_category_index_arg = args.at(0); - if (encodable_category_index_arg.IsNull()) { - reply(WrapError("category_index_arg unexpectedly null.")); - return; - } - const int64_t category_index_arg = encodable_category_index_arg.LongValue(); - std::optional output = api->SetCategory(category_index_arg); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.setTouchBoost" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - const auto& args = std::get(message); - const auto& encodable_enabled_arg = args.at(0); - if (encodable_enabled_arg.IsNull()) { - reply(WrapError("enabled_arg unexpectedly null.")); - return; - } - const auto& enabled_arg = std::get(encodable_enabled_arg); - std::optional output = api->SetTouchBoost(enabled_arg); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } - { - BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.refresh_rate.RefreshRateHostApi.isSupported" + prepended_suffix, &GetCodec()); - if (api != nullptr) { - channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { - try { - ErrorOr output = api->IsSupported(); - if (output.has_error()) { - reply(WrapError(output.error())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue(std::move(output).TakeValue())); - reply(EncodableValue(std::move(wrapped))); - } catch (const std::exception& exception) { - reply(WrapError(exception.what())); - } - }); - } else { - channel.SetMessageHandler(nullptr); - } - } -} - -EncodableValue RefreshRateHostApi::WrapError(std::string_view error_message) { - return EncodableValue(EncodableList{ - EncodableValue(std::string(error_message)), - EncodableValue("Error"), - EncodableValue() - }); -} - -EncodableValue RefreshRateHostApi::WrapError(const FlutterError& error) { - return EncodableValue(EncodableList{ - EncodableValue(error.code()), - EncodableValue(error.message()), - error.details() - }); -} - -// Generated class from Pigeon that represents Flutter messages that can be called from C++. -RefreshRateFlutterApi::RefreshRateFlutterApi(flutter::BinaryMessenger* binary_messenger) - : binary_messenger_(binary_messenger), - message_channel_suffix_("") {} - -RefreshRateFlutterApi::RefreshRateFlutterApi( - flutter::BinaryMessenger* binary_messenger, - const std::string& message_channel_suffix) - : binary_messenger_(binary_messenger), - message_channel_suffix_(message_channel_suffix.length() > 0 ? std::string(".") + message_channel_suffix : "") {} - -const flutter::StandardMessageCodec& RefreshRateFlutterApi::GetCodec() { - return flutter::StandardMessageCodec::GetInstance(&PigeonInternalCodecSerializer::GetInstance()); -} - -void RefreshRateFlutterApi::OnDisplayInfoChanged( - const DisplayInfoMessage& info_arg, - std::function&& on_success, - std::function&& on_error) { - const std::string channel_name = "dev.flutter.pigeon.refresh_rate.RefreshRateFlutterApi.onDisplayInfoChanged" + message_channel_suffix_; - BasicMessageChannel<> channel(binary_messenger_, channel_name, &GetCodec()); - EncodableValue encoded_api_arguments = EncodableValue(EncodableList{ - CustomEncodableValue(info_arg), - }); - channel.Send(encoded_api_arguments, [channel_name, on_success = std::move(on_success), on_error = std::move(on_error)](const uint8_t* reply, size_t reply_size) { - std::unique_ptr response = GetCodec().DecodeMessage(reply, reply_size); - const auto& encodable_return_value = *response; - const auto* list_return_value = std::get_if(&encodable_return_value); - if (list_return_value) { - if (list_return_value->size() > 1) { - on_error(FlutterError(std::get(list_return_value->at(0)), std::get(list_return_value->at(1)), list_return_value->at(2))); - } else { - on_success(); - } - } else { - on_error(CreateConnectionError(channel_name)); - } - }); -} - -} // namespace refresh_rate diff --git a/third_party/refresh_rate/windows/refresh_rate_api.g.h b/third_party/refresh_rate/windows/refresh_rate_api.g.h deleted file mode 100644 index 70ef60a0..00000000 --- a/third_party/refresh_rate/windows/refresh_rate_api.g.h +++ /dev/null @@ -1,233 +0,0 @@ -// Copyright 2026 Qoder (qoder.in). All rights reserved. -// Use of this source code is governed by a BSD-style license. -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -#ifndef PIGEON_REFRESH_RATE_API_G_H_ -#define PIGEON_REFRESH_RATE_API_G_H_ -#include -#include -#include -#include - -#include -#include -#include - -namespace refresh_rate { - - -// Generated class from Pigeon. - -class FlutterError { - public: - explicit FlutterError(const std::string& code) - : code_(code) {} - explicit FlutterError(const std::string& code, const std::string& message) - : code_(code), message_(message) {} - explicit FlutterError(const std::string& code, const std::string& message, const flutter::EncodableValue& details) - : code_(code), message_(message), details_(details) {} - - const std::string& code() const { return code_; } - const std::string& message() const { return message_; } - const flutter::EncodableValue& details() const { return details_; } - - private: - std::string code_; - std::string message_; - flutter::EncodableValue details_; -}; - -template class ErrorOr { - public: - ErrorOr(const T& rhs) : v_(rhs) {} - ErrorOr(const T&& rhs) : v_(std::move(rhs)) {} - ErrorOr(const FlutterError& rhs) : v_(rhs) {} - ErrorOr(const FlutterError&& rhs) : v_(std::move(rhs)) {} - - bool has_error() const { return std::holds_alternative(v_); } - const T& value() const { return std::get(v_); }; - const FlutterError& error() const { return std::get(v_); }; - - private: - friend class RefreshRateHostApi; - friend class RefreshRateFlutterApi; - ErrorOr() = default; - T TakeValue() && { return std::get(std::move(v_)); } - - std::variant v_; -}; - - - -// Generated class from Pigeon that represents data sent in messages. -class DisplayInfoMessage { - public: - // Constructs an object setting all non-nullable fields. - DisplayInfoMessage(); - - // Constructs an object setting all fields. - explicit DisplayInfoMessage( - const double* current_rate, - const double* max_rate, - const double* min_rate, - const flutter::EncodableList* supported_rates, - const bool* is_variable_refresh_rate, - const double* engine_target_rate, - const bool* ios_pro_motion_enabled, - const int64_t* android_api_level, - const bool* is_low_power_mode, - const int64_t* thermal_state_index, - const bool* has_adaptive_refresh_rate, - const std::string* display_server, - const int64_t* monitor_count); - - const double* current_rate() const; - void set_current_rate(const double* value_arg); - void set_current_rate(double value_arg); - - const double* max_rate() const; - void set_max_rate(const double* value_arg); - void set_max_rate(double value_arg); - - const double* min_rate() const; - void set_min_rate(const double* value_arg); - void set_min_rate(double value_arg); - - const flutter::EncodableList* supported_rates() const; - void set_supported_rates(const flutter::EncodableList* value_arg); - void set_supported_rates(const flutter::EncodableList& value_arg); - - const bool* is_variable_refresh_rate() const; - void set_is_variable_refresh_rate(const bool* value_arg); - void set_is_variable_refresh_rate(bool value_arg); - - const double* engine_target_rate() const; - void set_engine_target_rate(const double* value_arg); - void set_engine_target_rate(double value_arg); - - const bool* ios_pro_motion_enabled() const; - void set_ios_pro_motion_enabled(const bool* value_arg); - void set_ios_pro_motion_enabled(bool value_arg); - - const int64_t* android_api_level() const; - void set_android_api_level(const int64_t* value_arg); - void set_android_api_level(int64_t value_arg); - - const bool* is_low_power_mode() const; - void set_is_low_power_mode(const bool* value_arg); - void set_is_low_power_mode(bool value_arg); - - const int64_t* thermal_state_index() const; - void set_thermal_state_index(const int64_t* value_arg); - void set_thermal_state_index(int64_t value_arg); - - const bool* has_adaptive_refresh_rate() const; - void set_has_adaptive_refresh_rate(const bool* value_arg); - void set_has_adaptive_refresh_rate(bool value_arg); - - const std::string* display_server() const; - void set_display_server(const std::string_view* value_arg); - void set_display_server(std::string_view value_arg); - - const int64_t* monitor_count() const; - void set_monitor_count(const int64_t* value_arg); - void set_monitor_count(int64_t value_arg); - - - private: - static DisplayInfoMessage FromEncodableList(const flutter::EncodableList& list); - flutter::EncodableList ToEncodableList() const; - friend class RefreshRateHostApi; - friend class RefreshRateFlutterApi; - friend class PigeonInternalCodecSerializer; - std::optional current_rate_; - std::optional max_rate_; - std::optional min_rate_; - std::optional supported_rates_; - std::optional is_variable_refresh_rate_; - std::optional engine_target_rate_; - std::optional ios_pro_motion_enabled_; - std::optional android_api_level_; - std::optional is_low_power_mode_; - std::optional thermal_state_index_; - std::optional has_adaptive_refresh_rate_; - std::optional display_server_; - std::optional monitor_count_; - -}; - - -class PigeonInternalCodecSerializer : public flutter::StandardCodecSerializer { - public: - PigeonInternalCodecSerializer(); - inline static PigeonInternalCodecSerializer& GetInstance() { - static PigeonInternalCodecSerializer sInstance; - return sInstance; - } - - void WriteValue( - const flutter::EncodableValue& value, - flutter::ByteStreamWriter* stream) const override; - - protected: - flutter::EncodableValue ReadValueOfType( - uint8_t type, - flutter::ByteStreamReader* stream) const override; - -}; - -// Generated interface from Pigeon that represents a handler of messages from Flutter. -class RefreshRateHostApi { - public: - RefreshRateHostApi(const RefreshRateHostApi&) = delete; - RefreshRateHostApi& operator=(const RefreshRateHostApi&) = delete; - virtual ~RefreshRateHostApi() {} - virtual ErrorOr GetDisplayInfo() = 0; - virtual std::optional Enable() = 0; - virtual std::optional Disable() = 0; - virtual std::optional PreferMax() = 0; - virtual std::optional PreferDefault() = 0; - virtual std::optional MatchContent(double fps) = 0; - virtual std::optional Boost(int64_t duration_ms) = 0; - virtual std::optional SetCategory(int64_t category_index) = 0; - virtual std::optional SetTouchBoost(bool enabled) = 0; - virtual ErrorOr IsSupported() = 0; - - // The codec used by RefreshRateHostApi. - static const flutter::StandardMessageCodec& GetCodec(); - // Sets up an instance of `RefreshRateHostApi` to handle messages through the `binary_messenger`. - static void SetUp( - flutter::BinaryMessenger* binary_messenger, - RefreshRateHostApi* api); - static void SetUp( - flutter::BinaryMessenger* binary_messenger, - RefreshRateHostApi* api, - const std::string& message_channel_suffix); - static flutter::EncodableValue WrapError(std::string_view error_message); - static flutter::EncodableValue WrapError(const FlutterError& error); - - protected: - RefreshRateHostApi() = default; - -}; -// Generated class from Pigeon that represents Flutter messages that can be called from C++. -class RefreshRateFlutterApi { - public: - RefreshRateFlutterApi(flutter::BinaryMessenger* binary_messenger); - RefreshRateFlutterApi( - flutter::BinaryMessenger* binary_messenger, - const std::string& message_channel_suffix); - static const flutter::StandardMessageCodec& GetCodec(); - void OnDisplayInfoChanged( - const DisplayInfoMessage& info, - std::function&& on_success, - std::function&& on_error); - - private: - flutter::BinaryMessenger* binary_messenger_; - std::string message_channel_suffix_; -}; - -} // namespace refresh_rate -#endif // PIGEON_REFRESH_RATE_API_G_H_ diff --git a/third_party/refresh_rate/windows/refresh_rate_plugin_c_api.cpp b/third_party/refresh_rate/windows/refresh_rate_plugin_c_api.cpp deleted file mode 100644 index 8ea0cc3b..00000000 --- a/third_party/refresh_rate/windows/refresh_rate_plugin_c_api.cpp +++ /dev/null @@ -1,155 +0,0 @@ -// Windows implementation of RefreshRatePlugin. -// -// Uses QueryDisplayConfig for accurate refresh rates (rational numbers like 59.94Hz) -// and EnumDisplaySettings for enumerating all supported modes. -// Control is query-only on Windows — rate control requires system-level access. - -#include "include/refresh_rate/refresh_rate_plugin_c_api.h" -#include "refresh_rate_api.g.h" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace refresh_rate { - -class RefreshRatePlugin : public flutter::Plugin, public RefreshRateHostApi { - public: - static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); - - RefreshRatePlugin(); - virtual ~RefreshRatePlugin(); - - // RefreshRateHostApi - ErrorOr GetDisplayInfo() override; - std::optional Enable() override { return std::nullopt; } - std::optional Disable() override { return std::nullopt; } - std::optional PreferMax() override { return std::nullopt; } - std::optional PreferDefault() override { return std::nullopt; } - std::optional MatchContent(double fps) override { return std::nullopt; } - std::optional Boost(int64_t duration_ms) override { return std::nullopt; } - std::optional SetCategory(int64_t category_index) override { return std::nullopt; } - std::optional SetTouchBoost(bool enabled) override { return std::nullopt; } - ErrorOr IsSupported() override { return false; } - - private: - double GetCurrentRate(); - double GetMaxRate(); - std::vector GetSupportedRates(); - double GetCurrentRateViaQueryDisplayConfig(); - double GetCurrentRateViaEnumDisplaySettings(); -}; - -void RefreshRatePlugin::RegisterWithRegistrar( - flutter::PluginRegistrarWindows* registrar) { - auto plugin = std::make_unique(); - RefreshRateHostApi::SetUp(registrar->messenger(), plugin.get()); - registrar->AddPlugin(std::move(plugin)); -} - -RefreshRatePlugin::RefreshRatePlugin() {} -RefreshRatePlugin::~RefreshRatePlugin() {} - -double RefreshRatePlugin::GetCurrentRateViaQueryDisplayConfig() { - UINT32 pathCount = 0, modeCount = 0; - if (GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &pathCount, &modeCount) != ERROR_SUCCESS) { - return 0.0; - } - std::vector paths(pathCount); - std::vector modes(modeCount); - if (QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &pathCount, paths.data(), - &modeCount, modes.data(), nullptr) != ERROR_SUCCESS) { - return 0.0; - } - for (UINT32 i = 0; i < modeCount; i++) { - if (modes[i].infoType == DISPLAYCONFIG_MODE_INFO_TYPE_TARGET) { - auto vsync = modes[i].targetMode.targetVideoSignalInfo.vSyncFreq; - if (vsync.Denominator > 0) { - return static_cast(vsync.Numerator) / static_cast(vsync.Denominator); - } - } - } - return 0.0; -} - -double RefreshRatePlugin::GetCurrentRateViaEnumDisplaySettings() { - DEVMODE dm; - dm.dmSize = sizeof(dm); - if (EnumDisplaySettings(NULL, ENUM_CURRENT_SETTINGS, &dm)) { - double rate = static_cast(dm.dmDisplayFrequency); - if (rate > 1) return rate; - } - return 60.0; -} - -double RefreshRatePlugin::GetCurrentRate() { - double rate = GetCurrentRateViaQueryDisplayConfig(); - if (rate > 1.0) return rate; - return GetCurrentRateViaEnumDisplaySettings(); -} - -double RefreshRatePlugin::GetMaxRate() { - auto rates = GetSupportedRates(); - if (rates.empty()) return GetCurrentRate(); - return *std::max_element(rates.begin(), rates.end()); -} - -std::vector RefreshRatePlugin::GetSupportedRates() { - DEVMODE dm, current; - dm.dmSize = sizeof(dm); - current.dmSize = sizeof(current); - EnumDisplaySettings(NULL, ENUM_CURRENT_SETTINGS, ¤t); - - std::set rateSet; - int modeNum = 0; - while (EnumDisplaySettings(NULL, modeNum, &dm)) { - if (dm.dmPelsWidth == current.dmPelsWidth && - dm.dmPelsHeight == current.dmPelsHeight && - dm.dmDisplayFrequency > 1) { - rateSet.insert(static_cast(dm.dmDisplayFrequency)); - } - modeNum++; - } - if (rateSet.empty()) rateSet.insert(GetCurrentRate()); - return std::vector(rateSet.begin(), rateSet.end()); -} - -ErrorOr RefreshRatePlugin::GetDisplayInfo() { - double currentRate = GetCurrentRate(); - auto supportedRates = GetSupportedRates(); - double maxRate = supportedRates.empty() ? currentRate - : *std::max_element(supportedRates.begin(), supportedRates.end()); - double minRate = supportedRates.empty() ? currentRate : supportedRates.front(); - bool isVRR = (maxRate - minRate > 30) && supportedRates.size() <= 4; - - flutter::EncodableList rates; - for (double r : supportedRates) { - rates.push_back(flutter::EncodableValue(r)); - } - - DisplayInfoMessage msg; - msg.set_current_rate(currentRate); - msg.set_max_rate(maxRate); - msg.set_min_rate(minRate); - msg.set_supported_rates(rates); - msg.set_is_variable_refresh_rate(isVRR); - msg.set_engine_target_rate(60.0); - return msg; -} - -} // namespace refresh_rate - -void RefreshRatePluginCApiRegisterWithRegistrar( - FlutterDesktopPluginRegistrarRef registrar) { - refresh_rate::RefreshRatePlugin::RegisterWithRegistrar( - flutter::PluginRegistrarManager::GetInstance() - ->GetRegistrar(registrar)); -} diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index c04e20ca..a5f26955 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ