From 3c05f99458810bc22d0c21864d5e7e3011405062 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 19:29:21 +0300 Subject: [PATCH 001/157] docs: dev container and local database stack guide How to open the dev container, start the docker/ stack, connect Querya to each database (from the host and from inside the container, MySQL 8.4 TLS, Mongo auth source), run the test sections, e2e tests, the live MySQL test and the extension tester, plus known gaps and troubleshooting. --- docs/README.md | 1 + docs/dev-containers.md | 219 ++++++++++++++++++++++++++++++++++++++++ docs/getting-started.md | 3 +- 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 docs/dev-containers.md diff --git a/docs/README.md b/docs/README.md index e607ba2c..30f164df 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ Index of Querya Desktop documentation, grouped by audience. ## 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..cbba4bfa --- /dev/null +++ b/docs/dev-containers.md @@ -0,0 +1,219 @@ +# 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) | `ghcr.io/cirruslabs/flutter` plus the Linux desktop toolchain (clang, cmake, ninja, GTK 3, libsecret, SQLite headers, Xvfb) and the Docker CLI with the Compose plugin. `DISPLAY` is `:99`. | +| [`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. | + +When the container is created it runs `flutter config --enable-linux-desktop` +and `flutter pub get`; every time it starts it tries +`docker compose -f docker/docker-compose.yml up -d` and prints a hint if Docker +is not reachable. + +## 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 + +`localhost` inside the container is the container itself, **not** the host, so +`localhost:5432` finds nothing even though the port is "forwarded" (the forward +goes from the container to your machine, and the databases do not listen in the +container). Pick one: + +1. **Join the stack's network** (works on Linux Docker Engine and Docker Desktop): + + ```bash + docker network connect querya-dev_default "$(hostname)" + ``` + + Then use the service names as hosts: `postgres`, `mysql`, `redis`, `mongo`, + `clickhouse` (with the container-side ports 5432, 3306, 6379, 27017, 8123). +2. **Docker Desktop only:** `host.docker.internal` with the host ports. +3. **Share the network namespace** of a database with the dev container, so + that `127.0.0.1` works for tests that hard-code it (see the live MySQL test + below): start that one container with `--network container:$(hostname)`. + +### 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. + +## 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` (`sudo apt-get install bubblewrap`) + and unprivileged user namespaces; without them they skip. Inside a container + they may also skip because nested namespaces are restricted: 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 does +not work for it. Use a server like the one CI starts: + +```bash +docker run -d --name mysql-live -p 3307:3306 \ + -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=querya \ + -e MYSQL_USER=querya -e MYSQL_PASSWORD=querya \ + mysql:8.0 --default-authentication-plugin=mysql_native_password + +MYSQL_PORT=3307 flutter test test/core/database/stream_take_drain_test.dart +``` + +Inside the dev container add `--network container:$(hostname)` (and drop +`-p`) so that `127.0.0.1:3306` is that server, and leave `MYSQL_PORT` unset. + +### 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. + +## Known gaps + +- **Flutter version.** CI pins Flutter **3.47.1**; the dev container image is + `ghcr.io/cirruslabs/flutter:3.41.6` and the image registry has no 3.47.x tag + yet. To match CI inside the container: + + ```bash + FLUTTER_ROOT="$(dirname "$(dirname "$(readlink -f "$(which flutter)")")")" + git -C "$FLUTTER_ROOT" fetch --tags + git -C "$FLUTTER_ROOT" checkout 3.47.1 + flutter precache --linux && flutter pub get + ``` + + This changes the image's SDK for that container only. + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `Docker stack not started` on start | Docker is not reachable from the container: check that the host daemon runs and the `docker-outside-of-docker` feature is installed, then `cd docker && docker compose up -d`. | +| `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 | See *From inside the dev container* above: use the service names after `docker network connect`. | +| 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/getting-started.md b/docs/getting-started.md index a89d52fd..d90e08ea 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -86,4 +86,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). From 8bc3f7c7da311f8b419cfb334a465933217f781f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 19:44:15 +0300 Subject: [PATCH 002/157] docs: headless levels in the dev container guide --- docs/dev-containers.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/dev-containers.md b/docs/dev-containers.md index cbba4bfa..30149442 100644 --- a/docs/dev-containers.md +++ b/docs/dev-containers.md @@ -107,6 +107,30 @@ container). Pick one: The **Demo Playground** (a bundled SQLite database) needs none of this. +## Headless modes + +"Headless" means no display and no window. There are three levels; the first +two work today, the third is a recipe that is not wrapped in a script yet +(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 | see below | + +L1 needs a tester built from 0.4.19 or newer: older ones exited 0 whatever the +result. + +L2, by hand (untested in the container, it is the same recipe as a normal +Linux run): + +```bash +Xvfb :99 -screen 0 1280x800x24 & # DISPLAY=:99 is already set +QUERYA_PORTABLE=1 flutter run -d linux # data goes to ./QueryaData of the bundle +import -window root shot.png # ImageMagick, if installed +``` + ## Run the app in the container The container has no display of its own. Two ways: From 0ac6c92877e83d731866209106f5dd31d7775627 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 19:52:27 +0300 Subject: [PATCH 003/157] wip: headless image, RPC trace in the tester --- .github/workflows/headless-image.yml | 101 ++++++++++++++ bin/querya_ext_tester.dart | 11 +- docker/headless/Dockerfile | 49 +++++++ .../extensions/harness/extension_harness.dart | 24 +++- .../harness/extension_harness_report.dart | 125 ++++++++++++++++++ .../harness/extension_harness_test.dart | 60 +++++++++ 6 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/headless-image.yml create mode 100644 docker/headless/Dockerfile 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/bin/querya_ext_tester.dart b/bin/querya_ext_tester.dart index 95681fbd..172121b3 100644 --- a/bin/querya_ext_tester.dart +++ b/bin/querya_ext_tester.dart @@ -27,6 +27,7 @@ Options: --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 '''; @@ -42,6 +43,7 @@ Future main(List args) async { var timeoutMs = 5000; String? junit; String? tap; + String? trace; var color = stdout.hasTerminal; String? value(int i, String flag) { @@ -69,7 +71,8 @@ Future main(List args) async { '--database' || '--timeout-ms' || '--junit' || - '--tap': + '--tap' || + '--trace': final v = value(i, a); if (v == null) return 2; i++; @@ -95,6 +98,8 @@ Future main(List args) async { junit = v; case '--tap': tap = v; + case '--trace': + trace = v; } default: if (a.startsWith('-') || dir != null) { @@ -153,5 +158,9 @@ Future main(List args) async { stdout.write(report.toConsole(color: color)); if (junit != null) File(junit).writeAsStringSync(report.toJUnitXml()); if (tap != null) File(tap).writeAsStringSync(report.toTap()); + if (trace != null) { + File(trace).writeAsStringSync(report.toTraceJson()); + stdout.writeln('RPC trace of ${report.rpc.length} calls written to $trace'); + } return report.isSuccess ? 0 : 1; } 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/lib/core/extensions/harness/extension_harness.dart b/lib/core/extensions/harness/extension_harness.dart index a6b0e4ea..ea179cdf 100644 --- a/lib/core/extensions/harness/extension_harness.dart +++ b/lib/core/extensions/harness/extension_harness.dart @@ -194,6 +194,7 @@ class QueryaExtensionHarness { final total = Stopwatch()..start(); final profile = ExtensionProtocolProfile.forTarget(targetVersion); final checks = []; + final rpc = []; var extensionId = p.basename(extensionRoot); var abort = false; @@ -328,15 +329,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) { @@ -451,6 +472,7 @@ class QueryaExtensionHarness { checks: checks, totalDuration: total.elapsed, peakRssKb: peakRss > 0 ? peakRss : null, + rpc: rpc, ); } } diff --git a/lib/core/extensions/harness/extension_harness_report.dart b/lib/core/extensions/harness/extension_harness_report.dart index 5005b2ee..82613739 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; @@ -77,6 +180,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/test/core/extensions/harness/extension_harness_test.dart b/test/core/extensions/harness/extension_harness_test.dart index 58bc7f96..fbd3e1c9 100644 --- a/test/core/extensions/harness/extension_harness_test.dart +++ b/test/core/extensions/harness/extension_harness_test.dart @@ -341,4 +341,64 @@ 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'}}, + ), + ); + + final connect = report.rpc.firstWhere((c) => c.method == 'db.connect'); + final request = connect.request! as Map; + expect(request['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')); + }); + }); } From 1673515f42fb6bfb634abcdb949492b0e4663a62 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 19:53:22 +0300 Subject: [PATCH 004/157] feat(ext-tester): --trace of every RPC call, querya-headless image and its workflow - querya-ext-tester --trace : every JSON-RPC round trip with timing, request and response sizes and a bounded, redacted view of the data - docker/headless/Dockerfile: the release's tester (SHA-256 checked) with bubblewrap on Debian, non-root, entry point querya-ext-tester - .github/workflows/headless-image.yml: builds, smoke-tests and pushes ghcr.io/queryahub/querya-headless: and :latest after a Release - docs/extension-testing.md: --trace, the image, a CI recipe with a database service and --sandbox in a container Refs #1487 --- docs/extension-testing.md | 97 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/docs/extension-testing.md b/docs/extension-testing.md index 91f01481..bdc6b924 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -71,6 +71,7 @@ querya-ext-tester ./my-extension \ | `--sandbox` | run the plugin in the OS sandbox (`bwrap` on Linux, `sandbox-exec` on macOS) when available | | `--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 @@ -80,6 +81,102 @@ 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. +## 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. + ## GitHub Actions This repository contains a composite action in From 0e6125fa08dcd4d6e3cffe5a95c8c982aac4214e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 20:04:40 +0300 Subject: [PATCH 005/157] fix(updater): package installs (AUR, deb, rpm) are updated outside the app The AUR, .deb and .rpm packages put the bundle in /opt/querya-desktop, owned by root. The in-app updater treated it as a portable bundle: it downloaded the zip, quit the app and ran a replace script that could not write there, so the update failed and nothing started again (seen going from 0.4.18 to 0.4.19 with the AUR package). - UpdateInstallContext detects the owning package manager (pacman, dpkg, rpm) for /opt/querya-desktop and any bundle folder the user cannot write; both count as managed, so the dialog offers the update command (or the release page) instead of Download. - The zip installer refuses such a target before the app quits. - The replace script checks that the target is writable and, on any failure, logs the reason and starts the app again. --- CHANGELOG.md | 4 + lib/core/updater/app_updater_service.dart | 4 + .../installers/linux_appimage_installer.dart | 8 ++ .../installers/update_install_context.dart | 109 +++++++++++++++++- .../installers/update_install_utils.dart | 27 +++-- .../updater/update_platform_installer.dart | 10 +- lib/features/updater/update_dialog.dart | 22 +++- .../update_platform_installer_test.dart | 77 +++++++++++++ 8 files changed, 243 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e309798..8b50708a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **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. + Everything merged to `dev` since 0.4.18: the NoSQL workbench, SSH tunnelling and Production safeguards, the ER diagram, charts and plans, the MCP server, a rebuilt SQL workspace toolbar, a UI kit, and a round of session and connection correctness fixes. ### Added diff --git a/lib/core/updater/app_updater_service.dart b/lib/core/updater/app_updater_service.dart index b5fe0c42..fc462096 100644 --- a/lib/core/updater/app_updater_service.dart +++ b/lib/core/updater/app_updater_service.dart @@ -202,6 +202,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/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/update_install_context.dart b/lib/core/updater/installers/update_install_context.dart index 54d51a64..dd29552f 100644 --- a/lib/core/updater/installers/update_install_context.dart +++ b/lib/core/updater/installers/update_install_context.dart @@ -9,11 +9,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 +46,105 @@ 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); + } + + bool get isManagedPackage => + isSnap || + isFlatpak || + linuxSystemPackageManager != null || + isLinuxBundleReadOnly; + + /// 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'; + } + 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..ff0489c1 100644 --- a/lib/core/updater/installers/update_install_utils.dart +++ b/lib/core/updater/installers/update_install_utils.dart @@ -153,20 +153,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" 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/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index 7d912159..cde282ac 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -2,6 +2,7 @@ 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/unsaved_work_registry.dart'; @@ -437,7 +438,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 +482,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 +553,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/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 { From 0d22e035cf9a46266da762b678bd767a7c38ba80 Mon Sep 17 00:00:00 2001 From: Jah-yee <166608075+Jah-yee@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:16:20 +0800 Subject: [PATCH 006/157] docs(readme): fix Linux install instructions, package filenames, and repo status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1500 Fixes: - Demote repo.querya.app to Coming Soon (refs #1059), promote GitHub Release assets - Fix .deb filename: querya-desktop-linux-amd64.deb → Querya-Desktop-*-linux.deb - Fix install command: dpkg -i + apt-get install -f → apt install ./*.deb - Add libfuse2 / libfuse2t64 note for AppImage on Ubuntu 22.04+ - Add Arch Linux AUR install (yay -S querya-desktop) - Add local Flatpak install instructions - Add macOS Gatekeeper unsigned-build bypass (xattr -cr) --- README.md | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e768c3fb..25495347 100644 --- a/README.md +++ b/README.md @@ -122,41 +122,42 @@ 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 -``` +# Debian / Ubuntu (.deb) +sudo apt install ./Querya-Desktop-*-linux.deb -**Fedora / RHEL (DNF repository):** +# Fedora / RHEL (.rpm) +sudo rpm -i Querya-Desktop-*-linux.rpm -```bash -sudo dnf config-manager --add-repo https://repo.querya.app/rpm/querya.repo -sudo dnf install querya-desktop -``` +# 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 -Or download the latest `.deb`, `.AppImage`, or `.tar.gz` from [Releases](https://github.com/QueryaHub/Querya-Desktop/releases): +# Arch Linux (AUR) +yay -S querya-desktop -```bash -# Debian / Ubuntu (.deb) -sudo dpkg -i querya-desktop-linux-amd64.deb -sudo apt-get install -f - -# 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. +> **Unsigned builds:** If Gatekeeper blocks the app after installation, run: +> ```bash +> xattr -cr /Applications/Querya.app +> ``` + --- ## Building from Source From 2873811a8384b5a18c78cc87ccd8a899a3ae30d2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 14 Oct 2026 09:59:31 +0300 Subject: [PATCH 007/157] feat(branding): replace default Flutter icon with Querya logo App icon for macOS, Windows and Linux (AUR/Flatpak), plus the logo in the About dialog and Preferences > About. --- assets/images/querya_logo.png | Bin 0 -> 18766 bytes lib/features/help/about_dialog.dart | 14 ++++++++++---- .../settings/preferences_about_section.dart | 14 ++++++++++---- .../AppIcon.appiconset/app_icon_1024.png | Bin 102994 -> 69861 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 5680 -> 7164 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 520 -> 711 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 14142 -> 16165 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 1066 -> 1601 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 36406 -> 38233 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 2218 -> 3333 bytes packaging/linux/aur/querya_desktop.png | Bin 36406 -> 38233 bytes .../flatpak/com.queryahub.querya_desktop.png | Bin 36406 -> 38233 bytes windows/runner/resources/app_icon.ico | Bin 33772 -> 285478 bytes 13 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 assets/images/querya_logo.png diff --git a/assets/images/querya_logo.png b/assets/images/querya_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b0dd4048871c1ebe7d1127e85ba06c403b6b6023 GIT binary patch literal 18766 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ⅅt9H;Wv{9MD|yCI$(YE%xM|aH#X!cR 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 literal 0 HcmV?d00001 diff --git a/lib/features/help/about_dialog.dart b/lib/features/help/about_dialog.dart index 59c29445..64157193 100644 --- a/lib/features/help/about_dialog.dart +++ b/lib/features/help/about_dialog.dart @@ -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/settings/preferences_about_section.dart b/lib/features/settings/preferences_about_section.dart index af02cb6b..75eb42d2 100644 --- a/lib/features/settings/preferences_about_section.dart +++ b/lib/features/settings/preferences_about_section.dart @@ -45,10 +45,16 @@ class PreferencesAboutSection extends StatelessWidget { ), ), 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: theme.colorScheme.primary, + ), ), ), ), 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 82b6f9d9a33e198f5747104729e1fcef999772a5..8c54ac28c40cb32f3db3571fb16bc49c0141a3b8 100644 GIT binary patch literal 69861 zcmeFZhdb5pA3uJdV`Zx-8Bxh9BBQd-(NI=qWRqmCGBVGRBnf4PaI&%~dmc?mMmEPP zNw$n*oa3DDeR_Ys-~ZuvUB52Zb<*p;@7Hrap5uO=qV%s{WoO}I0RXUTUb}1v06qLG zJ=n(xKQMx)!U4bn&CBY>_eU4^{9lNSgiP$TJkM_`Uv^muY6uFnj1zi+IDhFq-EsA2 z^j97n?KjL7ch(%Ya^A9D8dx6CYK-&o|CIE@@Z-cuiRbbA-p6~1{E%(E?>VZQ?(F%M zB=1cvm|rd_dHVF;@$0#Jp_#Aq&z?BZ9rd`dsB1m?-1TdaivR!5|3@SsEMeVz4mcTU zQkPGTsA0|l;3N>5P-7KML?r$QSJ7A2+-s?KO2IP#P^EU5ZaJ56iJK0~H+2d1?DP=c z-h1?3wG??A0GxLn-}yG@w#})GTi_X&+84UzdUErVQIo{2fuAb~%sl{ZmLH;<5D&RL zE}cTUBn!MAb53LVHw88bC0Vx#@C4amjx_f{y|+u|H4IfJ)yC+)51cUcY$#jUv|Nz` zz)P%9y?$Xc+s(Ci3Ajgau49|cuQxcpwH{WI9=-OH7aINjc;4;E(&_A&0z#HCKbkOg zNyS!lc}1&~k!)ZS{Wgod2VWCvndQ56@P4PC#Wcrn2}W>i8*mz%Mw@NrWWI|#J6wL% z9I8%4ti5vAfRQ47fL?K-KJ-ja@3t_faq>p%CT=REHuIl|uF7_XLjN1Z$6m zNp2*F+?L@OGb~rfN6&aCN4R4Q53(QuxF_B9lEBtGefnO#%2!#hJ!_o9YM9_-N|1iB zIzFXG$)Kj&2cIKw5P(sG_@U;;qpOYPV)J(izLqVVPRzkvSCzG0rdD>pg(UjAWf44T zI~M?G6-+UpR=jImS}#}_ru|ddB$K>?T3wFdm7RLMyFEfR*I&MCF5bYl%gR_W0WchO zPeWxHr+VE0XA*@-pZ@hz72WsqgVJc!LKwb79n-HKoVb~-a_+)WMR+=p)8wG!aFBCK z4$stgwxqWFUW3bjge#4zH#1T+IoGCDV%Jm_>s~Z*0I+!F!upcS>dO3^PFK-x-B|Vr zfMXLH{QlJq(~`sHVa2|iqgLjx-nucI`qT3O!12`c8IQ90xynwJUwPH~D`5DIY=G+W zF4}17gQEHKjxbDO6zM11Zcfv3gN*Ik*stM{5;_1(`X$$d{Bc5g!c$5Lww2$0DI8v) zV~^qd?R2vG=ys!0qm8>+|4{a6*5H;mvy}B8kx-odeFHm=wG!t0TR*)mD8CZmwx;5g z+U_bn$i#Pm0aQH*qy$A6Rp>jdR<`l~Rfp=vUh&V_VtG_!5P)5jlj)4xxy*E#r5>J$cY z=~gdL%Goafsir&ZxM#&qwz@(r6_T5Rh7co(6SgJQjlLD1ELK$vI9j8!+<@*h0O%9N zL(sU*DB^LRYK}dSyEI)*S#*N}uKcQ%ODPNKx!~%D8*( zsGPSD%@Z6BvW#*wW?Ne8&jk&i?VGjh$SGs$XM;4P)j94Z@NPE>td?7RHVXxyNBXNY zPl5cD-dka=nr|BcJ#U4%kv%fv0B0Z~xK(>(`)X=M_x;v0jL=;{(i4Oz1LE*OR;?cq zK$;b;oei)W&u)*QmRl$9tO`{9shfP; zksKSJM&OHq>!CvI`F3nB3tZ5%3SVd)&$peT78m4>NtD~mqodZ&ds)Kc(k7tg_!q3< zCO^#}2a*fFo=uol@8GsqZ9T#Q3L)q6W=Ey=x%z!ut`0*xX)3-sFd5231b; zeBK4bb$x)A8aT1Ma(7DT=04jgD9~pmZ!coGGr7EO?3;QEs@UL3Jf`7P4c_g8>rgWV z2S0Ss&EMFE+lPImfdJLEYxjODEqexrU_ORXL)gfWtoc9l?%Co>HdCJgn=Pqpd|%GGi|d( zUvP>WJflAxj!Cbibd@h1ro%uC&F9YH;_@#8eQg8DSIn4XASu#UV#sZ(3!Zm>|L-kP zX8W$)dW(iK5vOluW&vJ#-@A5AwL$L3Rwxdf)I)CMlcG!@%#KaI2)BzUZ0pVQQGFQz zBLymG*9A&{|VsXepP?(DWeB^Oh71|ghmSFnpsGUggj~1e#$vznga5+^B zw-M9>b&#Y_kG{y>uxUKJ!~m2M$m_Jdxs%D{NM{>g)w&tQZSye1BWpO|Y< zreAKB`;=my(4+%$(<88244^L?%U#N=zvNx*U@(aA$OgbdQ-flawP|AngqNYBuv(Je zIJp#fgc%NlIuYP|6#*1~a!?Xf7P5+J+~^$lpepn~Rj+m+-#H zT#FCK5Rm8rb^Q4S8p98dxiS-EX)uxT0|I55M z3s^c8^$x!@LhT9rpNvEe;u#-X?0fe9Amaau@_{(0dTq`|Ddqt>jFih1Xcs*`7p0kH zr0B2xU_eBulxgn4?`ns3G6{A~gP%-H(j}Bcz;F?8bw`(PUamEju|6Y2F zZI*%d(mS89|99C%1TIAW>r~bP&02d*-~#9t9}R#L=cWKTGIR6r5Y2{gpOQLRxz4c* z8_=Waxq-P&Gb6Q7GOoj(`g%U(86*IkYn6`ih#Nc%9xzf3Ja}%-1?^00!FY5jAqVt~ z@;R^KmrL-x{{-Hd$c?bfMTk5B6=(#`cVuk2d@!8K{|3WIg(o>F7hl2lBGIZ(z}V3W zc1Ve?VmP&T7DDvr=NQdGcGayGvz2gKDbwFky)(O2bQdA~^&}$RY=5gLgYBA98XE(4 z@Sy)HX4-q~9cF6ezhlwR7!u{NMJmbAvLFWri3^kj0dk7v3T9<4P)GnChx=}sjtQ+D z`^G9B-+(0pb@VXfh`Ef+@f)e zeKpYCB~dSdytWXHJa7eDlU%sPC|o1LNR{?j_n&5^EveVadKF#Vp^YzI^gh|*5%L-T zw{D~HE6rCw4E50S;uOxMOTLVLhsnE$o|?j3>TVWW2*J{J-g&!wq@g#e8YhAtPPv^$ zU+_o-eGuxTq3K!MK-pgHk&XXo7I=Fa`GT9yLq-ZI^TpGE8>E>gF5GL9oIFBP8el&j zb{t*(27U4yQxCLf`Pbq@d!`m1O55eeY_|U-Bhp&2&-Sc@t^R(rLw727#V4pbH3PA| zQ&uL?tHn>B481NQe1mtK5@E7j;xoaaZ%e`F}=8`2l}9l>_aAndMS4Y?8K z050Tm?8S@Ml31VYPqJC>{^x-OuUM{Nwdg3~&RCa8p4G7p1^LH1uo}9aDuQrpK{}ES ze>wqQjkA8B7Jb;%=9DD)X{ZFoR~7%>RlVc;L*9=HYKMpRChrUQ?P30yVaAn6nCuBA zS`qGawK(fj`1Rh@T!e>=CLM&!SZ;JURL-!a%~m-}M^VOJIj`WYf8y;Qns>Zg-2L}0 z|A}iq>^TTS`i|soXSaO3*Ff2bY{tf!ML*0bM@nP;I1M#=#LJ{q@Or17Q?+bDlU9;i zjDL9Nt+3obS|i}Afy8;I9&*%qLT^yE_n%eE*9~<4u}h(Q5jxH_^9_@@sc)}?9DWgB zw>-JAh|zk-0F?og!r9M`oBRnoNujYRgV#CkPX{TS>zWEON2%NPOyAWxI*jG*S2&t7 zv=@X}7!?Ig?BiQU%eTU4dn+d99XFq3O|!Rry^(eX{Un)1n7PLm+R>SW1&Vgo)hBzd zCwIGZX;_A)v)KEVX-Muscodk>+uBa%!ezEJO!yl=q%0B#4iWA4lE7Kc&eARJNnkWHOg zDKtTp+~k^SpuH1UUlRJL{1By0`D0JiFOsmlK>!Rh)9QHIk( zOm!^^!YN4k^DH1ns{Ie9=>0v>J(`o4qa!pCx<(*B0xRy%fX*-g1^C=-s!2HItpj`v z(ronChH`oJW3{2Ng*WeNo!zx@HtRWae{O_ozo{lpTl3Rm&&_7Z1UtY;wPg9{YWe2f zyO`-@jO7*$fU2IwYa+rwBj7^~anP?Pl|y|cN3n3DJ;VMsq|~FjG7xp#xG3+bg*HVc zBj+h0#bM5(jU-Io)y6okJsLil_isiV=C~$#P4ZFj4o{2eu3$fr3FMmtTkk zR#L_BVk_hOsdDej^&+je2dORzA-NijaRQ}Vu`~%?gLXj0S)oV!4`r}VWyh;9_SR4U`Poa*e`f%@A)^KjczetQP| zFYm%^$*hsB;;LEG`vO(5?RU>#MB-#c0too@2+&2LNk+U{Nu#2fO}L#I@m!b%w7*p) zdG1bV;dNg9g+gY7D*$`@##!LJ$D12pH!UdgP~C2CBy*3`{HWg&>L8WoWj9~DU2Hw? z&nV2$GxdT__=0z_-GB$lQDOUV#8KJ-1LH54a6Zyl{a_cpshpKBgdw8~wrF>n5tkk(y(PeD-tkKOGj^NA@0!s;Q(V zAf3Fp!Pvb3BFgP^XHhujgBp#4sYhD&Ea{C%W4`Ve8_tyi`=Q)9`zon3-7($dXxIO^ zERs2l)jcr9Xb9M!Ah8ykG(u>JF@zZ&c|5NKRc8Xa^qrrov*6lg^-D@cx+vw*vtC3YxB)Ft3`x;OTtmpi9CLH_ z)^F;XjRx(g#~pX3&fGZKm{=R3lnqJNH2%aIp!c$pdJcc@-&QQEg9IQnBFFTl81&~Z zq##5dBhgC{dtO&BkZk4-)6!n>^Q~ZXVw|01wdtI!3l@nE_U@)I?e+fe-BuT_A&j*d z#iuSvU?1v(0;&Iu#7Qw1nA0<0?`Iq5zwe@?l-ciUe;%PZ0EpUGcLM9YAI&1PWqgrE z7{LwV4{xw?Re21|^fy;;U;&~2vGdFnN8L%n)zI>v5kYr0c$jPc01|Usp?z{vj+^PX z4A1zMS|Tf{wB-kCm5BQqh&G6BZM=5ULB!aB3J7o_aTfoy3*FBZ4isEzig)(@*Jf-K{wOK7`G7tj2x^6{FI9tqy=W8$44<7rIZoCx9U;YKix= z2zv}Px@#K!+nAkoty(1QwQz?YE4p18Asjx$wVwg&mHF;&%3o^h(ACD3fWiqT8rQ(% zreF?lobzoH`p0Jni)sXlp#r0~QMBzQZ4N;90TLUe`H{MFg|@h-Ku3%+eHN>f1Gscw zZe!-|rEgQCHz%BF55;ShOrq`{!G0ZNHNSK32q>aS!Te?q=l!0M;Qu*;NzHUBf5%?@ zY+mrY3kN{;jY2N2yo)s01u-8be^?UL`u8zo@9SirI+vVoJ!|tfdhIZLhGjhD{4ys= zZtvV@tx$AhXwqy28rhmTw<#f7o+QnAn(L|02dkw^VFa1pk?r5zl8C zgP+ua&n9d{`+rh@*4~OIU)XA<2fTj`B?1bonvBg0bxh!owNkOMXpXVcsWS;TBr~3; zScha@FAvV^jZcYJX-^QdY5AqS7QGhDz!S}|6Y+q2@%pEW)}hDBZ>fo)@VhEmMzUE( zZ=PA0mEU}3b9#{E1pr^d-1*th_wc{!ZHNtL&R;?Zx6qeUXB`E>XxMC?3DdfumcP%b zzJVMa)2y@OAzz7X;(dMPZr#|o;~`r9o8sdG#X630hJsdbnPk$Ev1FyvA3X;XCbEP? zQoM@iOruIbrb*`Hua2#1b2jmbU297TmhA{~=PmTH+T5RO@hlzDlBYXQ;m^N*|Fh{X zYvtE&-BagKIb`zFHQF!TlJLpT-UmH(0c>(ifjD=AozyRh7s4}C?xuB9YqLK zd^M8HFgr3<&M~h{x;k*-e8X-?xYF)b6UH4?I!I+1+oGJ{DaUmqX9?ehEQU#ueP(aR zZih_Dd3x#+;(w8fB_1T@qU{0$v&VP3ur1bO(D$a}HZ|No?@EA#*zzN(@(*JbTXj45!u zoxc0ouh`!$CGgy`@$A$Kr9j5S&d&qIkAiCBQ1;Q^0Rtfc^TE6YdgQTC@wY?eR^8zEeVT9#)5uW73N$DB=KdZIVP#CRB z&x{`Oj*)qjpF#bI1kZZ?3nos$Wi;&2lh^Te7P-$geN|XX4 z`;{Av=qx{mn_OF%nf7pF%>{0=G6g>F=pDyq9^UW2`fAQMS`2P`iGP8D4{$^<<%kH<;fvKrX)L+xay{wB9tl+T(bdKd$rF>&|(I1po`dbIX z=Z{Qe>-cABX*?fXV&o`FmuGnPN08BFOs}EQw3$FGa3mac58jSU%F6owvpPCWHAJUR z%zE(KT#k8Dph3i^o%_mkpg_dw-0!FNkvgLD6437+=+-O9Yah- z1g^*fdF5{HLdbdu|CZDJHY>h~fY-Zi#G*>io7TG~dqGQ!+b8u--|68DR{dPEv)Ob4 z>mFpVvd^}8{E&NObN3nUDizhTo%Pr9A!Ht1L5$h@?4+?rU_3JeqV+j+f>@`$4Df!L zajIj{0~D=V6|bm+1K@l8OCWQ4KL9dQdA;-Vlel+MO!99Rtl3xUjG9jnlIG?utn7S0 z;pF}pkg9bM=M7f4v2;=S;DO=c;*Mq?^NbJt0~eYMKKQHUlHb2PB%RgwEy`4W=kt1+ zs;{y3LDPC;m;x8d5854XRk~GiL|PodwLxdGw1{^`PVM|-n)OVQ!B*>wM>T?jk5so@ zok(_@Y@RnH31)UPU*q`I7^bF5S}ONsM?AuPk9*c$A!w`=S2Vq_=>0hmou>A;NYp0C zL{7}6@8@R68fyRoe2Y2LO+biT)8ryFJc5l*m1!lrg^W~ydhMxugzxgV~3vW?#EN+d}2occ%&nSJdM#C4hErLg1E3qY;%Qr zNM4(00zDEGt@V#6XTK_ql&@LYAG6rxIHQBf|KR>@V#_lR#6KN56+B4(5*|o+5ElLZ z^2c{I?x?59*D-Z;G$A8`@gjhRjFK%R=bD9R%_Yk ztZ9SycgQKc+ZH)6W$3&bi~9+nLlp@3ea{KgB`MTw?ROt0VRz3hnkW%?lw>#rRK)IE zwHUAn7nxi`&{utbEP>TBOf3nlSvK6NvEmDABUO3*cq(cgV1Don1zqU~aaCs4>jMIJ z@?ng>TblvQ8(>*vT`jIXJn$0s@qZJEkzMSsQXY-Cz9dzUw^ouJ; zLj0t$q`<-p~ z;CoxSv6x0MJchvaE>g!}1}C}Nlo%LU>*}_0VlWUjiC)P2~S76h=&#zw@NiV%sgi(?@E$u}7CToQuDyQB5%Vb{X z6B|W`sLJE@M~Y|cP5!Xa&Tp}o<-RoNIfyv4{{S5k*ituIUKC$o@*P3`VGVyQ?Or<_znVDd>q?`wCb)BGRp z2I8+Fg`KZ*=9zr8PO!A-+kUUTRd^SP>m7lKX~+#d#I=P!AZf;lxCVybkiQ%Z+`|`@ zumY)%Mk_rg?h{)a?yB1_7~4E7>O-7cDpIH{`tiwO_VfI%(gzdPpgKI zHn%kNCuF5()r!+)`5QA#bolu&%Z=uB!J!U6HQ^P4oqd+p^lO(|WJ$jo<}$ohcr3Pz z%lL!BCjZXs<>MXCDP=m^mrS=}va;&8pt+}Whot9MVYL$ro}awn+Oi~DXmISH;giHS$%T1csZD>C3?l9r=_yq`Y76BDPn6w=thq*&U-CHq@w=PcG` zq2Rre!5KR76(gQOZ;bIgHBjep_tR|5?0_^6yv9w` z@^`x3Avo&2y*FAlYv)URdhM=2wW22Ca?))UVR4Ez#Px7{hqqY@+6a+)6!W}*U|+J( zPJyDz_9Z^dU#5n&jB=;l?Nc!GN>~v9@s4LH5*XFfyK(zq>37PB1B)9%;3V?oGWbUB zruZEV#eeogmBcQqH@>rrul&|1eIQY0WcfDhL65i_s>HScl;}OpewSG0zEJICN2bW? zIV+)_FU_7q5KZUSYFl#W?9UX}HF4hyN8paVU?U${6?+W9v`*+q)8ldk`h#YkvV6m) z^pXl6>yH40SdEU}C0}~e3hyBOk6ZHutY3h4in=F~zB=7s7aR>~i>!9;CiHtx zn}muU&_VPs8X^+Z!Ux|NM1H&+&k)}F&QEaJrRR$T)-bgN;T}wibJ|{xl&6rlOj!_Z zHc8zA2#^-to=U}S?cBnxzzeIB;2*7vD$(A=Di9l>*t$hS2c^ZG`Z(%pSAC{BCR@$DDB?_8aV6`Q3o*F)oS; z<2^h87{EE5Vlz8bKe>m_>E_;v#_9zkrwb`e$i#F~V_|D(eld7Yj4hFX|6TeFDUYcxk>Mti6h~fCb8P8D7_1WteRJ_2{%T8W#r{k?!-TM&?SSKAyKy9g; z>^4)+vj)=Vs_Cg9+WAGEPK~*%h?fb7Tz3MyS^)05Ujnkg2IWHLMi+KHj~OMt%NqQ$ zx6+^F8DeJILb?G_*pd`O2jmp(h5Mp_#J^nSvV zUzVZvq-Go)4~#;i70r0MEgkJDfz_&Tb7!6m7{e_p-YVcAN`}xtO$aL2&6N7};*g`$K~?4Q z)puplFG6Bq>)Wx?YH|SQYy_&)99wUneP1VmeQY?w!9KZOvAZQSSS^MxjK=Jkb!Mn7 zm(hX8qEWI3!)Y(+*BEx7KUw~_k_|^}2-K)vpJJ+-CB_x_dlid>v#!j`j96zg(-G88 z6paWlB03hl`9D5ddHvlG$a;#ULj3&QhRS}WyfTzmjxjK8*@X3yb@7K-(5fq%qIK_V z6{9$)JM55;2^~iCUy&Ry>{J#Sq%>l*a?)4KgEH_7VJ?EAYuYtg^hO}ru`);O$Zb;@ zuMmky0nQqN0s~i%#?Y_{O(6qcp4ZNiQ>c}qnbfc+^?5Hc5a`|xA$WK&o3WgeNOKMi1?y%#UDjhGA-?BOwas(Ff~`FG zc`LHYW{+H}k$Khg&iQt=lJX>r1h$Sa3t^}zsDoo^@1v*Fb{Uh;ax^9zqMUZ<_x2xj zSYFAPblwdyEbJ^~llyd@1N3pF#z)n&Jy)x?R^tjA{d2~3c9^dRn-+@)zpcf)c5!3f z-?g^CuF%jz)J?tZF%0K!Ww#bBeomS!oM>k9{d)%Wy2FN=eb;E3gOd%E?Dw_&IE8}E zVi>goBx2m_O_^499x8e?n_5dYzil$1Tja|F$K{Sx44q(@+P!(iEqXw$>LN>P&7U8E zvCJuL!r5IvVn%lo@Fnj)Y|W3&Qmz@5tLnk_qqzVB_VYW~n!fJq7w0tjN@!q$fYGs1 zJrOUN(#x;NFoj#n*G2UI(Fy33=C2q(*S7ZzxsMi$8x|0oi^DnEn4Jq&yT#oJjBxho zvl2UWQun{%oH#}m_u=3;57gQ{KEf_`q;$_s{?EfOX^#j;=$#d)yqA-TGVj!zH3^#6 zd0+hUW>v|2V)Mf37k6unqS7Oc>S=p?ZdkbgLTNYURsQ6CySxep&~E?K_!2j82`5;A zf+)AEc%lILu}9;ym`T~@xkM+HR$H&MKRDU;g*A3VP`35D5IX6Sf)NYOM1nbzfx5P*A3($n_t%v!19((KY%xS=xAQP3phd?xv6F`IMT3Cr<3tr*FWbL zZl5;FKI+(+@KOSAX9T2onQvw~HHf??)~PYhxcfYH>nV$)7VU0LzN%FtF{LT{%3Cu5 z>?3t}|JLNv%}hX4BwhD+n#~mY{;Co6itPT+cft;&17xnX_6 zX7G{j)<@S82uOomh|7-3hi+k4^cyDld+0rMvX-2eIEy=JjirI|<;8rx!JtTle2q@N1&M=P5UaAy z&nGb2NX+VvSHGS&<2uM3pt>4Tvv7H_t$~_LdgJne)$`0l^%S}$#mq(ncR#n_H=olAv)OG?8N(Og&y;qIFGHVet};jnICDrx(8cH z1Nx^69>9*O{PT$kDW%|{Zs(zvEs3PL$3r(y=+>m%?pJDe?G$_sp4K!*8G@(@JKWK| zFSg4OhjbBRJ+f~N8ZkuAV*gp|=FL=8KP+miC(QtDstw>YpT-D&x7wlP9TpnqP6E`E8H?I@5D@*gB)ZuMK|Y#9Xx3wYLQTR&*4HRPv@TpkRwp{~%< zh>vb%~|lE(q2}&&mcx zWOe$5n9>=7-y~a~v0F7WX`S8Lv!6Rh*I6;9;}-t&V}~g>>*u0wE3$woc!>g>^uxm& zeTP)>Mec-2iEiyup^}o=GYJjW6Tal;lOFs|1joxY_YjvSma#LNb?mn~eY8lixLjJ5lWc; zeH-&&_uP_L`w}zPCe?ZNkLc1~#AH8Fh|1v55G3e!9<=hnJE9&C*l=#XB?w~_xw3u1 zp|aU8d`UwWag(DW=MKdsNk%5kuGz$~li%ITW!Nv$3;B9 zj}&}=80JdQ_L9clY04k3$`TQqaQ!@;{EH~k+Ht#T?hR+5z0>8_ln?j;vc+o?8?G#W zfTMlI1g%;Ux-Ce!A9plhTd0(cqMd9Sv?#*RbIY4@88&m={=Ad4(x>{+uw*_TXe= zJ#ZV1HGkpNua_PEnVw`u$b4rBIEZaK3QIK%;pUwTQx{7a*EkrzU#~6YhhtVSZ(@WqI?9V`ElL z*=1{Pwc*%j_n=)Es64hh@4U*574h{PDGSLWySoRHKdhad+vaBk6>!KCxQJ&g_q?M+ zHc2yyvZVb>zr?@Te*h+*=s(2q2b)<+X`t4_&W@Agphki$9$2jAPI!Sbn}ZZ++ZyGm>i z(iQD&d-ZzMEMzW-wjUCmZ$cRiS-2{USjYT6a&4m__#YxPdd8Bx z<7J$xD)(v#X5nf^8CIi|=ULyE@jEsWeJK5v6A0iQ4lg=IN*Y}+Z%wTG`w%ALB$K{Q z1RndPM1gYMTlJln-RCjNjgD`7eOa|DP3(eEsCF)x_iB5XYY3#z>$TiIJ4&92b6M%8 z<|{qA8c*8yjxDA^v-h{Fd+Q#QQH0O&x0AJqe_4;7DJ@0=qY` zYIG$3vAF1Oe>w8HS>WSqmI3Y2r5OSAU#Y3IIR@5w=ZU@0JSAn{c}{9|a+y`x-a7CvrI_|h_X!9=(ZU{wgN zUBpassNVloTNTowEW*I{P$1;mmJZ`eHJ|*Tcy{J56}mE#Y#M9pyEj@sgJeD)Hn&-1 zg`IMw`maWZ;blGclYy+-7CgHG0aJpbTkVr3TZ4kW!@_WI6o#nQH(?a3z&~F+T^I5r zJ0~i3)z3eVT8Y=<<#n0~V01ZWhYPQ9-+C2AjO0eYCmd|`UCZlVeI8l8l0ZeP68yx5 zm_d~+^ogk+TdIeuPq(M@`Y_!o*7uATz16PqS1lc9c=qclS)F7LlfE83nC+Aq0S&_V zWeM!%6&d|FC~enI+}NBDS~j^v`sG>ZNLuC-v?U0(&Uk z5l5+I_v!INqjb}eGWGo@+rxt6!?*mj5XR2-t6e11c)*PStr9e%TG3Q#)%#nRQeFP# zq}vysl{>HqXG1ipPLq+rb?50s^n#Wv=YTrpExCKSkAjVzsql$ox2it?~)X!B30)k_=o1*%BZjb&b|Dz?o3- z8Dmj7huT^<{gft#UNs)=nXuUQ-Zcng;mbOFyB`vwJey6uCSovAH}I;H{-Xr`s?kCmi!L@ z^L;eVaE-3UTY?mJ_epZB1?wrc3J8AQqFb)H+~~{IPu7Rr-lG#Jf=&g{^+*SAp!waC zkTMq-%WCDqLIeY*GDg?$;a|o6FxZNrL#N{)ugOZvj6N%`s9KG+YQKZAR=Z<6ODkH0 z!VkHjx=Atx2B@$Egy@A9E=jxlhRm_Wc8U9D-WUT_T6O{Ij5 zuE0@j7}A05vGlotai&nSpjx~QPvp<)YlzFL=^4=I@5(vpoQ2)u0LBBlh~dmmkE(ah zV|(6BoVnmY-LDgKA)6{O%?o?9nkU=G-}3=KCn$}ALgdwYt?b)I$r=dDKuMNwbUO+f z0)qetf<@s{ei}sEb+&$wpN;fJp!2>Wp(-m7Cd^J%+-Ox6o<|{pg^4vyvsbJ4+SFbd zUv(5zgwwZ&p(ts0FG^JlZNB%r^(2zMn%LCCC+udKY@X$bZosh-`Y zhQyN+oKQ7wuNjO$RF{~3AzsIXptfN&x<-j>*M6NaM-R;5cPPNV>44GNCAtPz>KXBb z`G#thcQ5wRmIXQWpF-dxzA{Nz8W6YZsgl4dy_E1Q_^oD=Zsl|k1zZ*EseD>R!BQ_jCdO>m5h9ryDX&)kHi?MDicKr`+@ugXifU+ zdP5rHm_I|+cB@1SR(HlL(uN>3$v0c;uIP4P+oG#{H#)m=oj(^?ZY2B~S-k97df0I? zyXJ6JR#`3Kc0jYdI`k%3>6MLVZTtE@bu0P6NQb^hGk%A+sn&uc)KjA^f8B|mijPMu zIQax~0g|BWl8LZ@ExQU;wKCR&kpCMiR8HpQxb;vsF|y2~?6B7Ekbm-hIJ=C}({oZW+m zNbxId01oiOQO0VAjnMJNm6W8;#LHLR4M7s`$3=G|V8Bp44(aN6SGZo_tIERI5*}+V z%%GoQaX|D1Qf9aL1Or!nw~l|gAEm$CX|;^BK%=e>Qisib*D~Hft-+U0lv)O7Kp1Sk$`3V*L&U1P;95&!Zz$_X>CcK#%TesvbK(f$&l z1fn@Dj|*7~F7%u{wzNBEbK{O}g<=MhHth{?rFn*C;}D~&?@i}i_n5uPB5aN+mFY(3 z5o>vs|JM{jQDhyM(j5Nufk8KBrJVu7I}wrUr_ggeM*A?m&~x+B#8j~ zc)CSwi1u0;BaEB{Z7Ou?du_T1k%Ik(U~c*7(sq`KFbGMcqhCW&-Wps>Mh=<|pl_AiS+XeickhU-!mN`_GKbmb z!;`P7p7NBL`b(Bvsa@*M?G~9mqQBI2=UgDH8%kVyTF+*ujkgQi)-OCjNMPxQ;SJSd zUv+N?Z_pO08kQv&O1>b`>(G;NrEW(XO#PS0W_d;##G#oXWd_>|vUb1B*5Sy810jsm zSHc`_zFCIN(t<_dqj<6dzqoc zraDV}kAEZeK4Jf;oC{&2ZlG|fXixlCvukp|z!19=;r66kO`%H=&%}aR34aiVjc_eD z>+xRDz{Em!mbg2EARVFQ_Fp+6-$tq3?v0Hi*FF>77IMkjdgVE42AG<-MVWss!sEMO z{A4M`c&Gm|S7>T6ZJrDWJGTVq83{JHwoUf`es$v9p|W7E4O7OGCRD?*`<|_5yHkS8 z-#2*gmJIq=tDRV@3d|m19gbX6b06hUR%2d_8=z+Kx{Hl;j}pliInJ0F)TdkOp|a(- z;Lowww~pc0f;Ss&PJh`7*$Fjh2@c2<3y=tq(929oPH7iU>iVLc659P$+woUk?c)cNeE;m!j}tUE%Q3=im*4m%HFRGL;AnID zPOL#~p*L&YDYmOD`QhVlIWyldiu3HU@yVMk&VJsPqeUGIcfWCS=#EWM_4psUfWvK4 z*HJfO9pAh#I#ydS_Os+}w4Ay1i^1N-y5w20r1Y%8HFDxzRyfnuc`D1o*e*c*(^&kI zPfg_aF8f>0XtkV~-179RFr;7o6;P-UPBqTIG#4wKr`(V@H*Zwqs6VnC>JY><@cmqN z7sbvYh%s^Lh<<*fULjm_HZ>{3Tj9Kd+llt9HGSh!v(80f?j0^IkG`*cWTG$)8tpwY^m|@+!SVJ`~=R}yvX)y2% zg@5MV<7T}ubLdW$3aT(-t|Kk04j0iLZT_Mm`%`MMd1ku4hx7G_vFs;14m_+oFZexH z?t}>w3U&PPO1qB+F31#>y^{5f^Dx@_a>tk5)6jA9i$|rb7?vV^x+UAsH*DH!N)F=8Q6)7Y&I8e!6XBRs2RY$ zu`&b-Peuqeg7w$l1e%~~WpZc)3SzV?hpxL4N6k9pvW9xc9zsP({!TIN8awywr%Sdl zUcNkUgt^m7HE3O-TN}3PXmZThJ28KBylCQ0#=Ib*l=i_lkqd_oo7@KLPzhOCBACwy zmS}g;?_q_zPHC)N>ZP?!GPcJtW#loPHGcT5$jcB~I@W&8wf>HCntkN|h;#84M5HW- z8ghk2T$q0R(AioG7TgP)!QQdDJ8j)A^S!dVHfpa%U{p9C;^0tW6Ir=a1`QfYlcoNO zl&($R#!ix4QK64UYMGiZdrYCf+w0w0L47Kb&@WeG1WDHN^)W$ZURS*C@|7DcWq80n z6C&t$CZU0Ee@%UVL}T6trm}%5QJBA&59YrM@|ms4GX7e*ZDbnFjaC0P5je&FR`rb` z&?;EsZ7V(U`^aiEgH)%k6LI&5u4S-p!mQ2Ex9UFgm-R*Dwcu|tZqjhJv*TU@RC@o$ zj(x*68CK#WrCsl0=Etin!W%hUy@u>IJx23lDbEKqnLOV}hhA|=M|yqNYhC*i!JLBk zcTYsFo;~8e*Q4i5+;&Y1+>7`H|DTC@TEI>2&k!7ZE{H0h=#pK35%P4w@gQtDdDJc- zo~(1D+3ktXMNO-WM>U{t?G$RKHElooJQ=8-aCowZ{Oih0@||hl-Un-NRMF-_as4Sr z-z{9TG~*!$WCQmm8h>=M*)w$Ig+kZDoal2=(+Qi5Tl{y_X_m*`yOJs%wc zinyLeMRkdvQ!tnoL|{j0m_BpgS}U@KTu{TVYAaNk^68_0Crn4Z6&s=P?I(1dIpgH_ zQz|Aa$iP$&Rb1H7RwW-~_3767)m>K!I7phhE+&7E@=#iBkH?qosf#TE5hGA%bBVNU z(mi}VuQAZX#4q^HXw8?IpNqjC^1!_QZCEj2McBvpaEkcZHlJlVj4*!q=DH`qveG=^ zr4-#&AJT83l-bgck4r@fuFtvA#$hDkHaXbcc%J2eGGL5}Aiw=BI?-EQ***8Q@U;o! z-o0t|b9OeBl84sJU)Gg;it1?Co!{#Jjw+!nF)0L+!ueX3LN{ZV3RFDNg)l*^=KDf` z#s5IiYNm0PwPdN(3aKF924i4Uu4YOT%P&|B^cXf&erRxP>n?n4zcKj!jwZs`{HQF0 zt<}?t(-)1$B4L1wc_t%XQ~({afjGF{5WL2$YkB`hbmb7d>-x48ZJyRrmN%5yvpIw> z?|R41Hx}|*aqO=jyHK5})rs_(fWqNDkqbX&Mg`A0DYySjnX9Rv$OP;+uH77vZBi$o zcJHO3{GVteK$QT^xF3n>F`Wy!X~DQ+W5%U$CkrM&fA$PFLl;t!tJOql=9o-m#nW|D z5;eglv+v`lA>aQ))O*G?m9<}>Cm^7qSO5_aK}DK0r3ylV1*I!Um!{GQi1Z!{Dk6x| zOCXB$7Fy^8P(*s~RXT(kdJA`F-uu7z%lziU%;998y`Qq4wbovk-f^92>5hw+1=)n{ z82Kx1^}r~hGP9no`g@jGz=KiF68p`Lw?m%&$s7Z26*o<)tfJq_?y?uI@Lx&ZWE6yC zj=9wsMz_$?3SiAW|zEkfQ@_=&-qgS19*9jOQTi#=Qz2;jaA3LhsdTB9~L9mgctJC}wZI!^KfC zH_B0UblEM$(J;fnP!%+;h3}Lyth8V~U!yg_xCD{9&~MtY*%?i&x&=*83Tu(?65VMP zCFIZE@(q2}U_HzLMu8;gQG4k}^TvRXr213*th+Gdf#CU#xU%h3(Tm4XqKUhB5R-1k znlb+sX<6JSYS|qQNPt+F65@f#%)g5GS&cfFPLXKDV6<-1m>XYo05cmde(DIl6*z9$ z>G1RH@iIc!KUCfevSFm`)OUWIV_96koU*FFp;}D;0e;!4gm#8P^o;`}06&5P8VrCU zsRX8@CH>cRF+NlF<-Ut_Mln?!F3FEi`+vs{20PhpPsW$8O-C2~Te~{6S>q#Bx)Zus z!Ys+|0Hj5LWZzyU9RFD(?=n45iOQzCV%`8vU7tvn0QCA^cF^Smo6 zYx$H>Z>m=Oa_D;XQcW0O`G<=sh!6}u4#;*?pWo0&T+2w0{AL;ahgv=4~WNtA??{;*Dhhr)WnvZXM*$Q9XQ= z+(r`yFp0j?$3<45gv^=7^`T2Fz=;SDDFGF^1G_S?IVC5%ec8*jNg&E}e*kPEPOqIz z;pl%;=+&>2hu|=$<3mmrbM2KzmRvT|>DQW{?+!?pNjxM-`Y%Ya(l=9qim27pQ>Wi- zuljt0ScZ#@I)?fxywcD7;gfL*g9ZUU?zwYRY)AKOug_l?*k=8%MU++_JHIy8#`wHR zOY4;}vgTYgBzYbV{R7n~qfL=D;nLV1aj}WvK4;hv6AlHZr6tBZZCDvx`cms(e#yZ{ zsi8}0So!F~(nj49E5>tOYip62kagWUN&7NE0Q(fE4Z5uX%aW*-fs;*JVKk0hFjZlt zVZw)B`ul*Z$5=Q|x2gh7)xy}ZZv6p1+ErA?Jdj#)q(KO zp+3~SSiMZhUVeKzM;LcuDM}H~VGd4Z91BuWCeMYxIyahdkhj&BpJ(gktc;8S>I%Lu zh`h#dF~P{?NA2%(O8DTFuUU}uBt0$Ii+$Q@uS7mGUseD73pv!-R-nuMzdzKnUHidx z9B=l7uUAmea{>cq^=`FTBYA+nyti&eam(nzYx;L1`!}0*jwTxo>v=|BU;rxj>~tzK;4|A^ZQ7?@B@Pwo7CK$(olMy{%Sksx z4>%2Vyl5y+)jHM8!(l&0uA92{dZ4;5n|*;ochG7R-}g^)`H}pMc^zrJyq=W3IP`&c zRodLb+5c&p*|7`8d&?y*smvh&`}$nq%zYF8?K~;BMOKv0W<${y7q?zR4VZKJwYok) z4=}F`jsZs^yi^Sr3=qABNUxK6TGsm`lCD~uz?OwUCm&EjHarIp#**b_j^1gHEhgoK z?fO-uK8{p~LDgcBGWFV%H;85+^kj`v3mEGanihGl!MO z`Lz-U4QFZ9D0tttY4cHrF}!BeU;uA|ABoP(VoU4}9zW3cCJyLFwu_v6XaqJh=zqIf z29p;rUeeilFLE8R3nm2TS6%QUuHu-@ZQfqxh%Lsk{u`2+Bb)C45k&CW6w-$W)?x4v z^mg!HCKYZkH}8@vBlhBjwV^(4Ci|~c0*G!l8JaUip#bg2ndqAFzpBIjCQTNN`5w8* zt&`GNlLzX4ja(L|WcUR9PpAd~n!wV!4|N2}U) zPjUC{N{sg&mUTBeF+j5B>na@3;x8RlIvL+bcTMD-{JXYP5$k&UoA!G_1K0hu8hx&B zGtYFp|8pe*Ca2P8=s!OyNy^g2;7=#RvDf-&=H<>+y33ZIc;|I9D~>r*{i?v zj`~#T3>&fk!P=b_C^p&9+wAvimkyii*QhYo%LeR&8F$t zr8Mm}@!qHHS34@4M8q$g2&WQv>Mp-<)@>1U5~UTA)KfT4o|D_J3wY!hJAClnd4|o( zZ8qyzDew3nqI+pC+>zrN4NiGl@_xwv_}W@UFX8y5pUgRkfr4q_x=i&xC?kHXv^<>n zkk@fKPz5MphL}9Rbk@kvAB7}Pt(p9Ud`;vI{1moAjvvoy1w5!Wb1kezzBzpMSl?_n z)bywa(ODdwWPmB|#;zHl$^!8ku+q{KO|O?aK1v_0J&0(s3SWSri~pq2|LlA@YeGh7 z&lnV^tbSY>Cj~eCEY~~#ZUmpV>D(a#!;HUbH#h!)Hz{tn8-CW6*gVXvXKNgVVuLq( zZ-lfH>-mq1tQQ~Ikr+1<4ZN9?Tj`$HaX8`VKML{7Z`jDL(-JUz@aVIn{hXpTU+0IZMFa_TlHA7 zXExibgY*&LZEEMPtbWl*81KDy3TQ9xHmh}eZw&_Iu_Yar7$p$S7Zf6s%eGv~Z@X#m zlb6O9Tg48mU%7NQrUDJo1P{%GKewLc0bTLJ`e`tRHjHPaLIY|sWb-iIXQiv{b&0-N zj~n(y&fwNZOsdCzo83b36YwHLKj3h!)JCsA|?v6qF+y3`KVE znjJrk-$vf%(#~<9Rcd?r5HwBQDRJ@Dm`8X zN)Vh?12v_@fI7r)i{%WzAInN?na{D*eg(D{4FA@P7EG=H;lhq5=_TB})i{_2BS&WQ zCO00NY`Ew@TaxTZh~g6^?gs3SyZ!SC5G71_wgoE#Li_rT=(D8Uz~#FQ9qH1F`=(xk zE2WIse9BJ|SC-qc*{PB57gd*~G4FO4dM`0QC7+>yoEmw=V|;`dlg zal@&oRQwXb0;?j@{+T$Pb{rz=J4YWG3@yh|;T)1Xb)e+aikXRAKHrECbcKQ(D0vRa z?3V7!OXuB|Ds4>+R`R<-y~NAU$3T>*1(i>JW*6S9h4`B@savg`EH5IaQ9tk)D>Xfv z>PnJ0)|?}38SVvER)7JEq&ghyGG#@X@W1lRKM(x|#1G`%3-be+es2*hVn0j)nzQ`d zLrN8PFX{z+Vpa;^Qn$CgstylVZN0qs{`{fJxrRU_%OG}q|k zQO)(+yn>A^HCMl6*mw8IEwMmP{(Jhs=&>fF+sf05jtOe8557%3|4Jo3HcRps)`9%< zvEdB_Y;@AG!VK_xB8~`9J>A`&>t}=2m0qlTu)sfi0Ak^}sw`vzS>MfRG~+AXm~Gz$ z2*~pRO4g5I?<3bX%;+A3zYw=4w{G>A3qtXD>ZmeJSPTp8WGdu4-k%X})}@xPN0$i% zx$~{<bRWg1tJRi!JBoGs}-5OF#X?h@tsB~?2x)RNp8 zMNj>|q<3JPG|Z%+voilSEt~~iD+j{UUe};RKnh#Zu}SV89m7Zr^90zHwmW|S4fa`5 zj?)wi641Q?t>E>L0|WGq@+EU`W;iZ$dp-q0LOH2VefH!-0UC3u8cpnlvvOfzv%GXw zsS9)&?+tDzbBc&)-m`~n?v}-#!t8DmI*Zu|F7_5VlZRVzydCcH@nF9CAPv0qhsedH z++Uio8Y9px_w*X{Uq&q`ys}p+R>C!&@Bu{hlR_6{wO|7yH)@OWE=2P_Ejz4C^x5{1 zLqX}EKwVnJFdOSDYIo&yzm1jeeU;g1aL5f175~waz9(~;3=E`jS10h){PY+3p z*k-CUHpzG$NkeWuexh%|+CKd@CAe@lW%V)*uI=}4XN8@mRU7x&r|&RyJSz11Z41B` zKtJx5kAo$cd8DpNbR*IkzZ`}|OeV{q_b2ntpv&2p269dOzGMf~-iO!B%5CEmf9+@W zX$|7_?f3yIRqTJIhD*unkug zNv1@b?jqb~NG@mHFCedlJKfHbK^#;xwQAl{fn69?!D!v^pv+8loKqk!<#@1yfSKFi`WogJ>B0XKoM!8u&v z8rThg6%q>@z$#7~w zjW_e|{rWVl$shXYYL*nxN!IyUeS-$qIg}Pi6MZdhF-!T2Fs@rFDP< zb`SJC>7zeIRd6HR$z?QDp%(dJ-9)Fu{5G@0>Y4Y@N8*s0*8GzGaq_3VgroTI(t83- z;RKJ|l0ul{sd>O@2NWqFl8%u%359~x@<@kMZpS{G*pWlSCy3T%1j@O=53(T4y-`Xi zJExwF_nKWYTi9)Ezq!Euh_2eKXV1xBPd{=0y2`0Eh=Fzz=p`410}KEUx_{FXLR0Dn zMz3$~TZ%tcdM28vx@!RqtF%ajt)!3M`wI}2ROvt#)(5&(9XA!p?@hgykM%@loXP-0 zy-}=<*pqZT9(U{L{ZV|p0!^5CQm*ds2Zv)UC>@LyQVpSwS;|?9P2BwGzInTd92jOi zMw1E&IcID6UH_k8Bo~=+&%b4^74peD%_*cLisx(uS_n zm=7vnO@rRAcs(5a{`Cb19U7u++ZKa%`@C(;YSw8cN z35lcXP+i&`2tsrR25bEufFMy5pM_JHgZ;D_HW9Ig%QPKl1ks7hK0TlmM@gT6^gw~| zh*>bDC~N1erOs%izD)|4WF(C3-!0LotpQ@iXi-ytqX$}tv7dmKmLvngpr_2~F!qm` zPv(jsdH~g19zr`WG1{B+@9LMo8@sYhSohsvYAm0(1ZQ#1!Nba0_li{Lhr0TOzO?&r zbANJysn^kQT762;1up7`b-;8$^LXz#r+&TJ-N`k9>Z2cC5v1}T9;_d`2|sJ)S9Is* z{|q^tgedqG$KZ%t8g1clX5JGNZaG@fww490F$`-~|HOg^W4|%kbz76FZS8kJ=XO3d zl#uQca0EK{J!5*4(~c@I-Y)C@+2uvVOKUE7T4co!2uNlGQ;M_ZE~iw5M$&*dC?w*v z$$~sr{}Fyi=bUA;n2EnCzg9rLV>B)G$vL(1HH?_uVQ<s+DDLWp*>lYqeYu3Qu z!Y+SWj;vboiXv>K4x8Y8NAc`2W~G_>U&vphqS92|Pa>z*BjV$`V#SWX?t6@-hJNpT z=oLXQ>pfA-Hn;lX+;I5M6Z=aD*J!y14M4|yC&YsmDSrh9epGzevQ*`QZ~)+vRzGq^+2}tCo*SFWs=E3+!F-*)s0?+@8kuOUwewC&pE3 zE8DaB&H93L%)-Qg*;icPDx2HCVY7~&plG%8%BYrCUZd{idk5;4@<0UemZbfZ7UJ;& zTbeghrtq=jsJt3c+Z+NkXP$@yDvtRrnXLZMPbPxUP;c0%rosJNFpODKWml=b9M&qwXj_eg9Mn zl)Zn-HY?6b6PBQG%xF@&`Sv6y4fgEQfEl{-{T7)?89?5%@^eQGrB+wb5ph?cAx2RC z1g%GO;d6Z=*X6=*$(sS@Zxualu_mmg&7`9z+O_4($v&0VQB`1-rt(n+*C zpl2bUDHr-kB@8;-D|HO+sea~T*jxs~v;&nQ^G8Fb{}m9g;58GWr~3KFI^wMf=e|2b zAr1X0s{w`*)?=mXMVl$ulG6EYK5fXoykzA`duY`bu#HE(2B+?~!*aPACZl?SwXQAn z`irvV56$UE5ha$+R->S1t3a@hG2f!WmHU6kWj{|-%qk0PJ}XUyylQljki9W}nEk2D zlDPBU^o}mdtB%8N?NWZb>Ot7TLN}0}5pm4W&?(9i|MgZF>MA>T?BVY|CgyWah6b10 zy4C!Ql|gu4S`{`>#8o6>*)T^co^#!vED_;6(J`N^up^>z$kh$T*58Ab$Q@^(PqvQMYyOX+KISyf6 zpjKWPM9#+eegZsr!^%l~cewW?ATsti->gZ*9P~)xQ=~b8^)iT*VIT9`?k=i?^h^US zHN58GH_%Hp(+Z)&gTX(GJpL_)mwV3GHou1?gV*(wx^}a)a&J3hKyR6a%u0hUI+`_f zfI{+;s^bS(kpjq`32K5lA{Y-4eg`=x6EQMnm?RWJdYG$7crmsJbT-0;3GtoaJ@s?V zJ>AbBy9I??iFGB2Jb?ZVasqym2+c%_1c$6=bY;XpLK0+7Ayv+mci-h8%9c8MeY2j! zq(Q>g|70)@?ej4L|K2DyPSNWp_C&6;F`i~ZTY=^`a8?uWjykm=zXK*`c_b7Bn6fox z-&>d%t?;?GZ?Gq*>RHcSSLFePgCxy_JV#GODX&HED@Wyqc5)dhu>Pbx6E>D5ip_1k zB4Dgi5FHb+`gD82Xiw2K1f{bR#ns6{jXncDhipm4R9JZ>`BPq*tUUKc7s3{AqyJMj z2C&X2SQ&g5>Uz^;4^9_-ZG0|fb6A|FmskYILIoi}lJ{bNzo+Dj7xy+VN`!-=G;g*V!%+&<|+j$_MU8R^?88CtH3-L3Cm1g zu?{FXx(+=M9$sehD`a=Jn5s06wTbOA1R&uxmWWpoROzqW$$vbEr$^t@yltKmn8Ma@v zX({a{Y0$U%v^xiG8n6qR>IMSB3#~3@k3|t|77t*Og}~uM^k{;j0dE-tP1qR^bb>jY zr?UcXunmec6(HB?18gl6up5Wc{>U+sX}xWWJowe+uZb0(NhJKxXR& zXkBbaPi*)*L9e@fsyTi=Sk7;qX!JDkBknC^R7+}kmwS7|ZO`9%nI3Qez?wLG zg=QkdY4x(#f;vi$wnLio*wpJ%KRcHajp^@bL#*t;K0O~WIhrYG;`0h87>YlRO2FR9 zdqo6OaaeVdlO7xS)Mp*I3>@|~g)4)-SEj%?X1yA!(8R>hYcT~Y0&+JffxX2+H^AFr zCP)d+Lt0*adViOCh{A=n8wNQ_doK6(_t?FSv*25*p3hC}--=E&IDXR{beQ?kPpwA! zALlhfZfOf>sW_KK zM6?!`W}KSyhHYgb#$P*bTYF9b9c3-#YMyZ$9-k#jjCQ91FoKBt2N|)0prr|giWdVQuBKF!?&FmjzPiuBmvY`Z<1h6hz13#e?kax1 z=HotALTs=)Q0u8MKNCe26Dyyl)}K^JWI#5@`%W2(JT=6yy<@525S?ZIm}Tz}TyQse zYm5OB0X!0jpSF9rH=Lt<@AewrRfUqXZrdSfxz#ZYi_Kyl;>(C<@QJ#uG^vW0f_3q* zqJny2v?O^*=j#~wR?x8Yvk}o7^5WSmBEwvl*r8>LJ0`z!VMXM6`5XP0U#U)d2h4xC zPA7=AJHWzGbRt?f^D_~YgS^=n$v+Le@C0TK?wbTG_y`HN<%tUT-I&)9m8;$gV?pAc zJc#zjuLg~ufwF&qY7QUKC6jk76B{P_3eK}Q=*4z)V) zaT=&Pg&dAi+;~6RMqpp40=UW;or=Qn&R?|@`|iu=sA%9Mja8?9>O?!R_;AZkw(6?y zeX~fd;yh8%!|gQnZRztNrEfKw9Z8byrc^d{3LG?suf2(icM32>d^`Mxie?*WtGCy( zclNsJ&<+)4jBNh=V-{dUeX#P>@C-?&p8YKW(>q2{6;7do3r1nHW{bK`h6zsN03~) zXPt0mDEs1>Q}%%@$o_dgH}SJP&;ziwKB9Q4R7}RugH4gS^{0(iCOLqng1NthQN76g zOpjK_f$Al#w#tm4*zouAv{;i?45DoNCKO%_iXfRp z9Se)?KB|5}=&i&~z7BLM6AZmJdaB$SR0A(S&nVj@D!W`VV$6NRYu;h|#*g@~!=kJF zOYZ)n`!@y=;musnS|r4!JfeXA;`72eEjx0TVTH^f7;Q_2%l|GNqrB)&>awbvd%e?1 zaY{% z`7WkH@On#=L{AI1JJdkqa1aL)5~!`3cSgMjA^?f@!1bEEYzqb)d?m`Gjxuhzc`Dem zar|C#%1x8|2^|(QUW=8cRMd{^pK{wT26qUe-K97BoJBOAs<`uv+W=&TMM(Id#dRms z#kFNc19}7lQ7)e$wL~B}G?E{Thn4brg=oU=Ay|h>G+_oS?Z1~!wThX9;9f&RgI&K_ zTNCBaR=#PR7=3Z{6Hs&>x+=bgG8{gX|H1S2!_aujYcV9!b{n(EJ_-2-Tr;E)U|c=D ze46+m6g=Dd4;7qNpU+J9JC5c1U1&MGwDM<$j9FyF8gdMzGaw8}Oamc(b!1v@8`wRlHk)Wo&t-B4E9UTD6}I=eg~+n+&b#j^0X^(aGu8385cQ-1 zefPcm_P!DWfd0CMSE@?`kiUoqS65NJJsI%D&ll~|>rUkV_J0ZY`Pkv1!J zm@5Or_3q&$^(kmbIdzN^U0iCx7N_E2_YgC-=ojQi6EKvu7B{EnAGqZtn%8Iga9D|$ zy#Q2MhrOD=IBpDnusa%Xbg@MkRmGbPI-eR!vX^+ED=^z4524TbE2hOtIN5Z+MO<0v zOG@+%$s~;{G!s5FuKIG}8*uHeT6qOWZ-K2+E)yLsGK&X$-{J-e-Qnwp>bEzqVK3VbB`7ai6@;S?#XoWn)59;NkpQI+vn#dLRzl)6)tXAiIW zU!LPN5sxvI&D)zHT3x3`qGvLQMx`8&)QbQ*stm_wU)AFJmE5>|62uws2wp+4oa!fm z0zZ@z#$VJ`gInJsqaG|b@HL+4sjeTT;VopTnH~|Gp{J*1q(gLJ3vVW}vulX3vg6ufBTT%=D-P|l(=)$i5<8YU> z=>v6sIM1c=9Vx&x@+Dlr-KlxbE?hDcW_&vSCe4Ha=n}MJ9RRpmBtqGpjh>iAO-4Qc z8|FAJZ`$EJT=f&s-2?9f)tgW3n_qCfeI6qwY-2|j`GtYdi?d>c;+to&!N#+Zinc!s zS5#$bCOngF`n?6*RzAOnP?7k5&jdSubDz51NL~eIpgBDU=#pTRHC*>@W?O8QPpZPR z@dXj$>6zS8Yid=`H9RjHruAxV4B)?E(l`B%fUf|52{U9wnGbw*_4Zrb^v=wi2AYzb zoJ`_<1$hcT?h!${o>j#~O}{uM{wA;hOBba$UN&Z}bKKY+z7`I$RTK*{wL%|+K%G=p`eAvcq9xD~lGMv)AYj6(@t_XMg>~|P=3t(4RS2vPF1BPem z558Z)YJmy!B&GI^}2nCl3ia66jQ0UBT zYDB!-W=RUrsi?H^11?wi9Q$b4d5B_^TN5{z(vwkO3{h0;>Y>ro%ZG2mPqcoeGHB5d zXy5`_N6+AC2dQOxC=ifcD=ywEc?9Pf9lf5id-6072iY6KMQJN@nXcy0ghWt11bdD7 zCv-lVUw19~s_O};77)8c+V7QC{^hN6D)9dZ8jW~yny{6X$mq7;<46caX>09mEqnfL zl~#x*u^@Z>ByJFV0V{Zn90EPMpC8sw>e<>71O7so0!4{Y1yxH*@$>$_|4S?@yS!iY zvz&dk)7rlqj&N)ji5CFc`xzafDsXd)y>g3W$hD(x!S50n}AdK>`{(Ygd=2l5}7 zjT+bl25MP%+1*rcCSZ}JzI_0 zpB$JPCe5HeIGE`c&!_of?EF-PSfYxb#xMsUAl~md@w8{6X`~FZOrazB^P3)dhvR_) zoccq`3*!&FQ4)qL$O-)Y`EIqe$R?}gOE(}?C@}l5x6RWLOwmu2^n%v_kGf`mA(1bh zgUIhOI@LIvQfJ6K4Oc0EAUrKpIpPqZ3F}!tys~~x4D%1{@UH-qF-Kj4g}Ec-35`!7 zB~-MqOu6!S>L@Al=_a##!-?QGO5aPf?>HMmVeg(fssuiLN4Q}@(`MIvb^Ez*t3Ma* zRxbRMsZi@R5I8_NS7T(v?gbbs*RtFG>T{mZ2kfb93=W9RL0$%`v1l7aYJ;kFt(o5U z7>N;a2&dCB_oOB7Jaq1&Ea#{Sngw0?M3tg;z5)u(njuvx^R4y|wMa$Rf{L!0(6%vN zFZXI7&N5PgcKv|_8HVN}ghe}jg-%}l%YVzjaT~zdoWLlWq1zMozs)O{GDCB?_wtpA z|H7+q7GxwobKBO(y}A$JZ>%_xz-OBG5tAb25YIa^;q(|W751`AvYaFMUKPZRYhZ?M z`)gx5MezR)f}mI-xuqmng2eP}yU*`^F;^*wtmeh10e%jr4V*0SrNCPN-p8pz4}l6h z2t&1YJEdzOCIqqp*S$do#%0v#=%uB@lx9Y4sCOu{JhAmQb<~joKfC&zrhv}ohS2`~ zU%-i29#GhxUFKFaQ_0qJRFDKE5=9Ap+lvCqegrT+WS^)_62d&ldk)NiN;b0W;XGK% z;R$YoYQC+irQTN#r>Mv+qd`$yuL|=_oL)bB2`PEF1@47#U0d~aCrcd(fXP*O;l`;= z<{5)M(#!#PKi*%_VuVs{$R-QG83`iA3G_7*_xS&&$g_IW&vA0dDm z?K)~r=?t?Jf0GWfg@yOR09Yn z+JljlRjg46!~>p^GH#XpkFRG<-aiH(3$L?Y=ZOj>Ab z;nmG+Y#=PDye~irkCzlh>ox_ybZ3GXJTr>47aWRY`i0uYH3cDp03t~ZM-Aot7qITn zT+2!HJNap_A`vt05l(gT;uE@}ZU#ZLYv$cVu$m9wM>h#XE~g(Fpt5XVvY}SbIyl1;Fd~)-ecNi6K^8sTTyNXLk*{VJm zRUcaJHW&{T#iKpkEr5391rv;|o;teZggWl8c^r5h8diERT)hVxH+1nELiP_h(M((e z_8WsC488NSe0pyeGtyY&hPc+;oxxG<+nVyRx z7A8Y+luD|PWMm510<$g0k8`5|ndE|lR>#C>>!24w`6&CLkD+Dv=&Tb2(J$m)N-4;S zW~X5_PL_6zrTUl+peJAve+4|_EF~i5U@PJN$V2h@xMKir;ZEPBWnPeSqolq9x2_B( zx1R?L@-&cqZ0^|FEy#|AQpqBN)1X6N)BCevubid43!Gd4FW>r?oT3Ba4|K}E1^d^n z#7w9OieE$hBMo{bqaKRW;AVF{7u%TFFP(vK-JLSLjS})YPM2TuBu!6L(P%^U zDKxAq{}*a#W+FBKnPVCZig2d&#x$9riz)TEDZb1z ze7hg;$M<}|HD43|4iW`a8sm1CC!_;%goFfkWeN9>35Je!7!SLtaMZUd0mx6iHeg(G z%Xre!+TT4>Qyz|GrG%2kABYsGq-PFOtDu;Bv_+AaD*DVw(>xke=IhN_$=oaTXI7J$I)3EvC}{|?ZoB{&$Qd*{6&X69 zpIep7vr@wAUpz{A|}{jc%~@Y4veZa31?| zo6!|z>=#S83QcZIXZZY*jwD+c9Z6KyZ93^mT|=F~FPA3Okg3Tg;*NmQ^{z@JN#Eb` zJz)d7LdhE6?zg5-h~?eBp#qB#(-NY2^d0k;nYsMog&the>o%0C3qL5Se->~pj^TP8 zECCpv3ON=%erl@i@0hA7fAarb$Zv=&%ho)CD8dG9@6t+%sxu~<>NdlU9pvl3CJj9JOgksdY-adeqs}>j_kj# zKIbkP0j*e{UXA@%F%4pMl<_+%(*B9IPPy{`tr@ zS((}^%^Hy7@)eqy^>th3h*kAa+y%;t?UMB2(6uXOS6BDT4|)0cK7$HP?}))|Rogms z{3SJnEpYH4c*`t{2iygtzVKToF0%1`E-C(V|9wi3eWMi9|1gcg?A%8E8(_i;9-<6Z z70(4Y@86TELt7V#120N}=+?0R?|C5BtuC(FT0R^CgS7gAO9X=s((mFmRu~peIiT_D zc|vLRQq7A|R03v>H*Z+s%j8K}CWkie{sIJn^b-WS8ho81n%y^DjhE(;t=F5QpOKw^#_p0v+B+Gs z8lEIc+!VIb)oPhuBZ5jCB!KV~25 zCan^W%D-tKl!gsSV~h%P5gI!jE4QKS$b}W%<8A6c#9^RbvIqK7e|Mv@L0b<-s{dN3 zd-(bG-1w7^v=3R(O;O-$4V=G9aUxx-b;Y>=7py-FoV4iT^7k@X*3WBBpqXfkOhaf% zIZ;#waiyt;bq;>g7m&@Mo8>8}jFL>g9!UqrYbap7(5HK^e3{T z|6V&4*;g2A{a?}s-1PW^l^5U!us$H#sU7tYifrTcaZhAHyTQ%>E#BWVIT(_>92LAE&}K;>3IJ}Fpv-6My4m(Q8f12h1F z@&<#!cA-^OnE(57cRQT@2hhIaegGY_cZ2(wQP>1D3{FR+ip39D41kQ)f{iNuGsfcS zu{8fZG&|R>Gl}WaiRj#?0HHB3=0kCA+IG9I6&PzxZg)qr_smvTFn|{JI<3ceN~r1E z&9K&|!ZTk`JpvZyu4)up-3z2b9ivNx#cB%(^#SZUgV`gM)_J2Mm7}J99V$UcpPBjn z#}WKY{>{~f?kKWPq>3BIC2?q36tmp3M!x#3@Q)^WeB73@tmp+yUzJ5zmdXjlvD-`?~r@yfoqg>)D5Qsx>(ov5}Oe8!ipdyPxAL zo*^r{H420ZdCjNQVWpM4wXWRiB^6653I5tmHp4Y+=HAZy zdB$Z!gtGv^+)x^e7b50Kkc>sysbzzcs2z=R?Vi)M!YFOlNFM7yckW^Q6QV!PlyhRWsvvr~0?a?(M6T!pXI zsJ#Xl#K-<^uNCdNNrQX2chLj5eKCOGnm<#NZckoVtIbe>V_lEXGaw}4@KmOSm>6+f zyTGZp1c}MpyLb~T0#e!hkGU7?bj-9ZygGAXfV@Nxq)2${ z17a9e3?jg&ekR2;1ga4NaWkQGmXPrSUUL4z?>&vS&&Y<1( z!En}i{2;+}w<+enB ztQ;J!e&AB_!uJh^?BP@}_UYO#zxj4ZVN>&02rZg*FVGM}CLX6plP9?C4|fZ#JPaTI zm6HZdf>RMN1SX7cu(b;yew{!=Bb0VDs4~i}9s%srDHB|ju3u~Y?{^@gInS}b9BFU+ zUXn$4UTEEemlc?)`=t{hAO>i#2En;X2pIJO27Vq2B$vgy4?QYcqeBPIohby|1uyk zbc(>Z7^XvJ&!p?XYs^f$mIj&EIjPX+Kq8ZM8AT*}X*}`o1cXKqlT}6%J9O)?t|>7` zKO;KF{xw8e-b6xYSzVgtcBu9)Eri=gFLp(73pIFMO!wba z%M^lsr0Hpc1_=ZHmu2+_m3FIcom#)Q)3mB{>CKK44i5_~ej%S5iGB`H)qY%}={4Z5 z;$HTbw)De;DZmA+iM`gkP!Mq2Gf>s~;B6FtB-Kl)8x{$QsO<*8vMNk!b0utlIsB+? z+V}DU3|kPmPVYUXb)2H`~dQeZ_fVl!}vaf8{np!1sRU#&!Hj~%8@HyOzNmaSjg0fnlxHXm@F4_ zfQaif$L5s?Yi2>lREKH|AxKwWxo!wUs5Y@#xAld*`5D9^f1KQrU5#qVC217AW;*H} zm@9&7l3H)Tc+e+644>$U5VgHCw?sRj96n)tR8z8~paD1_L)^{&>f08pYtK2BhAc`u zp)-z?yDr`{r;mejt4USH6sFO~OeNqiz}9ZJK9sWWcmf>-2*hZe;JDuV$6Jy7Oxloc zbYRu_na`C(wR2S4{Xdr{EK3saEnBC%q-`@I97~lTb1R^|_ufK~buhPJxlg!@5grH-@{{_4#K=iCCQ5S@!njzI`wpmHHN!9}yDm1u?5IX0q%P;kPqvU)H6YedXcE-Vq zEcOq$NBXGT83nhf`7wH|_SJTtZd&_GjaDF}2W$E7>7C`7AE)B?qbb6e*VfpSDH#Xr zx7n|Z2=+fX0Xc%sp66^Khnb6fO$`fjfKK3T;Z%@mw3%&uT@-sRH`wNLN1k%x`!Lje zSu2S9AM5&4(g>+Cp4E|d?qRHXcxcWPgC;dcu5!I0l2ky;c>;R8SH9Vz@3z@} zd=U5Aahe4Qs&D6g8AqX{{%Ov3wfZ+xAt%trU}rvTp*fanwb}A+7XSqZxVp+TtKs*% z+;g`sy zmBDQ68K0X{^wD#fB)}q``aP}|C0`*rKJ)wC3}w8MEw4w53C!-ouy>inD+1Soq}un? zNQUaX4a6=zX7`W)ruur?ptw4<&D`+cEn)7R8qXncRTN;YP8X+3cx#eFv>xE8m%3&E zQgPC*fA-ruBakY?8RTg%QNljhspt!bKid3uHsto$l8gd|D*yz;=W*^O$V#|vUo+-B zh2%-nkGG*P9!kF$0~{g&Y=NX zg&*5G(f@y&1=Da?x|J^9pvb3X8?FiCiF)R5m{z?{C$}@&uIj(uN6%-ym~4k*saYBX z`N^|yU6ZQD8yr$zI~5nXWa~cR!cT(@K?HrHOCIk2{n5Pdmh69k#_)7%A`@yChi4kG z2JW-JbqwPA8xhAl<%c8vIb(B%Ow*iFG~$3+2sl^w0pQ30>gll3Zn8^UKX^&}jRWiq z2zh$Ij|~`?R3K8W-yFUdOdDE%JRSx-mZ)Pvr3uJMLvO>&w^X4d;PY~seuki{TkWIN+#DU5m$tTDMcPt3*rXat@A>kt`ofNF_jb9Z>5~8L z6v>e)wP-7|&Lt8n3@IY4Vbb08`&!6FUnICQFETKatSM-2_^73k-g7V&Mc3SXENZs9 zZ!JY$?AH8px#M9J{Z!JoCp|gA!YIPePidl5Wo`7uEMQb+H(7MtGSm#cU@xRZ;49I* zCoTr#@XTtkZ2wi_K=uLt@JD$U_4^QTt+BotId|-^EA5GaskjjW)HbasV-XSNW4cGr z!N=|Sty`6$M(*4a8~D9MJ!UPL?%`!pO$<7vu`fADOCW`pGSjYbcvmoe@q1!Ay-*0% zgsvXQ9gnsXM@kUa$HnB5=y_i9tnS7gV`pT@qU{OZCN6WWVjd8h;`x{!1d4{A)nbHA z<}mIAs`T`sr9;*smCf=)?0=_dq1lxiII(*;?U&-WUGyQSSjV7BVWK$t|6%IQ}$iC0mO4c#PI>U^a z`CdMs-}5}bzk2znx$pZruX8=lG3ckv|2!0M?@~Q$TaM zSzL^_t#~^T(Nb<~Z~%~!^i5`7B8(TZ*P{$j6Vb1|nZtII^2_nNi;Pld$xnW;^P%mV z2V3kv%3c4|xkIi z{<9%d)$=ZiplD$~3jImD9bhGf*+q8s=*(ES`UHGn(_?xFl>Io9dljZNpu(7D=;fRQ z6n6dr?+4?05|XHouA#dsZiUuP>}e;P|LO{zL*|-q9d#jaH#t5R>x7r%J1uAQ+Utz) zom+RJK_J~8d|+*BhO|`Ant+c#y^kXXmj}JB19C(f%YG4e_P|KEOLnQSc(2<9OoH8? zG5!fG8VQAg?O5-AMdp*7x6^5nu)*zR`4cdNO6ya;upjXNi3qt$y6k+qJ-(qR$f~^%WceSvlaZayn@~{uSzh+7Yqhn#Lqmln2vb_ln^ZfafuWz51bFfld`Nq zUU?43eE#`;s7adKq`Ii$%Rdo#Kj02sQ2f*upmD2dxs{VQ;gOowk~QH`AdA}h;9QiLu`k5E35K6(Z-yAy&m3!Va*Q}(-GLG$yYvEK0xBPId^hH zULbqV%Fp-HEZ8ro{eo+yf6rn<>}_toP|f4EQDs?;-?_)h#)Nx3sW+@#Oh|UadDKx& z?nzJt*06aIb&S-ujRi(wd3-Eatc@^7-F?E|58VPiER4;$bp*{y~3+pay{YvOh`{=8n7eBs$Y zs>DS`%~K-9pj7{B=vIhwU2~K9yQ-&-}4{ zc3v*#(Mo}T$?C9|E8B58cV6#dl^T8|3F$Dut)X zR?T_AZ*P0Sm3MWI!f)pS7PtTC`1TocX|(Up53QV;Ec`MRVS4JqZN9J^54v`;o-k6? z8#S*7R$a!n8<=+P0=YCGyZZI_B5$d;s-Z#|(KUH3!IV;Txl5KZQf4j5v)8mgD0gx$ z{!ru|*QZ}H$NTmAcPi^>j}4}pLUHA;LCY_Cb(QQZm{by^cd7Bce(2CSVeqkX2mu(| ztpq+l+AlzJwt6HQvlD;Hr93ipZtC*iBtMErXfAj%*$ORnUHw!<$80(Mhh--VnCh~q!z-%m*uPD=< zH7;Yd5SZO^$M%+m#4iq4H9-?jDECMTP9F5L$xYkel1*|Rt01$W zezVKa%VIc_s16fE_+t9#0k2;>(sceObP-7R;Jm6}c*;fzb>l8YU=^DU=p!W_!V2y_ zosi(+ycu>>kvQ^Wid&0gueE&JY-Fr)z$QOOoy8GZyVsv{dJ0uOIH7yJC*~?frWrvY)$lao-`%U{R@Y&P=d zD%_Isxd+1e%nJF7dJnsdtJpBX5BmOVeNFyT1z-<47w|IKSb)%NQ2CLs|DjsInSt?) z2(RmJ0J+0u{@g)xpiMe34)-1crzegZDacE~cU^yC-B1e74(yX*pLF6rbomVf;jKE) zKRaO8s5tc3-%wXj^!)F${H-l1X{zl*rnRJD+^@zzWO|w2Bj+yZnhp@QQCOSmn8p0e zs=0;0EAUmN+~B9Grp`dP4uf$$mxCh?j%TR<8iXYZ7gH4Qp(09wHd@vw!5VwUJ!}Kx ztW$Fk;H-Sm_&p^^BqqP=3E@+dT0Njx%x(gJvatF1zkHhtTY0d-;f>@KR6|&54mdD_ zjWjSF(Osc7mC4|33rPjd9UeBzoAG0<6`>Mbl#Tr^JTeix5e_?lddIR<50=%p>6Fb_ z-9ymKJVP&yk=X+TG_&EO7gSs11mWrv`Q0w8kgM@5F-;|}>_>)MA? zF6*Ta1c}7wKQgl!OuKQf5C4Y5kXmNTWaAS#~zs ztsvm>l*fAq5?Gl#RAczGnFZ^$a#H2he2P(l+|EqV#Ju+D%uquW8UE=Yvotcsx}JXA zm3AS`KGqnO4nziI%aIuh>~gNFbrA>Zvb;WkC$*u_;kmYZ$F;ta$yblJ*S~HW;9=8j z^y?)JpQ$I?!Q!u)X(r9Nm77PO?*ceF0AKK*?;aDRkICi~C)$*de)!{W8gR5fStL+z z0;nQ@XeOeTe#Wd26d4%^ZkYQssW1u%jd(F<@>dZsCHqjjm?wRD$`=z8yefjv5MhM6 zKkH1t;Lc?gyi(i*RvnHegX7TE?VU#Ni$^Kgr*o`dZb@I)} zDj%1Z2I|fe$5>!g%u-!vNJ=;J-2+OCg>rgMlFKFWwzcnP{!J5S1mG#%dak7XpFtwR zP~5?SGt1@O+pR01I@;(bS4-Sos>2N+bj9T3S^Q?xe#7@Shx&TU&CeEvm?R%X5c+eF zbP=rbZq{!mrj;DCe~r(?u^&6lqs#O`f+&nF35RWZG%*SoMRL<5jqh(1O`=3lCVZI` zt#d4U?_%%$U3Br=W4BGnT$DZrML!4i*X#8pn?V7_-n(*huz^-~j8iFXssuE$tG8-b zT?##tg{QV=Tye83pe0*t3XlVQkzRuSam)>S8Ae)yB&9;CG99+uK9_0aGNUmSV|;## zGccVxnpyhru-S6}P4QX0NGl1+d`1({Ne^zE1_BWWHFeh6Yt*fv?~E!R3oC|0bsp4# zm3G5r>7~_$C%*#P0oyW@t?EQ9HMRz|WdD`zIad6X&n7LNStU_sGaG;3so%wA=FPD? zbj>NMA>;nn=Cv}Ss)v3xpWbq*H!WqqFhT6}KGI>cz7i&I+rRleRLT4K_TKO?P5EhtfmmM3(y5LlTZKcVE-99h>l`^YJE~%Rt4ZFkfEq zZQA{o47AV7$k|YsEXccEFNVTi@&|>GNQ6* z((2n6n!_Rp6YZPJJbUxC!`b?HDDE8hic-RiQ>V)(nF%ifimHdth^1j}R?OGsavK^p zk~YbHghVm%wj@7L+HK>T=!4`H)M3a@NI8(n^|B0IwzWjExbW^g??XVA5}pTrwu;wp z%RB~ZZ(XKaYl}ZNr(>dnI@e}K)6+r?mW@U$o{HWEtgY`7%LMeKSdZTl$Rgx1XB6nv z!_Y_=9p*mwy#9Ted7|y7G0PbcxT_NePQL+r8=QgGyVJ=vHv?OzBf#VjG`O>VxBLz@ zH1%_!aXxY&T{;-m{CAY}7ZmBgWc15JO#Gc9w%5yM`de|KniGDpQ)x_{CMTc`?~x=| z4$;5fW9>;SO^c2UJvfF8ww{B(CFH)H>%(vQ&J^xi0K%e_>Q29%M(+_MkcBu$_lNqQ zsNI1z$;98$f|yja{Yd z_y;_(lo{5$fw_i>Yp!iyIu)`(^#}9Q5v3aQfc@yuEG2J7gMq-H?Dt^Z#kMW;>$+BN zN5K$II)2|ll`nHQKIt=w9XbMP^Q^P2YoSvpurJKwe}I9jnKL$X9xg9_`5X>AW(brg zNL5XaTbEM`OcA`$tI0puSDK(0#6@NT_RELr zu_PG1yNR%2`>3f}v5{r$(7{V)>;m9;M(lWdX~YP?OI2n?&d_`Tm6a9Inl0>r75`?* z7!5P(#Mq_qeg0(a3oeVaN5}~t9-xwN_)JUlO2J*lG%9g)j-5W!1Yc#*3S2&Xlxg53 zXFZU$T9sf)Lr(=%&rUjn${pf@Y+UxqFkQWmT`$$kp`mFH*X!>;88H& zm0XGB1)B4`)d1`Dq1Mo?v3g=p2k;*P<*zzRun6Bn`u7g&ldoVn*6KSGnu843k5`bU zu?aD4@J0sCdtZRKoT=qN_~0F&Hb;uk%%@1>l2_6Rej$^ouZj2$?zUV|Yu+2>1WSrD z0+_h5SHxgd?KvwdcUC2+$@}|l$e6N7Iv#KP<|!vELDkivdMnK){`jWFAD6{@?+#VQ zbFrZi&J=$}uilC^m682A=p!HWbBp!@(7YZy-L%sWrYX5g`flk5zdy5cgn$|o!v;CD z|CemH+$i|E7l++Sk11H}&AI{_@csSH`^7=ij24crQe=;~vxv)ymiYODiBzEG3muFq zwc0D~Gg#AK`*?iN1dJRRKgi1w+;lzcjZH+Lu)Msw&;B1Qv&w9rXB2+(YQUNPjISrd zYJ6_D(>p^RH4E>VQK!%Kll?b}%3aq6Q{r|yl9TDOpeycAx6B;b{JRrGJPW;a9|~oH zZYJbV@~1a#tr#x=1)5)D&%{Ph9^@&Zd4NqnO1W``-*Z*5r1RK5w?Y+-Z}{g$CXF{l zzC_E^=mC}aPThBhE&_3}i_+;z3hNn|Jh=L_h~ww*1-XE|C?)kwue7r+6lC$El+U;DX8ttuGtPX zdr^ru2b*V}%t4%OA1puJXzqnARS9;}#p@P z?3+AeL5?42Ip)M5az<_`kbC+r1Hctgi3K`;VBN2H=sXI`utxeT0P7sLO3wTSI=2e~ zm#u#S-muk}BVgtEL-IgdG;sjw#c)xm*t@4yfO~dV=EAx9o9FTG5v3Bmm;VuC71f-& zm*&5FFVr?1=N-^&uj8;bC}15#@zBLKkv4gn=v_QJ(-$GBgZ{3e+2E&)9at%=&VLUa z0C213V-`+5k-L%YhEt{TtSjSCk%`QQmjfDHVuhy|Gb=*W5bf|OMK1Xhhm$Q+6K$`7 zj_tW>9e~*_2;EW^qW8}jlh>Wu6$c*&+a_nwQ^A$CCCBT4p|AoK*hvDl2H8K#`h!^Y-ms^Ke0g5`!2X|hgDK|-EZzA3G{`4URxu}W;i{x4gP zKf9be)4Wi{!9LQ^<;w*wG}zDwiLwJ5pHTr@H4ZOL^GeYu(5|u~Ir?Qh`^Z2c59gn-)i1`9?)e&hGwwMi)L z@m`OywIRoAK6U!t1+t=r%%6P#(j*4ifnD*ie4harnvVucwxt*Snq3WE^51iRaWZWF ze#ra2UaFJc381&-WB>WS+(-_tKG=xZx)nO|8xG6vy%xZsamul z@I}T_lW7SUvuy;(KK?K4F)|1-oh5?55dOWA+seeGUtndTw4TVgu7se%21b9gCvIX_ zqb@mgOZF94v^?{8KKBKY1;)acUcL1k3p>_E#tinye2obxsoZ<@LPGgKXgRhVS&C&4 zmkrC8J9t8W(-%RDHB3+ax7SI{J5#4^pPjNT;+SEYd95qe zA*|knfWG3YNlS)ud9ZmqwHQS<$|G=eUnMyX)~RO7!A)&DwOd3avy}9l;ROb6M|V~S zdj|pv3w(`aBKg2V=SjPBiOa(=xS5>Z>Gwdei`K$=hxEC`aU_;++U8rGVo=1eOqKCi z3@mSdaB?NG6xLy#k)l77q_i^g)Q%R(8)FRkR>hRb&0vqys#RXd^>p{Ew|bi5E2GPN zILV(90ppa=76(x#DCPM=B#?Y@!;Z=XYw z^;$n*`?ppMQ6skEZI{;4GxhZrUms4-nL6nc*>$}~ptqOUu%&QNCeOjSJ!ZIREdUpN z2vKnZ47~v*xao(?OmnhD-W-B3Ph4KvnkgYWv;0Rn^=971o+V92tZc!~fKD+J;$Ldi zn$+=%$(5$yF&-Ya)pAYT7nYZndRE(WuZ})DbCT;dlDzaA6>19eS!AGba-sS{zqrH1 z-@S60KUF}OF{?hpl90x!(srS5s5b(JhLhX+zWk7j1w_WKle1Cu!N0QUdsvaH7%5Al z4qMp24TnUnq9vL5!L~$Z+a;eq~f_cN21 z7DN^em|nHW|CJ6y$7=Hl?6)1j2fe4u-&RX{JY7L=ou7QOAFL~Ws&->dJyEN?wPbbS z=@zPWXNU2bQ*VC{dzULr--`Kw2zQ@>Yu*yo9NcOnVoHjMjl`6xiS!BUYhl)5hu(%a zG>9@i%GG%P`9T-QR-C8yF~u|4hqaOfl040CI%(EvK02ZKHeSU*`9*;WbV1|Ap~Gv; zW_I4I(Mk)Gjl{O*-ffX7B}41y1 zR1l#S@pZ>rId8&H^1CuDU;z-%ZyOpEC@tx*hCM8VG}KukeY>lvhT2DkxRtYf#$JZ0 z4w^fUi9@*R2?>`jl-@(v!GdMFU-_MD#?U`~$=b$*Pi>|F#JxA9UoASNy4Kp!_!YlN z3C8PzDn~{*HlUBXc1) zG52m`e#X8024Lg_9onHr^4xj*M4L-NvEtH?kbfoR14(!$Hiuz1Q};FjlY zndX?4*^SuChnrxxs@lAjj8y6ftUvMN7x>Ptv8!szu*Z1NH#M)@D9!Eg^BJSoUL!Cx z)M;GX)4_giJw}mkD4k(4?sYVX=Xv&h<}6@Dxd35&(#le&XC*#{l{nV0_Ha|q-54sP z+6^KkjG^1CJIZLNiJ07N3{PvZ3?S@P99!K#Wh~BP;WlZI^ZmZ9<-oUJAwUT!zr`A@_{q|>9?A_A5+i}V(m$tzym#(Fb8 znUvX{-dRFToZuWzbTwTp^=kNenf-flDPA;qth-hOp6gmJeD)l zz3BgYtP}EnYV0rAYpyondpjDk9%&stC1c9;G2?yg%FsPt6@!XqUNkg`lW=qJOg^?2 z9J@A6eMFch$+4g}>-GU|Rr z07lP{hkfK29$mlC7&ZGwJn<^{M4htAwKy7A=%m6a)uzDAe>9=ay1BJvabq2V5*ja- zY3%qrKlZDYm3^TJN)s1zCkt>kde=T%-p;B&%pdgMCu?fE1fefLVGkGlG)L^L)WgY_ zm*_t{|7lmBI@ZD=y3ECy-O{O6>kUx@Z+Q$+@bN%s~qZ#XrJShCs;tHn)cM2LrBwmq01eA?WAH6?s_Nff5~72wgrU4na}5F$zI-TUVxx zgOb-cl^-JzZRDtZlVY%RSRZgv#2CV}pH-Wj=o*$uziq;Fzh20-rS9IG42LB59x)-3 zhhIK-!<_ngcv1N%^ZNz}HWu;+YoLXZJ0g@oZnvBH9HZB0G*tKYYii1!l4erq{nFld zNqG62m}k23i+W2xCIwT|VR_#ZucGChN8C%LJnh<(_{z~!=wgG6hc*Kj#@9?cM7e68 zRz-QjJ4r<&*-&TOR;Sx2Z&5Xo{!B^kQO8cXPsP z_WRd~)(iT*BBs)l^>2^NQEJ?fLsg=dFfC$dklCw6dzMSM_))}_Le~Q7q69v?wU^(Pds9bP5_S%qybd5wN2A;l1)FlKApb0mHKfyxoz{X z5feW=;G6Q9U~;EFAzI9TZ36bI!50b(Im-mu{T-<-l#Iw6Ru&oyQL1R_ulZ#ou)wUb z-06;>b1~KVXS%^VCA2d++XMOUKYx967a+sd-m&6|_p($HHygwgpX$UAmnX{g3A%=* zq<0nsCs|y#yByz$Yj5#{g;8~F$tV`yY;9U>7^`v2&jfiy3&>-OCyI+FdZ0ga8kI#A zTcjA}DHLh>-yOk5x!1ZGdl;gP#Ed1zwzm^t1qt3Ey$%r(+QpTHls`w^`koRUw{>0+ zRDT`p;d=BKxhH^Y9*ZBflyv*OG7%-n1ud}y8Q?ILEW6Fbx1OJe$sR)&pMLm%tlt1t(Rj+L$7 z?hjNdroT`&M-pF%>JMMrd<2hpZd4Ryx!S+(R=Z+h)9|S!en4Pv$^X;*N2w>J%;Sh9 zcoX?+sI?@6-d8;2?mVdqsZ484fU(o>^`PC48OK7^(<^uao0_l6Vb_EEevb8YC+Ma} z{Ok^wa+s33q$^dr6yyG%4*Xp5RA8J7O!XmB)qfk4Z1plj!60+9(>5R0Q94b3 zSN5vl7YJ6pUB|R}!_FlkH6H#oXN^0(8>Gemte9CtJ8ay9>G6c*kfUpnm9XzrM|S1S zP;-I_t(RHkd5dOzyEY8?&Tso(t9K|8LbTb~%a=DsPC)OXFoRItr^w$PK{Z>%+RuB6 zoI=Hr0m~|v%ip=WtA@NPu-;zo1`g3=X(VvOF;vGB?K{mJc2_SzCa21aM$DpuCB{yK z2-MiemuwCc7 z&N1$E2nrS?gtjt?ey?4xX&c_V*b`wYr)+=X8SWEpM@eGFPvb6D`P((T2d~QT=4VqT zvrFeG#;nJid)zW_bXQM|bmf8!3#WVZt<CQ&#R5Vytt$fW07Jbq)fDzbH^qct*;)FBdFr??@zaDd`xp z#tqs`D#VD7TuIa#Sv|@-*|8evkL?*@e^Ac+1}@pe(N0y~E~#du$1cq_!`1-hQkWjt zXZ8%t&h_?FE-p~S6ziu)>*b~gR^O(kWq*S8ftsy*rqP7ymaVh*eKcehSj7CsvU9@| zQ7c{K)41Tm>n!dXf6bl)2)LSFcYAbA|3HbCO|M%<{N;=))muOwg_45M-+Xwx)8E zP6xHnM0R?5lcRv+`UviUP_nlHYk%_{yz&J?U%unK39Fs|_e063NNnGkVkqhoaVrkG zQFQL71*?8pl=pjWCL2@7<%M+Cws69o&UwBp4m;czsvl9E-lUZ{P=1Oj%w841)JLx^ zCmoziI;U+zwUorI#Q1Qvj`3YPR6s2jm1s+m2^?`D{ zF0)x_41IS^4W-+?W%@bu7x1-mn*HVC5PJk>hgw4gacJN*(uG zxH_R=uF_|%bKC5};TlAIjSEtS05JaF;PqtMjq$aHz6c;5v-uHSD-cb=m=hM>o$^W_ zqOa3*JJFV_$TQ^IPk5<~9C@PSlNd)_B{eHL^WDm~n*Vqu6rLAkyC0r{-kA9#CJ0jM zeciXLO|Ts__-I8@tPl^Ip3iq$W+`PpOdX6RR4_H8mR1T}a5>nYAXzRFiI6kIMK;<> zjNLTBCE0hrzQ>QKd9vl%*Re!xlUz*3>pzzO_E6I5H8v2iycDo+%$ip<6U`2p0WS#s zV?zjjWa|~WNlIh_G7Z<}iI2KS#_aI&cu#x_DN)<4@_U{~N5OC61zy@wFLJfwSA z<3`KBoMBV`5dQARkAPjblGz3C4CCh7Kht4OKHoc?SfUpWuA!PQlZL_Zr8P1m4k3cn zBhi_l6yQ<94rl)-DOH-FU-uujux;2d*)w4>i%#x4ZdX8ZGqATi@d*p;mz3ba8}v99l`X>*7 zNjcOQjm;5k$2imbH0?j?@rDH@xfC*2=%^ifXOmIKed+m|WN=eL;<3_J1^OU)Ef;yr zt1{*xb_sFyuo}Yys2};4UbE^MedEL%78}B0$|~wcq!GugiL?-IMGokREQR62qQZ7H zbu1anP%94*wzQsY23sppEq9EW#y?%@Q|MD6a|G_prGBiyh6uEhZOR_oZA~}-YsM;d zqf$L@aH;R&WQnA!Jt=te#zrtAWIg%lc0b}iOttYdQh9rdFfFUhqreI~r}l!7eUM#C-0XUtwG^skt)K;~x490c(Im(t!IyZAY#kZwG~g|C_{WnbI^hWs94r$pWC>|5O@5J?A`7^} zy~VGN^dDFJv86qo-^dez-Pz0sPK^3#Ep$oIf7i0D^Xm^5$P;)+=w5Qi*!JFN-*V8i zpsj?1H4oo*UcOJKYB<^tc1J?wN|If`XO~8Xlcxh6?sn6D)^cPOL|ees932&}2@2?L z$9^3_%b}fA=*x1&U{^C@JM_T5Y$G~X+`h^!V zzafYbN*ZIq(n_CcN-G@17N4=|O*W=a$yWvZN^K=ZZYrVvt$O|&81u+}Pxac-zhtfeO=byy-)=PHbtE;s5e)`3DY zcr_fqekdGXk(%(OiZC1!PpPrLec|ld)(s_;nXtz{9K}Ok?rYe$${Fc><(cC@A$EOM z{UV*E-Se>QqE_|l0A@();9Wkw463B5Qw;FQHD`KvlxsEJQ&6vEHU&q!ks5acaDm25 zyM>P{?wA07vU@t-t`bH=1D4W8?Zhs#*U=)v)nj^vQ(k7R&Z5ajSLK$(Ar_!Pk_A54 z$F7mmjoZv&ss}D7=C1#CFjXd~X2;uzn{SjaKa*Sba0|S1z7zbfXX5RexbtySTXx5d z0rb#)w`Y69}52a#i8{b7HS>{-8~~B=$Mosu;SK@J|_&se9tfpErC8nJ*`zv2C=EI5qB+ zilNcc;_~j@ug>NjL|WDJ8~6s1u%++HJUk!~Z&lb{(|a~<2B`pAH)vMVQb(yav(v6kd_VX`=>(mcvV*M)ui)i$N*Pur5=y)gykC)IX8FSb0cBX z+((fof=^e8hmDiiK*zU8TCRxNhWB<|Xn)$N%LKU{ypuX!GfX{=p?39m%gDI@B1q^q zHZLU%@fHHrIj%D=-Z=fZ1`-xr9q1>`=K&_Z^l4HZToJvoK?p&7o;~H<=jOD5-s8Lm zQm^Q{yzN*#xb9=9nH_l}fwb+4FHo4x!L_fBu?T9~!BGqVGs219e9XbkZ|&A$`4M8o z^sUt4cFdIQdL2iFuZCJKn>pyvo_T%Yq3+ty@UUuxkg)Pjo?vQo$O=gAUAlOOUK6vK zr@-$8Z#&9dTLu|e_W2o7@*PhAzu*H+jek|u;Yv8lhqnIxWQ)~FGiz6#m(EJ(Gfh~J zkCo|2cZ}U($)`Ooef(s(x*$~W=@Awpa6-gZ5Fx(Rm=^cCcE(VpSKHTJ>rXcY&ZjM3 zT`=$P#_3H`PL)JNJ)X?;@qgHf!Hd<-EqxU}%?kM)T%OxUMg}-b`&RVQUk+`uVz8ACi&TTEo{lIB393^^qsD69>PLW=Ig*Zlo~sw+P8zlD-*x7`&_r$S%>)V4eDF zmqj?a0GX$}Ewg0LVuOaREk1tIddJ?PLQ$^Hx*37_TDOxu>{Yh(W8U^_Xup%nP4jn=1e5mvkF$dB=q5kg6t?)S|6g^rxcw>mvBMVfV7X z7QK4B?LxR?7#(tG-;9Zq!;i1MEbXHp{6Lgd_r;g=C-W{lT6n#a=mc9{6@FV*yIkNb z?EAt?Xx|9op!9p1!IS{rtL*Wtg6RtttjDOvY20AM2HdgT!Id>ip}m-4v0lc&}MiM&xpuyqsL&~J%g*bfpN`^4~ers zq=KNQVs$ZmzVaP%I)E2wa=k30I{Lq2iiEs_C~*ivaQi;%Jx`~NROj;2-o@V+MeBsb z50Z3sH5`0GugGekQk~AZ0Q#j-;QO_QU3(?JZ|d;fBDq>a zZ)zJu7k3OEeV+&V`yAv`T>6IHK`k42%3a!`tOz3UB$>^s?Z+(--Sz~tNQ+GzbO{swR%U>l9X<`xU zAxgW+bYygoXY}t&(Ck6ulfMG^qqaQ(+V8Hu75BxS{bFbk;2oQ_*Nh=ys^-yPBo9vv z)z07Pb?%&9g4Dm#rZ2vSToU>!go|}sjG>}-%v^otb=^Ro_(x$cBOUOjntwrqDa;HG zRs!IcwrC@vEx-g{QG1YDzqmC(K`S@!$NHgZ-&S6H5D&vuMJ2~tdJVc*JXyh#L@76J znM`-BvYWHo9dCC^Nys)y8+gt_tetH5eb5j{Gqq`@jgucELA}=gm)I$a25kAq{Kx_O z6)_>}h|2H|Hi6fEHuX7^Y`ZNUwks-@&(Yqg{P&h; zySwh|T*Sd|Z3$RfhRn7j>%U2bNqOQaulPa_J-MRFr8>RPGz6Nwac%|o88rB%Q zp?NGsiUhafVaQTFuwPK>&}hPi=WF}8lV4Xn1W0XJQjL%BJXxt6%KCgz;U9KOTSS?7q0oLCc` z_pd|!AXqxUuF%HZWtxKLZ}&*HMoCmGV6S0~ri=7A|TiWIMTEQeREBsLd zukx0|4NuuL%4HMOg@-3!jn(>Xy?{;{R-^C<@d`}|qjj|RtGbfA88@7A^?|=;1u4#U zEv0-pHkpy)g*eB21x#=>*j!T1<48B(XHFovAAy81H)z!5uOX=sges7K9uuL&y|}*4 z>-dzkPv~a4B*a7H*_$BEIgW>Ef^I*pGF9x};P7_P?<$I#cYyq)>1V{ zEQg@;G2k+toNOd8#zs#D3KmamURbk=gt=vP<91wCZ5VV!ja$=LI%{EkK1W?Bs z_GMgKB2W+!;r^af^DIJb|kx zTGse3veY4O`K@Tn`x830Hafp;P3(mn<$W{b)pw8-9XCj-Z~6P z1^staA9lBK0YaxGP-og+Az&42LVOKaRoTOB0j`?C~;FsFrFBg2O% z=RpC}G&R!o@^nw2b<1w98ujtRLLaAx5wWn(28~sFX6Dlc9HLHX1~JCR4`C5YA<^Er zoV0?>f)mhXNc?|BrVxkA#ZQ((C;C%HrZ-Wiuj+KNy-Q2-*ivw-{{c@6&erol66BQ4 z4#!h(9M+9KajK4*bg`ZEF-q!K)kAWpUGto>V{Ik8>B&YhT7KbX7q+zN*c8=1T4(j4 zlGj^I>}#@4{Gn0Wzn_(^C>)s`M>u3YD%YV2S!pw`j+a}y*cf>@J%ZRG{&%FTa<+D4 z_&ce)X*lEoPMf{9HF&8v*(X7!`}r52NR<#^Nmk*rcZwnA1aem;98f^MPzxsr7sJOo z{;&f5sn2TW&5b4LRN>)X6!(HJVj5_=CWVw>4-euZT@@MMzNHgzh;`+I+3kdHIlA(h zHpCYGe?OkxgOFJ9^Qiq5q__7`{LAEkZL9!II4rUQ&{+!!6sWL-BWQWdl<0w$nmnc~333+wQB8L5B^R+CqBsi_XG&(}sq z_4X^6GVxC?1R<0ghTcAcX{x^MNg;ngZKX?TOeU2uJ)T5LM%EpT`MJ9*(bJng7@bz` z_MtYlTfuMj{OPpu_k9z4QN;SEFcbKpWx|7+^iT(fIqgdb;o-8m88l_m_`1#Q^A0Ro z7!7a-j)eJvIKcHvU&z`cb9iD@27Sm8YpsCx(f8+kQT<6Z;Gyt%W;*vKUhK-{pZvqk z89K4Jf`=|CI^^455y>yA&?8sU6&bv>xT~j)OBV)Lq{3RgaTSb|X^}DV1#O&`jf-!l zst@uMT#U55-sDwVB!t~+r{-qm?s0mfhujaVgvNm>=Lu*T)y>~$iP zof&ytF|-^NJab?JL$kVKfh5Pwk4xrBH|*`Iu7jq;$B*MV6OD#Vf%v1*XsOjggY^F2 zjv~hT;))otDRMN-Q38IU)UmcyH3-=Y?>ys)qgfdWr}eK$sZkc_oIw^A(*zc?7eyO1dnDEAI^XhQVJ1;UJvjYgTsj&qp z{H)GwyTlbm3f!vOEn}{5@0+D%d>Suq+>>c~y5%2@Hu2R|Bu!$1WWyR9NBn0ftHCh< z@ryUx;mNIEoJ9QkQa9C?;p8}^SiAUz8v%_QA`h z!_@yJK|!RGhHf#-a}GuIM?R6y6&&^4_&-EB6rGA#NCQZ z2qA+4bJOCs8aWuW!7>Ys^M7v1`-+p(!^=N-ng*0}2=n2oSBRFVj=v8Z?IAazPeHd7 zF-@p1=?{*5ar=#iJi{B$79Che@%Q=eCIsOk_#}>x*ogqPP=M$ZDK4WL0Y!yyuF z#^BSuf#HXmVT&3pI4 z$6Qt4z}M-~Re1@ZoPAq1neR^9Bj~_CL7uAB>5)O6zmxsY^oD80ow=rZ5>ukF@$vh) zJ=a+Ri)V-BG>DSd zVk^P1T~e!H9r7MdwXmW(C|;WD-W4uhHYLn24+5)4y?t}(LRZT|Uu#*UUZ_oS231Bj zc|p1jj0azzg_il5A-fB&IAn2+SqVFxvNMe(2Nonwv_MrAyp$WVg5H_0bbY!Uul6WMxg4e5relaZ_FxUu zO_iz0X09@W^)*7*>VD?x<;*z`>s183hF!jLIq~ENFthF|cA;u}z~(oxzIh`%T~5Y7 z#AF-E5(Ly@#7)%t$5RrY^c^$0PdMDX0nh>eg{0Z#)*6lFjb2q4dS)P0Ta)8+e`N66 zNp)P&cf%$GXNcex=3bx3DQ#i>)0OFw$XZoaeVxhSMeN=L`&z_E-w;Q@?|_`i5DE`9e^D%^JX zCuQ!yFDwrV_k18vr!ZOiD298T{^Wc|5rb8WL&B6#uvHTLCAkf-H(YVKnLVBTPeQBC zeD!$%dJz5_n+fPK7g_hzoj}4kISaAc=)Kj}DU|SWUU03sP3#iwR)*}%q)l9lZW5&U z#;s$H{;?#}E!1H&WfFP+s=TBdX}g_;9fXSQkf`~Z5Fd_}=-&Py4gQaw_4pLK9P*bM!UuwI=6I<=(dWqI4F1L= zOkXR&A0_uXmC*)89=-EX&szpuuEmC&tPJrKRQ|1Y}Lm8do3ZuRi_HPK(D{;sM^6j)>kn|rMrVV-X4iDSg&i<0V_N?e!|2l6)^xj@|lRy5YnvSEB36&U>c`2VP zAR=FIKk_RIAMzOYzhCLx9DV!}7jvg$VvVj-u-2Fj>h4u}HQyHxJ$1nIi{>o$el?Frh* zMJZ3|m0Ft^y?3JdedDM|+`G+@Ya~>)!>01? zGsYa{t$C+9Y^p@rku3_lwVtty&FuR2A)hpri0Wh#Wdjv{*n5xyxwoMurzxb5)&EP>`2^p{FCbX);|8i;mwEqFYW&-C{u1|E;M$@+S^rl z1JCR4leTyanmqH#B(8Wt%z{YB6I);TggCzQ;Eut*h4!RL6iu zN^FP$R?C6cZZR%uNt;*YV})+4mXj5?(zbkUK;PG6ik4RCx`y7Z8?3dd+G(+|Tr(o- z=O3}!RdGMXE3V)wm=b7*pgxe^LDFuouPW4T%F=#EGmJA^=|e&O8?iN$DgNbPCJL&X zqD)mWE}wE{jg`*qo?Mc1e(aLvG;PM+!9nk}(B+Kc#mu1{ZOM5gA_PnoVmK^W^|xtE z5P)B(D3pOd8k>UqvI+i^ZdtG~-%8(ABlV%gR&?T}i{`zl-qMC`1J|~mXNHV=MgPC{ z-ovY@rt2F%3B4#%6f7821O+J~MQT8?gd(6I(gX!Wnus*1iJ%k_L69awPyy*hItWCi zNDb0^Kzi>a5R!Zo@B4e6Z+-v4yViTwwU%CH&di=Ydv=@Oo?~}QAz6d4@fylEKK@&F z(yuFEsKo9W6Dz8_`-Y)dtjF#~_Zc^PoM&~umWMe@WWZx<@ACG;FhBebHXUJ7B6{sU zH?DNKUQO^mfB)A%hhZuwaxy*gYc}LYRd&B)41O4aSPL99V2tw$^NY8?ZhuvvhN<~) zf8AOdad}+k@nG|{T1UrMDlcMao3~r@ST?jYq~jpP3Ritf;94XVA$}J!G`Owz7|Z%j zrui}`-V%5`8p%?bB8R{g$qXU5Ea2*Qfy)k6KF&$!*yi81QwX?)t=jk6BIQ~BDQ!$z zcujHvd2u)#t3Efj+v|_mmpoj_j+R4X#9H);an^Rvu zk;7-1e(1FE{@S7;SE)IgHf3>sF;^ia9V=9WAWyKVw#pnlzV-(MfhO8b5+TAp+1*1$ zlL`6eyTNxC6lB>KT8ow3S3``BV^L~%FE-UbP6nZ~{zg^OD$D$`VtL-#?^ za%Z96eRE2I@eO$|M0%+W@-Gs8CC_Iks=z4@be;3fPl^Ubln z`N4@d8vYNF^39>8X0t^zBB+oG;IDr|Mad~v8sE9Y;y%A|$)U9B(I3R^h*%R{XXV^c zmXl@3CoP@?VH_Sga4>q9UGhIkU%F6J6%@D>e@98y#4`1D% z&5Qdu?%GR~y8y-dWqm`Q>BkV(BGPV_!-y%0?*iw8!x$sqvSwD)a2diVPbs}M9&7Nm zyXPrKZzg_`?kC?)l2v;GoYOHqZ|6TbAPsHR^S{HW-%@gL{N#VlE*{&Jl`getI{6YH zf;GLXv(v+RV}n7~cPH0d2QvyXvPt8rWr(2ZGH~F|3Q`ml_FwwO1!~AGW9Ce$(ayGx zFGH2YAAb#DHuh%Ci%$C|nCF*4&i&@F$S`Hz>oCLyy*b7MHC%xU3I0p$``Bq1A1|1% z|7)#OzW*j}NWoA-*Wr7Ku7>{39j3w~n9D3*z3;-FYY7OKJ*XNL@vmZzIA{cEWaX+> z6Y{USY4n34jtA5y{K0~9$HT#j-!RI0dmK@I#Rzs!7?LO8>)3^)pF1xVeOtctkdkWz z2}9Bj{~HgbMk+Zt)Ow)6ynC7(q!u??avVZDB4X_f#r4it2r4$R46j?}#{k?)(_cFk z$CQD(`J<-sHk!m!0-wborrTMDy(y|(_TJM~PsaQ#et`sbRoSvqhM@1s-x5)g)c{m5 zOx-GG$1phr z@=cQ&P`h!YIkbWdQj9xG{`WRFUF@H%yb11Y`77rmB*0JlCSFXUKu=K+j@w9*Gb9{QoXkRV9F>FX~S=I_p2 zOE&ZIDV4EWXKRW~EnhnVS(ActGuysODLqN9kIZHt|d|DW_EdJc3xT+Eh~s!NO6j9lp1|58=46&PpQ z&nIS>E}NlcZ?18O`>Z~bl{;aMq?w1<_6xSDrw=+ze9|uU5z(Rui;K^-jf38`hoqS;a1(@@S)($9iJ2~c&m$w z!Ej6;IDRxC@^20esNHGPLHr>j*Sf{x9hKZ>IBs87Ay9sLYKz{dstPLYaO*lFo&0pn zBaQZh&uviFyY!rVCz6HLN0iidG%V_lpL`qp=i+)15{hCFgSzOTs555_bBaH|nz6Mo z^+Acwp6m{*l$y`m74@*5ez~yh%_Np(o}F!;-4NGD1l1c&g)pgM!TuD0-icp+TWJXn zTWLRB`n4!=Pey&*GNU1JZ3V;Q!x{8oOe<5XsK&+I{%r`{!X~(JtBY=tc zFOVKGyGs{yR}@EtwvsZ2D9deV9O_v*~yc{$4&JH&c~H=n}-~3r<$KMa~3+ADfhq(e6;%LyJHno`b??P zhSlBrUn*0lryWi2f!oe1yFVl=r&sP4o0@;mjos5NH51=)ad?sJKdI+FRU0{vQ$>tC z_p>2UFUz2KNcr*tLt+@Uvz(BS6y4b=>~tu2T?}|dnBJOH-giEoD z5S}MsI6!4?*aTEZi%&jafTGUOopzg^m7da`Ovfx&U1R>b$|~t|S+}#Z?xy_F^a#nw z_LWohRKH^lF2M0r?*@MujAj+J?DS5ldz(+Eeij-~LAro?F`oN`9Nx0t71`b={k@MR zGsiymYEd%c;tCud>_#7nmju*(i!;u5NDt7CnEC0DiqzyK?RxI!iMsEU;umYZXulT% zK|pXJgx%?K(8_=wK&6Om9<5jjT6T5gqgUJT&_P>5T#k0-X3K{qu9)Y}y#1aH{Ve=a*nRg#UN5e;Tfh2_aZYLObw`8knU8lr6lW_uv9Aoc+A7{( zdb{pZY@p#!kz`H3L|FGcR)y4@W^x2nxKvq**Ff4fMJF@ z(oPUAl}>po{;oD5zSlv@(L_PE+Rt;6{RLm9ANkqK#pXbuPkT4gvSU%bVt2CNiyFtr ziyEGf$hv*fQk$p8P_oxh%*aSA_Auij@+D|p?9r^8 zQTvfw4|AWazW$JRP0lpjp)9_~L83sSAgA~KfhFY!+7AXY-YW(TI*M-XDFoah>?NbU zJg(q0SiVw@$#+^XjaOnHWP_@3m3PoNfm$h0R;cx9XrW}-p=?>C$}9q_#qKUT(7#MJ zttr?>yY$fz`LUe2=}bSj>v5@E^?k+Ev5&hmi1~3$ZD_0+sRJY(nJ2q@kVp-@+H)%~B8bFbFWyHz&mI;JsdT z-+v><)a3i45<1ZmWY^JoH6xhQKCKD!cRp@Mzg$w+G^3 z9GIAuAzd(_a}}}aNj~#o=&!E^-NqPp!7hLUFbRU)2+W-c*hv7uWHko|{rCMB`g$U7*sU_OUl`cp5Gkkh->9g=J zfa0KlGD)?I+(3@MAijYA?_*=AWqT;Q-Yj|OKa-SA4;O$$q<+e5RW%jjE0G zOceL1`bMxVaooxJBKVX9uT? z=rl?U4tW%`OnPK(K&RMaqk(Wo!1H}^P> z(gx}}oB5G>h25@>HixEHf0;5_NtaU!iGcqhgsP$elJzaYlzko3V6TAodB^7UDqpCE8wr&YxUU%OwXz8DHpm-2mr+i zp*g{8_nXHjE<;SV+{2obN9)7y0Q;!}e(JySu@`_wfCL=n;LQ#=an`S3Mv%H?eS@-VQIxLoQ+2oy+I$6w58w6b-p)llH8_Rx7unyn)S#?KN@0}3 zyY3zz62EmAbE)>g!$l$aW71%pO25qAQ~*q>Wm9%e4w!cHS=glAE6@KPSMj8lclT(? zMNK2h!TN`h{GA6vSp7?bD;IkIG<_qS-@Dv6MmEGyyOY^n(NBp4J z^B47b0en0fGoYmNS15pE55uS~@7>|W0q$?EHZvx4-a+q2Za2EsWqCSIckudGf20@C z#0}0SCH!_pV_yBqU!oGey}6` z5uANR?-tYfUi{Ghz}V?kEe-f!VTyV^k6lh1QAAh)?XC~16~>;gly7b0DOW9&i6aX? zYb`pYGO!6AP6>#Ehw#CnAcvk$8ez90Vop#CWsVugz!`jqjm-sRgjh_`6VUN z3`n-sI6(Y%Db$kC^I-%UOO^YA<3dSNLjkD1IkS4A2biKQndI?E@w9DVL+YGF3UBVG z(z$BW3E+`XC%|nl{0);@T*U4ownzA>l}{`JmOG9TVdnCkzK1c*p0oFu)aQZ>4so}p zH6mpq)1x$O0cPs+6=y0DXS2_KR0^ywA0Sm}eQ9;BtGUTz>(gq#Di^vg!)wgB_xd5N zi9Lmk6&F1VqC)PZSbj~Wi&M(pyct%#DDfaHRjf+yCS+4g{p3!$bW(vsBn)sun(v59 za=*Cc?%`zLWsa&@(4WOB>7?%Rl+9o zcYoZ{yK1=mdP0g3>kQ&|_ZMmUZpMTj4m7pEeP$ZjHM+VVUbXl`@3Bw6#q0eAn`fSW zq1h1J!{2f9890sNHI>w9EG+b4R~^LBuW6Xgsbot?y|nqay8Gl z4P=Vad|AxY`%RQX9D^L8HvJh9(~w&qs=+>k;N*Pn7+v%pbdh@x<;-g`>l9w z_laZs^M7$It_`bY_=AG^Ze>tsn1l^~t(t?Ktk@4QhRGTqDREzxKr2I>Z&51NyvGMG z+yEwZSF7dzD>h`!B2avsrH}mY$Lp!(i+{y-_PqT?zox@|yq3$6I1wTzUvuL{;Fi_! zl_eVtQwoRG1h@;sTy>G$$v`|sfnim*&NN6btI>>Vc>O(aGx z=UkEsxOK^Q<{}KJ7C3;zv;) z#LAlzbi>?9cyzB$-PH}oXt8)LVmos0d;!Isuu9ExN2X+sQRY&)0PX52Vx=hE zp;8CHr~TdtvCi)MZnpPbsDlN(`__gD^)k%ynvsInlY0){jsoBC*uITfjP!L$q^q!0 zZBV1rmB(RP<)OiEi8h=bdMWZ~$Nmvfb*PpW;EY_uWOx$t!ko#5f`qd57PxBeEWD6g zp~G_C4VLSuV7xWMkJjY}S84^REn&DMCuz=7yWsBZOG2=5%aIe%DBg=8HgVAijAYg7 zs1R5Bl=9|CKK4&TKf)->{Eo-N>%5nIkK)%_M>D^(fJM14VrqX2z7bjuf)i5sT1{3 z>u#|3XP!(3Ji#+b8`O~TEdD}Ze0FgV+zJ|d_+9S}TLuhEUw8JEQ>(c#*{ z8Hlr>{<+A*%J=3S_=0P6(^o{DkUdHx_#^Y{kt_UkNByAEavb=t=ic3`#aW9i>}jRy z)LR(KyZYIghGOF9jQ@cF#ExU73RDlalv|3U^7f(^IQoq&D~hTFElA%&xTB9W)mt1J zb$>1^a~c*U>97!&c9fRh{sK*0Okh0(#BBV^tJ?x%oXUKB43Er3Asr<}2VYz0Zw6ob zlCDr-Yub^pvzNqktQ`O~P^y04!`PBj&}gf0^*y$fDCl?N)>Zot+po>AT)=ulnA6(f z4|K9W>5^Z7EXHn8;y~a7`g>4ttm7`)T)AG2t}66QJ5O%^i1a;C>6Z>5SV7=JX5hJ3 ze&ox&Ci#nz=~DviM**P6T6yqg`-M0Z;xVXx0na&^?t})LpUCbe9cV6;Q9U49WG~#b zX^OsCpx2EeDHkImqkt1Q_=*|d&>noW^x!7_7f9fUi4;w=L6kLmFOcpdrpIOrSnla@ zH^XMY$^(PL7(7WkT=d<`glK(M<@$AD=o_lVG*K8HEm9Y18vlvj273Kd_-;`$Nl)u- zUTBDn5X`D$`f7ZW3gkCV?}P7!Gv}n+J`x3E5r+2Xz`vLGTiT~k@-fy_x~DcRbSkILOB@W-g6mT)!IEyo6Qwc3i3-{DUm#M+5o_V(<*)nbP@vSUH#D{s zIWd+0e&W+OxvmwrK}%cK1YHhR#N{T~)O-y1boYac|HDp}f3f+RS-6B>`~GUqK2{wW z)RL-T6+TCaVjd#>;PUDF!cOl8m%6JmER}ecH44Z>+wMtbD`mD+-NQIbfvq3(y(0a+ z?;$((EX^R&*MN$>BnEw3R10$1G$(G#+g@7ar7@kS?^b9$+j`BK_g4b*)_dA)C_9E4 zChl@kagz{MsRs^Hz*BIcgF0M)zH8=k=mrgVuvVh7_g0iRQ@tt+o*kz*D13KAC%SZ> zAbjL1okbjHP9{-L4^W>upKJc~k5WuTsld;)9>SKU%}^MA%;Vje8R)lh4<{h4dj>uy zNXE+YLv$kqLFVZxkKa7+m~ns%L^X9gwtg`HSs&zNxar%z=O&5Fa03VCsayh}CFZYL zxZwAO)35cIHBqlWoud(70^%1bQJ%((35GCDBe1ufG9#Q5Ne5}sH>$L&IC?H6xJwa~~Q8aR&l1x1T6ECr~YKX0|cQ9%v= zHx*YMhW!eUw5f4R)x}IZlM(9{n(Ap-CmI!V|0NrqNd4)-hgfoH=>$GMc!ad@gXowC z5b9!cRmBwM;mI#tH=&B+EI8|(`^D6c?Sr&T9MV~k-gM5EM|+@c2g^qyYULcc>2u)1 zJ4oO}_eB}WvzOnQDE~*O+!vL|E^adfk9}ke?SOzW?6)OxiCS}sdLwVxH%Yb|P3(ny z>}d3q0q}}Glq6DpV_>Lh8mbv{9~ImEo9!?VxA$qn5v!{fA!77KP~}|rAo{%OO4yZM zG%!C~#zP^ht@=>Rx}qQ|Id;Yj?b^vg=Mio^w}COMC}?St+urR$cdINLgcpEES=?4c zn>V9Z-A3VwXJI}eFn2E4oQ0^vV|w41Jn<}_%aYtyX4HZN+pd{}m(8?23|OVsYIdPK zr~go3#j&VJ#>#(8D5jlx$qpgEfpTV&lBKSR z!Y`ANLWU6M{Sm290H|MW`N6@!a(0@tEpUcK4JFr{E2!I+%`uXZB)q!e%@0zIALo|HUjEhj_Gq@u%LYT z=x7MV3vWEYGkhCg~3qQu`gj9Jvj=+i%)3Gx>A zjpyr7>4nQj(H&GSwhrnZzrY19o+n=dw7AO+kkg^VV`SG>E$iMhXxX_Sx&gx#_-u~& z%%iIpzY<+nzul^L?um4aRVO;H`X9Xz!VnB`UiU(wiO$5$=+VP4^I-UvBm;O&+UIY_ zik$>yc8M1CfE9x_kug@wXFaqXK12>b5nHsFYs*aM&708tVp`TZCfb_4ks1l&*C1;p ze$2crGzq3x*JFUftRCNbL2nr~I^;#J7)Q?Ad}<}30pwmNy-973HyE1wHG3FV74HEY z@K;9tj(>w0w#M{)IKe9*o@1h+z}Y}NtIjcaYJ#o7WisIgSfe=l0#zBis;f%XvY>7p1#k4$ypRj7}pX*Y*K=hbF3KoaVABtSe?#oJc$rMfmo{E=tMczG5opw&{}5aJ60S+wP+m=epaTO(Xo z>72k!bY5CS_e9$JMNQiY+R-?G%RzOzWcKNY(jxvW(>|(yqh#+3PMHw!UEvAzx0uU07=fE*sa!y9Cjf|HmmKB7yqoJzK+F@y5&Jynm9zdOh#-c2%#|v z45>(+#{C<41l2LiAyR8s^#%LWZ*TQGDqSoWA<~KA(;Gt7mW&fJ_Uo$g6}Hj-sd^7n zxiTREm2RHI3T?*<^P@tNya{)iaDH@p)L#g^A?xj}U~L;|PcnGnJX2f(b{=GLY4el+ zVp;0>YapbRLW$iNS*?wzu6r72wv@xK^87WW4MSa^*FY5ySB0dF_}IkrJe41qR36E!#;R$@tcvv4uR} z85u2?q5*R6b0F{PpAyeh!oD*ZaPyz)4dUkBuMQ|Da+~G4)hr>xUZ_3f!Tk+QI#=lH zy-Im98pFI3H{t%UFCyuX{%b@)r#}dNp;Veq(|%Iho{LX2GUh1l{JPX-F=!9J{UOo7 z+R+E=CCF|SbMgD=%uvD^&>Hgpwj-!wf3Be2%dfkOmPWezI?_UTlf(jwwymVirNZ9z z>qC>`uq|p7-K&81zBqOv;}OHHl*%}2(rFSs*VABacE$R3N(`8-{WJA1Na41B9_N(f zvT}JZ(Y*mpAX@AdBuq2zf|CKOr{8X|&RL(sfNLZ!o)-dLMja1F(aiBwuuJX!)}7J- zJEZHIy-5iCa{E4Z=Z;G<`Gp38F27dqbN!n-_AM+tW2n35D5Afmgn$H1E1j|$Q4+Zd zixy$pGGJ5xmknRTU0p+LTM(w&>fWCu5g&qn!F<;C6!}uWOyLMNH`UFA|H-iVQD&Me zq5X>%b&>~$LSde~|F``c;%49e3r*+NyOM+U3MJ4~h7B3mr;F4OB?H=ew87 z?~#)r|5wISFi+&gXCB7akENu)M+_eXy+h4@jI`QE`^I>;_f#%LF(~+IT><-e9uKCK zfi4n{RLocKj|8A}n%N;R>^z|IZPz7C2Kr{#41*9o@eD3jWu<~oz$CTpxbQOQ?;(Ez zT_Dw+1GfRy;fWsZ-EQU}PYymtedOX~-8#`J91(wsYNAaukbcuGnSooYELq>RN2b#i z@LAOK%O=}jw&jROqqlyKI$7whg+PhV;s}?RQf8#mBDUnhiffN;t+Ocf=D6nxk2?Oa zz`##n{eb$dXk$6F9g=#cd;wE7@vUPBp#)yGYF%KbCf6B@Q!dmwuGPIAN7sXUOPyX! zY^0g&c8kHawDOrl_s*M*MCP3o$T^YmZ!OJk)p*n@lgfc5ybC*|pn$Q5t81x|3CDXb zof?n&1h=}}K>2&Non>6u>wxycbt0>fb>eo2$ccWaXk!N)B72shRu;b*{b zl4s@YW8gcI+DUw!9zTJ=8vc`oLZdB4nh67?WOX6qMeXT0+sY}h<>+ttd4tK6UXZ++ zFx>*7h*|&q>o{)L8N!V)^zGY1^Gh_PMvBWtVd#1|$_Uf0*CThW*!=a&2|xoh@a#kj z)uPUAxNQxgFpQQ8+~d9iXdbW`)M;EAV5XJzLh|tay(J^&hmojZEHWsKeh;FVLpFPkSi$8EIF61Np~R>AcnIWXUH8WpX1%3zODV zW6VnWq=C?+cW_;64ZGuidAQ8X>~>^0fAq;DwGjA2R@|D5AbTg}^1S@=SK;DQFccDq zqQNhi+NC)L`c|o*Pn;)s%A@1Q2{TJ*Q=sgq&fLOKa8ki8)aOEe$N2jLE2%~U;SSnb zS%BFUWTIc7Q+}Yk{>|q#BkrlTFU?7mX{i?L#eJa&M}a@G3)PW)|)wuq^(pg74TA-WKbeiHwI=Bh>ii@A>rS@ zp(^ixkm&~8N|sNNt+V=q#6UTUnURye#Olbr*!^Lh*U3k=h-%nv88jm;^cvjCopcMH zb{4J^1EFGJB0mCpl#UPmMs60f_KjO!jI4I?UHv!2)j&qvRw_nHPyKwpsT=wT^>U^&eyGuBNu8HxyWjU{X`K0pNM~t($p}t?w*ylxe2{F znIz8Oj_kO^7IhY8WzLkUjDmvc+d`-~B^updOjEPlUG-XX*-!}v$5wyd%dw5iC|aGL z2#9+C1D=pZ*RyQ8`W^qgmA?Z~iUX7y}K}g`NR=`;puqe(J^?XNdQqS-- z#sRd3>P?jwu{XwDZeQ!9bE0V8P2eHu-11#CR2$5gAaYQC%lHd85CoSx9ZSOR-C$Za ziGitUQ`x8!fLVRgnZlUJ9L5S^=ICe+jfj6;-gIzMTU`AD7n1es*v>`%Kz@7&4Ja`u zGTy9K!Z^yQ5Baz*Ge>KcwoK4u=M?BJFQxx+G7Zls_%ex^nh^$zaitodpg{B#Siip8 z?}ahWa_W6A)eXsuDCi$+%Ay))j#9g|XvK{CYWO|g{WBmOH`6Se^JS`Y$ic zB!nqJguRRB0PGjtrd)3|twawE26Uf-GA79uEtKkb)clXS`DseU2;#{ZSfSjm4m_7| z9fUB5rn3xPJWxh6lynMu7Ym){2JcJroQ9)g#tHIkdP##wi8T<4=>N118%Mmo2{~C% zZo=IOUb=vN3{D|X{aBw^2->UD1>B|&waJ-+E7aRMaMcH4(rkMc474;1b{vriSUI$D z6AW2(U#!#kG|r4iJ?Ah?m`*FZlf;_B`D(8NR5CY7G?}tmt6PQGz88nL)_ezHoOx2c z5(~r`EAF4i$#aiKGYZk^)7R&If$ke8c#W?r{lT`{ph2!ArHK_>3Suvf!mQY$cp0mN z;W|Oo63@v!(SI|tvH+TIT#!F?ScJikk($9v;od(3zP42;3H0t>9?D1^*tB_o9? zcmR_(<|xeLyve=~PQm{3y2}MPy1ST`Pb_X*S@mf|wmwI9dlWK7LF!VLfKIZcF{|`Z zo~R=g!^~uWuo{g-(Kj5a_b^G6Y0#vDTCIY}7y+E|*W0nHI#|Za1BJ#A55ucgXXP&7 zyvALkS?6vs?{^3h)PcM_=zVNK;!ceCqM&*Sd$to_J%UZ7{!tJH;r_oV0t!uHpbUJ2 zM7TDyK0VoCmHD*|dA{(LI9Y*h#v%}HIZZ7~r!vyk0vVw$8x-6fxg#fqY43$Q!Kt~w zAe|VPAzkj>=aq&*#Azo%Q?O`yjIjcLpz8X60MhuVNw2TpSa1`xmv072*&&6klVs$T?A!GZ<6;|RDh8mSw?=uRLS zaF3e`S|vf}Oq|T7)PO(-R9m`S08HhI=zE2THNkx3(@$cYk$h3N8b)zy-2EE6pQQ5k z${LroFf{)2EZrR79Uz1eEZ>fz%L9oR!IE~~(T6{mosep&#g(AU+-eaGlZVx{4pP74 z_nIW!Ojs%axc1v`-}{)c;S0cHFoMZz)Y<#qx(a`|V|@A)c$qmw zajQOM1o=6;??`4)Q~0gL?WX#wbDRUz4U}0L2G59@4+wt=Ui;l@ijzvjQg#xd-irSXjP28UI-$B>Gv!Kp%S3?-we&hAap9gG5oRKCD;e~Wu zTNQ^{Z5|tDR3DCyHN+Z47ov&Aa9~OdB^L0gz`saXcDy zU=@>Mg{H{AF`3zHtBYh*?`WlW*O+C~3Bmi%{cD0FYFRhH4_6;U?Wyijg*$R{fxWvye_86{zKA>lh;N zlZCZ8_MAGzLYP~x1lf8ELrz+o`v2Oh>H?zwTX1VO)k+l?PNCRwh? zv8)Um|G;>YV+dU8c4n^p0LlO3cDV^x;m>%-t*gjvm{0t6ju>t`x3<0N+jGek*Dlv0 z#WMTVtabt)l?vPznIuR`n@7kIjuT!mpC^6W(SSa=EXkEbB+s3q`Gj;qTAFQ3 z;YqNd8g)ES4So>Vj2!42Cx^7|!GyBpl2OoXr6S|M&7#;W>o2l;i^6qy;rU^hifJ`; z*PedMw-T~QIf|%Dz+T>8hCxEK@j7yC^KvS#$l6=zt!LSYieC9zqhODRL70dut+P7tyTdr7g9$PlZc}7_gg6<83IGOYI)4#XIUcU~`sYmX zXxh2B}p=G zKR171y(w-`jskjl=z?1o&rRQta&uBXyGV2hSeG#8L-$G^K(0iKK(FCqvSciWebbrc zdyO?Lj8p+FzxC?|(~1{TiADCOttfqVS8!X32o3Z}JL-7~3nYY)zY5z^W+4f{M76V_W0j)rU-eB9F)*U zyVI%UfVh593GFIfvqyWEoxS@!Uer#6D+d1nA~i}Z9@*34OelL-a$&x2I$+4P+=2z< zY_buX5YXMptT%nt@ahC%*ddR7*8QUCwO1#8{p_!JW*LUbv@JtnGH1UXR4g2ojPaLO ze1d8k@lXZZB=ExYl=lIq(CY2dwz9~y;SyW>rt>QUA`%C;wIg1(VD;i0&{_ly^%oKAfU`U+~TsM9f@I#S3dw4%0aTdk9EMfNIhkFNevZ1*)hw$qF7EbZV=e7 zPGQ>JL?U(HhO{dItEN*I%($iIi8?f(Vz)8yhya+kd(3KX?V6z3yH!o~u1(2i%$TkB zSuUr(i;Bz{i$FS+y+vSMed~#uPe)I7PpXe|rH!{LmC5wu(J9SqK<|{QMh^{(*Wx&Q|u5*9fJ8`Ttayo;o+sXC3KHgQxWGoDd+4f5*C2~(~ zdI8AZ-!*PcGUu3o#GvRJ%QGCmH(*F(&8(5?_!nnF6C(r;Y1p0>i?Ho^96{oD4?&?) zYnbqWxSwR!q0P%VcmfE{nep=D?Zu`z+v>fGTQGJ0+Iof+KMHlqwNJGmj7&;`LF(&M z?^u6ZtHwLGR|{HcPqeCxhu{h6DnZZS0U#u?ywCRH`|ygL87{6q-WOM=A;R$Odp-iq zMM?rz<`IR}$zt#6sAOP3YAQ+Q;8ZJd^&T=`t8>*KaCqgl{_R!eo1$|p&wlcIr=mCZ z{1<1_fUjfq{IGYIDtL#B)WyYleXP#cy9`eNVejiRc%0&D>c>0ywSOxw+3wZ?J literal 102994 zcmeEugo5nb1G~3xi~y`}h6XHx5j$(L*3|5S2UfkG$|UCNI>}4f?MfqZ+HW-sRW5RKHEm z^unW*Xx{AH_X3Xdvb%C(Bh6POqg==@d9j=5*}oEny_IS;M3==J`P0R!eD6s~N<36C z*%-OGYqd0AdWClO!Z!}Y1@@RkfeiQ$Ib_ z&fk%T;K9h`{`cX3Hu#?({4WgtmkR!u3ICS~|NqH^fdNz>51-9)OF{|bRLy*RBv#&1 z3Oi_gk=Y5;>`KbHf~w!`u}!&O%ou*Jzf|Sf?J&*f*K8cftMOKswn6|nb1*|!;qSrlw= zr-@X;zGRKs&T$y8ENnFU@_Z~puu(4~Ir)>rbYp{zxcF*!EPS6{(&J}qYpWeqrPWW< zfaApz%<-=KqxrqLLFeV3w0-a0rEaz9&vv^0ZfU%gt9xJ8?=byvNSb%3hF^X_n7`(fMA;C&~( zM$cQvQ|g9X)1AqFvbp^B{JEX$o;4iPi?+v(!wYrN{L}l%e#5y{j+1NMiT-8=2VrCP zmFX9=IZyAYA5c2!QO96Ea-6;v6*$#ZKM-`%JCJtrA3d~6h{u+5oaTaGE)q2b+HvdZ zvHlY&9H&QJ5|uG@wDt1h99>DdHy5hsx)bN`&G@BpxAHh$17yWDyw_jQhhjSqZ=e_k z_|r3=_|`q~uA47y;hv=6-o6z~)gO}ZM9AqDJsR$KCHKH;QIULT)(d;oKTSPDJ}Jx~G#w-(^r<{GcBC*~4bNjfwHBumoPbU}M)O za6Hc2ik)2w37Yyg!YiMq<>Aov?F2l}wTe+>h^YXcK=aesey^i)QC_p~S zp%-lS5%)I29WfywP(r4@UZ@XmTkqo51zV$|U|~Lcap##PBJ}w2b4*kt7x6`agP34^ z5fzu_8rrH+)2u*CPcr6I`gL^cI`R2WUkLDE5*PX)eJU@H3HL$~o_y8oMRoQ0WF9w| z6^HZDKKRDG2g;r8Z4bn+iJNFV(CG;K-j2>aj229gl_C6n12Jh$$h!}KVhn>*f>KcH z;^8s3t(ccVZ5<{>ZJK@Z`hn_jL{bP8Yn(XkwfRm?GlEHy=T($8Z1Mq**IM`zxN9>-yXTjfB18m_$E^JEaYn>pj`V?n#Xu;Z}#$- zw0Vw;T*&9TK$tKI7nBk9NkHzL++dZ^;<|F6KBYh2+XP-b;u`Wy{~79b%IBZa3h*3^ zF&BKfQ@Ej{7ku_#W#mNJEYYp=)bRMUXhLy2+SPMfGn;oBsiG_6KNL8{p1DjuB$UZB zA)a~BkL)7?LJXlCc}bB~j9>4s7tlnRHC5|wnycQPF_jLl!Avs2C3^lWOlHH&v`nGd zf&U!fn!JcZWha`Pl-B3XEe;(ks^`=Z5R zWyQR0u|do2`K3ec=YmWGt5Bwbu|uBW;6D8}J3{Uep7_>L6b4%(d=V4m#(I=gkn4HT zYni3cnn>@F@Wr<hFAY3Y~dW+3bte;70;G?kTn4Aw5nZ^s5|47 z4$rCHCW%9qa4)4vE%^QPMGf!ET!^LutY$G zqdT(ub5T5b+wi+OrV}z3msoy<4)`IPdHsHJggmog0K*pFYMhH!oZcgc5a)WmL?;TPSrerTVPp<#s+imF3v#!FuBNNa`#6 z!GdTCF|IIpz#(eV^mrYKThA4Bnv&vQet@%v9kuRu3EHx1-2-it@E`%9#u`)HRN#M? z7aJ{wzKczn#w^`OZ>Jb898^Xxq)0zd{3Tu7+{-sge-rQ z&0PME&wIo6W&@F|%Z8@@N3)@a_ntJ#+g{pUP7i?~3FirqU`rdf8joMG^ld?(9b7Iv z>TJgBg#)(FcW)h!_if#cWBh}f+V08GKyg|$P#KTS&%=!+0a%}O${0$i)kn9@G!}En zv)_>s?glPiLbbx)xk(lD-QbY(OP3;MSXM5E*P&_`Zks2@46n|-h$Y2L7B)iH{GAAq19h5-y0q>d^oy^y+soJu9lXxAe%jcm?=pDLFEG2kla40e!5a}mpe zdL=WlZ=@U6{>g%5a+y-lx)01V-x;wh%F{=qy#XFEAqcd+m}_!lQ)-9iiOL%&G??t| z?&NSdaLqdPdbQs%y0?uIIHY7rw1EDxtQ=DU!i{)Dkn~c$LG5{rAUYM1j5*G@oVn9~ zizz{XH(nbw%f|wI=4rw^6mNIahQpB)OQy10^}ACdLPFc2@ldVi|v@1nWLND?)53O5|fg`RZW&XpF&s3@c-R?aad!$WoH6u0B|}zt)L($E^@U- zO#^fxu9}Zw7Xl~nG1FVM6DZSR0*t!4IyUeTrnp@?)Z)*!fhd3)&s(O+3D^#m#bAem zpf#*aiG_0S^ofpm@9O7j`VfLU0+{$x!u^}3!zp=XST0N@DZTp!7LEVJgqB1g{psNr za0uVmh3_9qah14@M_pi~vAZ#jc*&aSm$hCNDsuQ-zPe&*Ii#2=2gP+DP4=DY z_Y0lUsyE6yaV9)K)!oI6+*4|spx2at*30CAx~6-5kfJzQ`fN8$!lz%hz^J6GY?mVH zbYR^JZ(Pmj6@vy-&!`$5soyy-NqB^8cCT40&R@|6s@m+ZxPs=Bu77-+Os7+bsz4nA3DrJ8#{f98ZMaj-+BD;M+Jk?pgFcZIb}m9N z{ct9T)Kye&2>l^39O4Q2@b%sY?u#&O9PO4@t0c$NUXG}(DZJ<;_oe2~e==3Z1+`Zo zFrS3ns-c}ZognVBHbg#e+1JhC(Yq7==rSJQ8J~}%94(O#_-zJKwnBXihl#hUd9B_>+T& z7eHHPRC?5ONaUiCF7w|{J`bCWS7Q&xw-Sa={j-f)n5+I=9s;E#fBQB$`DDh<^mGiF zu-m_k+)dkBvBO(VMe2O4r^sf3;sk9K!xgXJU>|t9Vm8Ty;fl5pZzw z9j|}ZD}6}t;20^qrS?YVPuPRS<39d^y0#O1o_1P{tN0?OX!lc-ICcHI@2#$cY}_CY zev|xdFcRTQ_H)1fJ7S0*SpPs8e{d+9lR~IZ^~dKx!oxz?=Dp!fD`H=LH{EeC8C&z-zK$e=!5z8NL=4zx2{hl<5z*hEmO=b-7(k5H`bA~5gT30Sjy`@-_C zKM}^so9Ti1B;DovHByJkTK87cfbF16sk-G>`Q4-txyMkyQS$d}??|Aytz^;0GxvOs zPgH>h>K+`!HABVT{sYgzy3CF5ftv6hI-NRfgu613d|d1cg^jh+SK7WHWaDX~hlIJ3 z>%WxKT0|Db1N-a4r1oPKtF--^YbP=8Nw5CNt_ZnR{N(PXI>Cm$eqi@_IRmJ9#)~ZHK_UQ8mi}w^`+4$OihUGVz!kW^qxnCFo)-RIDbA&k-Y=+*xYv5y4^VQ9S)4W5Pe?_RjAX6lS6Nz#!Hry=+PKx2|o_H_3M`}Dq{Bl_PbP(qel~P@=m}VGW*pK96 zI@fVag{DZHi}>3}<(Hv<7cVfWiaVLWr@WWxk5}GDEbB<+Aj;(c>;p1qmyAIj+R!`@#jf$ zy4`q23L-72Zs4j?W+9lQD;CYIULt%;O3jPWg2a%Zs!5OW>5h1y{Qof!p&QxNt5=T( zd5fy&7=hyq;J8%86YBOdc$BbIFxJx>dUyTh`L z-oKa=OhRK9UPVRWS`o2x53bAv+py)o)kNL6 z9W1Dlk-g6Ht@-Z^#6%`9S9`909^EMj?9R^4IxssCY-hYzei^TLq7Cj>z$AJyaU5=z zl!xiWvz0U8kY$etrcp8mL;sYqGZD!Hs-U2N{A|^oEKA482v1T%cs%G@X9M?%lX)p$ zZoC7iYTPe8yxY0Jne|s)fCRe1mU=Vb1J_&WcIyP|x4$;VSVNC`M+e#oOA`#h>pyU6 z?7FeVpk`Hsu`~T3i<_4<5fu?RkhM;@LjKo6nX>pa%8dSdgPO9~Jze;5r>Tb1Xqh5q z&SEdTXevV@PT~!O6z|oypTk7Qq+BNF5IQ(8s18c=^0@sc8Gi|3e>VKCsaZ?6=rrck zl@oF5Bd0zH?@15PxSJIRroK4Wa?1o;An;p0#%ZJ^tI=(>AJ2OY0GP$E_3(+Zz4$AQ zW)QWl<4toIJ5TeF&gNXs>_rl}glkeG#GYbHHOv-G!%dJNoIKxn)FK$5&2Zv*AFic! z@2?sY&I*PSfZ8bU#c9fdIJQa_cQijnj39-+hS@+~e*5W3bj%A}%p9N@>*tCGOk+cF zlcSzI6j%Q|2e>QG3A<86w?cx6sBtLNWF6_YR?~C)IC6_10SNoZUHrCpp6f^*+*b8` zlx4ToZZuI0XW1W)24)92S)y0QZa);^NRTX6@gh8@P?^=#2dV9s4)Q@K+gnc{6|C}& zDLHr7nDOLrsH)L@Zy{C_2UrYdZ4V{|{c8&dRG;wY`u>w%$*p>PO_}3`Y21pk?8Wtq zGwIXTulf7AO2FkPyyh2TZXM1DJv>hI`}x`OzQI*MBc#=}jaua&czSkI2!s^rOci|V zFkp*Vbiz5vWa9HPFXMi=BV&n3?1?%8#1jq?p^3wAL`jgcF)7F4l<(H^!i=l-(OTDE zxf2p71^WRIExLf?ig0FRO$h~aA23s#L zuZPLkm>mDwBeIu*C7@n@_$oSDmdWY7*wI%aL73t~`Yu7YwE-hxAATmOi0dmB9|D5a zLsR7OQcA0`vN9m0L|5?qZ|jU+cx3_-K2!K$zDbJ$UinQy<9nd5ImWW5n^&=Gg>Gsh zY0u?m1e^c~Ug39M{{5q2L~ROq#c{eG8Oy#5h_q=#AJj2Yops|1C^nv0D1=fBOdfAG z%>=vl*+_w`&M7{qE#$xJJp_t>bSh7Mpc(RAvli9kk3{KgG5K@a-Ue{IbU{`umXrR3ra5Y7xiX42+Q%N&-0#`ae_ z#$Y6Wa++OPEDw@96Zz##PFo9sADepQe|hUy!Zzc2C(L`k9&=a8XFr+!hIS>D2{pdGP1SzwyaGLiH3j--P>U#TWw90t8{8Bt%m7Upspl#=*hS zhy|(XL6HOqBW}Og^tLX7 z+`b^L{O&oqjwbxDDTg2B;Yh2(fW>%S5Pg8^u1p*EFb z`(fbUM0`afawYt%VBfD&b3MNJ39~Ldc@SAuzsMiN%E}5{uUUBc7hc1IUE~t-Y9h@e7PC|sv$xGx=hZiMXNJxz5V(np%6u{n24iWX#!8t#>Ob$in<>dw96H)oGdTHnU zSM+BPss*5)Wz@+FkooMxxXZP1{2Nz7a6BB~-A_(c&OiM)UUNoa@J8FGxtr$)`9;|O z(Q?lq1Q+!E`}d?KemgC!{nB1JJ!B>6J@XGQp9NeQvtbM2n7F%v|IS=XWPVZY(>oq$ zf=}8O_x`KOxZoGnp=y24x}k6?gl_0dTF!M!T`={`Ii{GnT1jrG9gPh)R=RZG8lIR| z{ZJ6`x8n|y+lZuy${fuEDTAf`OP!tGySLXD}ATJO5UoZv|Xo3%7O~L63+kw}v)Ci=&tWx3bQJfL@5O18CbPlkR^IcKA zy1=^Vl-K-QBP?9^R`@;czcUw;Enbbyk@vJQB>BZ4?;DM%BUf^eZE+sOy>a){qCY6Y znYy;KGpch-zf=5|p#SoAV+ie8M5(Xg-{FoLx-wZC9IutT!(9rJ8}=!$!h%!J+vE2e z(sURwqCC35v?1>C1L)swfA^sr16{yj7-zbT6Rf26-JoEt%U?+|rQ zeBuGohE?@*!zR9)1P|3>KmJSgK*fOt>N>j}LJB`>o(G#Dduvx7@DY7};W7K;Yj|8O zGF<+gTuoIKe7Rf+LQG3-V1L^|E;F*}bQ-{kuHq}| ze_NwA7~US19sAZ)@a`g*zkl*ykv2v3tPrb4Og2#?k6Lc7@1I~+ew48N&03hW^1Cx+ zfk5Lr4-n=#HYg<7ka5i>2A@ZeJ60gl)IDX!!p zzfXZQ?GrT>JEKl7$SH!otzK6=0dIlqN)c23YLB&Krf9v-{@V8p+-e2`ujFR!^M%*; ze_7(Jh$QgoqwB!HbX=S+^wqO15O_TQ0-qX8f-|&SOuo3ZE{{9Jw5{}>MhY}|GBhO& zv48s_B=9aYQfa;d>~1Z$y^oUUaDer>7ve5+Gf?rIG4GZ!hRKERlRNgg_C{W_!3tsI2TWbX8f~MY)1Q`6Wj&JJ~*;ay_0@e zzx+mE-pu8{cEcVfBqsnm=jFU?H}xj@%CAx#NO>3 z_re3Rq%d1Y7VkKy{=S73&p;4^Praw6Y59VCP6M?!Kt7{v#DG#tz?E)`K95gH_mEvb z%$<~_mQ$ad?~&T=O0i0?`YSp?E3Dj?V>n+uTRHAXn`l!pH9Mr}^D1d@mkf+;(tV45 zH_yfs^kOGLXlN*0GU;O&{=awxd?&`{JPRr$z<1HcAO2K`K}92$wC}ky&>;L?#!(`w z68avZGvb728!vgw>;8Z8I@mLtI`?^u6R>sK4E7%=y)jpmE$fH!Dj*~(dy~-2A5Cm{ zl{1AZw`jaDmfvaB?jvKwz!GC}@-Dz|bFm1OaPw(ia#?>vF7Y5oh{NVbyD~cHB1KFn z9C@f~X*Wk3>sQH9#D~rLPslAd26@AzMh=_NkH_yTNXx6-AdbAb z{Ul89YPHslD?xAGzOlQ*aMYUl6#efCT~WI zOvyiewT=~l1W(_2cEd(8rDywOwjM-7P9!8GCL-1<9KXXO=6%!9=W++*l1L~gRSxLVd8K=A7&t52ql=J&BMQu{fa6y zXO_e>d?4X)xp2V8e3xIQGbq@+vo#&n>-_WreTTW0Yr?|YRPP43cDYACMQ(3t6(?_k zfgDOAU^-pew_f5U#WxRXB30wcfDS3;k~t@b@w^GG&<5n$Ku?tT(%bQH(@UHQGN)N|nfC~7?(etU`}XB)$>KY;s=bYGY#kD%i9fz= z2nN9l?UPMKYwn9bX*^xX8Y@%LNPFU>s#Ea1DaP%bSioqRWi9JS28suTdJycYQ+tW7 zrQ@@=13`HS*dVKaVgcem-45+buD{B;mUbY$YYULhxK)T{S?EB<8^YTP$}DA{(&)@S zS#<8S96y9K2!lG^VW-+CkfXJIH;Vo6wh)N}!08bM$I7KEW{F6tqEQ?H@(U zAqfi%KCe}2NUXALo;UN&k$rU0BLNC$24T_mcNY(a@lxR`kqNQ0z%8m>`&1ro40HX} z{{3YQ;2F9JnVTvDY<4)x+88i@MtXE6TBd7POk&QfKU-F&*C`isS(T_Q@}K)=zW#K@ zbXpcAkTT-T5k}Wj$dMZl7=GvlcCMt}U`#Oon1QdPq%>9J$rKTY8#OmlnNWBYwafhx zqFnym@okL#Xw>4SeRFejBnZzY$jbO)e^&&sHBgMP%Ygfi!9_3hp17=AwLBNFTimf0 zw6BHNXw19Jg_Ud6`5n#gMpqe%9!QB^_7wAYv8nrW94A{*t8XZu0UT&`ZHfkd(F{Px zD&NbRJP#RX<=+sEeGs2`9_*J2OlECpR;4uJie-d__m*(aaGE}HIo+3P{my@;a~9Y$ zHBXVJ83#&@o6{M+pE9^lI<4meLLFN_3rwgR4IRyp)~OF0n+#ORrcJ2_On9-78bWbG zuCO0esc*n1X3@p1?lN{qWS?l7J$^jbpeel{w~51*0CM+q9@9X=>%MF(ce~om(}?td zjkUmdUR@LOn-~6LX#=@a%rvj&>DFEoQscOvvC@&ZB5jVZ-;XzAshwx$;Qf@U41W=q zOSSjQGQV8Qi3*4DngNMIM&Cxm7z*-K`~Bl(TcEUxjQ1c=?)?wF8W1g;bAR%sM#LK( z_Op?=P%)Z+J!>vpN`By0$?B~Out%P}kCriDq@}In&fa_ZyKV+nLM0E?hfxuu%ciUz z>yAk}OydbWNl7{)#112j&qmw;*Uj&B;>|;Qwfc?5wIYIHH}s6Mve@5c5r+y)jK9i( z_}@uC(98g)==AGkVN?4>o@w=7x9qhW^ zB(b5%%4cHSV?3M?k&^py)j*LK16T^Ef4tb05-h-tyrjt$5!oo4spEfXFK7r_Gfv7#x$bsR7T zs;dqxzUg9v&GjsQGKTP*=B(;)be2aN+6>IUz+Hhw-n>^|`^xu*xvjGPaDoFh2W4-n z@Wji{5Y$m>@Vt7TE_QVQN4*vcfWv5VY-dT0SV=l=8LAEq1go*f zkjukaDV=3kMAX6GAf0QOQHwP^{Z^=#Lc)sh`QB)Ftl&31jABvq?8!3bt7#8vxB z53M{4{GR4Hl~;W3r}PgXSNOt477cO62Yj(HcK&30zsmWpvAplCtpp&mC{`2Ue*Bwu zF&UX1;w%`Bs1u%RtGPFl=&sHu@Q1nT`z={;5^c^^S~^?2-?<|F9RT*KQmfgF!7=wD@hytxbD;=9L6PZrK*1<4HMObNWehA62DtTy)q5H|57 z9dePuC!1;0MMRRl!S@VJ8qG=v^~aEU+}2Qx``h1LII!y{crP2ky*R;Cb;g|r<#ryo zju#s4dE?5CTIZKc*O4^3qWflsQ(voX>(*_JP7>Q&$%zCAIBTtKC^JUi@&l6u&t0hXMXjz_y!;r@?k|OU9aD%938^TZ>V? zqJmom_6dz4DBb4Cgs_Ef@}F%+cRCR%UMa9pi<-KHN;t#O@cA%(LO1Rb=h?5jiTs93 zPLR78p+3t>z4|j=<>2i4b`ketv}9Ax#B0)hn7@bFl;rDfP8p7u9XcEb!5*PLKB(s7wQC2kzI^@ae)|DhNDmSy1bOLid%iIap@24A(q2XI!z_hkl-$1T10 z+KKugG4-}@u8(P^S3PW4x>an;XWEF-R^gB{`t8EiP{ZtAzoZ!JRuMRS__-Gg#Qa3{<;l__CgsF+nfmFNi}p z>rV!Y6B@cC>1up)KvaEQiAvQF!D>GCb+WZsGHjDeWFz?WVAHP65aIA8u6j6H35XNYlyy8>;cWe3ekr};b;$9)0G`zsc9LNsQ&D?hvuHRpBxH)r-1t9|Stc*u<}Ol&2N+wPMom}d15_TA=Aprp zjN-X3*Af$7cDWMWp##kOH|t;c2Pa9Ml4-)o~+7P;&q8teF-l}(Jt zTGKOQqJTeT!L4d}Qw~O0aanA$Vn9Rocp-MO4l*HK)t%hcp@3k0%&_*wwpKD6ThM)R z8k}&7?)YS1ZYKMiy?mn>VXiuzX7$Ixf7EW8+C4K^)m&eLYl%#T=MC;YPvD&w#$MMf zQ=>`@rh&&r!@X&v%ZlLF42L_c=5dSU^uymKVB>5O?AouR3vGv@ei%Z|GX5v1GK2R* zi!!}?+-8>J$JH^fPu@)E6(}9$d&9-j51T^n-e0Ze%Q^)lxuex$IL^XJ&K2oi`wG}QVGk2a7vC4X?+o^z zsCK*7`EUfSuQA*K@Plsi;)2GrayQOG9OYF82Hc@6aNN5ulqs1Of-(iZQdBI^U5of^ zZg2g=Xtad7$hfYu6l~KDQ}EU;oIj(3nO#u9PDz=eO3(iax7OCmgT2p_7&^3q zg7aQ;Vpng*)kb6=sd5?%j5Dm|HczSChMo8HHq_L8R;BR5<~DVyU$8*Tk5}g0eW5x7 z%d)JFZ{(Y<#OTKLBA1fwLM*fH7Q~7Sc2Ne;mVWqt-*o<;| z^1@vo_KTYaMnO$7fbLL+qh#R$9bvnpJ$RAqG+z8h|} z3F5iwG*(sCn9Qbyg@t0&G}3fE0jGq3J!JmG2K&$urx^$z95) z7h?;4vE4W=v)uZ*Eg3M^6f~|0&T)2D;f+L_?M*21-I1pnK(pT$5l#QNlT`SidYw~o z{`)G)Asv#cue)Ax1RNWiRUQ(tQ(bzd-f2U4xlJK+)ZWBxdq#fp=A>+Qc%-tl(c)`t z$e2Ng;Rjvnbu7((;v4LF9Y1?0el9hi!g>G{^37{ z`^s-03Z5jlnD%#Mix19zkU_OS|86^_x4<0(*YbPN}mi-$L?Z4K(M|2&VV*n*ZYN_UqI?eKZi3!b)i z%n3dzUPMc-dc|q}TzvPy!VqsEWCZL(-eURDRG4+;Eu!LugSSI4Fq$Ji$Dp08`pfP_C5Yx~`YKcywlMG;$F z)R5!kVml_Wv6MSpeXjG#g?kJ0t_MEgbXlUN3k|JJ%N>|2xn8yN>>4qxh!?dGI}s|Y zDTKd^JCrRSN+%w%D_uf=Tj6wIV$c*g8D96jb^Kc#>5Fe-XxKC@!pIJw0^zu;`_yeb zhUEm-G*C=F+jW%cP(**b61fTmPn2WllBr4SWNdKe*P8VabZsh0-R|?DO=0x`4_QY) zR7sthW^*BofW7{Sak&S1JdiG?e=SfL24Y#w_)xrBVhGB-13q$>mFU|wd9Xqe-o3{6 zSn@@1@&^)M$rxb>UmFuC+pkio#T;mSnroMVZJ%nZ!uImi?%KsIX#@JU2VY(`kGb1A z7+1MEG)wd@)m^R|a2rXeviv$!emwcY(O|M*xV!9%tBzarBOG<4%gI9SW;Um_gth4=gznYzOFd)y8e+3APCkL)i-OI`;@7-mCJgE`js(M} z;~ZcW{{FMVVO)W>VZ}ILouF#lWGb%Couu}TI4kubUUclW@jEn6B_^v!Ym*(T*4HF9 zWhNKi8%sS~viSdBtnrq!-Dc5(G^XmR>DFx8jhWvR%*8!m*b*R8e1+`7{%FACAK`7 zzdy8TmBh?FVZ0vtw6npnWwM~XjF2fNvV#ZlGG z?FxHkXHN>JqrBYoPo$)zNC7|XrQfcqmEXWud~{j?La6@kbHG@W{xsa~l1=%eLly8B z4gCIH05&Y;6O2uFSopNqP|<$ml$N40^ikxw0`o<~ywS1(qKqQN!@?Ykl|bE4M?P+e zo$^Vs_+x)iuw?^>>`$&lOQOUkZ5>+OLnRA)FqgpDjW&q*WAe(_mAT6IKS9;iZBl8M z<@=Y%zcQUaSBdrs27bVK`c$)h6A1GYPS$y(FLRD5Yl8E3j0KyH08#8qLrsc_qlws; znMV%Zq8k+&T2kf%6ZO^2=AE9>?a587g%-={X}IS~P*I(NeCF9_9&`)|ok0iiIun zo+^odT0&Z4k;rn7I1v87=z!zKU(%gfB$(1mrRYeO$sbqM22Kq68z9wgdg8HBxp>_< zn9o%`f?sVO=IN#5jSX&CGODWlZfQ9A)njK2O{JutYwRZ?n0G_p&*uwpE`Md$iQxrd zoQfF^b8Ou)+3BO_3_K5y*~?<(BF@1l+@?Z6;^;U>qlB)cdro;rxOS1M{Az$s^9o5sXDCg8yD<=(pKI*0e zLk>@lo#&s0)^*Q+G)g}C0IErqfa9VbL*Qe=OT@&+N8m|GJF7jd83vY#SsuEv2s{Q> z>IpoubNs>D_5?|kXGAPgF@mb_9<%hjU;S0C8idI)a=F#lPLuQJ^7OnjJlH_Sks9JD zMl1td%YsWq3YWhc;E$H1<0P$YbSTqs`JKY%(}svsifz|h8BHguL82dBl+z0^YvWk8 zGy;7Z0v5_FJ2A$P0wIr)lD?cPR%cz>kde!=W%Ta^ih+Dh4UKdf7ip?rBz@%y2&>`6 zM#q{JXvW9ZlaSk1oD!n}kSmcDa2v6T^Y-dy+#fW^y>eS8_%<7tWXUp8U@s$^{JFfKMjDAvR z$YmVB;n3ofl!ro9RNT!TpQpcycXCR}$9k5>IPWDXEenQ58os?_weccrT+Bh5sLoiH zZ_7~%t(vT)ZTEO= zb0}@KaD{&IyK_sd8b$`Qz3%UA`nSo zn``!BdCeN!#^G;lK@G2ron*0jQhbdw)%m$2;}le@z~PSLnU-z@tL)^(p%P>OO^*Ff zNRR9oQ`W+x^+EU+3BpluwK77|B3=8QyT|$V;02bn_LF&3LhLA<#}{{)jE)}CiW%VEU~9)SW+=F%7U-iYlQ&q!#N zwI2{(h|Pi&<8_fqvT*}FLN^0CxN}#|3I9G_xmVg$gbn2ZdhbmGk7Q5Q2Tm*ox8NMo zv`iaZW|ZEOMyQga5fts?&T-eCCC9pS0mj7v0SDkD=*^MxurP@89v&Z#3q{FM!a_nr zb?KzMv`BBFOew>4!ft@A&(v-kWXny-j#egKef|#!+3>26Qq0 zv!~8ev4G`7Qk>V1TaMT-&ziqoY3IJp8_S*%^1j73D|=9&;tDZH^!LYFMmME4*Wj(S zRt~Q{aLb_O;wi4u&=}OYuj}Lw*j$@z*3>4&W{)O-oi@9NqdoU!=U%d|se&h?^$Ip# z)BY+(1+cwJz!yy4%l(aLC;T!~Ci>yAtXJb~b*yr&v7f{YCU8P|N1v~H`xmGsG)g)y z4%mv=cPd`s7a*#OR7f0lpD$ueP>w8qXj0J&*7xX+U!uat5QNk>zwU$0acn5p=$88L=jn_QCSYkTV;1~(yUem#0gB`FeqY98sf=>^@ z_MCdvylv~WL%y_%y_FE1)j;{Szj1+K7Lr_y=V+U zk6Tr;>XEqlEom~QGL!a+wOf(@ZWoxE<$^qHYl*H1a~kk^BLPn785%nQb$o;Cuz0h& za9LMx^bKEbPS%e8NM33Jr|1T|ELC(iE!FUci38xW_Y7kdHid#2ie+XZhP;2!Z;ZAM zB_cXKm)VrPK!SK|PY00Phwrpd+x0_Aa;}cDQvWKrwnQrqz##_gvHX2ja?#_{f#;bz`i>C^^ zTLDy;6@HZ~XQi7rph!mz9k!m;KchA)uMd`RK4WLK7)5Rl48m#l>b(#`WPsl<0j z-sFkSF6>Nk|LKnHtZ`W_NnxZP62&w)S(aBmmjMDKzF%G;3Y?FUbo?>b5;0j8Lhtc4 zr*8d5Y9>g@FFZaViw7c16VsHcy0u7M%6>cG1=s=Dtx?xMJSKIu9b6GU8$uSzf43Y3 zYq|U+IWfH;SM~*N1v`KJo!|yfLxTFS?oHsr3qvzeVndVV^%BWmW6re_S!2;g<|Oao z+N`m#*i!)R%i1~NO-xo{qpwL0ZrL7hli;S z3L0lQ_z}z`fdK39Mg~Zd*%mBdD;&5EXa~@H(!###L`ycr7gW`f)KRuqyHL3|uyy3h zSS^td#E&Knc$?dXs*{EnPYOp^-vjAc-h4z#XkbG&REC7;0>z^^Z}i8MxGKerEY z>l?(wReOlXEsNE5!DO&ZWyxY)gG#FSZs%fXuzA~XIAPVp-%yb2XLSV{1nH6{)5opg z(dZKckn}Q4Li-e=eUDs1Psg~5zdn1>ql(*(nn6)iD*OcVkwmKL(A{fix(JhcVB&}V zVt*Xb!{gzvV}dc446>(D=SzfCu7KB`oMjv6kPzSv&B>>HLSJP|wN`H;>oRw*tl#N) z*zZ-xwM7D*AIsBfgqOjY1Mp9aq$kRa^dZU_xw~KxP;|q(m+@e+YSn~`wEJzM|Ippb zzb@%;hB7iH4op9SqmX?j!KP2chsb79(mFossBO-Zj8~L}9L%R%Bw<`^X>hjkCY5SG z7lY!8I2mB#z)1o;*3U$G)3o0A&{0}#B;(zPd2`OF`Gt~8;0Re8nIseU z_yzlf$l+*-wT~_-cYk$^wTJ@~7i@u(CZs9FVkJCru<*yK8&>g+t*!JqCN6RH%8S-P zxH8+Cy#W?!;r?cLMC(^BtAt#xPNnwboI*xWw#T|IW^@3|q&QYY6Ehxoh@^URylR|T zne-Y6ugE^7p5bkRDWIh)?JH5V^ub82l-LuVjDr7UT^g`q4dB&mBFRWGL_C?hoeL(% zo}ocH5t7|1Mda}T!^{Qt9vmA2ep4)dQSZO>?Eq8}qRp&ZJ?-`Tnw+MG(eDswP(L*X3ahC2Ad0_wD^ff9hfzb%Jd`IXx5 zae@NMzBXJDwJS?7_%!TB^E$N8pvhOHDK$7YiOelTY`6KX8hK6YyT$tk*adwN>s^Kp zwM3wGVPhwKU*Yq-*BCs}l`l#Tej(NQ>jg*S0TN%D+GcF<14Ms6J`*yMY;W<-mMN&-K>((+P}+t+#0KPGrzjP zJ~)=Bcz%-K!L5ozIWqO(LM)l_9lVOc4*S65&DKM#TqsiWNG{(EZQw!bc>qLW`=>p-gVJ;T~aN2D_- z{>SZC=_F+%hNmH6ub%Ykih0&YWB!%sd%W5 zHC2%QMP~xJgt4>%bU>%6&uaDtSD?;Usm}ari0^fcMhi_)JZgb1g5j zFl4`FQ*%ROfYI}e7RIq^&^a>jZF23{WB`T>+VIxj%~A-|m=J7Va9FxXV^%UwccSZd zuWINc-g|d6G5;95*%{e;9S(=%yngpfy+7ao|M7S|Jb0-4+^_q-uIqVS&ufU880UDH*>(c)#lt2j zzvIEN>>$Y(PeALC-D?5JfH_j+O-KWGR)TKunsRYKLgk7eu4C{iF^hqSz-bx5^{z0h ze2+u>Iq0J4?)jIo)}V!!m)%)B;a;UfoJ>VRQ*22+ncpe9f4L``?v9PH&;5j{WF?S_C>Lq>nkChZB zjF8(*v0c(lU^ZI-)_uGZnnVRosrO4`YinzI-RSS-YwjYh3M`ch#(QMNw*)~Et7Qpy z{d<3$4FUAKILq9cCZpjvKG#yD%-juhMj>7xIO&;c>_7qJ%Ae8Z^m)g!taK#YOW3B0 zKKSMOd?~G4h}lrZbtPk)n*iOC1~mDhASGZ@N{G|dF|Q^@1ljhe=>;wusA&NvY*w%~ zl+R6B^1yZiF)YN>0ms%}qz-^U-HVyiN3R9k1q4)XgDj#qY4CE0)52%evvrrOc898^ z*^)XFR?W%g0@?|6Mxo1ZBp%(XNv_RD-<#b^?-Fs+NL^EUW=iV|+Vy*F%;rBz~pN7%-698U-VMfGEVnmEz7fL1p)-5sLT zL;Iz>FCLM$p$c}g^tbkGK1G$IALq1Gd|We@&TtW!?4C7x4l*=4oF&&sr0Hu`x<5!m zhX&&Iyjr?AkNXU_5P_b^Q3U9sy#f6ZF@2C96$>1k*E-E%DjwvA{VL0PdU~suN~DZo zm{T!>sRdp`Ldpp9olrH@(J$QyGq!?#o1bUo=XP2OEuT3`XzI>s^0P{manUaE4pI%! zclQq;lbT;nx7v3tR9U)G39h?ryrxzd0xq4KX7nO?piJZbzT_CU&O=T(Vt;>jm?MgC z2vUL#*`UcMsx%w#vvjdamHhmN!(y-hr~byCA-*iCD};#l+bq;gkwQ0oN=AyOf@8ow>Pj<*A~2*dyjK}eYdN);%!t1 z6Y=|cuEv-|5BhA?n2Db@4s%y~(%Wse4&JXw=HiO48%c6LB~Z0SL1(k^9y?ax%oj~l zf7(`iAYLdPRq*ztFC z7VtAb@s{as%&Y;&WnyYl+6Wm$ru*u!MKIg_@01od-iQft0rMjIj8e7P9eKvFnx_X5 zd%pDg-|8<>T2Jdqw>AII+fe?CgP+fL(m0&U??QL8YzSjV{SFi^vW~;wN@or_(q<0Y zRt~L}#JRcHOvm$CB)T1;;7U>m%)QYBLTR)KTARw%zoDxgssu5#v{UEVIa<>{8dtkm zXgbCGp$tfue+}#SD-PgiNT{Zu^YA9;4BnM(wZ9-biRo_7pN}=aaimjYgC=;9@g%6< zxol5sT_$<8{LiJ6{l1+sV)Z_QdbsfEAEMw!5*zz6)Yop?T0DMtR_~wfta)E6_G@k# zZRP11D}$ir<`IQ`<(kGfAS?O-DzCyuzBq6dxGTNNTK?r^?zT30mLY!kQ=o~Hv*k^w zvq!LBjW=zzIi%UF@?!g9vt1CqdwV(-2LYy2=E@Z?B}JDyVkluHtzGsWuI1W5svX~K z&?UJ45$R7g>&}SFnLnmw09R2tUgmr_w6mM9C}8GvQX>nL&5R#xBqnp~Se(I>R42`T zqZe9p6G(VzNB3QD><8+y%{e%6)sZDRXTR|MI zM#eZmao-~_`N|>Yf;a;7yvd_auTG#B?Vz5D1AHx=zpVUFe7*hME z+>KH5h1In8hsVhrstc>y0Q!FHR)hzgl+*Q&5hU9BVJlNGRkXiS&06eOBV^dz3;4d5 zeYX%$62dNOprZV$px~#h1RH?_E%oD6y;J;pF%~y8M)8pQ0olYKj6 zE+hd|7oY3ot=j9ZZ))^CCPADL6Jw%)F@A{*coMApcA$7fZ{T@3;WOQ352F~q6`Mgi z$RI6$8)a`Aaxy<8Bc;{wlDA%*%(msBh*xy$L-cBJvQ8hj#FCyT^%+Phw1~PaqyDou^JR0rxDkSrmAdjeYDFDZ`E z)G3>XtpaSPDlydd$RGHg;#4|4{aP5c_Om z2u5xgnhnA)K%8iU==}AxPxZCYC)lyOlj9as#`5hZ=<6<&DB%i_XCnt5=pjh?iusH$ z>)E`@HNZcAG&RW3Ys@`Ci{;8PNzE-ZsPw$~Wa!cP$ye+X6;9ceE}ah+3VY7Mx}#0x zbqYa}eO*FceiY2jNS&2cH9Y}(;U<^^cWC5Ob&)dZedvZA9HewU3R;gRQ)}hUdf+~Q zS_^4ds*W1T#bxS?%RH&<739q*n<6o|mV;*|1s>ly-Biu<2*{!!0#{_234&9byvn0* z5=>{95Zfb{(?h_Jk#ocR$FZ78O*UTOxld~0UF!kyGM|nH%B*qf)Jy}N!uT9NGeM19 z-@=&Y0yGGo_dw!FD>juk%P$6$qJkj}TwLBoefi;N-$9LAeV|)|-ET&culW9Sb_pc_ zp{cXI0>I0Jm_i$nSvGnYeLSSj{ccVS2wyL&0x~&5v;3Itc82 z5lIAkfn~wcY-bQB$G!ufWt%qO;P%&2B_R5UKwYxMemIaFm)qF1rA zc>gEihb=jBtsXCi0T%J37s&kt*3$s7|6)L(%UiY)6axuk{6RWIS8^+u;)6!R?Sgap z9|6<0bx~AgVi|*;zL@2x>Pbt2Bz*uv4x-`{F)XatTs`S>unZ#P^ZiyjpfL_q2z^fqgR-fbOcG=Y$q>ozkw1T6dH8-)&ww+z?E0 zR|rV(9bi6zpX3Ub>PrPK!{X>e$C66qCXAeFm)Y+lX8n2Olt7PNs*1^si)j!QmFV#t z0P2fyf$N^!dyTot&`Ew5{i5u<8D`8U`qs(KqaWq5iOF3x2!-z65-|HsyYz(MAKZ?< zCpQR;E)wn%s|&q(LVm0Ab>gdmCFJeKwVTnv@Js%!At;I=A>h=l=p^&<4;Boc{$@h< z38v`3&2wJtka@M}GS%9!+SpJ}sdtoYzMevVbnH+d_eMxN@~~ zZq@k)7V5f8u!yAX2qF3qjS7g%n$JuGrMhQF!&S^7(%Y{rP*w2FWj(v_J{+Hg*}wdWOd~pHQ19&n3RWeljK9W%sz&Y3Tm3 zR`>6YR54%qBHGa)2xbs`9cs_EsNHxsfraEgZ)?vrtooeA0sPKJK7an){ngtV@{SBa zkO6ORr1_Xqp+`a0e}sC*_y(|RKS13ikmHp3C^XkE@&wjbGWrt^INg^9lDz#B;bHiW zkK4{|cg08b!yHFSgPca5)vF&gqCgeu+c82%&FeM^Bb}GUxLy-zo)}N;#U?sJ2?G2BNe*9u_7kE5JeY!it=f`A_4gV3} z`M!HXZy#gN-wS!HvHRqpCHUmjiM;rVvpkC!voImG%OFVN3k(QG@X%e``VJSJ@Z7tb z*Onlf>z^D+&$0!4`IE$;2-NSO9HQWd+UFW(r;4hh;(j^p4H-~6OE!HQp^96v?{9Zt z;@!ZcccV%C2s6FMP#qvo4kG6C04A>XILt>JW}%0oE&HM5f6 zYLD!;My>CW+j<~=Wzev{aYtx2ZNw|ptTFV(4;9`6Tmbz6K1)fv4qPXa2mtoPt&c?P zhmO+*o8uP3ykL6E$il00@TDf6tOW7fmo?Oz_6GU^+5J=c22bWyuH#aNj!tT-^IHrJ zu{aqTYw@q;&$xDE*_kl50Jb*dp`(-^p={z}`rqECTi~3 z>0~A7L6X)=L5p#~$V}gxazgGT7$3`?a)zen>?TvAuQ+KAIAJ-s_v}O6@`h9n-sZk> z`3{IJeb2qu9w=P*@q>iC`5wea`KxCxrx{>(4{5P+!cPg|pn~;n@DiZ0Y>;k5mnKeS z!LIfT4{Lgd=MeysR5YiQKCeNhUQ;Os1kAymg6R!u?j%LF z4orCszIq_n52ulpes{(QN|zirdtBsc{9^Z72Ycb2ht?G^opkT_#|4$wa9`)8k3ilU z%ntAi`nakS1r10;#k^{-ZGOD&Z2|k=p40hRh5D7(&JG#Cty|ECOvwsSHkkSa)36$4 z?;v#%@D(=Raw(HP5s>#4Bm?f~n1@ebH}2tv#7-0l-i^H#H{PC|F@xeNS+Yw{F-&wH z07)bj8MaE6`|6NoqKM~`4%X> zKFl&7g1$Z3HB>lxn$J`P`6GSb6CE6_^NA1V%=*`5O!zP$a7Vq)IwJAki~XBLf=4TF zPYSL}>4nOGZ`fyHChq)jy-f{PKFp6$plHB2=;|>%Z^%)ecVue(*mf>EH_uO^+_zm? zJATFa9SF~tFwR#&0xO{LLf~@}s_xvCPU8TwIJgBs%FFzjm`u?1699RTui;O$rrR{# z1^MqMl5&6)G%@_k*$U5Kxq84!AdtbZ!@8FslBML}<`(Jr zenXrC6bFJP=R^FMBg7P?Pww-!a%G@kJH_zezKvuWU0>m1uyy}#Vf<$>u?Vzo3}@O% z1JR`B?~Tx2)Oa|{DQ_)y9=oY%haj!80GNHw3~qazgU-{|q+Bl~H94J!a%8UR?XsZ@ z0*ZyQugyru`V9b(0OrJOKISfi89bSVR zQy<+i_1XY}4>|D%X_`IKZUPz6=TDb)t1mC9eg(Z=tv zq@|r37AQM6A%H%GaH3szv1L^ku~H%5_V*fv$UvHl*yN4iaqWa69T2G8J2f3kxc7UE zOia@p0YNu_q-IbT%RwOi*|V|&)e5B-u>4=&n@`|WzH}BK4?33IPpXJg%`b=dr_`hU z8JibW_3&#uIN_#D&hX<)x(__jUT&lIH$!txEC@cXv$7yB&Rgu){M`9a`*PH} zRcU)pMWI2O?x;?hzR{WdzKt^;_pVGJAKKd)F$h;q=Vw$MP1XSd<;Mu;EU5ffyKIg+ z&n-Nb?h-ERN7(fix`htopPIba?0Gd^y(4EHvfF_KU<4RpN0PgVxt%7Yo99X*Pe|zR z?ytK&5qaZ$0KSS$3ZNS$$k}y(2(rCl=cuYZg{9L?KVgs~{?5adxS))Upm?LDo||`H zV)$`FF3icFmxcQshXX*1k*w3O+NjBR-AuE70=UYM*7>t|I-oix=bzDwp2*RoIwBp@r&vZukG; zyi-2zdyWJ3+E?{%?>e2Ivk`fAn&Ho(KhGSVE4C-zxM-!j01b~mTr>J|5={PrZHOgO zw@ND3=z(J7D>&C7aw{zT>GHhL2BmUX0GLt^=31RRPSnjoUO9LYzh_yegyPoAKhAQE z>#~O27dR4&LdQiak6={9_{LN}Z>;kyVYKH^d^*!`JVSXJlx#&r4>VnP$zb{XoTb=> zZsLvh>keP3fkLTIDdpf-@(ADfq4=@X=&n>dyU0%dwD{zsjCWc;r`-e~X$Q3NTz_TJ zOXG|LMQQIjGXY3o5tBm9>k6y<6XNO<=9H@IXF;63rzsC=-VuS*$E{|L_i;lZmHOD< zY92;>4spdeRn4L6pY4oUKZG<~+8U-q7ZvNOtW0i*6Q?H`9#U3M*k#4J;ek(MwF02x zUo1wgq9o6XG#W^mxl>pAD)Ll-V5BNsdVQ&+QS0+K+?H-gIBJ-ccB1=M_hxB6qcf`C zJ?!q!J4`kLhAMry4&a_0}up{CFevcjBl|N(uDM^N5#@&-nQt2>z*U}eJGi}m5f}l|IRVj-Q;a>wcLpK5RRWJ> zysdd$)Nv0tS?b~bw1=gvz3L_ZAIdDDPj)y|bp1;LE`!av!rODs-tlc}J#?erTgXRX z$@ph%*~_wr^bQYHM7<7=Q=45v|Hk7T=mDpW@OwRy3A_v`ou@JX5h!VI*e((v*5Aq3 zVYfB4<&^Dq5%^?~)NcojqK`(VXP$`#w+&VhQOn%;4pCkz;NEH6-FPHTQ+7I&JE1+Ozq-g43AEZV>ceQ^9PCx zZG@OlEF~!Lq@5dttlr%+gNjRyMwJdJU(6W_KpuVnd{3Yle(-p#6erIRc${l&qx$HA z89&sp=rT7MJ=DuTL1<5{)wtUfpPA|Gr6Q2T*=%2RFm@jyo@`@^*{5{lFPgv>84|pv z%y{|cVNz&`9C*cUely>-PRL)lHVErAKPO!NQ3<&l5(>Vp(MuJnrOf^4qpIa!o3D7( z1bjn#Vv$#or|s7Hct5D@%;@48mM%ISY7>7@ft8f?q~{s)@BqGiupoK1BAg?PyaDQ1 z`YT8{0Vz{zBwJ={I4)#ny{RP{K1dqzAaQN_aaFC%Z>OZ|^VhhautjDavGtsQwx@WH zr|1UKk^+X~S*RjCY_HN!=Jx>b6J8`Q(l4y|mc<6jnkHVng^Wk(A13-;AhawATsmmE#H%|8h}f1frs2x@Fwa_|ea+$tdG2Pz{7 z!ox^w^>^Cv4e{Xo7EQ7bxCe8U+LZG<_e$RnR?p3t?s^1Mb!ieB z#@45r*PTc_yjh#P=O8Zogo+>1#|a2nJvhOjIqKK1U&6P)O%5s~M;99O<|Y9zomWTL z666lK^QW`)cXV_^Y05yQZH3IRCW%25BHAM$c0>w`x!jh^15Zp6xYb!LoQ zr+RukTw0X2mxN%K0%=8|JHiaA3pg5+GMfze%9o5^#upx0M?G9$+P^DTx7~qq9$Qoi zV$o)yy zuUq>3c{_q+HA5OhdN*@*RkxRuD>Bi{Ttv_hyaaB;XhB%mJ2Cb{yL;{Zu@l{N?!GKE7es6_9J{9 zO(tmc0ra2;@oC%SS-8|D=omQ$-Dj>S)Utkthh{ovD3I%k}HoranSepC_yco2Q8 zY{tAuPIhD{X`KbhQIr%!t+GeH%L%q&p z3P%<-S0YY2Emjc~Gb?!su85}h_qdu5XN2XJUM}X1k^!GbwuUPT(b$Ez#LkG6KEWQB z7R&IF4srHe$g2R-SB;inW9T{@+W+~wi7VQd?}7||zi!&V^~o0kM^aby7YE_-B63^d zf_uo8#&C77HBautt_YH%v6!Q>H?}(0@4pv>cM6_7dHJ)5JdyV0Phi!)vz}dv{*n;t zf(+#Hdr=f8DbJqbMez)(n>@QT+amJ7g&w6vZ-vG^H1v~aZqG~u!1D(O+jVAG0EQ*aIsr*bsBdbD`)i^FNJ z&B@yxqPFCRGT#}@dmu-{0vp47xk(`xNM6E=7QZ5{tg6}#zFrd8Pb_bFg7XP{FsYP8 zbvWqG6#jfg*4gvY9!gJxJ3l2UjP}+#QMB(*(?Y&Q4PO`EknE&Cb~Yb@lCbk;-KY)n zzbjS~W5KZ3FV%y>S#$9Sqi$FIBCw`GfPDP|G=|y32VV-g@a1D&@%_oAbB@cAUx#aZ zlAPTJ{iz#Qda8(aNZE&0q+8r3&z_Ln)b=5a%U|OEcc3h1f&8?{b8ErEbilrun}mh3 z$1o^$-XzIiH|iGoJA`w`o|?w3m*NX|sd$`Mt+f*!hyJvQ2fS*&!SYn^On-M|pHGlu z4SC5bM7f6BAkUhGuN*w`97LLkbCx=p@K5RL2p>YpDtf{WTD|d3ucb6iVZ-*DRtoEA zCC5(x)&e=giR_id>5bE^l%Mxx>0@FskpCD4oq@%-Fg$8IcdRwkfn;DsjoX(v;mt3d z_4Mnf#Ft4x!bY!7Hz?RRMq9;5FzugD(sbt4up~6j?-or+ch~y_PqrM2hhTToJjR_~ z)E1idgt7EW>G*9%Q^K;o_#uFjX!V2pwfpgi>}J&p_^QlZki!@#dkvR`p?bckC`J*g z=%3PkFT3HAX2Q+dShHUbb1?ZcK8U7oaufLTCB#1W{=~k0Jabgv>q|H+GU=f-y|{p4 zwN|AE+YbCgx=7vlXE?@gkXW9PaqbO#GB=4$o0FkNT#EI?aLVd2(qnPK$Yh%YD%v(mdwn}bgsxyIBI^)tY?&G zi^2JfClZ@4b{xFjyTY?D61w@*ez2@5rWLpG#34id?>>oPg{`4F-l`7Lg@D@Hc}On} zx%BO4MsLYosLGACJ-d?ifZ35r^t*}wde>AAWO*J-X%jvD+gL9`u`r=kP zyeJ%FqqKfz8e_3K(M1RmB?gIYi{W7Z<THP2ihue0mbpu5n(x_l|e1tw(q!#m5lmef6ktqIb${ zV+ee#XRU}_dDDUiV@opHZ@EbQ<9qIZJMDsZDkW0^t3#j`S)G#>N^ZBs8k+FJhAfu< z%u!$%dyP3*_+jUvCf-%{x#MyDAK?#iPfE<(@Q0H7;a125eD%I(+!x1f;Sy`e<9>nm zQH4czZDQmW7^n>jL)@P@aAuAF$;I7JZE5a8~AJI5CNDqyf$gjloKR7C?OPt9yeH}n5 zNF8Vhmd%1O>T4EZD&0%Dt7YWNImmEV{7QF(dy!>q5k>Kh&Xy8hcBMUvVV~Xn8O&%{ z&q=JCYw#KlwM8%cu-rNadu(P~i3bM<_a{3!J*;vZhR6dln6#eW0^0kN)Vv3!bqM`w z{@j*eyzz=743dgFPY`Cx3|>ata;;_hQ3RJd+kU}~p~aphRx`03B>g4*~f%hUV+#D9rYRbsGD?jkB^$3XcgB|3N1L& zrmk9&Dg450mAd=Q_p?gIy5Zx7vRL?*rpNq76_rysFo)z)tp0B;7lSb9G5wX1vC9Lc z5Q8tb-alolVNWFsxO_=12o}X(>@Mwz1mkYh1##(qQwN=7VKz?61kay8A9(94Ky(4V zq6qd2+4a20Z0QRrmp6C?4;%U?@MatfXnkj&U6bP_&2Ny}BF%4{QhNx*Tabik9Y-~Z z@0WV6XD}aI(%pN}oW$X~Qo_R#+1$@J8(31?zM`#e`#(0f<-AZ^={^NgH#lc?oi(Mu zMk|#KR^Q;V@?&(sh5)D;-fu)rx%gXZ1&5)MR+Mhssy+W>V%S|PRNyTAd}74<(#J>H zR(1BfM%eIv0+ngHH6(i`?-%_4!6PpK*0X)79SX0X$`lv_q>9(E2kkkP;?c@rW2E^Q zs<;`9dg|lDMNECFrD3jTM^Mn-C$44}9d9Kc z#>*k&e#25;D^%82^1d@Yt{Y91MbEu0C}-;HR4+IaCeZ`l?)Q8M2~&E^FvJ?EBJJ(% zz1>tCW-E~FB}DI}z#+fUo+=kQME^=eH>^%V8w)dh*ugPFdhMUi3R2Cg}Zak4!k_8YW(JcR-)hY8C zXja}R7@%Q0&IzQTk@M|)2ViZDNCDRLNI)*lH%SDa^2TG4;%jE4n`8`aQAA$0SPH2@ z)2eWZuP26+uGq+m8F0fZn)X^|bNe z#f{qYZS!(CdBdM$N2(JH_a^b#R2=>yVf%JI_ieRFB{w&|o9txwMrVxv+n78*aXFGb z>Rkj2yq-ED<)A46T9CL^$iPynv`FoEhUM10@J+UZ@+*@_gyboQ>HY9CiwTUo7OM=w zd~$N)1@6U8H#Zu(wGLa_(Esx%h@*pmm5Y9OX@CY`3kPYPQx@z8yAgtm(+agDU%4?c zy8pR4SYbu8vY?JX6HgVq7|f=?w(%`m-C+a@E{euXo>XrGmkmFGzktI*rj*8D z)O|CHKXEzH{~iS+6)%ybRD|JRQ6j<+u_+=SgnJP%K+4$st+~XCVcAjI9e5`RYq$n{ zzy!X9Nv7>T4}}BZpSj9G9|(4ei-}Du<_IZw+CB`?fd$w^;=j8?vlp(#JOWiHaXJjB0Q00RHJ@sG6N#y^H7t^&V} z;VrDI4?75G$q5W9mV=J2iP24NHJy&d|HWHva>FaS#3AO?+ohh1__FMx;?`f{HG3v0 ztiO^Wanb>U4m9eLhoc_2B(ca@YdnHMB*~aYO+AE(&qh@?WukLbf_y z>*3?Xt-lxr?#}y%kTv+l8;!q?Hq8XSU+1E8x~o@9$)zO2z9K#(t`vPDri`mKhv|sh z{KREcy`#pnV>cTT7dm7M9B@9qJRt3lfo(C`CNkIq@>|2<(yn!AmVN?ST zbX_`JjtWa3&N*U{K7FYX8})*D#2@KBae` zhKS~s!r%SrXdhCsv~sF}7?ocyS?afya6%rDBu6g^b2j#TOGp^1zrMR}|70Z>CeYq- z1o|-=FBKlu{@;pm@QQJ_^!&hzi;0Z_Ho){x3O1KQ#TYk=rAt9`YKC0Y^}8GWIN{QW znYJyVTrmNvl!L=YS1G8BAxGmMUPi+Q7yb0XfG`l+L1NQVSbe^BICYrD;^(rke{jWCEZOtVv3xFze!=Z&(7}!)EcN;v0Dbit?RJ6bOr;N$ z=nk8}H<kCEE+IK3z<+3mkn4q!O7TMWpKShWWWM)X*)m6k%3luF6c>zOsFccvfLWf zH+mNkh!H@vR#~oe=ek}W3!71z$Dlj0c(%S|sJr>rvw!x;oCek+8f8s!U{DmfHcNpO z9>(IKOMfJwv?ey`V2ysSx2Npeh_x#bMh)Ngdj$al;5~R7Ac5R2?*f{hI|?{*$0qU- zY$6}ME%OGh^zA^z9zJUs-?a4ni8cw_{cYED*8x{bWg!Fn9)n;E9@B+t;#k}-2_j@# zg#b%R(5_SJAOtfgFCBZc`n<&z6)%nOIu@*yo!a% zpLg#36KBN$01W{b;qWN`Tp(T#jh%;Zp_zpS64lvBVY2B#UK)p`B4Oo)IO3Z&D6<3S zfF?ZdeNEnzE{}#gyuv)>;z6V{!#bx)` zY;hL*f(WVD*D9A4$WbRKF2vf;MoZVdhfWbWhr{+Db5@M^A4wrFReuWWimA4qp`GgoL2`W4WPUL5A=y3Y3P z%G?8lLUhqo@wJW8VDT`j&%YY7xh51NpVYlsrk_i4J|pLO(}(b8_>%U2M`$iVRDc-n zQiOdJbroQ%*vhN{!{pL~N|cfGooK_jTJCA3g_qs4c#6a&_{&$OoSQr_+-O^mKP=Fu zGObEx`7Qyu{nHTGNj(XSX*NPtAILL(0%8Jh)dQh+rtra({;{W2=f4W?Qr3qHi*G6B zOEj7%nw^sPy^@05$lOCjAI)?%B%&#cZ~nC|=g1r!9W@C8T0iUc%T*ne z)&u$n>Ue3FN|hv+VtA+WW)odO-sdtDcHfJ7s&|YCPfWaVHpTGN46V7Lx@feE#Od%0XwiZy40plD%{xl+K04*se zw@X4&*si2Z_0+FU&1AstR)7!Th(fdaOlsWh`d!y=+3m!QC$Zlkg8gnz!}_B7`+wSz z&kD?6{zPnE3uo~Tv8mLP%RaNt2hcCJBq=0T>%MW~Q@Tpt2pPP1?KcywH>in5@ zx+5;xu-ltFfo5vLU;2>r$-KCHjwGR&1XZ0YNyrXXAUK!FLM_7mV&^;;X^*YH(FLRr z`0Jjg7wiq2bisa`CG%o9i)o1`uG?oFjU_Zrv1S^ipz$G-lc^X@~6*)#%nn+RbgksJfl{w=k31(q>7a!PCMp5YY{+Neh~mo zG-3dd!0cy`F!nWR?=9f_KP$X?Lz&cLGm_ohy-|u!VhS1HG~e7~xKpYOh=GmiiU;nu zrZ5tWfan3kp-q_vO)}vY6a$19Q6UL0r znJ+iSHN-&w@vDEZ0V%~?(XBr|jz&vrBNLOngULxtH(Rp&U*rMY42n;05F11xh?k;n_DX2$4|vWIkXnbwfC z=ReH=(O~a;VEgVO?>qsP*#eOC9Y<_9Yt<6X}X{PyF7UXIA$f)>NR5P&4G_Ygq(9TwwQH*P>Rq>3T4I+t2X(b5ogXBAfNf!xiF#Gilm zp2h{&D4k!SkKz-SBa%F-ZoVN$7GX2o=(>vkE^j)BDSGXw?^%RS9F)d_4}PN+6MlI8*Uk7a28CZ)Gp*EK)`n5i z){aq=0SFSO-;sw$nAvJU-$S-cW?RSc7kjEBvWDr1zxb1J7i;!i+3PQwb=)www?7TZ zE~~u)vO>#55eLZW;)F(f0KFf8@$p)~llV{nO7K_Nq-+S^h%QV_CnXLi)p*Pq&`s!d zK2msiR;Hk_rO8`kqe_jfTmmv|$MMo0ll}mI)PO4!ikVd(ZThhi&4ZwK?tD-}noj}v zBJ?jH-%VS|=t)HuTk?J1XaDUjd_5p1kPZi6y#F6$lLeRQbj4hsr=hX z4tXkX2d5DeLMcAYTeYm|u(XvG5JpW}hcOs4#s8g#ihK%@hVz|kL=nfiBqJ{*E*WhC zht3mi$P3a(O5JiDq$Syu9p^HY&9~<#H89D8 zJm84@%TaL_BZ+qy8+T3_pG7Q%z80hnjN;j>S=&WZWF48PDD%55lVuC0%#r5(+S;WH zS7!HEzmn~)Ih`gE`faPRjPe^t%g=F ztpGVW=Cj5ZkpghCf~`ar0+j@A=?3(j@7*pq?|9)n*B4EQTA1xj<+|(Y72?m7F%&&& zdO44owDBPT(8~RO=dT-K4#Ja@^4_0v$O3kn73p6$s?mCmVDUZ+Xl@QcpR6R3B$=am z%>`r9r2Z79Q#RNK?>~lwk^nQlR=Hr-ji$Ss3ltbmB)x@0{VzHL-rxVO(++@Yr@Iu2 zTEX)_9sVM>cX$|xuqz~Y8F-(n;KLAfi*63M7mh&gsPR>N0pd9h!0bm%nA?Lr zS#iEmG|wQd^BSDMk0k?G>S-uE$vtKEF8Dq}%vLD07zK4RLoS?%F1^oZZI$0W->7Z# z?v&|a`u#UD=_>i~`kzBGaPj!mYX5g?3RC4$5EV*j0sV)>H#+$G6!ci=6`)85LWR=FCp-NUff`;2zG9nU6F~ z;3ZyE*>*LvUgae+uMf}aV}V*?DCM>{o31+Sx~6+sz;TI(VmIpDrN3z+BUj`oGGgLP z>h9~MP}Pw#YwzfGP8wSkz`V#}--6}7S9yZvb{;SX?6PM_KuYpbi~*=teZr-ga2QqIz{QrEyZ@>eN*qmy;N@FCBbRNEeeoTmQyrX;+ zCkaJ&vOIbc^2BD6_H+Mrcl?Nt7O{xz9R_L0ZPV_u!sz+TKbXmhK)0QWoe-_HwtKJ@@7=L+ z+K8hhf=4vbdg3GqGN<;v-SMIzvX=Z`WUa_91Yf89^#`G(f-Eq>odB^p-Eqx}ENk#&MxJ+%~Ad2-*`1LNT>2INPw?*V3&kE;tt?rQyBw? zI+xJD04GTz1$7~KMnfpkPRW>f%n|0YCML@ODe`10;^DXX-|Hb*IE%_Vi#Pn9@#ufA z_8NY*1U%VseqYrSm?%>F@`laz+f?+2cIE4Jg6 z_VTcx|DSEA`g!R%RS$2dSRM|9VQClsW-G<~=j5T`pTbu-x6O`R z98b;}`rPM(2={YiytrqX+uh65f?%XiPp`;4CcMT*E*dQJ+if9^D>c_Dk8A(cE<#r=&!& z_`Z01=&MEE+2@yr!|#El=yM}v>i=?w^2E_FLPy(*4A9XmCNy>cBWdx3U>1RylsItO z4V8T$z3W-qqq*H`@}lYpfh=>C!tieKhoMGUi)EpWDr;yIL&fy};Y&l|)f^QE*k~4C zH>y`Iu%#S)z)YUqWO%el*Z)ME#p{1_8-^~6UF;kBTW zMQ!eXQuzkR#}j{qb(y9^Y!X7&T}}-4$%4w@w=;w+>Z%uifR9OoQ>P?0d9xpcwa>7kTv2U zT-F?3`Q`7xOR!gS@j>7In>_h){j#@@(ynYh;nB~}+N6qO(JO1xA z@59Pxc#&I~I64slNR?#hB-4XE>EFU@lUB*D)tu%uEa))B#eJ@ZOX0hIulfnDQz-y8 z`CX@(O%_VC{Ogh&ot``jlDL%R!f>-8yq~oLGxBO?+tQb5%k@a9zTs!+=NOwSVH-cR zqFo^jHeXDA_!rx$NzdP;>{-j5w3QUrR<;}=u2|FBJ;D#v{SK@Z6mjeV7_kFmWt95$ zeGaF{IU?U>?W`jzrG_9=9}yN*LKyzz))PLE+)_jc#4Rd$yFGol;NIk(qO1$5VXR)+ zxF7%f4=Q!NzR>DVXUB&nUT&>Nyf+5QRF+Z`X-bB*7=`|Go5D1&h~ zflKLw??kpiRm0h3|1GvySC2^#kcFz^5{79KKlq@`(leBa=_4CgV9sSHr{RIJ^KwR_ zY??M}-x^=MD+9`v@I3jue=OCn0kxno#6i>b(XKk_XTp_LpI}X*UA<#* zsgvq@yKTe_dTh>q1aeae@8yur08S(Q^8kXkP_ty48V$pX#y9)FQa~E7P7}GP_CbCm zc2dQxTeW(-~Y6}im24*XOC8ySfH*HMEnW3 z4CXp8iK(Nk<^D$g0kUW`8PXn2kdcDk-H@P0?G8?|YVlIFb?a>QunCx%B9TzsqQQ~HD!UO7zq^V!v9jho_FUob&Hxi ztU1nNOK)a!gkb-K4V^QVX05*>-^i|{b`hhvQLyj`E1vAnj0fbqqO%r z6Q;X1x0dL~GqMv%8QindZ4CZ%7pYQW~ z9)I*#Gjref-q(4Z*E#1c&rE0-_(4;_M(V7rgH_7H;ps1s%GBmU z{4a|X##j#XUF2n({v?ZUUAP5k>+)^F)7n-npbV3jAlY8V3*W=fwroDS$c&r$>8aH` zH+irV{RG3^F3oW2&E%5hXgMH9>$WlqX76Cm+iFmFC-DToTa`AcuN9S!SB+BT-IA#3P)JW1m~Cuwjs`Ep(wDXE4oYmt*aU z!Naz^lM}B)JFp7ejro7MU9#cI>wUoi{lylR2~s)3M!6a=_W~ITXCPd@U9W)qA5(mdOf zd3PntGPJyRX<9cgX?(9~TZB5FdEHW~gkJXY51}?s4ZT_VEdwOwD{T2E-B>oC8|_ZwsPNj=-q(-kwy%xX2K0~H z{*+W`-)V`7@c#Iuaef=?RR2O&x>W0A^xSwh5MsjTz(DVG-EoD@asu<>72A_h<39_# zawWVU<9t{r*e^u-5Q#SUI6dV#p$NYEGyiowT>>d*or=Ps!H$-3={bB|An$GPkP5F1 zTnu=ktmF|6E*>ZQvk^~DX(k!N`tiLut*?3FZhs$NUEa4ccDw66-~P;x+0b|<!ZN7Z%A`>2tN#CdoG>((QR~IV_Gj^Yh%!HdA~4C3jOXaqb6Ou z21T~Wmi9F6(_K0@KR@JDTh3-4mv2=T7&ML<+$4;b9SAtv*Uu`0>;VVZHB{4?aIl3J zL(rMfk?1V@l)fy{J5DhVlj&cWKJCcrpOAad(7mC6#%|Sn$VwMjtx6RDx1zbQ|Ngg8N&B56DGhu;dYg$Z{=YmCNn+?ceDclp65c_RnKs4*vefnhudSlrCy6-96vSB4_sFAj# zftzECwmNEOtED^NUt{ZDjT7^g>k1w<=af>+0)%NA;IPq6qx&ya7+QAu=pk8t>KTm` zEBj9J*2t|-(h)xc>Us*jHs)w9qmA>8@u21UqzKk*Ei#0kCeW6o z-2Q+Tvt25IUkb}-_LgD1_FUJ!U8@8OC^9(~Kd*0#zr*8IQkD)6Keb(XFai5*DYf~` z@U?-{)9X&BTf!^&@^rjmvea#9OE~m(D>qfM?CFT9Q4RxqhO0sA7S)=--^*Q=kNh7Y zq%2mu_d_#23d`+v`Ol263CZ<;D%D8Njj6L4T`S*^{!lPL@pXSm>2;~Da- zBX97TS{}exvSva@J5FJVCM$j4WDQuME`vTw>PWS0!;J7R+Kq zVUy6%#n5f7EV(}J#FhDpts;>=d6ow!yhJj8j>MJ@Wr_?x30buuutIG97L1A*QFT$c ziC5rBS;#qj=~yP-yWm-p(?llTwDuhS^f&<(9vA9@UhMH2-Fe_YAG$NvK6X{!mvPK~ zuEA&PA}meylmaIbbJXDOzuIn8cJNCV{tUA<$Vb?57JyAM`*GpEfMmFq>)6$E(9e1@W`l|R%-&}38#bl~levA#fx2wiBk^)mPj?<=S&|gv zQO)4*91$n08@W%2b|QxEiO0KxABAZC{^4BX^6r>Jm?{!`ZId9jjz<%pl(G5l));*`UU3KfnuXSDj2aP>{ zRIB$9pm7lj3*Xg)c1eG!cb+XGt&#?7yJ@C)(Ik)^OZ5><4u$VLCqZ#q2NMCt5 z6$|VN(RWM;5!JV?-h<JkEZ(SZF zC(6J+>A6Am9H7OlOFq6S62-2&z^Np=#xXsOq0WUKr zY_+Ob|CQd1*!Hirj5rn*=_bM5_zKmq6lG zn*&_=x%?ATxZ8ZTzd%biKY_qyNC#ZQ1vX+vc48N>aJXEjs{Y*3Op`Q7-oz8jyAh>d zNt_qvn`>q9aO~7xm{z`ree%lJ3YHCyC`q`-jUVCn*&NIml!uuMNm|~u3#AV?6kC+B z?qrT?xu2^mobSlzb&m(8jttB^je0mx;TT8}`_w(F11IKz83NLj@OmYDpCU^u?fD{) z&=$ptwVw#uohPb2_PrFX;X^I=MVXPDpqTuYhRa>f-=wy$y3)40-;#EUDYB1~V9t%$ z^^<7Zbs0{eB93Pcy)96%XsAi2^k`Gmnypd-&x4v9rAq<>a(pG|J#+Q>E$FvMLmy7T z5_06W=*ASUyPRfgCeiPIe{b47Hjqpb`9Xyl@$6*ntH@SV^bgH&Fk3L9L=6VQb)Uqa z33u#>ecDo&bK(h1WqSH)b_Th#Tvk&%$NXC@_pg5f-Ma#7q;&0QgtsFO~`V&{1b zbSP*X)jgLtd@9XdZ#2_BX4{X~pS8okF7c1xUhEV9>PZco>W-qz7YMD`+kCGULdK|^ zE7VwQ-at{%&fv`a+b&h`TjzxsyQX05UB~a0cuU-}{*%jR48J+yGWyl3Kdz5}U>;lE zgkba*yI5>xqIPz*Y!-P$#_mhHB!0Fpnv{$k-$xxjLAc`XdmHd1k$V@2QlblfJPrly z*~-4HVCq+?9vha>&I6aRGyq2VUon^L1a)g`-Xm*@bl2|hi2b|UmVYW|b+Gy?!aS-p z86a}Jep6Mf>>}n^*Oca@Xz}kxh)Y&pX$^CFAmi#$YVf57X^}uQD!IQSN&int=D> zJ>_|au3Be?hmPKK)1^JQ(O29eTf`>-x^jF2xYK6j_9d_qFkWHIan5=7EmDvZoQWz5 zZGb<{szHc9Nf@om)K_<=FuLR<&?5RKo3LONFQZ@?dyjemAe4$yDrnD zglU#XYo6|~L+YpF#?deK6S{8A*Ou;9G`cdC4S0U74EW18bc5~4>)<*}?Z!1Y)j;Ot zosEP!pc$O^wud(={WG%hY07IE^SwS-fGbvpP?;l8>H$;}urY2JF$u#$q}E*ZG%fR# z`p{xslcvG)kBS~B*^z6zVT@e}imYcz_8PRzM4GS52#ms5Jg9z~ME+uke`(Tq1w3_6 zxUa{HerS7!Wq&y(<9yyN@P^PrQT+6ij_qW3^Q)I53iIFCJE?MVyGLID!f?QHUi1tq z0)RNIMGO$2>S%3MlBc09l!6_(ECxXTU>$KjWdZX^3R~@3!SB zah5Za2$63;#y!Y}(wg1#shMePQTzfQfXyJ-Tf`R05KYcyvo8UW9-IWGWnzxR6Vj8_la;*-z5vWuwUe7@sKr#Tr51d z2PWn5h@|?QU3>k=s{pZ9+(}oye zc*95N_iLmtmu}H-t$smi49Y&ovX}@mKYt2*?C-i3Lh4*#q5YDg1Mh`j9ovRDf9&& zp_UMQh`|pC!|=}1uWoMK5RAjdTg3pXPCsYmRkWW}^m&)u-*c_st~gcss(`haA)xVw zAf=;s>$`Gq_`A}^MjY_BnCjktBNHY1*gzh(i0BFZ{Vg^F?Pbf`8_clvdZ)5(J4EWzAP}Ba5zX=S(2{gDugTQ3`%!q`h7kYSnwC`zEWeuFlODKiityMaM9u{Z%E@@y1jmZA#ⅅ8MglG&ER{i5lN315cO?EdHNLrg? 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 z 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 Pe7HEdAEg|=c=LY&YVNkY 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 13b35eba55c6dabc3aac36f33d859266c18fa0d0..6242f6edb79708d028d3c4b936eb718bd9f9b4ad 100644 GIT binary patch literal 7164 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ઊh_(FeW%|oD1VW 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 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 0a3f5fa40fb3d1e0710331a48de5d256da3f275d..be655ee83a81068f36b99ae20bc2ebeb8d8610bc 100644 GIT binary patch delta 688 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 literal 14142 zcmd6Og;yI-^luV^)8fV5-QA_QSJ2|x;;sP-6n87drBI3&FA`je7HDyID=vYMynKJ} zyz~Bq_x7AUGn<{A&CcAp_jB+4Ost-c>N6Zl8~_0DOkGXc0001@sz3l12C6Xg{AT~( zm6w64BA|AX`Ve)YY-glyudNN>MAfkXz-T7`_`fEolM;0T0BA)(02-OaW z0*cW7Z~ec94o8&g0D$N>b!COu{=m}^%oXZ4?T8ZyPZuGGBPBA7pbQMoV5HYhiT?%! zcae~`(QAN4&}-=#2f5fkn!SWGWmSeCISBcS=1-U|MEoKq=k?_x3apK>9((R zuu$9X?^8?@(a{qMS%J8SJPq))v}Q-ZyDm6Gbie0m92=`YlwnQPQP1kGSm(N2UJ3P6 z^{p-u)SSCTW~c1rw;cM)-uL2{->wCn2{#%;AtCQ!m%AakVs1K#v@(*-6QavyY&v&*wO_rCJXJuq$c$7ZjsW+pJo-$L^@!7X04CvaOpPyfw|FKvu;e(&Iw>Tbg zL}#8e^?X%TReXTt>gsBByt0kSU20oQx*~P=4`&tcZ7N6t-6LiK{LxX*p6}9c<0Pu^ zLx1w_P4P2V>bX=`F%v$#{sUDdF|;rbI{p#ZW`00Bgh(eB(nOIhy8W9T>3aQ=k8Z9% zB+TusFABF~J?N~fAd}1Rme=@4+1=M{^P`~se7}e3;mY0!%#MJf!XSrUC{0uZqMAd7%q zQY#$A>q}noIB4g54Ue)x>ofVm3DKBbUmS4Z-bm7KdKsUixva)1*&z5rgAG2gxG+_x zqT-KNY4g7eM!?>==;uD9Y4iI(Hu$pl8!LrK_Zb}5nv(XKW{9R144E!cFf36p{i|8pRL~p`_^iNo z{mf7y`#hejw#^#7oKPlN_Td{psNpNnM?{7{R-ICBtYxk>?3}OTH_8WkfaTLw)ZRTfxjW+0>gMe zpKg~`Bc$Y>^VX;ks^J0oKhB#6Ukt{oQhN+o2FKGZx}~j`cQB%vVsMFnm~R_1Y&Ml? zwFfb~d|dW~UktY@?zkau>Owe zRroi(<)c4Ux&wJfY=3I=vg)uh;sL(IYY9r$WK1$F;jYqq1>xT{LCkIMb3t2jN8d`9 z=4(v-z7vHucc_fjkpS}mGC{ND+J-hc_0Ix4kT^~{-2n|;Jmn|Xf9wGudDk7bi*?^+ z7fku8z*mbkGm&xf&lmu#=b5mp{X(AwtLTf!N`7FmOmX=4xwbD=fEo8CaB1d1=$|)+ z+Dlf^GzGOdlqTO8EwO?8;r+b;gkaF^$;+#~2_YYVH!hD6r;PaWdm#V=BJ1gH9ZK_9 zrAiIC-)z)hRq6i5+$JVmR!m4P>3yJ%lH)O&wtCyum3A*})*fHODD2nq!1@M>t@Za+ zH6{(Vf>_7!I-APmpsGLYpl7jww@s5hHOj5LCQXh)YAp+y{gG(0UMm(Ur z3o3n36oFwCkn+H*GZ-c6$Y!5r3z*@z0`NrB2C^q#LkOuooUM8Oek2KBk}o1PU8&2L z4iNkb5CqJWs58aR394iCU^ImDqV;q_Pp?pl=RB2372(Io^GA^+oKguO1(x$0<7w3z z)j{vnqEB679Rz4i4t;8|&Zg77UrklxY9@GDq(ZphH6=sW`;@uIt5B?7Oi?A0-BL}(#1&R;>2aFdq+E{jsvpNHjLx2t{@g1}c~DQcPNmVmy| zNMO@ewD^+T!|!DCOf}s9dLJU}(KZy@Jc&2Nq3^;vHTs}Hgcp`cw&gd7#N}nAFe3cM1TF%vKbKSffd&~FG9y$gLyr{#to)nxz5cCASEzQ}gz8O)phtHuKOW6p z@EQF(R>j%~P63Wfosrz8p(F=D|Mff~chUGn(<=CQbSiZ{t!e zeDU-pPsLgtc#d`3PYr$i*AaT!zF#23htIG&?QfcUk+@k$LZI}v+js|yuGmE!PvAV3 ztzh90rK-0L6P}s?1QH`Ot@ilbgMBzWIs zIs6K<_NL$O4lwR%zH4oJ+}JJp-bL6~%k&p)NGDMNZX7)0kni&%^sH|T?A)`z z=adV?!qnWx^B$|LD3BaA(G=ePL1+}8iu^SnnD;VE1@VLHMVdSN9$d)R(Wk{JEOp(P zm3LtAL$b^*JsQ0W&eLaoYag~=fRRdI>#FaELCO7L>zXe6w*nxN$Iy*Q*ftHUX0+N- zU>{D_;RRVPbQ?U+$^%{lhOMKyE5>$?U1aEPist+r)b47_LehJGTu>TcgZe&J{ z{q&D{^Ps~z7|zj~rpoh2I_{gAYNoCIJmio3B}$!5vTF*h$Q*vFj~qbo%bJCCRy509 zHTdDh_HYH8Zb9`}D5;;J9fkWOQi%Y$B1!b9+ESj+B@dtAztlY2O3NE<6HFiqOF&p_ zW-K`KiY@RPSY-p9Q99}Hcd05DT79_pfb{BV7r~?9pWh=;mcKBLTen%THFPo2NN~Nf zriOtFnqx}rtO|A6k!r6 zf-z?y-UD{dT0kT9FJ`-oWuPHbo+3wBS(}?2ql(+e@VTExmfnB*liCb zmeI+v5*+W_L;&kQN^ChW{jE0Mw#0Tfs}`9bk3&7UjxP^Ke(%eJu2{VnW?tu7Iqecm zB5|=-QdzK$=h50~{X3*w4%o1FS_u(dG2s&427$lJ?6bkLet}yYXCy)u_Io1&g^c#( z-$yYmSpxz{>BL;~c+~sxJIe1$7eZI_9t`eB^Pr0)5CuA}w;;7#RvPq|H6!byRzIJG ziQ7a4y_vhj(AL`8PhIm9edCv|%TX#f50lt8+&V+D4<}IA@S@#f4xId80oH$!_!q?@ zFRGGg2mTv&@76P7aTI{)Hu%>3QS_d)pQ%g8BYi58K~m-Ov^7r8BhX7YC1D3vwz&N8{?H*_U7DI?CI)+et?q|eGu>42NJ?K4SY zD?kc>h@%4IqNYuQ8m10+8xr2HYg2qFNdJl=Tmp&ybF>1>pqVfa%SsV*BY$d6<@iJA ziyvKnZ(~F9xQNokBgMci#pnZ}Igh0@S~cYcU_2Jfuf|d3tuH?ZSSYBfM(Y3-JBsC|S9c;# zyIMkPxgrq};0T09pjj#X?W^TFCMf1-9P{)g88;NDI+S4DXe>7d3Mb~i-h&S|Jy{J< zq3736$bH?@{!amD!1Ys-X)9V=#Z={fzsjVYMX5BG6%}tkzwC#1nQLj1y1f#}8**4Y zAvDZHw8)N)8~oWC88CgzbwOrL9HFbk4}h85^ptuu7A+uc#$f^9`EWv1Vr{5+@~@Uv z#B<;-nt;)!k|fRIg;2DZ(A2M2aC65kOIov|?Mhi1Sl7YOU4c$T(DoRQIGY`ycfkn% zViHzL;E*A{`&L?GP06Foa38+QNGA zw3+Wqs(@q+H{XLJbwZzE(omw%9~LPZfYB|NF5%j%E5kr_xE0u;i?IOIchn~VjeDZ) zAqsqhP0vu2&Tbz3IgJvMpKbThC-@=nk)!|?MIPP>MggZg{cUcKsP8|N#cG5 zUXMXxcXBF9`p>09IR?x$Ry3;q@x*%}G#lnB1}r#!WL88I@uvm}X98cZ8KO&cqT1p> z+gT=IxPsq%n4GWgh-Bk8E4!~`r@t>DaQKsjDqYc&h$p~TCh8_Mck5UB84u6Jl@kUZCU9BA-S!*bf>ZotFX9?a_^y%)yH~rsAz0M5#^Di80_tgoKw(egN z`)#(MqAI&A84J#Z<|4`Co8`iY+Cv&iboMJ^f9ROUK0Lm$;-T*c;TCTED_0|qfhlcS zv;BD*$Zko#nWPL}2K8T-?4}p{u)4xon!v_(yVW8VMpxg4Kh^J6WM{IlD{s?%XRT8P|yCU`R&6gwB~ zg}{At!iWCzOH37!ytcPeC`(({ovP7M5Y@bYYMZ}P2Z3=Y_hT)4DRk}wfeIo%q*M9UvXYJq!-@Ly79m5aLD{hf@BzQB>FdQ4mw z6$@vzSKF^Gnzc9vbccii)==~9H#KW<6)Uy1wb~auBn6s`ct!ZEos`WK8e2%<00b%# zY9Nvnmj@V^K(a_38dw-S*;G-(i(ETuIwyirs?$FFW@|66a38k+a%GLmucL%Wc8qk3 z?h_4!?4Y-xt)ry)>J`SuY**fuq2>u+)VZ+_1Egzctb*xJ6+7q`K$^f~r|!i?(07CD zH!)C_uerf-AHNa?6Y61D_MjGu*|wcO+ZMOo4q2bWpvjEWK9yASk%)QhwZS%N2_F4& z16D18>e%Q1mZb`R;vW{+IUoKE`y3(7p zplg5cBB)dtf^SdLd4n60oWie|(ZjgZa6L*VKq02Aij+?Qfr#1z#fwh92aV-HGd^_w zsucG24j8b|pk>BO7k8dS86>f-jBP^Sa}SF{YNn=^NU9mLOdKcAstv&GV>r zLxKHPkFxpvE8^r@MSF6UA}cG`#yFL8;kA7ccH9D=BGBtW2;H>C`FjnF^P}(G{wU;G z!LXLCbPfsGeLCQ{Ep$^~)@?v`q(uI`CxBY44osPcq@(rR-633!qa zsyb>?v%@X+e|Mg`+kRL*(;X>^BNZz{_kw5+K;w?#pReiw7eU8_Z^hhJ&fj80XQkuU z39?-z)6Fy$I`bEiMheS(iB6uLmiMd1i)cbK*9iPpl+h4x9ch7x- z1h4H;W_G?|)i`z??KNJVwgfuAM=7&Apd3vm#AT8uzQZ!NII}}@!j)eIfn53h{NmN7 zAKG6SnKP%^k&R~m5#@_4B@V?hYyHkm>0SQ@PPiw*@Tp@UhP-?w@jW?nxXuCipMW=L zH*5l*d@+jXm0tIMP_ec6Jcy6$w(gKK@xBX8@%oPaSyG;13qkFb*LuVx3{AgIyy&n3 z@R2_DcEn|75_?-v5_o~%xEt~ONB>M~tpL!nOVBLPN&e5bn5>+7o0?Nm|EGJ5 zmUbF{u|Qn?cu5}n4@9}g(G1JxtzkKv(tqwm_?1`?YSVA2IS4WI+*(2D*wh&6MIEhw z+B+2U<&E&|YA=3>?^i6)@n1&&;WGHF-pqi_sN&^C9xoxME5UgorQ_hh1__zzR#zVC zOQt4q6>ME^iPJ37*(kg4^=EFqyKH@6HEHXy79oLj{vFqZGY?sVjk!BX^h$SFJlJnv z5uw~2jLpA)|0=tp>qG*tuLru?-u`khGG2)o{+iDx&nC}eWj3^zx|T`xn5SuR;Aw8U z`p&>dJw`F17@J8YAuW4=;leBE%qagVTG5SZdh&d)(#ZhowZ|cvWvGMMrfVsbg>_~! z19fRz8CSJdrD|Rl)w!uznBF&2-dg{>y4l+6(L(vzbLA0Bk&`=;oQQ>(M8G=3kto_) zP8HD*n4?MySO2YrG6fwSrVmnesW+D&fxjfEmp=tPd?RKLZJcH&K(-S+x)2~QZ$c(> zru?MND7_HPZJVF%wX(49H)+~!7*!I8w72v&{b={#l9yz+S_aVPc_So%iF8>$XD1q1 zFtucO=rBj0Ctmi0{njN8l@}!LX}@dwl>3yMxZ;7 z0Ff2oh8L)YuaAGOuZ5`-p%Z4H@H$;_XRJQ|&(MhO78E|nyFa158gAxG^SP(vGi^+< zChY}o(_=ci3Wta#|K6MVljNe0T$%Q5ylx-v`R)r8;3+VUpp-)7T`-Y&{Zk z*)1*2MW+_eOJtF5tCMDV`}jg-R(_IzeE9|MBKl;a7&(pCLz}5<Zf+)T7bgNUQ_!gZtMlw=8doE}#W+`Xp~1DlE=d5SPT?ymu!r4z%&#A-@x^=QfvDkfx5-jz+h zoZ1OK)2|}_+UI)i9%8sJ9X<7AA?g&_Wd7g#rttHZE;J*7!e5B^zdb%jBj&dUDg4&B zMMYrJ$Z%t!5z6=pMGuO-VF~2dwjoXY+kvR>`N7UYfIBMZGP|C7*O=tU z2Tg_xi#Q3S=1|=WRfZD;HT<1D?GMR%5kI^KWwGrC@P2@R>mDT^3qsmbBiJc21kip~ zZp<7;^w{R;JqZ)C4z-^wL=&dBYj9WJBh&rd^A^n@07qM$c+kGv^f+~mU5_*|eePF| z3wDo-qaoRjmIw<2DjMTG4$HP{z54_te_{W^gu8$r=q0JgowzgQPct2JNtWPUsjF8R zvit&V8$(;7a_m%%9TqPkCXYUp&k*MRcwr*24>hR! z$4c#E=PVE=P4MLTUBM z7#*RDe0}=B)(3cvNpOmWa*eH#2HR?NVqXdJ=hq);MGD07JIQQ7Y0#iD!$C+mk7x&B zMwkS@H%>|fmSu#+ zI!}Sb(%o29Vkp_Th>&&!k7O>Ba#Om~B_J{pT7BHHd8(Ede(l`7O#`_}19hr_?~JP9 z`q(`<)y>%)x;O7)#-wfCP{?llFMoH!)ZomgsOYFvZ1DxrlYhkWRw#E-#Qf*z@Y-EQ z1~?_=c@M4DO@8AzZ2hKvw8CgitzI9yFd&N1-{|vP#4IqYb*#S0e3hrjsEGlnc4xwk z4o!0rxpUt8j&`mJ8?+P8G{m^jbk)bo_UPM+ifW*y-A*et`#_Ja_3nYyRa9fAG1Xr5 z>#AM_@PY|*u)DGRWJihZvgEh#{*joJN28uN7;i5{kJ*Gb-TERfN{ERe_~$Es~NJCpdKLRvdj4658uYYx{ng7I<6j~w@p%F<7a(Ssib|j z51;=Py(Nu*#hnLx@w&8X%=jrADn3TW>kplnb zYbFIWWVQXN7%Cwn6KnR)kYePEBmvM45I)UJb$)ninpdYg3a5N6pm_7Q+9>!_^xy?k za8@tJ@OOs-pRAAfT>Nc2x=>sZUs2!9Dwa%TTmDggH4fq(x^MW>mcRyJINlAqK$YQCMgR8`>6=Sg$ zFnJZsA8xUBXIN3i70Q%8px@yQPMgVP=>xcPI38jNJK<=6hC={a07+n@R|$bnhB)X$ z(Zc%tadp70vBTnW{OUIjTMe38F}JIH$#A}PB&RosPyFZMD}q}5W%$rh>5#U;m`z2K zc(&WRxx7DQLM-+--^w*EWAIS%bi>h587qkwu|H=hma3T^bGD&Z!`u(RKLeNZ&pI=q$|HOcji(0P1QC!YkAp*u z3%S$kumxR}jU<@6`;*-9=5-&LYRA<~uFrwO3U0k*4|xUTp4ZY7;Zbjx|uw&BWU$zK(w55pWa~#=f$c zNDW0O68N!xCy>G}(CX=;8hJLxAKn@Aj(dbZxO8a$+L$jK8$N-h@4$i8)WqD_%Snh4 zR?{O%k}>lr>w$b$g=VP8mckcCrjnp>uQl5F_6dPM8FWRqs}h`DpfCv20uZhyY~tr8 zkAYW4#yM;*je)n=EAb(q@5BWD8b1_--m$Q-3wbh1hM{8ihq7UUQfg@)l06}y+#=$( z$x>oVYJ47zAC^>HLRE-!HitjUixP6!R98WU+h>zct7g4eD;Mj#FL*a!VW!v-@b(Jv zj@@xM5noCp5%Vk3vY{tyI#oyDV7<$`KG`tktVyC&0DqxA#>V;-3oH%NW|Q&=UQ&zU zXNIT67J4D%5R1k#bW0F}TD`hlW7b)-=-%X4;UxQ*u4bK$mTAp%y&-(?{sXF%e_VH6 zTkt(X)SSN|;8q@8XX6qfR;*$r#HbIrvOj*-5ND8RCrcw4u8D$LXm5zlj@E5<3S0R# z??=E$p{tOk96$SloZ~ARe5`J=dB|Nj?u|zy2r(-*(q^@YwZiTF@QzQyPx_l=IDKa) zqD@0?IHJqSqZ_5`)81?4^~`yiGh6>7?|dKa8!e|}5@&qV!Iu9<@G?E}Vx9EzomB3t zEbMEm$TKGwkHDpirp;FZD#6P5qIlQJ8}rf;lHoz#h4TFFPYmS3+8(13_Mx2`?^=8S z|0)0&dQLJTU6{b%*yrpQe#OKKCrL8}YKw+<#|m`SkgeoN69TzIBQOl_Yg)W*w?NW) z*WxhEp$zQBBazJSE6ygu@O^!@Fr46j=|K`Mmb~xbggw7<)BuC@cT@Bwb^k?o-A zKX^9AyqR?zBtW5UA#siILztgOp?r4qgC`9jYJG_fxlsVSugGprremg-W(K0{O!Nw-DN%=FYCyfYA3&p*K>+|Q}s4rx#CQK zNj^U;sLM#q8}#|PeC$p&jAjqMu(lkp-_50Y&n=qF9`a3`Pr9f;b`-~YZ+Bb0r~c+V z*JJ&|^T{}IHkwjNAaM^V*IQ;rk^hnnA@~?YL}7~^St}XfHf6OMMCd9!vhk#gRA*{L zp?&63axj|Si%^NW05#87zpU_>QpFNb+I00v@cHwvdBn+Un)n2Egdt~LcWOeBW4Okm zD$-e~RD+W|UB;KQ;a7GOU&%p*efGu2$@wR74+&iP8|6#_fmnh^WcJLs)rtz{46);F z4v0OL{ZP9550>2%FE(;SbM*#sqMl*UXOb>ch`fJ|(*bOZ9=EB1+V4fkQ)hjsm3-u^Pk-4ji_uDDHdD>84tER!MvbH`*tG zzvbhBR@}Yd`azQGavooV=<WbvWLlO#x`hyO34mKcxrGv=`{ssnP=0Be5#1B;Co9 zh{TR>tjW2Ny$ZxJpYeg57#0`GP#jxDCU0!H15nL@@G*HLQcRdcsUO3sO9xvtmUcc{F*>FQZcZ5bgwaS^k-j5mmt zI7Z{Xnoml|A(&_{imAjK!kf5>g(oDqDI4C{;Bv162k8sFNr;!qPa2LPh>=1n z=^_9)TsLDvTqK7&*Vfm5k;VXjBW^qN3Tl&}K=X5)oXJs$z3gk0_+7`mJvz{pK|FVs zHw!k&7xVjvY;|(Py<;J{)b#Yjj*LZO7x|~pO4^MJ2LqK3X;Irb%nf}L|gck zE#55_BNsy6m+W{e zo!P59DDo*s@VIi+S|v93PwY6d?CE=S&!JLXwE9{i)DMO*_X90;n2*mPDrL%{iqN!?%-_95J^L z=l<*{em(6|h7DR4+4G3Wr;4*}yrBkbe3}=p7sOW1xj!EZVKSMSd;QPw>uhKK z#>MlS@RB@-`ULv|#zI5GytO{=zp*R__uK~R6&p$q{Y{iNkg61yAgB8C^oy&``{~FK z8hE}H&nIihSozKrOONe5Hu?0Zy04U#0$fB7C6y~?8{or}KNvP)an=QP&W80mj&8WL zEZQF&*FhoMMG6tOjeiCIV;T{I>jhi9hiUwz?bkX3NS-k5eWKy)Mo_orMEg4sV6R6X&i-Q%JG;Esl+kLpn@Bsls9O|i9z`tKB^~1D5)RIBB&J<6T@a4$pUvh$IR$%ubH)joi z!7>ON0DPwx=>0DA>Bb^c?L8N0BBrMl#oDB+GOXJh;Y&6I)#GRy$W5xK%a;KS8BrER zX)M>Rdoc*bqP*L9DDA3lF%U8Yzb6RyIsW@}IKq^i7v&{LeIc=*ZHIbO68x=d=+0T( zev=DT9f|x!IWZNTB#N7}V4;9#V$%Wo0%g>*!MdLOEU>My0^gni9ocID{$g9ytD!gy zKRWT`DVN(lcYjR|(}f0?zgBa3SwunLfAhx><%u0uFkrdyqlh8_g zDKt#R6rA2(Vm2LW_>3lBNYKG_F{TEnnKWGGC15y&OebIRhFL4TeMR*v9i0wPoK#H< zu4){s4K&K)K(9~jgGm;H7lS7y_RYfS;&!Oj5*eqbvEcW^a*i67nevzOZxN6F+K~A%TYEtsAVsR z@J=1hc#Dgs7J2^FL|qV&#WBFQyDtEQ2kPO7m2`)WFhqAob)Y>@{crkil6w9VoA?M6 zADGq*#-hyEVhDG5MQj677XmcWY1_-UO40QEP&+D)rZoYv^1B_^w7zAvWGw&pQyCyx zD|ga$w!ODOxxGf_Qq%V9Z7Q2pFiUOIK818AGeZ-~*R zI1O|SSc=3Z?#61Rd|AXx2)K|F@Z1@x!hBBMhAqiU)J=U|Y)T$h3D?ZPPQgkSosnN! zIqw-t$0fqsOlgw3TlHJF*t$Q@bg$9}A3X=cS@-yU3_vNG_!#9}7=q7!LZ?-%U26W4 z$d>_}*s1>Ac%3uFR;tnl*fNlylJ)}r2^Q3&@+is3BIv<}x>-^_ng;jhdaM}6Sg3?p z0jS|b%QyScy3OQ(V*~l~bK>VC{9@FMuW_JUZO?y(V?LKWD6(MXzh}M3r3{7b4eB(#`(q1m{>Be%_<9jw8HO!x#yF6vez$c#kR+}s zZO-_;25Sxngd(}){zv?ccbLqRAlo;yog>4LH&uZUK1n>x?u49C)Y&2evH5Zgt~666 z_2_z|H5AO5Iqxv_Bn~*y1qzRPcob<+Otod5Xd2&z=C;u+F}zBB@b^UdGdUz|s!H}M zXG%KiLzn3G?FZgdY&3pV$nSeY?ZbU^jhLz9!t0K?ep}EFNqR1@E!f*n>x*!uO*~JF zW9UXWrVgbX1n#76_;&0S7z}(5n-bqnII}_iDsNqfmye@)kRk`w~1 z6j4h4BxcPe6}v)xGm%=z2#tB#^KwbgMTl2I*$9eY|EWAHFc3tO48Xo5rW z5oHD!G4kb?MdrOHV=A+8ThlIqL8Uu+7{G@ zb)cGBm|S^Eh5= z^E^SZ=yeC;6nNCdztw&TdnIz}^Of@Ke*@vjt)0g>Y!4AJvWiL~e7+9#Ibhe)> ziNwh>gWZL@FlWc)wzihocz+%+@*euwXhW%Hb>l7tf8aJe5_ZSH1w-uG|B;9qpcBP0 zM`r1Hu#htOl)4Cl1c7oY^t0e4Jh$-I(}M5kzWqh{F=g&IM#JiC`NDSd@BCKX#y<P@Gwl$3a3w z6<(b|K(X5FIR22M)sy$4jY*F4tT{?wZRI+KkZFb<@j@_C316lu1hq2hA|1wCmR+S@ zRN)YNNE{}i_H`_h&VUT5=Y(lN%m?%QX;6$*1P}K-PcPx>*S55v)qZ@r&Vcic-sjkm z! z=nfW&X`}iAqa_H$H%z3Tyz5&P3%+;93_0b;zxLs)t#B|up}JyV$W4~`8E@+BHQ+!y zuIo-jW!~)MN$2eHwyx-{fyGjAWJ(l8TZtUp?wZWBZ%}krT{f*^fqUh+ywHifw)_F> zp76_kj_B&zFmv$FsPm|L7%x-j!WP>_P6dHnUTv!9ZWrrmAUteBa`rT7$2ixO;ga8U z3!91micm}{!Btk+I%pMgcKs?H4`i+=w0@Ws-CS&n^=2hFTQ#QeOmSz6ttIkzmh^`A zYPq)G1l3h(E$mkyr{mvz*MP`x+PULBn%CDhltKkNo6Uqg!vJ#DA@BIYr9TQ`18Un2 zv$}BYzOQuay9}w(?JV63F$H6WmlYPPpH=R|CPb%C@BCv|&Q|&IcW7*LX?Q%epS z`=CPx{1HnJ9_46^=0VmNb>8JvMw-@&+V8SDLRYsa>hZXEeRbtf5eJ>0@Ds47zIY{N z42EOP9J8G@MXXdei>QkNpsXX(-6A1ADJW)!;LnJ@~goP?-sf1DlDUgOLaRYVj)Q+7v zzQlX+Wp{RF&d0-E?Da)KP9-GH>S(kx-#P#P`G5cKoEi8ZH-FiA!;v_V`lRDScuop~ z*(>b7Y>LGaLh zYVigg&8L|!ytSLavGkW%D!#{hd6rD#Q=c{BeGTMUfwqT{Kp_kIj@95ttHp^cEwO$& z6Hjp~|C>evf`5b6ki*TVUNGYQ5P0N(p!>DkR)eqK-@Gwy#6^i33G}o)%Vu!zq-zqt z26^lg2CoV7VQ7mlHhbM4EUdo+fCFG8##;H7G>glIl-{m0jE=oR^O1f6 z&k}_OLVwT@jBALsw9$R`Ia-EKF!lQP$=|pNQX^3ZM^UL_0buG6RCR0hTV2(XNe(eK z@(Qg@{fKX&7?7L2!Q7SKGXIxx)~0WQA3|tJ9y!MGi{B)1bP%n2WA~gjteOJb_%$k^ z7~}Ej?yu73^%ImW9+|=1pvHcnt$%G^YmZ1h?7Z^JbH{H&wrVzSHFu! zQG*Ifqs!_F)Y)zOSt2sAK?a)6U`iG(225Q02_H@TigGbuA5~t@a{KLHFf;x-ltUy` zGNYd(aikx$QNx^#YE%i-AL(xSyWJ6zVJl8q$gNB=GyBIaZ`NW(P?URDe$U42G@=ko zwSUnv_*kW68|AMrbxQ%=5m4;_Dec7dO@%;q3O~qGGj3z7P+rTiF*l7^!H7%za94d{ zTXEH{`*=XMc|cJl1g8X%4-(d5_A7HiFo+c-(uRdS&;z=M2NX*oTtNH?Q&Kv-WJ+3e_-WB>u7>9ozT7P4aXwNVhwJ zdWQD4lY~y0ygyGAl~7e&FV5)m-=gPgfQ9X(GHNk~5bF};tTrRT)w zG0A4;uf9R{qxZ23#A%QGej?y$d#6>nUidq+kyPYRSmz9CN{YxWq}uI&i!u z@B}ZJ=FalpC>8J1H+&8!k)UM6Y5D=w(4IJaSEYbuN_8}?L>9| z5)*{QV3IdizD;KB7#*n-BwMjPE`WK7E50FKvKLNOY`3FeM;qc>QXhP5^Y>>N3!B>q! zCEOZmqzS8jvdQcHDL5~2(54nQ-a@)-2rrptb?!Rl_1umWM87NrcP|==Z&)o=6tG$X z*t8mq8nYA_DES;efF5Fi;J!4L1=HPX}pW|V`! zidR5)zaLs9{8;LiseZIcq%}ghxs@J9|#h$ jVOuOueiDNIpSAx0<^W@x=L3o800000NkvXXu0mjfgFg_3 delta 1046 zcmV+x1nK+145|o_BYyw^b5ch_0Itp)=>Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031 z000^Q000001E2u_0{{R30RRC20H6W@1ONa40RR91AfN*P1ONa40RR91AOHXW0IY^$ z^8f$?lu1NER9Fe^SItioK@|V(ZWmgLZT;XwPgVuWM>O%^|9_yEkU-*%n;uO3NYv;R zJsM3!u3ork^kPCHQDcY%MS{_&iC_RFh!ijwR8UDjc73ngDc$LATNA4nUU=-z%zN|u z&3kWVw!nt`=Ljfn_@-$dq_Gat2x)*+Cl$hevad;ftU?m>@}2Y@>0Q!$ilStXQ{D^JNiP(3%faD)Q3SZ!2dUuhq{A-2n<(cT z_B;jW0Do!kDoB8HKC)=w=3E?1Bqj9RMJs3U5am3Uv`@+{jgqO^f}Lx_Jp~CoP3N4A zMZr~4&d)T`R?`(M{W5WWJV^z~2B|-oih@h^D#DuzGbl(P5>()u*YGo*Och=oRr~3P z1wOlKqI)udc$|)(bacG5>~p(y>?{JD7nQf_*?*wALP1xfhU#(^&2+pTulG>3E13Ca z;>uVYpJROXS>h+~6JZY;HY+(-rLd8owiwZQ&R3`7Y50Xg?NJrl=fL6*6Q`cXFhk%~ zbl!)`6!6L!eBZW$Ynq_DAcG;NARS1VC$QCLagDB+=p2nWsJPlG{G`1taUr|3y$YDeNX#{=)#1Zk{;tz8` QaR2}S07*qoM6N<$fTL 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 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 2f1632cfddf3d9dade342351e627a0a75609fb46..74a50968621d09b6dd6c9f886f136e6f3d4cf365 100644 GIT binary patch delta 3330 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 M07*qoM6N<$f~JgFdjJ3c delta 2207 zcmV;Q2w?Yx8mbYHBYyw^b5ch_0Itp)=>Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031 z000^Q000001E2u_0{{R30RRC20H6W@1ONa40RR91K%fHv1ONa40RR91KmY&$07g+l zumAuE6iGxuRCodHTWf3-RTMruyW6FuQYeUM04eX6D5c0FCV&1TCZ-py|c5InY-=I zY-!`3OG*I1PedcfpaTWG zz}$w!e2{QT6j)uA1OU8-N{6BItSS$HvMSg(7bJx2Y=32dDp`!10~I{udIVa+2|G#V z0cEH-*RHY~<>3g?j?-cK^;&$8cpYTHEnP(3ixu>PseUTRQ<~jf%ed6Q17;aHDPh|Z z+@II^^V0LKW}5)w|dwIw_gRf zFMqt(ako~@r$m5lC959fGLHG5;r3$WrNJgK`drR2zZ`Bq_rA1%mrYB^t3n{*!QNze z^;M{I8oD7Lp^7cb+fR6L@YvzuR5#}? z#=HP7u8s)cnE$Rj=B?ab0O!v1*&ZX$r#f@YbLjj8?`i3ZwP3VkB!sEhD8YS}7Jq1z z7cjRFzn@CV5dp`pMDQjjHsOiR&7egYgXit7?Dn_dG+#7?=|p>M-Y_s5iTElq)ThyF z=cei9Q{?i9h3Uw-PD>dez5ZKhet=tPFB-(!?{0iIuf_XT*iMSn7ti=0Uo<^*%h2LXNy!0+zfA`9?*&!@XBwdb_G06#>FlRej|>cKdhBgR~P{WYHL3&1oj#B1njWD1HN+;vYVgcBpQu z2vF7Q(LHwT=3uM~8Mrym+v!t>D=Dj$KltcdNh<*XtPa~3X!OJ|%zuy&0a{%3GGwkD zKTeG`b+lyj0jjS1OMkYnepCdoosNY`*zmBIo*981BU%%U@~$z0V`OVtIbEx5pa|Tc zt|Lg#ZQf5OYMUMRD>Wdxm5SAqV2}3!ceE-Mvs!lAvqf?6tS zD1t*y5KBjx{~&;KB9p@;yjZ%A&Yr*OuvepcN(6Kn)m=*PK4I%T9~HOM)8+a`>dXyL z$mdN63+{@ot!nS0XE*Jkx~7Y6^lpnqH3zi2(X-qJAD}ioJ%5efer&7`l)>_->tVrZ zJHf7}ex3-E;6h6{S$!4H2gWGN~F(zYfI&I!<4C6Cj}86!!HVKso`!wlU}od~3= zLBi5c_EG(5M{~kuOY|sI5x`$yrcH9Pv#9jVMYMQQK1QD>Kqz`+<=I!K0Y6;MBrGf4 zOJ~kqN!6ZuyMKzdrww#G29;c2k7Pss?YK=Je|dmP%VM#v1II%QoLSKG-Ph@epI<&I zrZEo;mzznJpNvmr8m%6(C4e7>5#_iPSydcBWwii5)6QO12ymUPb`=Luxe`M%4`70h zLeu)GTpG!&M%#1X0kLzqMMp{xKt-O9mO@)C>h|{{UVp}ysK7G-B?0^Z8sOF7N$5Z# zfx$lo+fU-El%xc3Z8UTkm-)~N&i8X+w_gaG54vtARs}C~IkXDe-{d3=S_Q>kv*)6m z1XazTqw%=E5Xv7^@^^T7Q2wxr+Z2kVYrerj@E`hDauQ;_COrTE002ovPDHLkV1in54ut>! diff --git a/packaging/linux/aur/querya_desktop.png b/packaging/linux/aur/querya_desktop.png index 326c0e72c9d820600887813b3b98d0dd69c5d4e8..8cabeae9bcbbf2cf5e42c4afc0f58c2e80252026 100644 GIT binary patch literal 38233 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 diff --git a/packaging/linux/flatpak/com.queryahub.querya_desktop.png b/packaging/linux/flatpak/com.queryahub.querya_desktop.png index 326c0e72c9d820600887813b3b98d0dd69c5d4e8..8cabeae9bcbbf2cf5e42c4afc0f58c2e80252026 100644 GIT binary patch literal 38233 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 diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index c04e20caf6370ebb9253ad831cc31de4a9c965f6..a5f2695528a027f72c4efa9c36a44a7bca862cc4 100644 GIT binary patch literal 285478 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
)C#duNU3VxF4}k+kY9z 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 literal 33772 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 From bcec152d433a87ee6ed254ac0600e73ebe4d966a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 14 Oct 2026 10:23:13 +0300 Subject: [PATCH 008/157] chore: add bug report and feature request issue forms --- .github/ISSUE_TEMPLATE/bug_report.yml | 60 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 11 ++++ .github/ISSUE_TEMPLATE/feature_request.yml | 33 ++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml 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..fbb8adc4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +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: 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 From 330724fce8ed0768f4f785a783d4a23ce381021a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 14 Oct 2026 10:27:15 +0300 Subject: [PATCH 009/157] ci: add shared triage/stale workflows, link Discussions from issue chooser --- .github/ISSUE_TEMPLATE/config.yml | 3 +++ .github/workflows/community.yml | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 .github/workflows/community.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index fbb8adc4..bc791cd4 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,6 +3,9 @@ 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. 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 From a0f207c751443b08292baca38db3d43d7ec7ab25 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 14 Oct 2026 10:27:41 +0300 Subject: [PATCH 010/157] test(about): expect the Querya logo image instead of the database icon --- test/features/help/about_dialog_test.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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); From d83c0d19964a44a31f18b88fcadc5da63a3fb0ff Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 14 Oct 2026 10:50:05 +0300 Subject: [PATCH 011/157] ci(release): attest build provenance and document download verification --- .github/workflows/release.yml | 20 +++++++++++++++++++- README.md | 12 ++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdf190dd..c3270f74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -470,6 +470,10 @@ 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 }} @@ -512,6 +516,20 @@ 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-* + - name: Build release notes from CHANGELOG.md run: | set -euo pipefail @@ -546,7 +564,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 }}" diff --git a/README.md b/README.md index 25495347..d720112d 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,18 @@ Download the universal `.dmg` from [Releases](https://github.com/QueryaHub/Query --- +### 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 From eca1c4bc3668d8cf369ef4abf2427ca20978e6c5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 14 Oct 2026 10:57:07 +0300 Subject: [PATCH 012/157] ci: publish the dev container image to GitHub Packages --- .devcontainer/devcontainer.json | 5 +- .github/workflows/devcontainer-image.yml | 92 ++++++++++++++++++++++++ docs/dev-containers.md | 7 ++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/devcontainer-image.yml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index dbf02ce3..ff4ef266 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,9 @@ { "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": {} }, diff --git a/.github/workflows/devcontainer-image.yml b/.github/workflows/devcontainer-image.yml new file mode 100644 index 00000000..c19e5de4 --- /dev/null +++ b/.github/workflows/devcontainer-image.yml @@ -0,0 +1,92 @@ +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#^FROM ghcr.io/cirruslabs/flutter:\(.*\)$#\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 + 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/docs/dev-containers.md b/docs/dev-containers.md index 30149442..423f8b6d 100644 --- a/docs/dev-containers.md +++ b/docs/dev-containers.md @@ -13,6 +13,13 @@ list of seeded data see the header of [`docker/docker-compose.yml`](../docker/do | [`.devcontainer/Dockerfile`](../.devcontainer/Dockerfile) | `ghcr.io/cirruslabs/flutter` plus the Linux desktop toolchain (clang, cmake, ninja, GTK 3, libsecret, SQLite headers, Xvfb) and the Docker CLI with the Compose plugin. `DISPLAY` is `:99`. | | [`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 tries `docker compose -f docker/docker-compose.yml up -d` and prints a hint if Docker From 1eca2ff6b314048b4d40743e1901747afb10b25c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 14 Oct 2026 22:29:11 +0300 Subject: [PATCH 013/157] fix(windows): make the Open with registration an optional installer task The Applications\querya_desktop.exe keys were always written, so Querya appeared in the Open with menu without the user asking for it. They are now behind a new unchecked 'openwith' task next to the .sql and SQLite association tasks. Closes #1595 --- packaging/windows/querya.iss | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) 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 From f6526297d092183d65b7da76922ec1931e52829c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 06:55:12 +0300 Subject: [PATCH 014/157] fix(grid): remove the PiiMaskingController listener when a result grid is disposed _onPiiMaskingChanged and _onHorizontalScroll are extension methods, and a torn-off extension method is a new closure on every access, so removeListener never matched what addListener stored. The singleton kept every grid state (and its dataset and render tree) alive: ~5 MB per run. Both handlers are now stored once in late final fields and those are passed to addListener / removeListener. A widget test checks the singleton has no leftover listener after dispose, and a guard test fails if an extension method is ever passed to addListener / removeListener again. Closes #1555 --- lib/features/workspace/result_grid_view.dart | 16 ++++-- .../result_grid_listener_leak_test.dart | 44 +++++++++++++++ .../no_extension_method_listeners_test.dart | 54 +++++++++++++++++++ 3 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 test/features/workspace/result_grid_listener_leak_test.dart create mode 100644 test/guards/no_extension_method_listeners_test.dart diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 9d385f71..13a7c306 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -159,12 +159,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(); @@ -389,11 +397,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/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/guards/no_extension_method_listeners_test.dart b/test/guards/no_extension_method_listeners_test.dart new file mode 100644 index 00000000..49c8208d --- /dev/null +++ b/test/guards/no_extension_method_listeners_test.dart @@ -0,0 +1,54 @@ +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)!); + } + } + } + + final offenders = []; + for (final f in files) { + for (final m in listenerArg.allMatches(f.readAsStringSync())) { + 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.', + ); + }); +} From f6e2f0466553c0749b9da49a2f200cb486a94adb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 06:58:25 +0300 Subject: [PATCH 015/157] perf(updater): pulse the update chip a few times instead of forever An endless AnimationController.repeat keeps the engine producing a frame at the display rate for as long as a newer release is pending: an otherwise idle app burned ~30% of a CPU core (#1554). The chip now pulses for three breaths when it appears and then settles to its static colors, so the app is idle again. The pulse runs once per pending update and starts again only after the badge went away. Closes #1554 --- .../updater/update_available_badge.dart | 28 +++++++++-- .../updater/update_available_badge_test.dart | 49 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/lib/features/updater/update_available_badge.dart b/lib/features/updater/update_available_badge.dart index 0126aafd..71e9e098 100644 --- a/lib/features/updater/update_available_badge.dart +++ b/lib/features/updater/update_available_badge.dart @@ -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/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); + }); } From 4c502323faa83ec2d3321c82a366cf81ff392d9d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 06:58:56 +0300 Subject: [PATCH 016/157] test(guards): ignore comment lines in the extension-listener guard The explanation next to the new listener fields quotes addListener(_onPiiMaskingChanged), which the scan flagged. --- test/guards/no_extension_method_listeners_test.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/guards/no_extension_method_listeners_test.dart b/test/guards/no_extension_method_listeners_test.dart index 49c8208d..70eb109b 100644 --- a/test/guards/no_extension_method_listeners_test.dart +++ b/test/guards/no_extension_method_listeners_test.dart @@ -35,9 +35,13 @@ void main() { } } + // Comments may quote the pattern they warn about. + final commentLine = RegExp(r'^\s*//.*$', multiLine: true); + final offenders = []; for (final f in files) { - for (final m in listenerArg.allMatches(f.readAsStringSync())) { + 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'); } From 2bcd5cf45b036004e3b5a95a20e6ba3dddbbaaa4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:01:41 +0300 Subject: [PATCH 017/157] perf(history): build the one-line preview with an early-exit scanner _previewOneLine collapsed whitespace over the whole query with a regex and only then cut it to 93 characters, so a 100 KB migration in the history was copied whole for every row just to show its first line. previewSqlOneLine reads characters from the start, collapses whitespace on the fly and stops once the preview is known to be longer than 96. The output is the same as before; a test compares it with the old implementation. Closes #1545 --- .../workspace/sql_query_history_dialog.dart | 57 ++++++++++++++++--- .../sql_query_history_preview_test.dart | 44 ++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 test/features/workspace/sql_query_history_preview_test.dart diff --git a/lib/features/workspace/sql_query_history_dialog.dart b/lib/features/workspace/sql_query_history_dialog.dart index 69ec52b3..4e7bb8aa 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(); @@ -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/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..98f2d188 --- /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); + }); + }); +} From eca83148a5797391d625f387c68c26e01b1c8e23 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:03:54 +0300 Subject: [PATCH 018/157] perf(theme): parse and format hex colors from code units parseVsCodeColor and parseQueryaThemeColor built a substring and ran int.parse per channel (and 'FF$s' for 6-digit values); formatVsCodeColor made a toRadixString + padLeft string per channel. Loading a theme with thousands of token rules did that for every rule. Hex digits are now read straight from the code units and written through a lookup string, so a color costs its result only. Signs and spaces inside a value, which int.parse used to let through in some positions, are rejected. Closes #1541 --- lib/core/theme/parser/color_parser.dart | 125 ++++++++++-------- test/core/theme/parser/color_parser_test.dart | 39 ++++++ 2 files changed, 111 insertions(+), 53 deletions(-) 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/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'); + } + }); + }); } From 10ed868d78e344caf542dc58b2dd7d5f71a9641c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:05:01 +0300 Subject: [PATCH 019/157] test(history): compose preview inputs with interpolation (lint) --- .../workspace/sql_query_history_preview_test.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/features/workspace/sql_query_history_preview_test.dart b/test/features/workspace/sql_query_history_preview_test.dart index 98f2d188..ce57adc4 100644 --- a/test/features/workspace/sql_query_history_preview_test.dart +++ b/test/features/workspace/sql_query_history_preview_test.dart @@ -20,12 +20,12 @@ void main() { 'SELECT a, b FROM t', 'a' * 96, 'a' * 97, - 'a' * 96 + ' ', - 'a' * 96 + ' b', - 'a' * 95 + ' b', - 'a' * 94 + ' b c', + '${'a' * 96} ', + '${'a' * 96} b', + '${'a' * 95} b', + '${'a' * 94} b c', ('select x from t\n' * 20), - '\n\n\n' + 'x ' * 200, + '\n\n\n${'x ' * 200}', 'a' * 5000, 'word ' * 30000, ]; From 6c0cca629a4c35018b3be9494d3b4a4a0893a660 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:09:05 +0300 Subject: [PATCH 020/157] perf(widgets): split VirtualSelectableTextView lines only when the text changes build() ran text.split('\n') on every rebuild, so a result or routine body of thousands of lines was re-split on each resize, theme change or focus move. The widget is stateful now and keeps the split lines, refreshed in didUpdateWidget when the text differs. Closes #1532 --- .../widgets/virtual_selectable_text_view.dart | 36 ++++++++--- .../virtual_selectable_text_view_test.dart | 60 +++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) 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/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); + }); }); } From 736f82a54b16be74a66953b1cd4ce1733a961414 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:13:45 +0300 Subject: [PATCH 021/157] perf(actions): normalise command and schema-object search text once QueryaCommand.matches rebuilt a list of lower-cased fields and compiled a regex for every command on every keystroke; filterSchemaObjects lower-cased the haystack of every schema object per keystroke. The lower-cased haystack is built once per command / object (Expando, as both classes are const), the id tail comes from lastIndexOf, and the registry splits the query once and calls matchesParts per command. Closes #1528 --- lib/core/actions/querya_command.dart | 64 +++++++++++++---- lib/core/actions/querya_command_registry.dart | 6 +- lib/core/actions/querya_schema_object.dart | 10 ++- .../actions/querya_command_matches_test.dart | 71 +++++++++++++++++++ 4 files changed, 135 insertions(+), 16 deletions(-) create mode 100644 test/core/actions/querya_command_matches_test.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/test/core/actions/querya_command_matches_test.dart b/test/core/actions/querya_command_matches_test.dart new file mode 100644 index 00000000..6f2de5ad --- /dev/null +++ b/test/core/actions/querya_command_matches_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/querya_command.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; + +void main() { + final command = QueryaCommand( + id: 'querya.sql.formatDocument', + title: 'Format SQL', + category: 'Editor', + aliases: const ['Beautify', 'pretty print'], + execute: (_) {}, + ); + + group('QueryaCommand.matches (#1528)', () { + test('blank query matches', () { + expect(command.matches(''), isTrue); + expect(command.matches(' '), isTrue); + }); + + test('title, id, id tail, category and aliases match, any case', () { + expect(command.matches('FORMAT'), isTrue); + expect(command.matches('querya.sql'), isTrue); + expect(command.matches('formatdocument'), isTrue); + expect(command.matches('editor'), isTrue); + expect(command.matches('beautify'), isTrue); + expect(command.matches('pretty'), isTrue); + }); + + test('every whitespace-separated part must match, in any field', () { + expect(command.matches('format editor'), isTrue); + expect(command.matches(' beautify sql '), isTrue); + expect(command.matches('format nothing'), isFalse); + }); + + test('a part does not match across two fields', () { + // "Format SQL" ends in "sql", the id tail starts with "format". + expect(command.matches('sqlformat'), isFalse); + expect(command.matches('sqlquerya'), isFalse); + }); + + test('matchesParts agrees with matches for pre-split queries', () { + for (final q in ['', 'format', 'Format EDITOR', 'zzz', 'pretty print']) { + expect( + command.matchesParts(QueryaCommand.queryParts(q)), + command.matches(q), + reason: q, + ); + } + }); + + test('an id without a dot uses the whole id as its tail', () { + final c = QueryaCommand(id: 'plain', title: 'T', execute: (_) {}); + expect(c.matches('plain'), isTrue); + }); + }); + + group('filterSchemaObjects haystack cache (#1528)', () { + test('the lower-cased haystack is stable and case-insensitive', () { + final o = QueryaSchemaObject.postgres( + database: 'Sales', + schema: 'Public', + name: 'Users', + kind: QueryaSchemaObjectKind.table, + ); + expect(identical(o.searchHaystackLower, o.searchHaystackLower), isTrue); + expect(o.searchHaystackLower, o.searchHaystack.toLowerCase()); + expect(filterSchemaObjects([o], 'PUBLIC.users'), [o]); + expect(filterSchemaObjects([o], 'orders'), isEmpty); + }); + }); +} From a5a754f75e887da38b67cc579f5917d0d92e3a37 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:19:04 +0300 Subject: [PATCH 022/157] perf(parser): scan SQL for comments and strings with code units stripCommentsAndStrings compiled a RegExp and copied the rest of the script (sql.substring(i)) at every $, including $1 / $2 parameters, and indexed the text one-character string at a time. The loop now reads code units, with _skipLineComment, _skipBlockComment, _skipDollarQuote and _skipStringLiteral returning the next offset, and appends the untouched text in runs. The WHERE and SET regexes are static. The output is the same; a test compares it with the previous implementation on hand-written and random scripts. Closes #1511 --- .../database/destructive_sql_detector.dart | 174 ++++++++++++------ .../database/destructive_sql_strip_test.dart | 143 ++++++++++++++ 2 files changed, 256 insertions(+), 61 deletions(-) create mode 100644 test/core/database/destructive_sql_strip_test.dart diff --git a/lib/core/database/destructive_sql_detector.dart b/lib/core/database/destructive_sql_detector.dart index 8684dcdc..cbb94996 100644 --- a/lib/core/database/destructive_sql_detector.dart +++ b/lib/core/database/destructive_sql_detector.dart @@ -190,6 +190,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,84 +208,133 @@ 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) { + 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 (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. + /// + /// 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). static String stripCommentsAndStrings(String sql) { 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); + 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(); } @@ -420,7 +472,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/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'))); + }); + }); +} From 3937fb4e77e73da3b60084f9c2cdb7b00a564819 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:20:59 +0300 Subject: [PATCH 023/157] perf(charts): read ISO dates in ChartData.parseTime from code units parseTime ran a regex, replaceFirst and up to seven substring + int.parse calls per cell, for every cell of a candidate X column (up to 100,000 rows) when the Charts tab opens. The shape check is a small scanner over code units and the date / time fields are read back with _digitsValue; DateTime.tryParse still decides validity and takes the text as is (it accepts a space separator). A test compares the result with the previous implementation on listed and random strings. Closes #1514 --- lib/features/results/charts/chart_data.dart | 95 ++++++++++++-- .../charts/chart_data_parse_time_test.dart | 123 ++++++++++++++++++ 2 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 test/features/results/charts/chart_data_parse_time_test.dart 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/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); + }); + }); +} From 5d545b63af1efee4be9d4ecf35be3505c78557b6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:22:51 +0300 Subject: [PATCH 024/157] refactor(postgresql): drop the duplicate comment masking in the SELECT gate _splitPostgresStatements masked literals and comments with maskSqlLiteralsForLimitScan and then ran a second, hand-written comment masker over the result. The first pass already blanks comments, strings, quoted identifiers and dollar quotes, so the second one never changed anything. It is removed and the split reads code units. SqlStatementSplitter is not used here on purpose: it treats a backslash as a quote escape and a backtick as a quote (MySQL rules), so "SELECT 'a\\'; DELETE FROM t; --'" would be one statement for the gate while PostgreSQL runs the DELETE. Tests pin that and the nested block comments. Closes #1517 --- .../postgresql/postgres_table_utils.dart | 46 ++++++------------- .../postgresql/postgres_table_utils_test.dart | 30 ++++++++++++ 2 files changed, 43 insertions(+), 33 deletions(-) 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/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); }); From 24aad5f9d76e7fe7fe6a6ec146b262bd94fb18d3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:25:46 +0300 Subject: [PATCH 025/157] chore(csv): remove the unused resultGridAsCsvAsync It had no caller: the clipboard and export paths go through DataExportService.formatAsync. Its test and the doc reference are removed with it, and so is the dart:isolate import. Refs #1537 --- lib/core/csv/result_grid_csv.dart | 14 +++----------- test/core/csv/result_grid_csv_test.dart | 12 ------------ 2 files changed, 3 insertions(+), 23 deletions(-) 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/test/core/csv/result_grid_csv_test.dart b/test/core/csv/result_grid_csv_test.dart index 6c5d75c2..b29fea9d 100644 --- a/test/core/csv/result_grid_csv_test.dart +++ b/test/core/csv/result_grid_csv_test.dart @@ -32,18 +32,6 @@ void main() { }); }); - group('resultGridAsCsvAsync', () { - test('matches synchronous result off the UI isolate', () async { - const columns = ['a', 'b']; - const rows = [ - ['1', 'two,comma'], - ['quote', 'say "hi"'], - ]; - final asyncCsv = await resultGridAsCsvAsync(columns, rows); - expect(asyncCsv, resultGridAsCsv(columns, rows)); - }); - }); - group('writeResultGridCsv', () { test('streams the same content as resultGridAsCsv', () async { const columns = ['a', 'b']; From f19b07a88aa8a0f48c0eddeb2b9b5fd94e519d48 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:35:58 +0300 Subject: [PATCH 026/157] perf(grid): read the filter input by offset with precompiled patterns GridFilterEngine's lexer ran on every keystroke in the filter bar and, per token, copied the rest of the input (input.substring(i) / substring(p)) and compiled about ten RegExp objects (AND, OR, NOT, IS NULL, IN, BETWEEN, LIKE, comparison operators, identifiers). The patterns are static final fields without a ^ anchor and are tried with matchAsPrefix(input, offset), so nothing is copied or compiled while typing. Semantics are unchanged; tests pin keyword boundaries (android is not AND, note is not NOT), operators without spaces, IS NULL, IN, BETWEEN, LIKE and parentheses. Closes #1527 Closes #1512 --- .../workspace/grid_filter_engine.dart | 51 +++++++------- .../workspace/grid_filter_tokenizer_test.dart | 70 +++++++++++++++++++ 2 files changed, 96 insertions(+), 25 deletions(-) create mode 100644 test/features/workspace/grid_filter_tokenizer_test.dart diff --git a/lib/features/workspace/grid_filter_engine.dart b/lib/features/workspace/grid_filter_engine.dart index 7f2794e7..785ff915 100644 --- a/lib/features/workspace/grid_filter_engine.dart +++ b/lib/features/workspace/grid_filter_engine.dart @@ -362,6 +362,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 +401,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 +466,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 +485,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 +520,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 +530,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 +554,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 +579,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 +640,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()); 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]); + }); + }); +} From 0659f496a8e16bd2924256e5fcc13e5f36c50f30 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:37:21 +0300 Subject: [PATCH 027/157] perf(security): scan PII column names and values without regex detectPiiColumn ran two regexes, replaceAllMapped / replaceAll / split / where / toList / join and a third regex for every column of every query; maskPiiValue compiled RegExp(r'\D') and built a digits string for every phone and card cell. Column names are tokenised in one pass over code units, the api_key rule is a neighbour check on the tokens, and the answer per name is cached (bounded, per isolate). Phone and card masking count digits and read the last ones from the end without a cleaned string. Output is unchanged; tests compare with the previous implementation on listed and random inputs. Closes #1540 Closes #1516 --- lib/core/security/pii_masker.dart | 118 ++++++++++---- .../security/pii_masker_scanner_test.dart | 147 ++++++++++++++++++ 2 files changed, 236 insertions(+), 29 deletions(-) create mode 100644 test/core/security/pii_masker_scanner_test.dart 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/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"'); + } + } + }); + }); +} From 86a3c481c9242d254382fcc2665d30b2a99341cd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:39:40 +0300 Subject: [PATCH 028/157] perf(redis): build the key tree by index and sort names with one lower-casing RedisKeyTreeEngine.buildTree called label.split and parts.sublist for every key and concatenated the folder prefix segment by segment; every sort lower-cased both names inside the comparator. Segments are cut out with indexOf, the folder prefix is a substring of the label taken only when a folder is created, and names are lower-cased once per element before sorting decorated pairs. The comparisons are the same, so the order, ties included, is the previous one. Closes #1523 Closes #1551 --- lib/features/redis/redis_key_tree_engine.dart | 61 +++++++++----- .../redis/redis_key_tree_engine_test.dart | 82 +++++++++++++++++++ 2 files changed, 123 insertions(+), 20 deletions(-) diff --git a/lib/features/redis/redis_key_tree_engine.dart b/lib/features/redis/redis_key_tree_engine.dart index 695dce3a..2185d7f6 100644 --- a/lib/features/redis/redis_key_tree_engine.dart +++ b/lib/features/redis/redis_key_tree_engine.dart @@ -128,6 +128,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 +158,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 +169,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/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'], + ); + }); }); } From 851750084d0f3e5566f35a1d4bf6f5026b42031d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:42:32 +0300 Subject: [PATCH 029/157] perf(mongodb): check JSON path identifiers from code units appendJsonPath built RegExp(r'^[a-zA-Z_$][a-zA-Z0-9_$]*$') inside the function, so every node of every document in the tree compiled a regex on every render. The identifier check is a loop over code units. Tests pin the characters it accepts and the keys that fall back to the ["..."] form. Closes #1525 --- .../mongodb/mongo_document_tree_view.dart | 16 +++++++++++++++- .../mongodb/mongo_documents_view_modes_test.dart | 11 +++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/features/mongodb/mongo_document_tree_view.dart b/lib/features/mongodb/mongo_document_tree_view.dart index 985da922..5f0f068b 100644 --- a/lib/features/mongodb/mongo_document_tree_view.dart +++ b/lib/features/mongodb/mongo_document_tree_view.dart @@ -20,6 +20,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 +42,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'\"'); diff --git a/test/features/mongodb/mongo_documents_view_modes_test.dart b/test/features/mongodb/mongo_documents_view_modes_test.dart index efb041fd..8541c5ea 100644 --- a/test/features/mongodb/mongo_documents_view_modes_test.dart +++ b/test/features/mongodb/mongo_documents_view_modes_test.dart @@ -30,6 +30,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"]'); }); }); From 43d8f49ed826ee8b7012e42957e23601f59727bd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:44:13 +0300 Subject: [PATCH 030/157] perf(grid): resolve filter predicates once instead of per row _PredicateNode.evaluate re-derived everything from the operator and operand strings for every row: op.toUpperCase().trim(), targetValue.toLowerCase() and double.tryParse(targetValue), the lower-cased and trimmed IN list (O(rows x list)) and the BETWEEN bounds. The constructor now computes the operator, the lower-cased target, its number, the normalised IN set and the BETWEEN bounds once; evaluate only reads the cell. Semantics are unchanged: numeric comparison when both sides parse, otherwise case-insensitive string comparison, NULL / null / empty are null. Closes #1550 --- .../workspace/grid_filter_engine.dart | 115 +++++++++--------- .../workspace/grid_filter_predicate_test.dart | 58 +++++++++ 2 files changed, 114 insertions(+), 59 deletions(-) create mode 100644 test/features/workspace/grid_filter_predicate_test.dart diff --git a/lib/features/workspace/grid_filter_engine.dart b/lib/features/workspace/grid_filter_engine.dart index 785ff915..ccbd94d6 100644 --- a/lib/features/workspace/grid_filter_engine.dart +++ b/lib/features/workspace/grid_filter_engine.dart @@ -176,7 +176,10 @@ class _NotNode extends _FilterAstNode { } 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 +187,12 @@ class _PredicateNode extends _FilterAstNode { this.betweenMin, this.betweenMax, this.likeRegex, - }); + }) : _upperOp = op.toUpperCase().trim(), + _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,72 +205,56 @@ class _PredicateNode extends _FilterAstNode { /// per-row scan does not rebuild the same RegExp for every row. final RegExp? likeRegex; + final String _upperOp; + 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) { - 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); + switch (_upperOp) { + case 'IS NULL': + return _isNull(cellValue); + case 'IS NOT NULL': + return !_isNull(cellValue); + case 'IN': + return _inSet.contains(cellValue.toLowerCase().trim()); + case 'NOT IN': + return !_inSet.contains(cellValue.toLowerCase().trim()); + case '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 'LIKE': + return (likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true)) + .hasMatch(cellValue); + case 'NOT LIKE': + return !(likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true)) + .hasMatch(cellValue); + case 'ILIKE': + return (likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false)) + .hasMatch(cellValue); + case 'NOT ILIKE': + return !(likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false)) + .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()); + final numTarget = _numTarget; if (numCell != null && numTarget != null) { switch (op) { @@ -285,6 +277,8 @@ class _PredicateNode extends _FilterAstNode { } // String / Lexicographic comparison + final lowerCell = cellValue.toLowerCase(); + final lowerTarget = _lowerTarget; switch (op) { case '=': case '==': @@ -307,6 +301,9 @@ class _PredicateNode extends _FilterAstNode { } } + 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++) { 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..f8af9ac6 --- /dev/null +++ b/test/features/workspace/grid_filter_predicate_test.dart @@ -0,0 +1,58 @@ +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('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]); + }); + }); +} From caefea19b2974655e282b4227b9f2d4988488579 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:47:29 +0300 Subject: [PATCH 031/157] perf(sql): skip leading comments by offset in stripLeadingWhitespaceAndLineComments Each leading comment copied the rest of the script (substring + trimLeft), so a dump with a long header of comment lines in front of a large script was quadratic in memory. An index walks whitespace, -- lines and nested /* */ blocks and the string is sliced once. The whitespace set is the one trimLeft uses. A test compares the result with the previous implementation on listed and random scripts. Closes #1515 --- lib/core/database/sql_limit.dart | 52 +++++++-- test/core/database/sql_limit_strip_test.dart | 108 +++++++++++++++++++ 2 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 test/core/database/sql_limit_strip_test.dart diff --git a/lib/core/database/sql_limit.dart b/lib/core/database/sql_limit.dart index 42205440..0d4b9148 100644 --- a/lib/core/database/sql_limit.dart +++ b/lib/core/database/sql_limit.dart @@ -20,25 +20,55 @@ 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; } } +/// 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+)?', 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); + }); + }); +} From f02becaeffe7a727a71c56765672e6d43821d6e3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:49:36 +0300 Subject: [PATCH 032/157] perf(theme): walk scope prefixes by last dot in TokenStyleResolver _scopePrefixes split the scope into a list and built every prefix with sublist(0, i).join('.') up front, for each uncached scope lookup. The resolver cuts the scope at the last dot, one prefix at a time, and stops at the first rule that matches. The order of prefixes is the same, including empty segments; a test pins that. Closes #1530 --- .../theme/parser/token_style_resolver.dart | 16 +++++------- .../parser/token_style_resolver_test.dart | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) 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/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)); + }); } From 42a02903582d856135b7584694e3800c9a755175 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:53:11 +0300 Subject: [PATCH 033/157] perf(grid): no regex in formatNum, no comma copy in selection stats GridSelectionCalcEngine.formatNum compiled two RegExp objects and ran two replace passes for every stat on every selection change, and compute made a replaceAll(',', '') copy for every selected cell. formatNum walks the code units from the end to drop trailing zeros and a dangling dot. compute strips commas only from cells that contain one. Output is unchanged. Closes #1531 Refs #1520 --- .../workspace/grid_selection_calc_engine.dart | 17 ++++++-- .../grid_selection_calc_format_test.dart | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 test/features/workspace/grid_selection_calc_format_test.dart diff --git a/lib/features/workspace/grid_selection_calc_engine.dart b/lib/features/workspace/grid_selection_calc_engine.dart index b56d1291..dd9ba8c9 100644 --- a/lib/features/workspace/grid_selection_calc_engine.dart +++ b/lib/features/workspace/grid_selection_calc_engine.dart @@ -131,9 +131,11 @@ abstract final class GridSelectionCalcEngine { distinctSet.add(trimmed); - // Try parsing numeric values (stripping commas if present) - final normalized = trimmed.replaceAll(',', ''); - final parsed = double.tryParse(normalized); + // Try parsing numeric values (stripping commas if present). Most cells + // have none, so the stripped copy is only made when there is one. + final parsed = double.tryParse( + trimmed.contains(',') ? trimmed.replaceAll(',', '') : trimmed, + ); if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { numericList.add(parsed); sum += parsed; @@ -260,6 +262,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/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..45e2cc0d --- /dev/null +++ b/test/features/workspace/grid_selection_calc_format_test.dart @@ -0,0 +1,40 @@ +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 + }); + }); +} From 1c7a8c979d5062825dc1630771e15f104016ffb1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:58:21 +0300 Subject: [PATCH 034/157] perf(postgres): hex-encode bytea and cut date-only cells without temporary strings postgresByteaHexDisplay made toRadixString(16).padLeft(2, '0') for every byte (two strings per byte), and date-only cells ran toIso8601String().split('T') .first, which builds a list for every date cell of every row. Bytea is written as code units from a hex digit table into one buffer. A date cell takes the ISO text up to the first T by index, which also keeps the sign and six digits of years outside 0..9999. Closes #1529 Closes #1521 --- lib/core/database/postgres_result_cells.dart | 21 ++++-- .../postgres_result_cells_scanner_test.dart | 73 +++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 test/core/database/postgres_result_cells_scanner_test.dart 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/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', + ); + }); + }); +} From 97039f203b3f5c010d3ddb619f993cd3f0e6aaf3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 08:11:55 +0300 Subject: [PATCH 035/157] perf(grid): stream selection cells into the buffer, no regex per JSON cell toCsv and toTsv built a List per row and joined it; toJson compiled RegExp(r'^0\d+$') for every cell and parsed each number twice. Cells are written straight into the StringBuffer with a delimiter before every column but the first. The leading-zero check is a code unit loop, and ints and doubles are parsed once. Output is unchanged, including the trimRight of the whole text. Closes #1524 --- .../grid/grid_selection_manager.dart | 49 ++++---- .../workspace/grid_selection_export_test.dart | 114 ++++++++++++++++++ 2 files changed, 142 insertions(+), 21 deletions(-) create mode 100644 test/features/workspace/grid_selection_export_test.dart diff --git a/lib/features/workspace/grid/grid_selection_manager.dart b/lib/features/workspace/grid/grid_selection_manager.dart index 3766828c..9ceb4e23 100644 --- a/lib/features/workspace/grid/grid_selection_manager.dart +++ b/lib/features/workspace/grid/grid_selection_manager.dart @@ -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,13 +124,13 @@ 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 (int.tryParse(val) case final asInt?) { + map[colName] = asInt; + } else if (double.tryParse(val) case final asDouble?) { + map[colName] = asDouble; } else if (val.toLowerCase() == 'true') { map[colName] = true; } else if (val.toLowerCase() == 'false') { @@ -150,6 +147,16 @@ class ResultGridSelection { return const JsonEncoder.withIndent(' ').convert(result); } + /// `^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) { if (val.contains('\t') || val.contains('\n') || 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..b2c904f3 --- /dev/null +++ b/test/features/workspace/grid_selection_export_test.dart @@ -0,0 +1,114 @@ +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 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}, + ]); + }); + }); +} From 1f6e27603c635b86f6ec9ff68af589c7d0773404 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 08:18:14 +0300 Subject: [PATCH 036/157] fix(devcontainer): reachable databases, Flutter 3.47.1, bubblewrap, live-tests profile Section 1 of #1487. - scripts/dev/devcontainer_start.sh replaces the postStartCommand that hid failures: it starts the stack, waits for the databases to be healthy, joins the container to querya-dev_default and forwards localhost: to the services with socat, so psql -h localhost works from the container. - The image is Ubuntu 24.04 with Flutter cloned at ARG FLUTTER_VERSION (3.47.1, as in ci.yml; a guard test fails when they differ), plus bubblewrap and socat. The image workflow reads the version from the ARG and checks it. - Compose profile live-tests: mysql:8.0 with mysql_native_password on MYSQL_LIVE_PORT (3307), the server CI uses for stream_take_drain_test. - docs/dev-containers.md describes all of the above. Refs #1487 --- .devcontainer/Dockerfile | 28 +++++- .devcontainer/devcontainer.json | 8 +- .github/workflows/devcontainer-image.yml | 6 +- docker/.env.example | 2 + docker/docker-compose.yml | 40 +++++++++ docs/dev-containers.md | 88 +++++++++---------- scripts/dev/devcontainer_start.sh | 87 ++++++++++++++++++ .../devcontainer_flutter_version_test.dart | 28 ++++++ 8 files changed, 233 insertions(+), 54 deletions(-) create mode 100755 scripts/dev/devcontainer_start.sh create mode 100644 test/guards/devcontainer_flutter_version_test.dart 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 ff4ef266..cca3c70d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,14 +8,16 @@ "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/workflows/devcontainer-image.yml b/.github/workflows/devcontainer-image.yml index c19e5de4..ff03605b 100644 --- a/.github/workflows/devcontainer-image.yml +++ b/.github/workflows/devcontainer-image.yml @@ -40,7 +40,7 @@ jobs: - name: Flutter version from the Dockerfile id: flutter run: | - v=$(sed -n 's#^FROM ghcr.io/cirruslabs/flutter:\(.*\)$#\1#p' .devcontainer/Dockerfile | head -n1) + v=$(sed -n 's#^ARG FLUTTER_VERSION=\(.*\)$#\1#p' .devcontainer/Dockerfile | head -n1) test -n "$v" echo "version=$v" >> "$GITHUB_OUTPUT" @@ -70,7 +70,9 @@ jobs: - name: Smoke test run: | set -euo pipefail - docker run --rm querya-devcontainer:test flutter --version + 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' 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/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/docs/dev-containers.md b/docs/dev-containers.md index 423f8b6d..574434f7 100644 --- a/docs/dev-containers.md +++ b/docs/dev-containers.md @@ -10,7 +10,8 @@ list of seeded data see the header of [`docker/docker-compose.yml`](../docker/do | 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) | `ghcr.io/cirruslabs/flutter` plus the Linux desktop toolchain (clang, cmake, ninja, GTK 3, libsecret, SQLite headers, Xvfb) and the Docker CLI with the Compose plugin. `DISPLAY` is `:99`. | +| [`.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 @@ -21,9 +22,11 @@ cache (`build.cacheFrom`), so the first build pulls the toolchain layers instead 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 tries -`docker compose -f docker/docker-compose.yml up -d` and prints a hint if Docker -is not reachable. +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 @@ -83,23 +86,25 @@ the ports above. ### From inside the dev container -`localhost` inside the container is the container itself, **not** the host, so -`localhost:5432` finds nothing even though the port is "forwarded" (the forward -goes from the container to your machine, and the databases do not listen in the -container). Pick one: +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: -1. **Join the stack's network** (works on Linux Docker Engine and Docker Desktop): +- 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. - ```bash - docker network connect querya-dev_default "$(hostname)" - ``` +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. - Then use the service names as hosts: `postgres`, `mysql`, `redis`, `mongo`, - `clickhouse` (with the container-side ports 5432, 3306, 6379, 27017, 8123). -2. **Docker Desktop only:** `host.docker.internal` with the host ports. -3. **Share the network namespace** of a database with the dev container, so - that `127.0.0.1` works for tests that hard-code it (see the live MySQL test - below): start that one container with `--network container:$(hostname)`. +On Docker Desktop `host.docker.internal` with the host ports works as well. ### Per database @@ -182,10 +187,11 @@ scripts/ci/test_sections.sh check # every test is in a se ([`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` (`sudo apt-get install bubblewrap`) - and unprivileged user namespaces; without them they skip. Inside a container - they may also skip because nested namespaces are restricted: CI runs them on a - VM. +- **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 @@ -193,20 +199,20 @@ scripts/ci/test_sections.sh check # every test is in a se `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 does -not work for it. Use a server like the one CI starts: +(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 -docker run -d --name mysql-live -p 3307:3306 \ - -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=querya \ - -e MYSQL_USER=querya -e MYSQL_PASSWORD=querya \ - mysql:8.0 --default-authentication-plugin=mysql_native_password - +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 add `--network container:$(hostname)` (and drop -`-p`) so that `127.0.0.1:3306` is that server, and leave `MYSQL_PORT` unset. +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 @@ -223,26 +229,18 @@ a usage error. ## Known gaps -- **Flutter version.** CI pins Flutter **3.47.1**; the dev container image is - `ghcr.io/cirruslabs/flutter:3.41.6` and the image registry has no 3.47.x tag - yet. To match CI inside the container: - - ```bash - FLUTTER_ROOT="$(dirname "$(dirname "$(readlink -f "$(which flutter)")")")" - git -C "$FLUTTER_ROOT" fetch --tags - git -C "$FLUTTER_ROOT" checkout 3.47.1 - flutter precache --linux && flutter pub get - ``` - - This changes the image's SDK for that container only. +- **Headless L2** (the app under Xvfb in a script and a CI job) and the + extension-author container are not done yet; both are tracked in + [#1487](https://github.com/QueryaHub/Querya-Desktop/issues/1487). ## Troubleshooting | Symptom | Fix | |---------|-----| -| `Docker stack not started` on start | Docker is not reachable from the container: check that the host daemon runs and the `docker-outside-of-docker` feature is installed, then `cd docker && docker compose up -d`. | +| `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 | See *From inside the dev container* above: use the service names after `docker network connect`. | +| 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`. | 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/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', + ); + }); +} From 159b5cfd283cf2a8335c15b8e040cf6c0003be79 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 08:25:56 +0300 Subject: [PATCH 037/157] feat(dx): headless L2 script and a smoke workflow scripts/dev/headless_app.sh starts Xvfb on a free display, runs a copy of a built bundle with QUERYA_PORTABLE=1 on a throw-away profile, waits for the window, checks that it stays up, optionally takes a screenshot and stops everything. Exit codes: 0 ok, 1 died / no window, 2 usage or missing tool. The Headless smoke workflow builds the Linux bundle and runs it (on changes to the script, weekly, on demand) and keeps the screenshot and log. docs/dev-containers.md describes the three levels as one command each. Refs #1487 --- .github/workflows/headless-smoke.yml | 67 ++++++++++ docs/dev-containers.md | 38 ++++-- scripts/dev/headless_app.sh | 184 +++++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/headless-smoke.yml create mode 100755 scripts/dev/headless_app.sh diff --git a/.github/workflows/headless-smoke.yml b/.github/workflows/headless-smoke.yml new file mode 100644 index 00000000..eb6785c4 --- /dev/null +++ b/.github/workflows/headless-smoke.yml @@ -0,0 +1,67 @@ +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. 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 + - .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 + + - 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: 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/docs/dev-containers.md b/docs/dev-containers.md index 574434f7..1863a220 100644 --- a/docs/dev-containers.md +++ b/docs/dev-containers.md @@ -121,28 +121,46 @@ The **Demo Playground** (a bundled SQLite database) needs none of this. ## Headless modes -"Headless" means no display and no window. There are three levels; the first -two work today, the third is a recipe that is not wrapped in a script yet +"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 | see below | +| L2 | the real app under a virtual display, in portable mode | `scripts/dev/headless_app.sh` | L1 needs a tester built from 0.4.19 or newer: older ones exited 0 whatever the result. -L2, by hand (untested in the container, it is the same recipe as a normal -Linux run): +### 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 -Xvfb :99 -screen 0 1280x800x24 & # DISPLAY=:99 is already set -QUERYA_PORTABLE=1 flutter run -d linux # data goes to ./QueryaData of the bundle -import -window root shot.png # ImageMagick, if installed +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. + +Whether L2 should grow a real "headless Querya" (core services without a window, +so that `querya-mcp` does not need a running Desktop) is left open until this +exists. + ## Run the app in the container The container has no display of its own. Two ways: @@ -229,8 +247,8 @@ a usage error. ## Known gaps -- **Headless L2** (the app under Xvfb in a script and a CI job) and the - extension-author container are not done yet; both are tracked in +- **The extension-author container** (Dev Container Feature, test database + images, template repository, registry CI) is not done yet; it is tracked in [#1487](https://github.com/QueryaHub/Querya-Desktop/issues/1487). ## Troubleshooting diff --git a/scripts/dev/headless_app.sh b/scripts/dev/headless_app.sh new file mode 100755 index 00000000..a9e7a58d --- /dev/null +++ b/scripts/dev/headless_app.sh @@ -0,0 +1,184 @@ +#!/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 +# -h, --help this text +# +# Exit codes: 0 the app started, showed a window and stayed up; 1 it died, or no +# window appeared in time; 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 + +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 ;; + -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 "$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 + +runner=() +if command -v dbus-run-session >/dev/null 2>&1; then + runner=(dbus-run-session --) +fi +${runner[@]+"${runner[@]}"} "$work/bundle/querya_desktop" >"$work/app.log" 2>&1 & +app_pid=$! +say "started querya_desktop (pid $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 +} + +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 + +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 + +say "ok" +exit 0 From bf88846218a3b2f55f1f249385a48a45bd62ca5f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 20:41:52 +0300 Subject: [PATCH 038/157] feat(dx): Dev Container Feature that installs querya-ext-tester (#1487, part 3a) Downloads the tester from a release, checks SHA-256 against SHA256SUMS.txt, refuses releases before 0.4.19. Tested on Ubuntu and Debian bases plus a pinned-version scenario; published to ghcr.io/queryahub/features from main/dev. --- .github/workflows/devcontainer-feature.yml | 74 +++++++++++++++++++ .../devcontainer-feature.json | 18 +++++ .../src/querya-ext-tester/install.sh | 69 +++++++++++++++++ .../test/querya-ext-tester/pinned.sh | 9 +++ .../test/querya-ext-tester/scenarios.json | 8 ++ .../test/querya-ext-tester/test.sh | 10 +++ docs/extension-testing.md | 19 +++++ 7 files changed, 207 insertions(+) create mode 100644 .github/workflows/devcontainer-feature.yml create mode 100644 devcontainer-features/src/querya-ext-tester/devcontainer-feature.json create mode 100755 devcontainer-features/src/querya-ext-tester/install.sh create mode 100755 devcontainer-features/test/querya-ext-tester/pinned.sh create mode 100644 devcontainer-features/test/querya-ext-tester/scenarios.json create mode 100755 devcontainer-features/test/querya-ext-tester/test.sh 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/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/docs/extension-testing.md b/docs/extension-testing.md index bdc6b924..e7922504 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -177,6 +177,25 @@ 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. +## 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" } + } +} +``` + +`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 This repository contains a composite action in From 2f5d2721ac5eeb1a082bc18d09d8e17394546136 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 20:43:48 +0300 Subject: [PATCH 039/157] feat(dx): querya-testdb- images with the seeds baked in (#1487, part 3b) Dockerfile.testdb per database (postgres, mysql 8.0 native password, mongo, clickhouse, redis), built and smoke-tested on pull requests, pushed with the release version and latest after a Release. --- .github/workflows/testdb-images.yml | 145 ++++++++++++++++++++++++++++ docker/clickhouse/Dockerfile.testdb | 9 ++ docker/mongo/Dockerfile.testdb | 8 ++ docker/mysql/Dockerfile.testdb | 11 +++ docker/postgres/Dockerfile.testdb | 9 ++ docker/redis/Dockerfile.testdb | 10 ++ docker/redis/testdb-entrypoint.sh | 12 +++ 7 files changed, 204 insertions(+) create mode 100644 .github/workflows/testdb-images.yml create mode 100644 docker/clickhouse/Dockerfile.testdb create mode 100644 docker/mongo/Dockerfile.testdb create mode 100644 docker/mysql/Dockerfile.testdb create mode 100644 docker/postgres/Dockerfile.testdb create mode 100644 docker/redis/Dockerfile.testdb create mode 100755 docker/redis/testdb-entrypoint.sh 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/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/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" From f750dd6bc22eb4bb5c28aaf06fc623f935edba09 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 21:28:00 +0300 Subject: [PATCH 040/157] feat(ext-tester): --strict fails the run on warnings, and a strict input for the action (#1487) A missing OS sandbox is only a warning, so --sandbox could pass green without isolating anything. --strict turns warnings into exit code 1. --- .github/actions/test-extension/action.yml | 9 ++++++++ CHANGELOG.md | 4 ++++ bin/querya_ext_tester.dart | 15 ++++++++++-- docs/extension-testing.md | 10 ++++---- .../harness/extension_harness_report.dart | 5 ++++ .../extension_harness_report_test.dart | 23 +++++++++++++++++++ 6 files changed, 60 insertions(+), 6 deletions(-) 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/CHANGELOG.md b/CHANGELOG.md index aca93b66..80546183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`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. + ### Fixed - **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. diff --git a/bin/querya_ext_tester.dart b/bin/querya_ext_tester.dart index 6956ab20..7fd0ddc3 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,6 +25,9 @@ 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 + --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 @@ -46,6 +50,7 @@ Future _run(List args) async { String? table; String? database; var sandbox = false; + var strict = false; var timeoutMs = 5000; String? junit; String? tap; @@ -68,6 +73,8 @@ Future _run(List args) async { return 0; case '--sandbox': sandbox = true; + case '--strict': + strict = true; case '--no-color': color = false; case '--target-version' || @@ -168,5 +175,9 @@ Future _run(List args) async { File(trace).writeAsStringSync(report.toTraceJson()); stdout.writeln('RPC trace of ${report.rpc.length} calls written to $trace'); } - return report.isSuccess ? 0 : 1; + 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/docs/extension-testing.md b/docs/extension-testing.md index e7922504..899ead4b 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -69,13 +69,14 @@ 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 | +| `--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. 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 @@ -346,8 +347,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/lib/core/extensions/harness/extension_harness_report.dart b/lib/core/extensions/harness/extension_harness_report.dart index 82613739..224d18d7 100644 --- a/lib/core/extensions/harness/extension_harness_report.dart +++ b/lib/core/extensions/harness/extension_harness_report.dart @@ -151,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) => 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)')); From 8c1c19fc6c3722aa4755eb7f8bfcd8789e383715 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 21:38:44 +0300 Subject: [PATCH 041/157] feat(dx): headless L2 MCP end to end through querya-mcp (#1487, part 4) headless_app.sh gets --seed-sql (apply SQL to the profile database between two starts) and --run (a command while the app is up). headless_mcp_check.sh seeds a fixture SQLite connection, turns the MCP server on, shares it, and mcp_stdio_check.py speaks MCP to the app through the real querya-mcp. --- .github/workflows/headless-smoke.yml | 24 ++- .../mcp_stdio_check.cpython-314.pyc | Bin 0 -> 11037 bytes scripts/dev/headless_app.sh | 97 +++++++++-- scripts/dev/headless_mcp_check.sh | 58 +++++++ scripts/dev/mcp_stdio_check.py | 161 ++++++++++++++++++ 5 files changed, 321 insertions(+), 19 deletions(-) create mode 100644 scripts/dev/__pycache__/mcp_stdio_check.cpython-314.pyc create mode 100755 scripts/dev/headless_mcp_check.sh create mode 100755 scripts/dev/mcp_stdio_check.py diff --git a/.github/workflows/headless-smoke.yml b/.github/workflows/headless-smoke.yml index eb6785c4..fcc80d8c 100644 --- a/.github/workflows/headless-smoke.yml +++ b/.github/workflows/headless-smoke.yml @@ -2,13 +2,19 @@ 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. Not part of ci.yml: a Linux build takes +# 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" @@ -33,7 +39,7 @@ jobs: 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 + libsecret-1-dev xvfb xdotool imagemagick sqlite3 - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -57,6 +63,20 @@ jobs: --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 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 0000000000000000000000000000000000000000..18663043562a243d41362bdd3939100cbef01a29 GIT binary patch literal 11037 zcmbt4X>1$UnQzG9O;Myo>#(kobwqM>*_LBRQtil=gY!7wOg9_zHod`&P?T^i$g%cS_nqT{U zj~tR#lYy`rZrtFW{+D26to7Cf#W#ym`NFnBgs256ogm zD>GB_nnzaB<4P0EFq|bfk7w%Ogmt9ZCj1nZA3i?HiQxdJMAFT)6p2kub1z(uiPANp zb;cikp@mbXMed4ljT1P3C@6*%PLBDfIYH)*hlE%_uq06HIC#pjLJ*SJ;$nH%l(oP@``B}RIO`>oay zQ4Gj*Ga(wqGf#@Ra5xyA;;sahX$lttDT@+42iPrLwM7W%G0m~;2@}Qv1oIfwf`zS z`6GbKjxJ7wt*>!(AQu&6SqyY@`??VOcuMYq06PTQtRsFPaJCrX3E#K=394B#o?#sV zOzmZ|%mpJVf&9QqI$$cjm)OaE)~m~Yci{Kj_$gQw#={60hYVn-7MaER#ge48kuZ{mmI(&lvb(k6w=AcOg9=QSQKZeb0x**Uz`uYQ zGY>p`D08k+*S3N1np3ho3pg`)nhmM}HAYx9LKBH8v=t>uWfk$NqM9aS7vx}Ee1v=m zbTRz4CZXzKW&zkyc-me@vxm!lfsxrm=6ZLUt!d~wa+d*)myjb>Bftb)uCh@n;=jwN ztSp8u$SB&gQUMMy_k@I*$$)S~txa=7o6i>v2Nj>MttS-m3nBRkJfw%z!34P)s*h($ zqVsn5t?u=T*42vE#lR1PcZ2I~M^@X8tW_M93Sr1yooZABX-WhH1q5*>5*}%`s8%^P ziI9LUQw^gL5I_*vQ=|P-1;Szq1GK(iAPu@jnMTbH8d5cS=p$lYkd$VfRE5L!;#E;b z+FfYOoJ9j&E!=jNN*E<09|Gi??&@`S{afz(jRNOf|Es%i*W9Z4=iNW4`BBYgan;SE zH;yj4mo7gv=yr9@vEQ&fFp{!zc(8AjltE+LG`rT#WpA0wHj9t0un+Z&WrTTmWb9Ys zZ>Q+F9}y;GNT8{C>$mVQ3v9O&-F6bIos`~qMhHM9nqj$Z1qD�G>QX$$tL*ZQGWI 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 exit 2; } @@ -41,6 +52,8 @@ while [ $# -gt 0 ]; do --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 @@ -54,6 +67,10 @@ 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 @@ -125,13 +142,32 @@ 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 -${runner[@]+"${runner[@]}"} "$work/bundle/querya_desktop" >"$work/app.log" 2>&1 & -app_pid=$! -say "started querya_desktop (pid $app_pid)" + +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 @@ -147,21 +183,37 @@ window_up() { fi } -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" +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 - if window_up; then shown=1; break; fi + [ "$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 - 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" +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)) @@ -180,5 +232,16 @@ if [ -n "$screenshot" ]; then 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" < Date: Fri, 16 Oct 2026 21:45:40 +0300 Subject: [PATCH 042/157] docs(dev): L2 MCP check, seed/run options, what each level needs, extension-author pieces (#1487) --- docs/dev-containers.md | 64 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/docs/dev-containers.md b/docs/dev-containers.md index 1863a220..56bbd796 100644 --- a/docs/dev-containers.md +++ b/docs/dev-containers.md @@ -130,6 +130,15 @@ command and each runnable in a container or on a CI runner | 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. @@ -157,9 +166,36 @@ The [Headless smoke (L2)](../.github/workflows/headless-smoke.yml) workflow buil the Linux bundle and runs the script (on changes to it, weekly, and on demand), keeping the screenshot and the log as an artifact. -Whether L2 should grow a real "headless Querya" (core services without a window, -so that `querya-mcp` does not need a running Desktop) is left open until this -exists. +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 @@ -245,11 +281,27 @@ 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. + ## Known gaps -- **The extension-author container** (Dev Container Feature, test database - images, template repository, registry CI) is not done yet; it is tracked in - [#1487](https://github.com/QueryaHub/Querya-Desktop/issues/1487). +- **The extension registry** (registry CI and `StaticRegistryRepository`) and the + release hooks that publish the Feature and the images are not done yet; they + are tracked in [#1487](https://github.com/QueryaHub/Querya-Desktop/issues/1487). + Until then the images are published by hand with the *Test database images* + workflow. ## Troubleshooting From 5ae9e32ac91534dc09f0f3b37373bcfed8574371 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 21:52:55 +0300 Subject: [PATCH 043/157] feat(market): StaticRegistryRepository reads the extension registry index (#1487, part 5a) Lists extensions from a static index.json (GitHub Pages), hides yanked entries, non-release URLs and extensions the running Querya is too old for, caches the index. Installing is the existing safe pipeline (SHA-256, archive limits, sandbox policy); downloads are limited to github.com release assets. --- .../harness/extension_manifest_validator.dart | 4 +- .../market/static_registry_repository.dart | 175 ++++++++++++ .../static_registry_repository_test.dart | 249 ++++++++++++++++++ 3 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 lib/core/market/static_registry_repository.dart create mode 100644 test/core/market/static_registry_repository_test.dart diff --git a/lib/core/extensions/harness/extension_manifest_validator.dart b/lib/core/extensions/harness/extension_manifest_validator.dart index 1d8378f8..d04a4cd0 100644 --- a/lib/core/extensions/harness/extension_manifest_validator.dart +++ b/lib/core/extensions/harness/extension_manifest_validator.dart @@ -82,7 +82,7 @@ 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'); @@ -147,7 +147,7 @@ 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) { +bool? engineRangeAllows(HostVersion host, String range) { final r = range.trim(); try { if (r.startsWith('>=')) { diff --git a/lib/core/market/static_registry_repository.dart b/lib/core/market/static_registry_repository.dart new file mode 100644 index 00000000..fe2287dd --- /dev/null +++ b/lib/core/market/static_registry_repository.dart @@ -0,0 +1,175 @@ +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/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 'http_marketplace_repository.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, + super.client, + super.allowLocalhostInDebug, + this.cacheTtl = const Duration(minutes: 10), + DateTime Function()? now, + }) : _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; + final String indexUrl; + final Duration cacheTtl; + final http.Client _http; + final DateTime Function() _now; + + List? _cache; + DateTime? _cachedAt; + + 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); + _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, and compatible with [hostVersion]. + @visibleForTesting + static List parseIndex( + Object? decoded, { + required String hostVersion, + }) { + 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; + try { + final manifest = ExtensionManifest.fromJson(json); + final url = Uri.tryParse(manifest.downloadUrl ?? ''); + if (url == null || !isReleaseAssetUrl(url)) continue; + if ((manifest.sha256Checksum ?? '').trim().isEmpty) 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/test/core/market/static_registry_repository_test.dart b/test/core/market/static_registry_repository_test.dart new file mode 100644 index 00000000..12b0f095 --- /dev/null +++ b/test/core/market/static_registry_repository_test.dart @@ -0,0 +1,249 @@ +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 _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 = 'a' * 64, + 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'}) => [ + for (final m + in StaticRegistryRepository.parseIndex(decoded, hostVersion: host)) + 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('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'), + throwsA(isA())); + expect(() => StaticRegistryRepository.parseIndex([], hostVersion: '0.5.0'), + throwsA(isA())); + }); + }); + + 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, + 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); + }); + }); + }); +} From 125da8a4f546f26c2a547f2ff06d568b9dfe5919 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 21:57:16 +0300 Subject: [PATCH 044/157] fix(market): constant default in the registry test, drop a redundant import --- lib/core/market/static_registry_repository.dart | 1 - test/core/market/static_registry_repository_test.dart | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/core/market/static_registry_repository.dart b/lib/core/market/static_registry_repository.dart index fe2287dd..47dd7a63 100644 --- a/lib/core/market/static_registry_repository.dart +++ b/lib/core/market/static_registry_repository.dart @@ -9,7 +9,6 @@ import 'package:querya_desktop/core/extensions/harness/extension_protocol_profil import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; -import 'http_marketplace_repository.dart'; import 'marketplace_repository.dart'; /// The marketplace as a static file: `index.json` published by the extension diff --git a/test/core/market/static_registry_repository_test.dart b/test/core/market/static_registry_repository_test.dart index 12b0f095..76b5e26c 100644 --- a/test/core/market/static_registry_repository_test.dart +++ b/test/core/market/static_registry_repository_test.dart @@ -13,13 +13,14 @@ 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 = 'a' * 64, + Object? sha = _sha, String engines = '>=0.4.19', bool yanked = false, List tags = const [], From 278517fc58bebc799681b2e1499f6e70eb7772e3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 22:05:35 +0300 Subject: [PATCH 045/157] feat(dx): release hooks for the registry and the template, and the extension registry docs (#1487, part 5b) After a Release, repository_dispatch querya-release goes to the registry (test the new version, refresh compat.json) and to the template (move its image tag and CI matrix). docs/extension-registry.md documents the whole path from the template to the Marketplace; the checklist, README, changelog and dev-container guide point to it. --- .github/workflows/release-hooks.yml | 77 +++++++++++++++++++ CHANGELOG.md | 3 + docs/README.md | 1 + docs/dev-containers.md | 10 +-- docs/extension-registry.md | 114 ++++++++++++++++++++++++++++ docs/extension-testing.md | 4 + docs/release-checklist.md | 1 + 7 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release-hooks.yml create mode 100644 docs/extension-registry.md 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/CHANGELOG.md b/CHANGELOG.md index 80546183..990fec34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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. Not wired into the Marketplace page yet. 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). +- **Release hooks** — after a Release the registry and the template are told about the new version (`repository_dispatch`); needs the `QUERYA_DISPATCH_TOKEN` secret. + ### Fixed - **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. diff --git a/docs/README.md b/docs/README.md index 30f164df..808f2568 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ 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 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 diff --git a/docs/dev-containers.md b/docs/dev-containers.md index 56bbd796..dc6a2bde 100644 --- a/docs/dev-containers.md +++ b/docs/dev-containers.md @@ -295,13 +295,13 @@ You do not need this repository to write a database-driver extension. Start from 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 extension registry** (registry CI and `StaticRegistryRepository`) and the - release hooks that publish the Feature and the images are not done yet; they - are tracked in [#1487](https://github.com/QueryaHub/Querya-Desktop/issues/1487). - Until then the images are published by hand with the *Test database images* - workflow. +- **The Marketplace page still uses the mock repository.** The registry and + `StaticRegistryRepository` exist; wiring the page to them is the 0.5.0 work. ## Troubleshooting diff --git a/docs/extension-registry.md b/docs/extension-registry.md new file mode 100644 index 00000000..648e28be --- /dev/null +++ b/docs/extension-registry.md @@ -0,0 +1,114 @@ +# 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). 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 a Linux x86-64 build is exercised. + +## 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 index with another `schema` is refused, not half read. +- Hidden from the list: `yanked` entries, entries without a checksum, a + `downloadUrl` that is not `https://github.com///releases/download//`, + and extensions whose `engines.querya_desktop` range excludes the running + version. 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. + +It is **not wired into the Marketplace page yet** (that still uses the mock +repository): the live Marketplace is the 0.5.0 work. + +`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 899ead4b..e1c3789d 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -178,6 +178,10 @@ 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. +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 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index cd7ef0ce..0cdadc7f 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -89,6 +89,7 @@ Verify the 0.4.4 motion tokens, smooth animations, and high refresh rate support - [ ] 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). From a090adcbbea5558221ac1564bc3559304bcdb711 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 22:38:55 +0300 Subject: [PATCH 046/157] feat(market): the Marketplace lists the extension registry, not the demo catalog (#1568) installMarketplaceRepository() picks StaticRegistryRepository at startup (index URL and a mock switch by --dart-define). The Marketplace tab handles an empty and an unreachable registry (reason + Retry). The demo theme gets its real name. A test pins the extension template's manifest to the sandbox policy. --- CHANGELOG.md | 3 +- docs/dev-containers.md | 4 +- docs/extension-registry.md | 13 ++- lib/core/market/marketplace_repository.dart | 2 +- lib/core/market/marketplace_selection.dart | 37 +++++++++ .../pages/extension_manager_dialog.dart | 80 +++++++++++++++++-- lib/main.dart | 2 + .../market/template_manifest_policy_test.dart | 50 ++++++++++++ .../extensions/extension_manager_test.dart | 59 ++++++++++++++ 9 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 lib/core/market/marketplace_selection.dart create mode 100644 test/core/market/template_manifest_policy_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 990fec34..d46fb235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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. Not wired into the Marketplace page yet. 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). +- **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). - **Release hooks** — after a Release the registry and the template are told about the new version (`repository_dispatch`); needs the `QUERYA_DISPATCH_TOKEN` secret. ### Fixed +- **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. ### Planned diff --git a/docs/dev-containers.md b/docs/dev-containers.md index dc6a2bde..128da26f 100644 --- a/docs/dev-containers.md +++ b/docs/dev-containers.md @@ -300,8 +300,8 @@ The whole path from the template to the Marketplace is in ## Known gaps -- **The Marketplace page still uses the mock repository.** The registry and - `StaticRegistryRepository` exist; wiring the page to them is the 0.5.0 work. +- **The registry has no extensions yet**, so the Marketplace tab is empty until + authors add records (see [extension-registry.md](extension-registry.md)). ## Troubleshooting diff --git a/docs/extension-registry.md b/docs/extension-registry.md index 648e28be..38f1223e 100644 --- a/docs/extension-registry.md +++ b/docs/extension-registry.md @@ -68,8 +68,17 @@ with no secrets and a read-only token. Only a Linux x86-64 build is exercised. 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. -It is **not wired into the Marketplace page yet** (that still uses the mock -repository): the live Marketplace is the 0.5.0 work. +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. diff --git a/lib/core/market/marketplace_repository.dart b/lib/core/market/marketplace_repository.dart index fc4de3e8..7ab5371f 100644 --- a/lib/core/market/marketplace_repository.dart +++ b/lib/core/market/marketplace_repository.dart @@ -265,7 +265,7 @@ class MockMarketplaceRepository implements MarketplaceRepository { }'''; } return '''{ - "name": "\${manifest.name}", + "name": ${jsonEncode(manifest.name)}, "type": "dark", "colors": { "activityBar.background": "#050508", 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/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 86c576e8..8c60845d 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( @@ -395,13 +413,27 @@ class _ExtensionManagerContentState ), ), 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,6 +461,38 @@ 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), + PrimaryButton( + onPressed: () => unawaited(_loadData()), + child: const Text('Retry'), + ), + ], + ], + ), + ), + ), + ); + } + material.Widget _buildUpdatesTab() { if (_loading) { return const material.Center( diff --git a/lib/main.dart b/lib/main.dart index c7f06b6e..7cc1489c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ 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'; @@ -42,6 +43,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/test/core/market/template_manifest_policy_test.dart b/test/core/market/template_manifest_policy_test.dart new file mode 100644 index 00000000..89a2d0ae --- /dev/null +++ b/test/core/market/template_manifest_policy_test.dart @@ -0,0 +1,50 @@ +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 = ''' +{ + "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(SandboxPolicy.validate(manifest), isEmpty); + expect(ExtensionSupport.isPreviewOnlyManifest(manifest), isFalse); + }); +} diff --git a/test/features/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart index 36a796d7..8225472f 100644 --- a/test/features/extensions/extension_manager_test.dart +++ b/test/features/extensions/extension_manager_test.dart @@ -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: PrimaryButton( + 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, @@ -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')]; + } +} From 11878f9da389f95aeaf6b38a9aaced69cb39c3f5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 22:42:14 +0300 Subject: [PATCH 047/157] fix(extensions): QueryaButton for Retry (no shadcn buttons in lib/) --- .../presentation/pages/extension_manager_dialog.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 8c60845d..6488407d 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -481,9 +481,9 @@ class _ExtensionManagerContentState .small(), if (retry) ...[ const material.SizedBox(height: 16), - PrimaryButton( + QueryaButton.secondary( + label: 'Retry', onPressed: () => unawaited(_loadData()), - child: const Text('Retry'), ), ], ], From d213558d6ccc35d182e7af26cb3c166d1ea27913 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 22:50:56 +0300 Subject: [PATCH 048/157] feat(market): offer an extension only with a build for this platform (#1568) The registry's entries can list one archive per platform (linux-x64, macos-arm64, macos-x64, windows-x64) because native drivers ship that way. The reader resolves the map to this platform's downloadUrl, sha256 and main, and hides extensions that have no build for it. --- docs/extension-registry.md | 11 ++- .../market/static_registry_repository.dart | 36 ++++++++- .../static_registry_repository_test.dart | 78 ++++++++++++++++++- 3 files changed, 116 insertions(+), 9 deletions(-) diff --git a/docs/extension-registry.md b/docs/extension-registry.md index 38f1223e..1b1397f1 100644 --- a/docs/extension-registry.md +++ b/docs/extension-registry.md @@ -29,14 +29,16 @@ Querya Desktop ◄── index.json (GitHub Pages) ◄── publish (on merge, `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). Everything the Marketplace + 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 a Linux x86-64 build is exercised. +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. ## What the app trusts @@ -57,6 +59,11 @@ with no secrets and a read-only token. Only a Linux x86-64 build is exercised. } ``` +- 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. - Hidden from the list: `yanked` entries, entries without a checksum, a `downloadUrl` that is not `https://github.com///releases/download//`, diff --git a/lib/core/market/static_registry_repository.dart b/lib/core/market/static_registry_repository.dart index 47dd7a63..71f1403f 100644 --- a/lib/core/market/static_registry_repository.dart +++ b/lib/core/market/static_registry_repository.dart @@ -23,11 +23,13 @@ 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, - }) : _http = client ?? http.Client(), + }) : platform = platform ?? currentPlatformKey(), + _http = client ?? http.Client(), _now = now ?? DateTime.now, super( baseUrl: indexUrl, @@ -52,6 +54,11 @@ class StaticRegistryRepository extends HttpMarketplaceRepository { /// 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; @@ -60,6 +67,14 @@ class StaticRegistryRepository extends HttpMarketplaceRepository { 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. @@ -86,7 +101,8 @@ class StaticRegistryRepository extends HttpMarketplaceRepository { } final body = response.body; final decoded = await Isolate.run(() => jsonDecode(body)); - _cache = parseIndex(decoded, hostVersion: hostVersion); + _cache = parseIndex(decoded, + hostVersion: hostVersion, platform: platform); _cachedAt = _now(); return _cache!; } catch (e) { @@ -100,11 +116,15 @@ class StaticRegistryRepository extends HttpMarketplaceRepository { } /// Entries of a decoded `index.json` that can be shown: not yanked, with a - /// release-asset URL and a checksum, and compatible with [hostVersion]. + /// 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'); @@ -121,6 +141,16 @@ class StaticRegistryRepository extends HttpMarketplaceRepository { 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 ?? ''); diff --git a/test/core/market/static_registry_repository_test.dart b/test/core/market/static_registry_repository_test.dart index 76b5e26c..6aa9eb63 100644 --- a/test/core/market/static_registry_repository_test.dart +++ b/test/core/market/static_registry_repository_test.dart @@ -43,9 +43,11 @@ String _index(List> entries, {Object schema = 1}) => void main() { group('StaticRegistryRepository.parseIndex', () { - List ids(Object decoded, {String host = '0.5.0'}) => [ + List ids(Object decoded, + {String host = '0.5.0', String platform = 'linux-x64'}) => [ for (final m - in StaticRegistryRepository.parseIndex(decoded, hostVersion: host)) + in StaticRegistryRepository.parseIndex(decoded, + hostVersion: host, platform: platform)) m.id, ]; @@ -82,13 +84,80 @@ void main() { }); test('refuses an index of another format or shape', () { - expect(() => StaticRegistryRepository.parseIndex({'schema': 2, 'extensions': []}, hostVersion: '0.5.0'), + expect(() => StaticRegistryRepository.parseIndex({'schema': 2, 'extensions': []}, + hostVersion: '0.5.0', platform: 'linux-x64'), throwsA(isA())); - expect(() => StaticRegistryRepository.parseIndex([], hostVersion: '0.5.0'), + 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)); @@ -114,6 +183,7 @@ void main() { }) => StaticRegistryRepository( hostVersion: host, + platform: 'linux-x64', client: client, now: () => now, ); From 183ce69bf87ec347557400224f6411aa814c4fb8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 22:52:48 +0300 Subject: [PATCH 049/157] fix(ext-tester): send the --connect password with system.injectCredentials, like the app (#1487) A driver that takes its password only from the injected secret (the ClickHouse driver) could not authenticate under the tester: the password was in the db.connect payload, which such a driver ignores. The harness now injects it first and leaves it out of db.connect. --- CHANGELOG.md | 1 + docs/extension-testing.md | 14 +++-- .../extensions/harness/extension_harness.dart | 19 ++++++- .../harness/extension_protocol_profile.dart | 2 + .../harness/extension_harness_test.dart | 55 +++++++++++++++++++ 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d46fb235..3c8d4d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`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. diff --git a/docs/extension-testing.md b/docs/extension-testing.md index e1c3789d..54f83ebf 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -13,6 +13,7 @@ server, Flutter or the Querya Desktop source tree. 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 +21,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. @@ -78,9 +79,12 @@ querya-ext-tester ./my-extension \ Exit codes: `0` all checks passed (warnings and skips do not fail, unless `--strict`), `1` a check failed, `2` bad command line. -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. +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` diff --git a/lib/core/extensions/harness/extension_harness.dart b/lib/core/extensions/harness/extension_harness.dart index ea179cdf..66638e6c 100644 --- a/lib/core/extensions/harness/extension_harness.dart +++ b/lib/core/extensions/harness/extension_harness.dart @@ -153,7 +153,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 +169,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 @@ -381,10 +383,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}; @@ -394,6 +401,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); }); diff --git a/lib/core/extensions/harness/extension_protocol_profile.dart b/lib/core/extensions/harness/extension_protocol_profile.dart index b4290fa1..e1138657 100644 --- a/lib/core/extensions/harness/extension_protocol_profile.dart +++ b/lib/core/extensions/harness/extension_protocol_profile.dart @@ -80,6 +80,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( diff --git a/test/core/extensions/harness/extension_harness_test.dart b/test/core/extensions/harness/extension_harness_test.dart index fbd3e1c9..db362877 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( From 4b507fb83187db96eb974504efb9356dc12746fd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:00:04 +0300 Subject: [PATCH 050/157] feat(extensions): manifestVersion, the format version of manifest.json Absent means format 1. The model reads it, the protocol profile says which host version reads which format, the tester's manifest check rejects a format the emulated host does not read, and the app does not list, install or load an extension in a newer format. --- CHANGELOG.md | 1 + docs/extension-registry.md | 33 +++++++++++-- lib/core/extensions/extension_support.dart | 12 +++++ .../harness/extension_manifest_validator.dart | 14 ++++++ .../harness/extension_protocol_profile.dart | 32 +++++++++++-- .../extensions/local_extension_installer.dart | 3 ++ .../extensions/local_extension_registry.dart | 7 +++ .../extensions/models/extension_manifest.dart | 15 ++++++ .../market/http_marketplace_repository.dart | 3 ++ lib/core/market/marketplace_repository.dart | 3 ++ .../market/static_registry_repository.dart | 2 + .../extensions/extension_support_test.dart | 46 +++++++++++++++++++ .../extension_manifest_validator_test.dart | 38 +++++++++++++++ .../local_extension_registry_test.dart | 25 ++++++++++ .../static_registry_repository_test.dart | 12 +++++ 15 files changed, 238 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d46fb235..851d26b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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). - **Release hooks** — after a Release the registry and the template are told about the new version (`repository_dispatch`); needs the `QUERYA_DISPATCH_TOKEN` secret. ### Fixed diff --git a/docs/extension-registry.md b/docs/extension-registry.md index 1b1397f1..ce32be23 100644 --- a/docs/extension-registry.md +++ b/docs/extension-registry.md @@ -40,6 +40,32 @@ The extension's binary runs in the registry's CI, inside the OS sandbox, in a jo 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 + +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`: @@ -64,11 +90,12 @@ checked (hash, archive, manifest) but not run. `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. +- 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//`, - and extensions whose `engines.querya_desktop` range excludes the running - version. One malformed entry does not hide the others. + 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 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_manifest_validator.dart b/lib/core/extensions/harness/extension_manifest_validator.dart index d04a4cd0..663cb6ba 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'); diff --git a/lib/core/extensions/harness/extension_protocol_profile.dart b/lib/core/extensions/harness/extension_protocol_profile.dart index b4290fa1..459cace6 100644 --- a/lib/core/extensions/harness/extension_protocol_profile.dart +++ b/lib/core/extensions/harness/extension_protocol_profile.dart @@ -68,7 +68,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); @@ -98,9 +102,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 +130,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..1afaafa6 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -105,6 +105,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( diff --git a/lib/core/extensions/local_extension_registry.dart b/lib/core/extensions/local_extension_registry.dart index 2bbf375a..23ac974c 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'; @@ -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..5d2f0356 100644 --- a/lib/core/extensions/models/extension_manifest.dart +++ b/lib/core/extensions/models/extension_manifest.dart @@ -10,6 +10,16 @@ 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; + + /// `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 +49,7 @@ class ExtensionManifest { final ExtensionContributions? contributions; const ExtensionManifest({ + this.manifestVersion = 1, required this.id, required this.name, required this.version, @@ -110,7 +121,10 @@ class ExtensionManifest { factory ExtensionManifest.fromJson(Map json, {String? installPath}) { + final rawFormat = json['manifestVersion']; return ExtensionManifest( + // Not a whole number >= 1: the validator reports it; here it reads as 1. + manifestVersion: rawFormat is int && rawFormat >= 1 ? rawFormat : 1, id: json['id'] as String, name: json['name'] as String, version: json['version'] as String? ?? '0.0.0', @@ -180,6 +194,7 @@ class ExtensionManifest { Map toJson() { return { + if (manifestVersion != 1) 'manifestVersion': manifestVersion, 'id': id, 'name': name, 'version': version, diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 7ae19fb5..164674ea 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.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( diff --git a/lib/core/market/marketplace_repository.dart b/lib/core/market/marketplace_repository.dart index 7ab5371f..ab49033c 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( diff --git a/lib/core/market/static_registry_repository.dart b/lib/core/market/static_registry_repository.dart index 71f1403f..75c1f55f 100644 --- a/lib/core/market/static_registry_repository.dart +++ b/lib/core/market/static_registry_repository.dart @@ -4,6 +4,7 @@ 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'; @@ -156,6 +157,7 @@ class StaticRegistryRepository extends HttpMarketplaceRepository { 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); 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_manifest_validator_test.dart b/test/core/extensions/harness/extension_manifest_validator_test.dart index d7a49b84..5509e712 100644 --- a/test/core/extensions/harness/extension_manifest_validator_test.dart +++ b/test/core/extensions/harness/extension_manifest_validator_test.dart @@ -101,4 +101,42 @@ 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']) { + expect(_check(m, host), 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/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/market/static_registry_repository_test.dart b/test/core/market/static_registry_repository_test.dart index 6aa9eb63..ca20b172 100644 --- a/test/core/market/static_registry_repository_test.dart +++ b/test/core/market/static_registry_repository_test.dart @@ -79,6 +79,18 @@ void main() { 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']); }); From 73dae510449c089a943a5bc178afda3f64937e9b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:00:22 +0300 Subject: [PATCH 051/157] docs: manifestVersion in the tester's manifest check --- docs/extension-testing.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/extension-testing.md b/docs/extension-testing.md index e1c3789d..ffe24895 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -8,7 +8,8 @@ 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. From 37436d01297e8557d5ef030d718160ddd0e6a358 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:01:22 +0300 Subject: [PATCH 052/157] test: the template manifest sets manifestVersion 1 --- test/core/market/template_manifest_policy_test.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/core/market/template_manifest_policy_test.dart b/test/core/market/template_manifest_policy_test.dart index 89a2d0ae..09f6f7e6 100644 --- a/test/core/market/template_manifest_policy_test.dart +++ b/test/core/market/template_manifest_policy_test.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; /// 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", @@ -44,6 +45,8 @@ void main() { 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); }); From fead48b8ddaf2a3aeb0d7dbdd7036254a47f3868 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:02:43 +0300 Subject: [PATCH 053/157] docs: the extension manifest reference and how the manifest format changes --- docs/README.md | 1 + docs/extension-manifest.md | 130 +++++++++++++++++++++++++++++++++++++ docs/extension-registry.md | 2 + docs/extension-testing.md | 2 + 4 files changed, 135 insertions(+) create mode 100644 docs/extension-manifest.md diff --git a/docs/README.md b/docs/README.md index 808f2568..8d682b36 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ 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 diff --git a/docs/extension-manifest.md b/docs/extension-manifest.md new file mode 100644 index 00000000..b94e0b85 --- /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 `^x.y.z`. The app hides the extension outside the range; the tester warns when the emulated host is outside it | +| `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 index ce32be23..40b6dabc 100644 --- a/docs/extension-registry.md +++ b/docs/extension-registry.md @@ -42,6 +42,8 @@ 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 | diff --git a/docs/extension-testing.md b/docs/extension-testing.md index ffe24895..2ac27725 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -179,6 +179,8 @@ 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. From c300f25507e72691453a45f526a13d741b07f676 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:03:17 +0300 Subject: [PATCH 054/157] test: the trace redacts the injected password (it is no longer in db.connect) --- test/core/extensions/harness/extension_harness_test.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/core/extensions/harness/extension_harness_test.dart b/test/core/extensions/harness/extension_harness_test.dart index db362877..781e240a 100644 --- a/test/core/extensions/harness/extension_harness_test.dart +++ b/test/core/extensions/harness/extension_harness_test.dart @@ -435,9 +435,14 @@ void main() { ), ); + // 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['password'], '***'); + expect(request, isNot(contains('password'))); expect((request['ssh'] as Map)['passphrase'], '***'); expect(request['host'], 'db'); expect(report.toTraceJson(), isNot(contains('s3cret'))); From b0bfd9d87a21141e92ca060fbb14d2c02a780a50 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:09:19 +0300 Subject: [PATCH 055/157] test: the protocol profile now has system.injectCredentials (12 methods on 0.5.0) --- .../extensions/harness/extension_protocol_profile_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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', () { From 71e38481a9ad0db9c3a6d82e47ab012329da2ec5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:09:22 +0300 Subject: [PATCH 056/157] test: the manifestVersion check is about its own path only --- .../harness/extension_manifest_validator_test.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/core/extensions/harness/extension_manifest_validator_test.dart b/test/core/extensions/harness/extension_manifest_validator_test.dart index 5509e712..30d76549 100644 --- a/test/core/extensions/harness/extension_manifest_validator_test.dart +++ b/test/core/extensions/harness/extension_manifest_validator_test.dart @@ -113,7 +113,13 @@ void main() { 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']) { - expect(_check(m, host), isEmpty, reason: host); + // 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, + ); } }); From e2c042551935b898d2989b9e1f8ae7755cd9eb3e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:16:58 +0300 Subject: [PATCH 057/157] feat(extensions): engines ranges with several comparators (>=0.4.0 <3.0.0) The ClickHouse driver declares >=0.4.0 <3.0.0, which engineRangeAllows could not read (only >=x.y.z and ^x.y.z), so the extension showed on every version. Space separated comparators now all have to hold; * means any version; a range that is still not understood stays null (shown with a warning) instead of a guess. --- CHANGELOG.md | 1 + docs/extension-manifest.md | 2 +- .../harness/extension_manifest_validator.dart | 37 +++++++++---- .../harness/extension_protocol_profile.dart | 1 + .../extension_manifest_validator_test.dart | 53 +++++++++++++++++++ 5 files changed, 84 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d4bf7c..7868c124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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). +- **`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. ### Fixed diff --git a/docs/extension-manifest.md b/docs/extension-manifest.md index b94e0b85..f613f16a 100644 --- a/docs/extension-manifest.md +++ b/docs/extension-manifest.md @@ -54,7 +54,7 @@ test it is in [extension-testing.md](extension-testing.md). | `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 `^x.y.z`. The app hides the extension outside the range; the tester warns when the emulated host is outside it | +| `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 | diff --git a/lib/core/extensions/harness/extension_manifest_validator.dart b/lib/core/extensions/harness/extension_manifest_validator.dart index 663cb6ba..aab84e1c 100644 --- a/lib/core/extensions/harness/extension_manifest_validator.dart +++ b/lib/core/extensions/harness/extension_manifest_validator.dart @@ -99,7 +99,8 @@ List validateExtensionManifest( 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', @@ -159,16 +160,34 @@ 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. +/// `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`, `=')) { - 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 !(HostVersion.parse(c.substring(2)) < host); + 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 041ec61a..5b85ff80 100644 --- a/lib/core/extensions/harness/extension_protocol_profile.dart +++ b/lib/core/extensions/harness/extension_protocol_profile.dart @@ -31,6 +31,7 @@ final class HostVersion implements Comparable { 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) => diff --git a/test/core/extensions/harness/extension_manifest_validator_test.dart b/test/core/extensions/harness/extension_manifest_validator_test.dart index 30d76549..dc72bd00 100644 --- a/test/core/extensions/harness/extension_manifest_validator_test.dart +++ b/test/core/extensions/harness/extension_manifest_validator_test.dart @@ -92,6 +92,59 @@ 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('* 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'] = [ From d8ff9fe201080d0feed52fe8a4fe60b58630400d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:18:15 +0300 Subject: [PATCH 058/157] feat(ext-tester): --manifest-only checks manifest.json and starts nothing --- CHANGELOG.md | 1 + bin/querya_ext_tester.dart | 7 ++++ docs/extension-testing.md | 1 + .../extensions/harness/extension_harness.dart | 14 +++++++ .../harness/extension_harness_test.dart | 39 +++++++++++++++++++ 5 files changed, 62 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d4bf7c..5a17dbe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. - **Release hooks** — after a Release the registry and the template are told about the new version (`repository_dispatch`); needs the `QUERYA_DISPATCH_TOKEN` secret. ### Fixed diff --git a/bin/querya_ext_tester.dart b/bin/querya_ext_tester.dart index 7fd0ddc3..a71a5f67 100644 --- a/bin/querya_ext_tester.dart +++ b/bin/querya_ext_tester.dart @@ -25,6 +25,9 @@ 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 @@ -51,6 +54,7 @@ Future _run(List args) async { String? database; var sandbox = false; var strict = false; + var manifestOnly = false; var timeoutMs = 5000; String? junit; String? tap; @@ -75,6 +79,8 @@ Future _run(List args) async { sandbox = true; case '--strict': strict = true; + case '--manifest-only': + manifestOnly = true; case '--no-color': color = false; case '--target-version' || @@ -167,6 +173,7 @@ 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()); diff --git a/docs/extension-testing.md b/docs/extension-testing.md index 4ac3db1a..ed4d2e92 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -71,6 +71,7 @@ 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 theme-less pipeline step or a quick lint; 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 | diff --git a/lib/core/extensions/harness/extension_harness.dart b/lib/core/extensions/harness/extension_harness.dart index 66638e6c..7244abb8 100644 --- a/lib/core/extensions/harness/extension_harness.dart +++ b/lib/core/extensions/harness/extension_harness.dart @@ -187,11 +187,14 @@ 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); @@ -286,6 +289,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; diff --git a/test/core/extensions/harness/extension_harness_test.dart b/test/core/extensions/harness/extension_harness_test.dart index 781e240a..c8b5fd95 100644 --- a/test/core/extensions/harness/extension_harness_test.dart +++ b/test/core/extensions/harness/extension_harness_test.dart @@ -295,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')); From d3c270a32942b91136f3afc8a4643a2f989aa003 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:18:26 +0300 Subject: [PATCH 059/157] docs: reword --manifest-only --- docs/extension-testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/extension-testing.md b/docs/extension-testing.md index ed4d2e92..52ada48e 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -71,7 +71,7 @@ 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 theme-less pipeline step or a quick lint; not in 0.4.19 or earlier | +| `--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 | From e1031b47673cd174d6d8540f32c2366983901792 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:46:05 +0300 Subject: [PATCH 060/157] fix(settings): no wrong-state flashes, kept-alive panes, MCP section polish - Preferences: settings load in parallel; visited panes stay mounted with their own scroll position and are not rebuilt while hidden. - Data Grid: remove 'Confirm DML staging applications', which wrote the SQL destructive-confirmation setting and was read by nothing else. - About: the real app version instead of a hardcoded 0.4.17; the portable-mode future is created once. - MCP: draw nothing that depends on stored state until it loads; list MongoDB connections; product names in type badges; querya-mcp path on its own line; copy buttons and the access token in separate groups; recent calls as rows with a status icon, divider and time. - Bench: Preferences scenarios (open, each category, reopen, scroll, typing, toggles). --- benchmark/app_perf_bench.dart | 69 +++++ .../settings/preferences_about_section.dart | 33 +- lib/features/settings/preferences_dialog.dart | 179 ++++++----- .../settings/preferences_mcp_section.dart | 290 +++++++++++++----- .../preferences_master_detail_test.dart | 47 +++ .../preferences_mcp_section_test.dart | 74 ++++- 6 files changed, 523 insertions(+), 169 deletions(-) diff --git a/benchmark/app_perf_bench.dart b/benchmark/app_perf_bench.dart index 1dc5abab..840de99f 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; @@ -108,6 +109,12 @@ 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') @@ -268,6 +275,68 @@ Future _runAll() async { 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/lib/features/settings/preferences_about_section.dart b/lib/features/settings/preferences_about_section.dart index 75eb42d2..db425c59 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,13 +9,28 @@ 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); @@ -68,9 +84,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() @@ -91,7 +112,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(); diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 4ce70256..9f7c92d7 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -1,4 +1,4 @@ -import 'dart:async' show unawaited; +import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; @@ -50,6 +50,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 +100,26 @@ class PreferencesDialogContentState setState(() => _searchQuery = query.trim()); } + /// Reads every value in parallel: one round trip to the settings database + /// instead of nine in a row, so the panes show real values sooner. 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, channel, destructive) = await ( + s.getCheckForUpdatesOnStartup(), + s.getUpdateChannel(), + s.getConfirmDestructiveOperations(), + ).wait; + final (pg, my, sqlite) = await ( + s.getPostgresSqlStmtTimeoutSeconds(), + s.getMysqlSqlStmtTimeoutSeconds(), + s.getSqliteSqlStmtTimeoutSeconds(), + ).wait; + final (rows, hist, font) = await ( + s.getSqlResultMaxRows(), + s.getSqlHistoryMaxEntries(), + s.getSqlEditorFontSize(), + ).wait; if (!mounted) return; setState(() { _checkUpdatesOnStartup = startup; @@ -210,8 +227,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,6 +236,7 @@ class PreferencesDialogContentState @override material.Widget build(material.BuildContext context) { + _visited.add(_selectedCategory); final theme = Theme.of(context).colorScheme; final onPopover = theme.popoverForeground; @@ -347,11 +364,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 +378,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: 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,6 +437,27 @@ onPressed: () => material.Navigator.of(context).pop(), ); } + material.Widget _buildPaneHost(PreferencesCategory cat, ColorScheme 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, @@ -498,8 +535,8 @@ onPressed: () => material.Navigator.of(context).pop(), ); } - material.Widget _buildSelectedCategoryPane(ColorScheme theme) { - switch (_selectedCategory) { + material.Widget _buildPane(PreferencesCategory cat, ColorScheme theme) { + switch (cat) { case PreferencesCategory.general: return _buildGeneralSection(theme); case PreferencesCategory.appearance: @@ -529,16 +566,15 @@ 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( label: 'Release channel', @@ -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, @@ -668,10 +700,9 @@ onPressed: () => material.Navigator.of(context).pop(), 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_mcp_section.dart b/lib/features/settings/preferences_mcp_section.dart index 65ff81e6..71f557ff 100644 --- a/lib/features/settings/preferences_mcp_section.dart +++ b/lib/features/settings/preferences_mcp_section.dart @@ -39,14 +39,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 +81,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 +144,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, ); } @@ -161,28 +177,44 @@ class PreferencesMcpSectionState 'refused, and no host, user or password is ever sent to the model.', ), 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 +230,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 +249,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 +269,42 @@ 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( + fontFamily: 'monospace', + fontSize: 12, + color: wb.mutedForeground, + ), + ), + ), + const material.SizedBox(height: 10), material.Wrap( spacing: 8, runSpacing: 8, @@ -255,19 +312,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 +367,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 +384,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 +408,95 @@ 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( + fontFamily: 'monospace', + 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/test/features/settings/preferences_master_detail_test.dart b/test/features/settings/preferences_master_detail_test.dart index 9390d002..3d12141b 100644 --- a/test/features/settings/preferences_master_detail_test.dart +++ b/test/features/settings/preferences_master_detail_test.dart @@ -126,6 +126,53 @@ 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('search bar filters preferences and updates matches', (tester) async { await tester.pumpWidget(buildTestApp()); 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, + ); }); } From f3d0826a5dacac050d63907591addc9f47909535 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 00:03:46 +0300 Subject: [PATCH 061/157] fix(settings): MCP pane uses the editor's mono font stack; changelog The generic 'monospace' family costs a fontconfig lookup on first use (first MCP visit 10.4 ms build vs 4.3 on dev); the editor's QueryaTypography stack is already resolved when a workspace is open (4.2-5.7 ms). --- CHANGELOG.md | 5 +++++ lib/features/settings/preferences_mcp_section.dart | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 613268d6..475c676f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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), and settings load in one parallel read. +- **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. + ### Planned - Planned 0.5.0 — live Marketplace download and install diff --git a/lib/features/settings/preferences_mcp_section.dart b/lib/features/settings/preferences_mcp_section.dart index 71f557ff..31da9fb7 100644 --- a/lib/features/settings/preferences_mcp_section.dart +++ b/lib/features/settings/preferences_mcp_section.dart @@ -8,6 +8,7 @@ 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/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'; @@ -298,7 +299,10 @@ class PreferencesMcpSectionState extends material.State { maxLines: 1, overflow: material.TextOverflow.ellipsis, style: material.TextStyle( - fontFamily: 'monospace', + // 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, ), @@ -466,7 +470,11 @@ class _ActivityRow extends material.StatelessWidget { maxLines: 2, overflow: material.TextOverflow.ellipsis, style: material.TextStyle( - fontFamily: 'monospace', + // 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(), From 6427b15c66607e2c9fbcc7234308ce53f45c5e02 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 00:09:48 +0300 Subject: [PATCH 062/157] fix(settings): read preferences sequentially again (sqflite serializes them anyway) --- CHANGELOG.md | 2 +- lib/features/settings/preferences_dialog.dart | 28 ++++++++----------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 475c676f..158a9875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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), and settings load in one parallel read. +- **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. diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 9f7c92d7..71ac9cd4 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -100,26 +100,20 @@ class PreferencesDialogContentState setState(() => _searchQuery = query.trim()); } - /// Reads every value in parallel: one round trip to the settings database - /// instead of nine in a row, so the panes show real values sooner. + // 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, channel, destructive) = await ( - s.getCheckForUpdatesOnStartup(), - s.getUpdateChannel(), - s.getConfirmDestructiveOperations(), - ).wait; - final (pg, my, sqlite) = await ( - s.getPostgresSqlStmtTimeoutSeconds(), - s.getMysqlSqlStmtTimeoutSeconds(), - s.getSqliteSqlStmtTimeoutSeconds(), - ).wait; - final (rows, hist, font) = await ( - s.getSqlResultMaxRows(), - s.getSqlHistoryMaxEntries(), - s.getSqlEditorFontSize(), - ).wait; + 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; From b9827d6422f95f4f9366447dab83b234a1bda3e8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 05:25:06 +0300 Subject: [PATCH 063/157] fix(ui): use the editor's mono font stack instead of the generic 'monospace' family (#1633) PostgreSQL, Redis and MongoDB views, the DML and destructive-query dialogs, table staging, the recovery screen, the welcome tour and QueryaBadge(isMonospace) set fontFamily: 'monospace' alone. It resolves through fontconfig to a different face than the SQL editor's QueryaTypography stack and costs a lookup on first use. The result grid (cell renderer, cell editor, calc bar, groupings) is left as is; its font is #1553. --- CHANGELOG.md | 1 + .../mongodb/mongo_aggregation_workspace.dart | 7 ++++-- .../mongodb/mongo_document_tree_view.dart | 20 ++++++++++----- .../mongodb/mongo_documents_view.dart | 11 +++++--- lib/features/mongodb/mongo_indexes_view.dart | 5 +++- .../mongodb/mongo_query_workspace.dart | 8 ++++-- .../onboarding/welcome_tour_dialog.dart | 4 ++- .../postgresql/postgres_browser_views.dart | 25 ++++++++++++++----- .../postgresql/postgres_routine_view.dart | 5 +++- .../postgresql/postgres_sequence_view.dart | 12 ++++++--- .../postgres_table_privileges_dialog.dart | 8 ++++-- .../recovery/querya_startup_recovery_app.dart | 5 +++- .../redis/editors/redis_hash_editor.dart | 7 ++++-- .../redis/editors/redis_list_editor.dart | 7 ++++-- .../redis/editors/redis_set_editor.dart | 4 ++- .../redis/editors/redis_string_editor.dart | 10 +++++--- .../redis/editors/redis_zset_editor.dart | 4 ++- lib/features/redis/redis_cli_workspace.dart | 25 +++++++++++++------ lib/features/redis/redis_key_editor.dart | 4 ++- lib/features/redis/redis_keys_view.dart | 12 ++++++--- .../workspace/destructive_query_dialog.dart | 5 +++- .../workspace/dml_preview_dialog.dart | 5 +++- .../workspace/table_view_staging.dart | 5 +++- lib/shared/widgets/querya_badge.dart | 5 +++- 24 files changed, 152 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 158a9875..e8f44a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 diff --git a/lib/features/mongodb/mongo_aggregation_workspace.dart b/lib/features/mongodb/mongo_aggregation_workspace.dart index 09d55290..8de20940 100644 --- a/lib/features/mongodb/mongo_aggregation_workspace.dart +++ b/lib/features/mongodb/mongo_aggregation_workspace.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.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'; @@ -652,7 +653,8 @@ class _StageCardState extends material.State<_StageCard> { maxLines: null, minLines: 3, style: const material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12.5, height: 1.4, ), @@ -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_document_tree_view.dart b/lib/features/mongodb/mongo_document_tree_view.dart index 5f0f068b..a8523626 100644 --- a/lib/features/mongodb/mongo_document_tree_view.dart +++ b/lib/features/mongodb/mongo_document_tree_view.dart @@ -3,6 +3,7 @@ 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/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'; @@ -318,7 +319,9 @@ class _MongoDocumentTreeViewState extends State { '_id: $idString', style: material.TextStyle( fontSize: 11, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, color: cs.mutedForeground, ), ), @@ -540,7 +543,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 @@ -552,7 +556,8 @@ class _MongoDocumentTreeViewState extends State { Text( badgeLabel, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 11, color: cs.mutedForeground, ), @@ -615,7 +620,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, @@ -624,7 +630,8 @@ class _MongoDocumentTreeViewState extends State { Text( ': ', style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12, color: cs.mutedForeground, ), @@ -634,7 +641,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, diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index 6dfd8ac7..190c1143 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -5,6 +5,7 @@ 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'; @@ -797,7 +798,9 @@ class _DocumentCardState extends State<_DocumentCard> { maxLines: 1, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, color: scs.mutedForeground, ), ), @@ -850,7 +853,8 @@ 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, ), @@ -864,7 +868,8 @@ class MongoFieldList extends StatelessWidget { maxLines: 1, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, color: shadcnCs.mutedForeground, ), ), diff --git a/lib/features/mongodb/mongo_indexes_view.dart b/lib/features/mongodb/mongo_indexes_view.dart index 1c709b38..396ed3fa 100644 --- a/lib/features/mongodb/mongo_indexes_view.dart +++ b/lib/features/mongodb/mongo_indexes_view.dart @@ -3,6 +3,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; 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'; @@ -406,7 +407,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_workspace.dart b/lib/features/mongodb/mongo_query_workspace.dart index fc61c8b5..5fbeaf43 100644 --- a/lib/features/mongodb/mongo_query_workspace.dart +++ b/lib/features/mongodb/mongo_query_workspace.dart @@ -6,6 +6,7 @@ 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'; @@ -242,7 +243,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, ), ), @@ -500,7 +503,8 @@ class _MongoQueryWorkspaceState extends material.State { tab.error!, style: material.TextStyle( color: cs.destructive, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12.5, ), ), diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index d425f5d2..46747bea 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -5,6 +5,7 @@ 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_workbench_theme.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -79,7 +80,8 @@ class KbdBadge extends StatelessWidget { style: TextStyle( fontSize: fontSize, fontWeight: material.FontWeight.w600, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, color: Theme.of(context).colorScheme.foreground, ), ), diff --git a/lib/features/postgresql/postgres_browser_views.dart b/lib/features/postgresql/postgres_browser_views.dart index 3572faf9..45a0b238 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'; @@ -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, @@ -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, @@ -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, ), @@ -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, @@ -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, ), diff --git a/lib/features/postgresql/postgres_routine_view.dart b/lib/features/postgresql/postgres_routine_view.dart index fee87286..334fffa7 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'; @@ -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..5adfdef2 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'; @@ -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_table_privileges_dialog.dart b/lib/features/postgresql/postgres_table_privileges_dialog.dart index 253d8210..10ddd3ce 100644 --- a/lib/features/postgresql/postgres_table_privileges_dialog.dart +++ b/lib/features/postgresql/postgres_table_privileges_dialog.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/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/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -119,7 +120,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, ), @@ -214,7 +217,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/recovery/querya_startup_recovery_app.dart b/lib/features/recovery/querya_startup_recovery_app.dart index 5b116102..63e5561d 100644 --- a/lib/features/recovery/querya_startup_recovery_app.dart +++ b/lib/features/recovery/querya_startup_recovery_app.dart @@ -4,6 +4,7 @@ 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_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'; @@ -182,7 +183,9 @@ class _QueryaStartupRecoveryAppState child: material.SelectableText( widget.error.toString(), style: const material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 12, ), ), diff --git a/lib/features/redis/editors/redis_hash_editor.dart b/lib/features/redis/editors/redis_hash_editor.dart index 4d144f17..098914f0 100644 --- a/lib/features/redis/editors/redis_hash_editor.dart +++ b/lib/features/redis/editors/redis_hash_editor.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; 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; @@ -145,7 +146,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,7 +159,8 @@ class RedisFieldRow extends StatelessWidget { value, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, color: colorScheme.foreground, ), ), diff --git a/lib/features/redis/editors/redis_list_editor.dart b/lib/features/redis/editors/redis_list_editor.dart index e491fae7..6cb0b9c4 100644 --- a/lib/features/redis/editors/redis_list_editor.dart +++ b/lib/features/redis/editors/redis_list_editor.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; 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; @@ -236,7 +237,8 @@ class RedisIndexedValueRow extends StatelessWidget { value, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, color: colorScheme.foreground, ), ), @@ -254,7 +256,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..76c57e06 100644 --- a/lib/features/redis/editors/redis_set_editor.dart +++ b/lib/features/redis/editors/redis_set_editor.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; 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; @@ -126,7 +127,8 @@ class RedisMemberRow extends StatelessWidget { member, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, color: colorScheme.foreground, ), ), diff --git a/lib/features/redis/editors/redis_string_editor.dart b/lib/features/redis/editors/redis_string_editor.dart index 6c4d7d09..7972c1ea 100644 --- a/lib/features/redis/editors/redis_string_editor.dart +++ b/lib/features/redis/editors/redis_string_editor.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; 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'; @@ -41,7 +42,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 +53,8 @@ class RedisStringEditor extends material.StatelessWidget { bulk.toBase64(), style: const material.TextStyle( fontSize: 13, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, ), ), ], @@ -88,7 +91,8 @@ class RedisStringEditor extends material.StatelessWidget { maxLines: null, style: const material.TextStyle( fontSize: 13, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, ), decoration: const material.InputDecoration( border: material.InputBorder.none, diff --git a/lib/features/redis/editors/redis_zset_editor.dart b/lib/features/redis/editors/redis_zset_editor.dart index f8a8e575..6410ead7 100644 --- a/lib/features/redis/editors/redis_zset_editor.dart +++ b/lib/features/redis/editors/redis_zset_editor.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; 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; @@ -161,7 +162,8 @@ class RedisScoredMemberRow extends StatelessWidget { member, style: material.TextStyle( fontSize: 12, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, color: colorScheme.foreground, ), ), diff --git a/lib/features/redis/redis_cli_workspace.dart b/lib/features/redis/redis_cli_workspace.dart index 8109de68..f3f1fdd9 100644 --- a/lib/features/redis/redis_cli_workspace.dart +++ b/lib/features/redis/redis_cli_workspace.dart @@ -7,6 +7,7 @@ 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'; @@ -621,7 +622,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 +702,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 +735,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, @@ -801,7 +807,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 +818,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 +839,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 +868,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_key_editor.dart b/lib/features/redis/redis_key_editor.dart index 3725bfce..e5515793 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' as material; 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'; @@ -755,7 +756,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, ), diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 640472fa..d977545c 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -3,6 +3,7 @@ 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 @@ -541,7 +542,9 @@ class _RedisKeysViewState extends material.State { textAlign: material.TextAlign.center, style: const material.TextStyle( fontSize: 13, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, ), decoration: material.InputDecoration( contentPadding: material.EdgeInsets.zero, @@ -750,7 +753,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, ), ), @@ -908,7 +912,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/workspace/destructive_query_dialog.dart b/lib/features/workspace/destructive_query_dialog.dart index 24d7471d..ac00096b 100644 --- a/lib/features/workspace/destructive_query_dialog.dart +++ b/lib/features/workspace/destructive_query_dialog.dart @@ -1,6 +1,7 @@ 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/theme/querya_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -273,7 +274,9 @@ class _DestructiveQueryDialogState child: material.SelectableText( widget.sql, style: material.TextStyle( - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, fontSize: 12.5, height: 1.45, color: isDark diff --git a/lib/features/workspace/dml_preview_dialog.dart b/lib/features/workspace/dml_preview_dialog.dart index d99fc430..620527c0 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'; @@ -270,7 +271,9 @@ 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 diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart index 1b7abf1e..37acfea7 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'; @@ -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/shared/widgets/querya_badge.dart b/lib/shared/widgets/querya_badge.dart index 1d06c163..554efe3c 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 { @@ -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, ), From 255bdb486ad358263e8eed9420331d699d634507 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 05:38:22 +0300 Subject: [PATCH 064/157] fix(extensions): validate the manifest id before using it as a folder (#1560) ExtensionManifest.fromJson read the id unchecked, and the installers and uninstall joined it onto the extensions directory and deleted the result recursively, so "id": "../x" reached outside it. - fromJson rejects an id that is not letters, digits, '.', '_' or '-' starting with a letter or digit, contains '..', is a Windows device name or is longer than 128 characters. - ExtensionPaths.extensionDirectory(root, id) is the one way to get an extension's folder and refuses anything not strictly inside root; the local installer, the HTTP and mock marketplaces and both uninstalls use it. --- CHANGELOG.md | 1 + lib/core/extensions/extension_paths.dart | 12 ++++ .../extensions/local_extension_installer.dart | 2 +- .../extensions/models/extension_manifest.dart | 20 +++++- .../market/http_marketplace_repository.dart | 4 +- lib/core/market/marketplace_repository.dart | 4 +- .../local_extension_installer_test.dart | 30 +++++++++ test/core/market/extension_manifest_test.dart | 64 +++++++++++++++++++ 8 files changed, 131 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f44a50..673c8454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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). - **`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. 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/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index 1afaafa6..d1642333 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -124,7 +124,7 @@ class LocalExtensionInstaller { await _stopSessionsForExtension(manifest.id); final root = await _extensionsDirectory(); - final extDir = Directory(p.join(root.path, manifest.id)); + final extDir = ExtensionPaths.extensionDirectory(root, manifest.id); if (await extDir.exists()) { await extDir.delete(recursive: true); } diff --git a/lib/core/extensions/models/extension_manifest.dart b/lib/core/extensions/models/extension_manifest.dart index 5d2f0356..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'; @@ -16,6 +17,16 @@ class ExtensionManifest { /// 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; @@ -122,10 +133,17 @@ 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( // Not a whole number >= 1: the validator reports it; here it reads as 1. manifestVersion: rawFormat is int && rawFormat >= 1 ? rawFormat : 1, - id: json['id'] as String, + id: id, name: json['name'] as String, version: json['version'] as String? ?? '0.0.0', publisher: json['publisher'] as String? ?? diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 164674ea..17dba8aa 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -203,7 +203,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository { } 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); } @@ -277,7 +277,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 ab49033c..41b9287d 100644 --- a/lib/core/market/marketplace_repository.dart +++ b/lib/core/market/marketplace_repository.dart @@ -209,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); } @@ -326,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/test/core/extensions/local_extension_installer_test.dart b/test/core/extensions/local_extension_installer_test.dart index 81bc6247..0e27688a 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', { 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', + ); + } + }); + }); }); } From 40d1c416984b5835d60752c35e9556c740e6089c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 05:40:32 +0300 Subject: [PATCH 065/157] fix(security): zip limits cannot be bypassed by a header that understates sizes (#1561) SafeZipExtractor checks its limits against the uncompressed sizes the zip headers declare. Before archive 4.5.0 that size was only a buffer hint and an entry decoded to whatever its data held, so a crafted header bypassed every limit. Since 4.5.0 an entry is not decoded past its declared size. - Require archive ^4.5.2 (pubspec.lock is not committed, so the constraint is what CI and release builds resolve against). - SafeZipExtractor.readEntry reads an entry's bytes and reports the refusal as a SafeZipException; the local installer, the HTTP marketplace and the updater extract through it. - Tests: a zip whose headers claim 100 bytes for 64 KiB passes the header checks and is refused when read, from bytes and from a file. --- CHANGELOG.md | 1 + .../extensions/local_extension_installer.dart | 9 ++- .../market/http_marketplace_repository.dart | 7 +- lib/core/security/safe_zip_extractor.dart | 19 +++++ .../installers/update_install_utils.dart | 9 ++- pubspec.yaml | 4 +- .../security/safe_zip_extractor_test.dart | 69 +++++++++++++++++++ 7 files changed, 114 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f44a50..1dbca856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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. diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index 1afaafa6..eee54d8d 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; @@ -260,7 +261,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/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 164674ea..bdc8d317 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -225,7 +225,12 @@ class HttpMarketplaceRepository implements MarketplaceRepository { if (file.isFile) { final outFile = File(targetPath); await outFile.create(recursive: true); - final bytes = 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 { 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/updater/installers/update_install_utils.dart b/lib/core/updater/installers/update_install_utils.dart index ff0489c1..a536ff74 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); } diff --git a/pubspec.yaml b/pubspec.yaml index 1a80a8f5..ee61ffc8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,7 +30,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 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)); From 95ababdffa25693ca337deeb7c94982cd8207e03 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 05:42:32 +0300 Subject: [PATCH 066/157] fix(editor): Format SQL leaves literals, quoted identifiers and comments alone (#1566) formatSqlScript ran one keyword regex over the whole text, so it uppercased words inside 'string literals', "quoted identifiers", `backticks`, comments and $$ bodies, changing what the query does. - SqlLexicalScanner splits SQL into code, string, quoted identifier, comment and dollar-quoted segments, with SqlStatementSplitter's rules. - formatSqlScript uppercases keywords in code segments only. - SqlQueryTabSession.formatSql keeps the selection: keywords keep their length, so offsets do not move. It used to jump to the end. --- CHANGELOG.md | 1 + lib/core/actions/sql_script_format.dart | 25 ++- lib/core/database/sql_lexical_scanner.dart | 163 ++++++++++++++++++ .../workspace/sql_query_tab_session.dart | 10 +- test/core/actions/sql_script_format_test.dart | 58 +++++++ .../database/sql_lexical_scanner_test.dart | 79 +++++++++ .../sql_query_tab_session_format_test.dart | 29 ++++ 7 files changed, 356 insertions(+), 9 deletions(-) create mode 100644 lib/core/database/sql_lexical_scanner.dart create mode 100644 test/core/database/sql_lexical_scanner_test.dart create mode 100644 test/features/workspace/sql_query_tab_session_format_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f44a50..502ef3c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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). - **`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. diff --git a/lib/core/actions/sql_script_format.dart b/lib/core/actions/sql_script_format.dart index 2827cd14..0f9f3f6f 100644 --- a/lib/core/actions/sql_script_format.dart +++ b/lib/core/actions/sql_script_format.dart @@ -1,3 +1,5 @@ +import 'package:querya_desktop/core/database/sql_lexical_scanner.dart'; + const _kSqlKeywords = { 'ADD', 'ALL', @@ -61,10 +63,23 @@ const _kSqlKeywords = { final _kSqlWord = RegExp(r"[A-Za-z_][A-Za-z0-9_]*"); /// Uppercases SQL keywords. Enough for palette "Format SQL" without a parser. +/// +/// 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. String formatSqlScript(String sql) { - return sql.replaceAllMapped(_kSqlWord, (match) { - final word = match[0]!; - final upper = word.toUpperCase(); - return _kSqlKeywords.contains(upper) ? upper : word; - }); + final out = StringBuffer(); + for (final segment in SqlLexicalScanner.segments(sql)) { + 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/database/sql_lexical_scanner.dart b/lib/core/database/sql_lexical_scanner.dart new file mode 100644 index 00000000..96e25774 --- /dev/null +++ b/lib/core/database/sql_lexical_scanner.dart @@ -0,0 +1,163 @@ +/// What a stretch of SQL text is, lexically. +enum SqlSegmentKind { + /// Keywords, identifiers, operators, numbers and blanks. + code, + + /// `'...'`, with `''` and backslash escapes. + 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), with the same rules as +/// `SqlStatementSplitter`. 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. + static List segments(String sql) { + 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) { + 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 (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; + } +} diff --git a/lib/features/workspace/sql_query_tab_session.dart b/lib/features/workspace/sql_query_tab_session.dart index 62f94278..250b9dc8 100644 --- a/lib/features/workspace/sql_query_tab_session.dart +++ b/lib/features/workspace/sql_query_tab_session.dart @@ -99,11 +99,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), + composing: material.TextRange.empty, ); } diff --git a/test/core/actions/sql_script_format_test.dart b/test/core/actions/sql_script_format_test.dart index 7c74ad40..058d97e3 100644 --- a/test/core/actions/sql_script_format_test.dart +++ b/test/core/actions/sql_script_format_test.dart @@ -8,4 +8,62 @@ void main() { "SELECT id FROM users WHERE name = 'Ada'", ); }); + + group('leaves what is not code as written (#1566)', () { + test('string literals', () { + expect( + formatSqlScript("select * from t where status = 'not null'"), + "SELECT * FROM t WHERE status = 'not null'", + ); + }); + + test("doubled quotes and backslash escapes inside a literal", () { + expect( + formatSqlScript(r"select 'it''s in order', 'a\' or b' from t"), + r"SELECT 'it''s in order', 'a\' or b' FROM t", + ); + }); + + test('quoted identifiers, PostgreSQL and MySQL', () { + expect( + formatSqlScript('select "order", "desc" from t'), + 'SELECT "order", "desc" FROM t', + ); + expect( + formatSqlScript('select `limit` from t'), + 'SELECT `limit` FROM t', + ); + }); + + test('dollar-quoted function bodies', () { + const body = r'$$ begin update t set x = 1; end $$'; + expect( + formatSqlScript('create function f() returns void as $body language sql'), + 'CREATE function f() returns void AS $body language sql', + ); + expect( + formatSqlScript(r'do $fn$ begin null; end $fn$'), + r'do $fn$ begin null; end $fn$', + ); + }); + + test('line and block comments', () { + expect( + formatSqlScript('-- set in prod\nselect 1 /* and not here */ from t'), + '-- set in prod\nSELECT 1 /* and not here */ FROM t', + ); + }); + + test('an unterminated literal runs to the end', () { + expect( + formatSqlScript("select 'from where"), + "SELECT 'from where", + ); + }); + }); + + test('keeps the length of the script', () { + const sql = "select a, \"b\" from t -- x\nwhere c = 'and' and d is null"; + expect(formatSqlScript(sql).length, sql.length); + }); } 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..d8ff8b59 --- /dev/null +++ b/test/core/database/sql_lexical_scanner_test.dart @@ -0,0 +1,79 @@ +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); + }); + }); +} 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), + ); + }); +} From 79fa96bf44dfbc25d74a801a787e105351e21cab Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 05:46:27 +0300 Subject: [PATCH 067/157] fix(redis): one socket per database, the CLI on its own; UNLINK in delMany (#1569) All explorers of a connection shared one socket and sent SELECT before each command. The pair is not atomic: a folder delete in db0 kept sending DEL chunks after the user opened db3 on the same socket, deleting there, and a SELECT typed in the CLI was undone by the next key editor action. - RedisService keys sockets by connection, role and database. RedisConnection.database is SELECTed on every (re)connect and moved only by selectDatabase, so views no longer SELECT before commands. - New RedisSessionRole.cli: the explorer gives the CLI its own socket. - delMany: UNLINK, DEL on a server without it (remembered), later chunks pipelined, a failed chunk rethrown after the rest have answered; the keys view reloads after a failed folder delete. - RedisConnectionTestFake answers UNLINK. --- CHANGELOG.md | 1 + lib/core/database/redis_connection.dart | 84 +++++- lib/core/database/redis_service.dart | 37 ++- lib/features/redis/redis_explorer_view.dart | 30 +- lib/features/redis/redis_key_editor.dart | 15 - lib/features/redis/redis_keys_view.dart | 7 +- .../database/redis_database_socket_test.dart | 273 ++++++++++++++++++ 7 files changed, 408 insertions(+), 39 deletions(-) create mode 100644 test/core/database/redis_database_socket_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f44a50..f417ace0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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). - **`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. diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index e199041a..120483e6 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' show min; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -125,6 +126,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, @@ -267,6 +274,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 { @@ -406,9 +416,10 @@ class RedisConnection { } } - /// SELECT database index. + /// SELECT database index; later reconnects come back to it too. Future selectDatabase(int db) async { await sendCommand(['SELECT', db]); + database = db; } /// DBSIZE — number of keys in the currently selected database. @@ -745,21 +756,72 @@ class RedisConnection { return redisReplyInt(result); } - /// 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))) + redisCommandArg(key), + ], + ]; + + // The first chunk on its own tells whether the server knows UNLINK. + var total = 0; + var verb = _unlinkSupported == false ? 'DEL' : 'UNLINK'; + try { + total += redisReplyInt(await sendCommand([verb, ...chunks.first])); + } catch (e) { + if (verb != 'UNLINK' || !_isUnknownCommand(e)) rethrow; + verb = 'DEL'; + total += redisReplyInt(await sendCommand([verb, ...chunks.first])); + } + _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)) + sendCommand([verb, ...chunk]).then( + (reply) => redisReplyInt(reply), + 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(); @@ -834,6 +896,7 @@ class RedisConnectionTestFake extends RedisConnection { this.failType = false, this.getBytesResult, this.binaryScanKeys = const >[], + this.unlinkUnsupported = false, }) : listItems = listItems != null ? List.from(listItems) : [], super( @@ -861,6 +924,9 @@ class RedisConnectionTestFake extends RedisConnection { final bool failType; final List? getBytesResult; final List> binaryScanKeys; + + /// Answers `UNLINK` like a server before Redis 4.0. + final bool unlinkUnsupported; final List sentCommands = []; bool _firstScanDone = false; @@ -1035,7 +1101,9 @@ class RedisConnectionTestFake extends RedisConnection { } } return removed; - case 'DEL': + 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++) { 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/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index 12752acf..131a63d4 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -51,6 +51,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 +96,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 +118,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 +131,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 +150,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) { @@ -277,7 +302,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 +376,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), ), diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index e5515793..ddd9e340 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -183,7 +183,6 @@ class _RedisKeyEditorState extends material.State { _success = null; }); try { - await widget.connection.selectDatabase(widget.database); _ttl = await widget.connection.ttl(_cmdKey); _effectiveType = await _resolveType(); @@ -257,7 +256,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) { @@ -330,7 +328,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, @@ -358,7 +355,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( @@ -399,7 +395,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) { @@ -411,7 +406,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 { @@ -433,7 +427,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) { @@ -453,7 +446,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) { @@ -466,7 +458,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) { @@ -482,7 +473,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) { @@ -512,7 +502,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(); @@ -550,7 +539,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) { @@ -570,7 +558,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) { @@ -583,7 +570,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) { @@ -603,7 +589,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) { diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index d977545c..66275540 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -77,7 +77,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; @@ -189,7 +188,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); @@ -217,14 +215,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'); } } 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..aedb4884 --- /dev/null +++ b/test/core/database/redis_database_socket_test.dart @@ -0,0 +1,273 @@ +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'; + +/// 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(['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(['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); + } +} From 8b37b4ff06f87affeae9708c98b2340ad75f9961 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 05:49:34 +0300 Subject: [PATCH 068/157] fix(updater): macOS replace never leaves a broken or missing app (#1587) The replace script swallowed a failed mv (|| true), so cp -R copied the new bundle into the old one, and with set -e a failed cp after the mv left no app; the installer quit without checking anything first. - UpdateInstallContext.macAppBundleReplaceProblem: a translocated copy or a folder this user cannot write (a disk image, /Applications for a standard user). It counts as a managed install, so the update dialog explains it and offers the release page before downloading, and the installer checks it again before quitting. - The script moves the old bundle aside, copies with ditto (cp -R off macOS), restores .old when the copy fails, logs failures to ~/Library/Logs/Querya/querya-update.log and opens whatever app is in place. .old, the extracted update and the script are removed. - The extracted update is removed when verification fails. - Tests run the script under /bin/sh: success, stale .old, failed copy, unwritable folder, quoting; and the preflight cases. --- CHANGELOG.md | 1 + .../installers/macos_sparkle_installer.dart | 36 +++- .../installers/update_install_context.dart | 32 +++- .../installers/update_install_utils.dart | 45 ++++- .../macos_app_replace_script_test.dart | 173 ++++++++++++++++++ 5 files changed, 272 insertions(+), 15 deletions(-) create mode 100644 test/core/updater/macos_app_replace_script_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f44a50..c387d695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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). - **`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. diff --git a/lib/core/updater/installers/macos_sparkle_installer.dart b/lib/core/updater/installers/macos_sparkle_installer.dart index 8028eb8b..f1be5f34 100644 --- a/lib/core/updater/installers/macos_sparkle_installer.dart +++ b/lib/core/updater/installers/macos_sparkle_installer.dart @@ -31,21 +31,38 @@ 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); + newApp = found; + } catch (_) { + // The app keeps running: do not leave the extracted update behind. + try { + await extractDir.delete(recursive: true); + } catch (_) {} + rethrow; } - await _verifyCodesign(newApp); - final scriptFile = File(p.join(tempRoot.path, 'querya-macos-update-$pid.sh')); await scriptFile.writeAsString( buildMacAppReplaceScript( @@ -53,6 +70,7 @@ class MacosSparkleInstaller { newAppBundle: newApp.path, targetAppBundle: targetApp, executable: context.resolvedExecutable, + workDir: extractDir.path, ), ); await launchDetachedScript(scriptFile.path, const []); diff --git a/lib/core/updater/installers/update_install_context.dart b/lib/core/updater/installers/update_install_context.dart index dd29552f..0183cbe7 100644 --- a/lib/core/updater/installers/update_install_context.dart +++ b/lib/core/updater/installers/update_install_context.dart @@ -80,11 +80,39 @@ class UpdateInstallContext { 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) { + 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; + } + bool get isManagedPackage => isSnap || isFlatpak || linuxSystemPackageManager != null || - isLinuxBundleReadOnly; + 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). @@ -117,6 +145,8 @@ class UpdateInstallContext { 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.'; diff --git a/lib/core/updater/installers/update_install_utils.dart b/lib/core/updater/installers/update_install_utils.dart index ff0489c1..f0a16582 100644 --- a/lib/core/updater/installers/update_install_utils.dart +++ b/lib/core/updater/installers/update_install_utils.dart @@ -235,24 +235,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/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..9948650c --- /dev/null +++ b/test/core/updater/macos_app_replace_script_test.dart @@ -0,0 +1,173 @@ +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 folder this user cannot write, such as a disk image', () { + expect( + context(writable: false) + .macAppBundleReplaceProblemFor('/Volumes/Querya/Querya.app'), + contains('/Volumes/Querya'), + ); + }); + }); +} From 5e2916aa6cdd1b7bc466ba11a2fb1779d6a80f9d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 05:50:26 +0300 Subject: [PATCH 069/157] fix(macos): store secrets in the login keychain (#1586) flutter_secure_storage defaults to the data protection keychain on macOS (usesDataProtectionKeychain: true), which needs keychain-access-groups. No entitlements file declares it, and it cannot simply be added: it is restricted, and a Developer ID build carrying it without a provisioning profile does not launch. Reads and writes failed with errSecMissingEntitlement, shown as SecretsStoreUnavailableException. Pass MacOsOptions(usesDataProtectionKeychain: false) so the file-based login keychain is used. Nothing could have been stored in the data protection keychain without the entitlement, so there is nothing to migrate. --- CHANGELOG.md | 1 + lib/core/storage/connection_secrets_store.dart | 15 +++++++++++++-- .../connection_secrets_store_macos_test.dart | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 test/core/storage/connection_secrets_store_macos_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f44a50..15425cd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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). - **`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. diff --git a/lib/core/storage/connection_secrets_store.dart b/lib/core/storage/connection_secrets_store.dart index 363eb254..7fa31211 100644 --- a/lib/core/storage/connection_secrets_store.dart +++ b/lib/core/storage/connection_secrets_store.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; /// Backend for reading/writing per-connection secrets (password, connection string). @@ -9,8 +10,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 +62,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 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', + ); + }); +} From 49fd6139786116809c8ab63b4b7e4e94cae8e2bf Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 05:53:10 +0300 Subject: [PATCH 070/157] build(macos): ship the unsigned build without the App Sandbox (#1592) Without the MACOS_* secrets the release ships the app as Xcode built it: ad-hoc signed with Release.entitlements, App Sandbox on. That is a different program from the signed build (ReleaseSigned, no sandbox): HOME is the container, so ~/.querya, ~/.ssh, saved SQLite paths, sandbox-exec for extensions, the MCP endpoint file and the updater all break. - Release.entitlements becomes ReleaseUnsigned.entitlements with no entitlements at all; the Xcode Release configuration uses it, so local release builds match too. - release.yml fails the macOS job if the built app has the App Sandbox. - docs/macos-signing.md describes both files. - A packaging test checks the Release configuration and that neither shipped entitlements file has the sandbox. --- .github/workflows/release.yml | 13 ++++++++++ CHANGELOG.md | 1 + docs/macos-signing.md | 14 ++++++----- macos/Runner.xcodeproj/project.pbxproj | 6 ++--- macos/Runner/Release.entitlements | 14 ----------- macos/Runner/ReleaseUnsigned.entitlements | 18 +++++++++++++ test/packaging/macos_entitlements_test.dart | 28 +++++++++++++++++++++ 7 files changed, 71 insertions(+), 23 deletions(-) delete mode 100644 macos/Runner/Release.entitlements create mode 100644 macos/Runner/ReleaseUnsigned.entitlements create mode 100644 test/packaging/macos_entitlements_test.dart diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3270f74..68272670 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -251,6 +251,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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 673c8454..c7cf6987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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). - **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). - **`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`). diff --git a/docs/macos-signing.md b/docs/macos-signing.md index 32b713c5..15f1c827 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,10 +130,12 @@ 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. 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/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/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))); + }); + } +} From 1aa8778acc288f87f413e974585ff394f31eb0c9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 05:57:37 +0300 Subject: [PATCH 071/157] test(redis): compare recorded commands by value (#1569) --- test/core/database/redis_database_socket_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/core/database/redis_database_socket_test.dart b/test/core/database/redis_database_socket_test.dart index aedb4884..f43540de 100644 --- a/test/core/database/redis_database_socket_test.dart +++ b/test/core/database/redis_database_socket_test.dart @@ -197,7 +197,7 @@ void main() { final conn = connection(database: 3); await conn.connect(); await conn.disconnect(); - expect(server.commands, contains(['SELECT', '3'])); + expect(server.commands, contains(equals(['SELECT', '3']))); }); test('a reconnect comes back to the database, not db 0', () async { @@ -211,7 +211,7 @@ void main() { await conn.connect(); await conn.disconnect(); - expect(server.commands, contains(['SELECT', '4'])); + expect(server.commands, contains(equals(['SELECT', '4']))); }); }); From 685f49ed8209f671f35ff96b151d50de428091f3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 06:00:25 +0300 Subject: [PATCH 072/157] chore: leave CHANGELOG.md to dev --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eebba672..995e0edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **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). - **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) From f12ed5bb6d819ec5a518078fa5eab0b06afe6de8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 06:00:38 +0300 Subject: [PATCH 073/157] chore: leave CHANGELOG.md to dev --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79732218..995e0edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,10 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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). - -- **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). - - - **`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. From a760edb8add7ffaaf0b507799477daa588697d44 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 06:08:02 +0300 Subject: [PATCH 074/157] fix(theme): use theme border token for preview swatches and add mono fallback (#1654) --- .../settings/theme_editor_dialog.dart | 1 + lib/features/settings/theme_preview_card.dart | 2 +- .../settings/theme_editor_dialog_test.dart | 8 ++++++++ .../settings/theme_preview_card_test.dart | 19 +++++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/features/settings/theme_editor_dialog.dart b/lib/features/settings/theme_editor_dialog.dart index 7dbee598..b7ba96af 100644 --- a/lib/features/settings/theme_editor_dialog.dart +++ b/lib/features/settings/theme_editor_dialog.dart @@ -344,6 +344,7 @@ class _ThemeEditorColorRow extends material.StatelessWidget { fontSize: 12, color: cs.mutedForeground, fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, ), ), ), diff --git a/lib/features/settings/theme_preview_card.dart b/lib/features/settings/theme_preview_card.dart index b61d0136..2fd0ff8c 100644 --- a/lib/features/settings/theme_preview_card.dart +++ b/lib/features/settings/theme_preview_card.dart @@ -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: Theme.of(context).colorScheme.border.withValues(alpha: 0.6), ), ), ), diff --git a/test/features/settings/theme_editor_dialog_test.dart b/test/features/settings/theme_editor_dialog_test.dart index 28688753..b23a9b61 100644 --- a/test/features/settings/theme_editor_dialog_test.dart +++ b/test/features/settings/theme_editor_dialog_test.dart @@ -102,6 +102,14 @@ 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(); diff --git a/test/features/settings/theme_preview_card_test.dart b/test/features/settings/theme_preview_card_test.dart index 9cf26532..34cf73c3 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.colorScheme.border.withValues(alpha: 0.6), + ); }); testWidgets('shows loading state', (tester) async { From 17b25d60539689f2a51e3155e2a80f19d1e1545b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 06:21:03 +0300 Subject: [PATCH 075/157] fix: follow-ups to #1500, #1515 and #1566 README (#1500): the app installed from the DMG is "Querya Desktop.app", so the xattr command pointed at a path that does not exist; rpm -i does not install dependencies (libsecret), dnf install ./file.rpm does. sql_limit (#1515): injectSqlLimit upper-cased the whole comment-free script to read its first keyword, one more full copy of a large script. The keyword is now compared in place at the offset after the comments. Format SQL (#1566): the lexical scanner treated a backslash as a quote escape everywhere. In PostgreSQL and SQLite it is an ordinary character, so 'C:\', 'not null' was read as one literal followed by code and the formatter upper-cased NOT NULL inside the second literal. The scanner takes backslashEscapes (PostgreSQL E'...' strings still escape), and the formatter gets it from the tab's dialect; unknown dialects keep the MySQL rule. --- README.md | 8 ++-- lib/core/actions/sql_script_format.dart | 16 ++++++- lib/core/database/sql_lexical_scanner.dart | 32 +++++++++++--- lib/core/database/sql_limit.dart | 24 ++++++++--- .../workspace/generic_sql_workspace.dart | 2 + .../workspace/sql_query_tab_session.dart | 8 +++- test/core/actions/sql_script_format_test.dart | 28 +++++++++++++ .../database/sql_lexical_scanner_test.dart | 42 +++++++++++++++++++ test/core/database/sql_limit_test.dart | 19 +++++++++ 9 files changed, 161 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d720112d..f3f7fe29 100644 --- a/README.md +++ b/README.md @@ -128,8 +128,8 @@ Download the latest `.deb`, `.AppImage`, `.rpm`, or `.flatpak` from [Releases](h # Debian / Ubuntu (.deb) sudo apt install ./Querya-Desktop-*-linux.deb -# Fedora / RHEL (.rpm) -sudo rpm -i Querya-Desktop-*-linux.rpm +# Fedora / RHEL (.rpm; dnf also installs its dependencies, such as libsecret) +sudo dnf install ./Querya-Desktop-*-linux.rpm # AppImage (standalone executable; Ubuntu 22.04+ requires libfuse2) # Ubuntu 22.04+: sudo apt install libfuse2 # or libfuse2t64 @@ -151,11 +151,11 @@ flatpak install --user ./Querya-Desktop-*-linux.flatpak 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. +Download the universal `.dmg` from [Releases](https://github.com/QueryaHub/Querya-Desktop/releases), mount the disk image, and drag **Querya Desktop** into your `Applications` folder. > **Unsigned builds:** If Gatekeeper blocks the app after installation, run: > ```bash -> xattr -cr /Applications/Querya.app +> xattr -cr "/Applications/Querya Desktop.app" > ``` --- diff --git a/lib/core/actions/sql_script_format.dart b/lib/core/actions/sql_script_format.dart index 0f9f3f6f..ae21c5d4 100644 --- a/lib/core/actions/sql_script_format.dart +++ b/lib/core/actions/sql_script_format.dart @@ -1,4 +1,6 @@ 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', @@ -67,9 +69,19 @@ final _kSqlWord = RegExp(r"[A-Za-z_][A-Za-z0-9_]*"); /// 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. -String formatSqlScript(String sql) { +/// +/// [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)) { + 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); diff --git a/lib/core/database/sql_lexical_scanner.dart b/lib/core/database/sql_lexical_scanner.dart index 96e25774..f36296da 100644 --- a/lib/core/database/sql_lexical_scanner.dart +++ b/lib/core/database/sql_lexical_scanner.dart @@ -3,7 +3,7 @@ enum SqlSegmentKind { /// Keywords, identifiers, operators, numbers and blanks. code, - /// `'...'`, with `''` and backslash escapes. + /// `'...'`, with `''` (and backslash escapes where the dialect has them). string, /// `"name"` (PostgreSQL, standard SQL) or `` `name` `` (MySQL). @@ -43,8 +43,8 @@ class SqlSegment { } /// Splits SQL into code and the parts that are not code (literals, quoted -/// identifiers, comments, dollar quotes), with the same rules as -/// `SqlStatementSplitter`. An unterminated literal or comment runs to the end. +/// 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_]*)\$'); @@ -61,7 +61,12 @@ abstract final class SqlLexicalScanner { /// Segments that cover [sql] end to end, in order; neighbouring code is /// one segment. - static List segments(String sql) { + /// + /// [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; @@ -113,6 +118,7 @@ abstract final class SqlLexicalScanner { } if (code == _chSingleQuote) { + final escapes = backslashEscapes || _isEscapeStringPrefix(sql, i); var j = i + 1; while (j < len) { final curr = sql.codeUnitAt(j); @@ -123,7 +129,7 @@ abstract final class SqlLexicalScanner { j++; break; } - } else if (curr == _chBackslash && j + 1 < len) { + } else if (escapes && curr == _chBackslash && j + 1 < len) { j += 2; } else { j++; @@ -160,4 +166,20 @@ abstract final class SqlLexicalScanner { } 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 0d4b9148..47d79994 100644 --- a/lib/core/database/sql_limit.dart +++ b/lib/core/database/sql_limit.dart @@ -62,6 +62,18 @@ int _skipLeadingCommentsOffset(String sql) { } } +/// 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); @@ -207,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/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index aab422a1..600c5731 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -149,6 +149,7 @@ class GenericSqlWorkspaceState extends material.State { id: '${widget.sessionPrefix}_tab_1', title: widget.initialTabTitle ?? 'Query 1', initialSql: widget.initialSql, + dialect: widget.dialect, ), ); _appSettingsListener = () { @@ -259,6 +260,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; diff --git a/lib/features/workspace/sql_query_tab_session.dart b/lib/features/workspace/sql_query_tab_session.dart index 250b9dc8..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; @@ -104,7 +110,7 @@ class SqlQueryTabSession { void formatSql() { final value = controller.value; controller.value = value.copyWith( - text: formatSqlScript(value.text), + text: formatSqlScript(value.text, dialect: dialect), composing: material.TextRange.empty, ); } diff --git a/test/core/actions/sql_script_format_test.dart b/test/core/actions/sql_script_format_test.dart index 058d97e3..aa208ed3 100644 --- a/test/core/actions/sql_script_format_test.dart +++ b/test/core/actions/sql_script_format_test.dart @@ -1,5 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/actions/sql_script_format.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart' + show SqlDialect; void main() { test('uppercases SQL keywords and leaves identifiers', () { @@ -66,4 +68,30 @@ void main() { const sql = "select a, \"b\" from t -- x\nwhere c = 'and' and d is null"; expect(formatSqlScript(sql).length, sql.length); }); + + group('backslashes follow the dialect', () { + test(r"PostgreSQL: a literal ending in \ does not swallow the next one", () { + expect( + formatSqlScript(r"select 'C:\', 'not null' from t", + dialect: SqlDialect.postgres), + r"SELECT 'C:\', 'not null' FROM t", + ); + }); + + test('SQLite: the same', () { + expect( + formatSqlScript(r"select 'C:\' as p where x = 'and'", + dialect: SqlDialect.sqlite), + r"SELECT 'C:\' AS p WHERE x = 'and'", + ); + }); + + test(r"MySQL: \' is an escaped quote inside the literal", () { + expect( + formatSqlScript(r"select 'it\'s or not' from t", + dialect: SqlDialect.mysql), + r"SELECT 'it\'s or not' FROM t", + ); + }); + }); } diff --git a/test/core/database/sql_lexical_scanner_test.dart b/test/core/database/sql_lexical_scanner_test.dart index d8ff8b59..91e6277e 100644 --- a/test/core/database/sql_lexical_scanner_test.dart +++ b/test/core/database/sql_lexical_scanner_test.dart @@ -76,4 +76,46 @@ void main() { 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_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'); + }); + }); } From 33ed54e95f28751a4748a26dc9405d8edec8539f Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:27:29 +0300 Subject: [PATCH 076/157] fix(workspace): adapt SQL toolbar background to active theme and invalidate pane cache (#1577) (#1658) --- lib/core/theme/theme_controller.dart | 7 ++- .../workspace/generic_sql_workspace.dart | 33 ++++++++++- lib/features/workspace/sql_editor_chrome.dart | 2 +- test/core/theme/theme_controller_test.dart | 21 +++++++ .../generic_sql_workspace_sessions_test.dart | 56 +++++++++++++++++++ .../workspace/sql_editor_chrome_test.dart | 48 ++++++++++++++++ 6 files changed, 162 insertions(+), 5 deletions(-) diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 5f11fcac..711e019c 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -471,8 +471,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/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 600c5731..7b00f606 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -37,6 +37,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, @@ -170,6 +172,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 { @@ -1271,9 +1285,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, @@ -1358,3 +1370,18 @@ class GenericSqlWorkspaceState extends material.State { ); } } + +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/sql_editor_chrome.dart b/lib/features/workspace/sql_editor_chrome.dart index 4ca45a36..b3a35420 100644 --- a/lib/features/workspace/sql_editor_chrome.dart +++ b/lib/features/workspace/sql_editor_chrome.dart @@ -26,7 +26,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), diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index 44bf7d39..a67a36ed 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -354,6 +354,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(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(); 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/sql_editor_chrome_test.dart b/test/features/workspace/sql_editor_chrome_test.dart index 316a4b12..5d9132be 100644 --- a/test/features/workspace/sql_editor_chrome_test.dart +++ b/test/features/workspace/sql_editor_chrome_test.dart @@ -139,4 +139,52 @@ void main() { expect(outerGlow, const Color(0xFF00FFEE).withValues(alpha: 0.1)); }); }); + + 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), + ); + }); + }); } From af6392f120a28f8ec8b2b58f636a26c943324b3b Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:29:42 +0300 Subject: [PATCH 077/157] perf(grid): read thousands-separated numbers in selection stats without a copy (#1520) (#1657) Selection stats parsed a cell with a comma as double.tryParse(cell.replaceAll(',', '')), one copy per such cell on every drag update. GridSelectionCalcEngine.parseThousandsSeparated reads it from the code units: commas are skipped wherever they are, as in the copy; up to 15 significant digits and a power of ten up to 22 the value is computed exactly (an integer below 2^53 times or divided by an exact power of ten is one correctly rounded operation); longer numbers fall back to the copy. Cells without a comma still go to double.tryParse, and trim() returns the cell itself when there is nothing to trim. Checked against the copy on 500,000 random and money-shaped strings: no difference. 5,000 comma cells: 172 us instead of 382 us per pass. --- .../workspace/grid_selection_calc_engine.dart | 96 ++++++++++++++++++- .../grid_selection_calc_format_test.dart | 48 ++++++++++ 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/lib/features/workspace/grid_selection_calc_engine.dart b/lib/features/workspace/grid_selection_calc_engine.dart index dd9ba8c9..633c7923 100644 --- a/lib/features/workspace/grid_selection_calc_engine.dart +++ b/lib/features/workspace/grid_selection_calc_engine.dart @@ -131,11 +131,12 @@ abstract final class GridSelectionCalcEngine { distinctSet.add(trimmed); - // Try parsing numeric values (stripping commas if present). Most cells - // have none, so the stripped copy is only made when there is one. - final parsed = double.tryParse( - trimmed.contains(',') ? trimmed.replaceAll(',', '') : trimmed, - ); + // `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; @@ -254,6 +255,91 @@ 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`. + @foundation.visibleForTesting + 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 '-'; diff --git a/test/features/workspace/grid_selection_calc_format_test.dart b/test/features/workspace/grid_selection_calc_format_test.dart index 45e2cc0d..d119a500 100644 --- a/test/features/workspace/grid_selection_calc_format_test.dart +++ b/test/features/workspace/grid_selection_calc_format_test.dart @@ -37,4 +37,52 @@ void main() { 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, + ); + } + }); + }); } From 6d2c6f6a797ed49062e78f2a03e4571b7dca8708 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:34:08 +0300 Subject: [PATCH 078/157] perf(export): hand large copies to the isolate in chunks (#1537) (#1659) formatAsync ran one Isolate.run whose closure captured all rows. The captured object graph is copied on the calling isolate before the worker starts, so copying 50,000 x 20 cells to the clipboard stopped the UI for about 20-30 ms. Above about 20,000 cells (handOffCells) the rows now go over in chunks of that size, each formatted on its own isolate, with the event loop running between them; the parts are joined. formatChunk produces each part so that the join is exactly the format* output: the header (and JSON's '[') in the first chunk, ']' in the last, the JSON items split out of the indented array. The isolate calls live in separate functions so a closure captures only its chunk, not formatAsync's whole rows. Standalone Dart AOT, 50,000 x 20, Timer.periodic(1 ms) on the caller: longest gap 21 ms with one closure, 2-4 ms with 1,000-row chunks; total time ~85 ms -> ~55 ms. --- lib/shared/services/data_export_service.dart | 130 +++++++++++++++++-- test/shared/data_export_service_test.dart | 56 ++++++++ 2 files changed, 174 insertions(+), 12 deletions(-) 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/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)); + }); + } + }); } From 241f3ae73e92a22940304734e50cac22b81d63a4 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:38:02 +0300 Subject: [PATCH 079/157] ui(theme): use editorBackground and semantic palette tokens in SQL previews and document tree (#1644) (#1660) --- .../mongodb/mongo_document_tree_view.dart | 23 +++++--- .../workspace/destructive_query_dialog.dart | 8 +-- .../workspace/dml_preview_dialog.dart | 8 +-- .../mongo_documents_view_modes_test.dart | 51 +++++++++++++++++ .../destructive_query_dialog_test.dart | 55 +++++++++++++++++++ .../workspace/dml_preview_dialog_test.dart | 43 ++++++++++++++- 6 files changed, 167 insertions(+), 21 deletions(-) diff --git a/lib/features/mongodb/mongo_document_tree_view.dart b/lib/features/mongodb/mongo_document_tree_view.dart index a8523626..c4d88bc9 100644 --- a/lib/features/mongodb/mongo_document_tree_view.dart +++ b/lib/features/mongodb/mongo_document_tree_view.dart @@ -3,6 +3,8 @@ 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/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'; @@ -599,7 +601,8 @@ class _MongoDocumentTreeViewState extends State { }) { 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 && @@ -705,13 +708,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, + ColorScheme 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/workspace/destructive_query_dialog.dart b/lib/features/workspace/destructive_query_dialog.dart index ac00096b..794cbd89 100644 --- a/lib/features/workspace/destructive_query_dialog.dart +++ b/lib/features/workspace/destructive_query_dialog.dart @@ -262,9 +262,7 @@ class _DestructiveQueryDialogState 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), @@ -279,9 +277,7 @@ class _DestructiveQueryDialogState 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/dml_preview_dialog.dart b/lib/features/workspace/dml_preview_dialog.dart index 620527c0..46b6f419 100644 --- a/lib/features/workspace/dml_preview_dialog.dart +++ b/lib/features/workspace/dml_preview_dialog.dart @@ -259,9 +259,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), @@ -276,9 +274,7 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { QueryaTypography.monoFontFamilyFallback, fontSize: 12.5, height: 1.45, - color: isDark - ? const material.Color(0xFFE2E8F0) - : const material.Color(0xFF1E293B), + color: cs.foreground, ), ), ), diff --git a/test/features/mongodb/mongo_documents_view_modes_test.dart b/test/features/mongodb/mongo_documents_view_modes_test.dart index 8541c5ea..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'; @@ -193,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/workspace/destructive_query_dialog_test.dart b/test/features/workspace/destructive_query_dialog_test.dart index 890fa390..748088d4 100644 --- a/test/features/workspace/destructive_query_dialog_test.dart +++ b/test/features/workspace/destructive_query_dialog_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/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:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -275,5 +276,59 @@ 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.colorScheme.foreground, + ); + }); }); } diff --git a/test/features/workspace/dml_preview_dialog_test.dart b/test/features/workspace/dml_preview_dialog_test.dart index 4b70f425..7044d9cf 100644 --- a/test/features/workspace/dml_preview_dialog_test.dart +++ b/test/features/workspace/dml_preview_dialog_test.dart @@ -1,14 +1,20 @@ 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) { @@ -123,5 +129,40 @@ 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.colorScheme.foreground, + ); + }); }); } From feebcb7193093c440b3883307167379b8e657478 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:50:29 +0300 Subject: [PATCH 080/157] ui(theme): replace hardcoded material colors with QueryaWorkbenchTheme status tokens (#1643) (#1663) --- lib/core/theme/querya_theme_scope.dart | 15 ++++- .../extension_connection_form.dart | 3 +- .../querya_connection_tree_row.dart | 5 +- .../connections/ssh_tunnel_section.dart | 3 +- .../workspace/dml_preview_dialog.dart | 15 ++--- lib/features/workspace/grid_cell_editor.dart | 4 +- .../querya_connection_tree_row_test.dart | 56 +++++++++++++++++++ .../workspace/dml_preview_dialog_test.dart | 20 +++++++ .../workspace/grid_cell_editor_test.dart | 8 +++ 9 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 test/features/connections/querya_connection_tree_row_test.dart diff --git a/lib/core/theme/querya_theme_scope.dart b/lib/core/theme/querya_theme_scope.dart index a834d601..546ca1d2 100644 --- a/lib/core/theme/querya_theme_scope.dart +++ b/lib/core/theme/querya_theme_scope.dart @@ -59,7 +59,7 @@ extension QueryaThemeContext on BuildContext { QueryaTheme get queryaTheme => QueryaThemeScope.of(this); - QueryaWorkbenchTheme get workbench => queryaTheme.workbench; + 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, @@ -71,8 +71,17 @@ extension QueryaThemeContext on BuildContext { ? QueryaWorkbenchTheme.lightDefault : QueryaWorkbenchTheme.darkDefault); - QueryaEditorTheme get editorTheme => queryaTheme.editor; + QueryaEditorTheme get editorTheme => + QueryaThemeScope.maybeOf(this)?.editor ?? + (colors.brightness == Brightness.light + ? QueryaEditorTheme.lightDefault + : QueryaEditorTheme.darkDefault); QueryaSemanticPalette get semanticPalette => - QueryaSemanticPalette.fromTheme(queryaTheme); + QueryaSemanticPalette.fromTheme( + QueryaThemeScope.maybeOf(this) ?? + (colors.brightness == Brightness.light + ? QueryaTheme.lightDefault + : QueryaTheme.darkDefault), + ); } diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart index 6cda1a20..51800724 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; @@ -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/querya_connection_tree_row.dart b/lib/features/connections/querya_connection_tree_row.dart index 6d836029..66195fd6 100644 --- a/lib/features/connections/querya_connection_tree_row.dart +++ b/lib/features/connections/querya_connection_tree_row.dart @@ -106,6 +106,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { final theme = Theme.of(context); final primary = theme.colorScheme.primary; final muted = theme.colorScheme.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), ], @@ -205,7 +206,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { : material.Icons.star_border_rounded, size: 18, color: isPinned - ? material.Colors.amber.shade600 + ? wb.warning : theme.colorScheme.mutedForeground, ), onPressed: (_) => onTogglePin!(), diff --git a/lib/features/connections/ssh_tunnel_section.dart b/lib/features/connections/ssh_tunnel_section.dart index e44def2d..bbdc4755 100644 --- a/lib/features/connections/ssh_tunnel_section.dart +++ b/lib/features/connections/ssh_tunnel_section.dart @@ -196,6 +196,7 @@ class _SshTunnelSectionState extends material.State { material.Widget build(material.BuildContext context) { final theme = Theme.of(context); final cs = theme.colorScheme; + final wb = context.workbench; return material.Container( margin: const material.EdgeInsets.only(top: 16), @@ -458,7 +459,7 @@ class _SshTunnelSectionState extends material.State { style: material.TextStyle( fontSize: 12, color: _testSuccess - ? material.Colors.green + ? wb.success : cs.destructive, ), ), diff --git a/lib/features/workspace/dml_preview_dialog.dart b/lib/features/workspace/dml_preview_dialog.dart index 46b6f419..c741f740 100644 --- a/lib/features/workspace/dml_preview_dialog.dart +++ b/lib/features/workspace/dml_preview_dialog.dart @@ -63,6 +63,7 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { material.Widget build(material.BuildContext context) { final theme = Theme.of(context); final cs = theme.colorScheme; + final wb = context.workbench; final isDark = theme.brightness == Brightness.dark; final sql = widget.plan.toTransactionSql(); @@ -160,19 +161,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, ), ], @@ -192,12 +193,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( @@ -205,7 +206,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( @@ -241,7 +242,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), diff --git a/lib/features/workspace/grid_cell_editor.dart b/lib/features/workspace/grid_cell_editor.dart index dce589de..26196ac5 100644 --- a/lib/features/workspace/grid_cell_editor.dart +++ b/lib/features/workspace/grid_cell_editor.dart @@ -168,7 +168,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, ), ), @@ -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/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/workspace/dml_preview_dialog_test.dart b/test/features/workspace/dml_preview_dialog_test.dart index 7044d9cf..cfa09de9 100644 --- a/test/features/workspace/dml_preview_dialog_test.dart +++ b/test/features/workspace/dml_preview_dialog_test.dart @@ -164,5 +164,25 @@ void main() { QueryaTheme.lightDefault.colorScheme.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); + }); }); } diff --git a/test/features/workspace/grid_cell_editor_test.dart b/test/features/workspace/grid_cell_editor_test.dart index 2bd5cc2e..534952c7 100644 --- a/test/features/workspace/grid_cell_editor_test.dart +++ b/test/features/workspace/grid_cell_editor_test.dart @@ -4,6 +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/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:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; @@ -143,6 +144,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.colorScheme.destructive, + ); }); }); From 10a235735d7f283169b0d9295b4dace4934035b6 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:58:25 +0300 Subject: [PATCH 081/157] refactor(ui): migrate hardcoded colors in onboarding and badges to QueryaTheme tokens (#1509) (#1666) --- .../onboarding/welcome_tour_dialog.dart | 22 +++++------ lib/shared/widgets/app_dialog.dart | 4 +- .../widgets/connection_environment_badge.dart | 15 ++----- lib/shared/widgets/querya_button.dart | 9 +---- .../onboarding/welcome_tour_dialog_test.dart | 39 +++++++++++++++++++ 5 files changed, 58 insertions(+), 31 deletions(-) diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index 46747bea..d2d624c2 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -60,16 +60,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, ), @@ -389,7 +387,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.', @@ -401,7 +399,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.', @@ -414,7 +412,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.', @@ -427,7 +425,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.', @@ -440,7 +438,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.', @@ -634,13 +632,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), 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/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/querya_button.dart b/lib/shared/widgets/querya_button.dart index fc1b7c53..109670b0 100644 --- a/lib/shared/widgets/querya_button.dart +++ b/lib/shared/widgets/querya_button.dart @@ -292,11 +292,6 @@ 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); @override material.Widget build(material.BuildContext context) { @@ -313,7 +308,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 +321,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); diff --git a/test/features/onboarding/welcome_tour_dialog_test.dart b/test/features/onboarding/welcome_tour_dialog_test.dart index 2d931cfa..e99497f0 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; @@ -200,4 +201,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.colorScheme.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.colorScheme.chart4); + }); } From 8818fcfa8d999b55d8fb9f9c7b1a55f953a58fb0 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:03:03 +0300 Subject: [PATCH 082/157] ui(settings): fix nested SingleChildScrollView in PreferencesAppearanceSection (#1650) (#1668) --- .../preferences_appearance_section.dart | 17 +++++++---------- .../preferences_appearance_section_test.dart | 13 +++++++++++++ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index f4794773..a5c7e115 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -152,12 +152,10 @@ 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', @@ -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/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, + ); + }); }); } From 86ea84de39a5c1f5b5ac2aae04e849e2a02368f5 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:03:38 +0300 Subject: [PATCH 083/157] perf(results): decide column kinds from formatted samples, type-test the rest (#1548) (#1665) CompactResultDataset.fromRawRows formatted every non-null cell with the driver's cell formatter (and stringified it again) only to decide whether a column is int, float, string or generic, and a uniform column never left that loop early. The formatters print a column's ints, numbers and strings by the column's type (bytea, binary, boolean...), not by the value, so the first 60 non-null cells of a column now go through the formatter and the rest are type-tested; the loop also stops as soon as the column can only be generic. Standalone Dart AOT copy of the detection loop, 6 int + 4 double + 10 string columns: 50,000 rows 46 ms -> 20 ms; 5,000 rows (the default cap) 3.5 ms -> 0.5 ms per query run. Same kinds, including a column broken by a late value of another type and a bytea-typed string column. --- lib/core/database/compact_result_dataset.dart | 16 +++++-- .../database/compact_result_dataset_test.dart | 46 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/lib/core/database/compact_result_dataset.dart b/lib/core/database/compact_result_dataset.dart index 50f8e5d9..13627607 100644 --- a/lib/core/database/compact_result_dataset.dart +++ b/lib/core/database/compact_result_dataset.dart @@ -217,6 +217,10 @@ class CompactResultDataset { /// /// An optional [cellFormatter] callback can be provided to customize display string /// formatting for driver-specific types (e.g. blobs, timestamps, boolean tinyints). + /// Non-null cells per column that go through the cell formatter while + /// [fromRawRows] decides the column's kind; the rest are type-tested. + static const int _formattedSamples = 60; + static CompactResultDataset fromRawRows( List columnNames, List> rawRows, { @@ -249,7 +253,12 @@ class CompactResultDataset { if (val == null) continue; sampleCount++; - if (cellFormatter != null) { + // The driver formatters print a column's ints, numbers and strings + // by the column's type (bytea, binary, boolean...), not by the value, + // so the first [_formattedSamples] cells show whether the column + // prints as its values do. Formatting every cell only to decide the + // kind cost more than the packing itself (#1548). + if (cellFormatter != null && sampleCount <= _formattedSamples) { final formatted = cellFormatter(val, col); if (allInt && (val is! int || formatted != val.toString())) { allInt = false; @@ -272,9 +281,8 @@ class CompactResultDataset { } } - if (sampleCount >= 60 && !allInt && !allFloat && !allString) { - break; - } + // Nothing left to learn: the column is generic. + if (!allInt && !allFloat && !allString) break; } if (sampleCount > 0) { diff --git a/test/core/database/compact_result_dataset_test.dart b/test/core/database/compact_result_dataset_test.dart index 0248ed56..881fa4cd 100644 --- a/test/core/database/compact_result_dataset_test.dart +++ b/test/core/database/compact_result_dataset_test.dart @@ -233,5 +233,51 @@ void main() { expect(result.rows[1][1], '100'); }); }); + + group('kind detection with a cell formatter (#1548)', () { + // Formats every cell; counts how many it was asked for. + var formatted = 0; + String fmt(Object? v, int col) { + formatted++; + if (v is String && col == 2) return 'hex:$v'; // a bytea-typed column + return v.toString(); + } + + setUp(() => 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'); + }); + }); } From 954cdd93c41bb6e607bc2f93c03651d0f8519e81 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:06:57 +0300 Subject: [PATCH 084/157] ui(settings): use semantic status colors for extension installation errors and notices (#1651) (#1669) --- .../preferences_extensions_section.dart | 41 +++++- .../preferences_extensions_section_test.dart | 137 ++++++++++++++++++ 2 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 test/features/settings/preferences_extensions_section_test.dart diff --git a/lib/features/settings/preferences_extensions_section.dart b/lib/features/settings/preferences_extensions_section.dart index 48073646..cc0b8019 100644 --- a/lib/features/settings/preferences_extensions_section.dart +++ b/lib/features/settings/preferences_extensions_section.dart @@ -96,6 +96,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: [ @@ -126,11 +127,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/test/features/settings/preferences_extensions_section_test.dart b/test/features/settings/preferences_extensions_section_test.dart new file mode 100644 index 00000000..3191d53e --- /dev/null +++ b/test/features/settings/preferences_extensions_section_test.dart @@ -0,0 +1,137 @@ +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/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('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, + ); + }); + }); +} From 77d13119bb1fb34728197bfe7558d4fbfdd281e6 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:12:07 +0300 Subject: [PATCH 085/157] ui(settings): prevent horizontal overflow in shortcut keycaps and add mono fallback (#1652) (#1670) --- .../preferences_shortcuts_section.dart | 41 ++++++++++++------- .../preferences_shortcuts_section_test.dart | 35 ++++++++++++++++ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/lib/features/settings/preferences_shortcuts_section.dart b/lib/features/settings/preferences_shortcuts_section.dart index 67da3443..ca53338b 100644 --- a/lib/features/settings/preferences_shortcuts_section.dart +++ b/lib/features/settings/preferences_shortcuts_section.dart @@ -381,30 +381,40 @@ 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], theme), + ], ], - ], + ), ), ], ), ); } - List _keycaps(List keys, ThemeData theme) { + List _keycaps( + BuildContext context, + List keys, + ThemeData theme, + ) { + 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 +423,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), ), 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,6 +440,7 @@ class _PreferencesShortcutsSectionState keys[j], style: material.TextStyle( fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 11, fontWeight: material.FontWeight.w600, color: theme.colorScheme.foreground, 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, + ); + }); }); } From efc0f25ad42e77ee1864381a95e532dc9216b94d Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:18:06 +0300 Subject: [PATCH 086/157] fix(theme): token defaults without a theme scope no longer need a shadcn theme (#1671) #1666 (#1509) made the dialog backdrop use workbenchOrDefault.shadow. Without a QueryaThemeScope that getter fell back to colors.brightness, which is shadcn Theme.of and asserts when there is no shadcn theme, so every app_dialog test (a bare MaterialApp) failed and dev went red. The fallbacks of workbenchOrDefault, editorTheme and semanticPalette take the shadcn theme's brightness when there is one, else Material's. The app always has both scopes, so nothing changes there. The settled-tint test now expects the theme's shadow colour, which the backdrop has used since #1666, instead of plain black. --- lib/core/theme/querya_theme_scope.dart | 17 ++++++++++++++--- test/shared/app_dialog_test.dart | 7 ++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/core/theme/querya_theme_scope.dart b/lib/core/theme/querya_theme_scope.dart index 546ca1d2..4b3ed85e 100644 --- a/lib/core/theme/querya_theme_scope.dart +++ b/lib/core/theme/querya_theme_scope.dart @@ -59,6 +59,17 @@ extension QueryaThemeContext on BuildContext { QueryaTheme get queryaTheme => QueryaThemeScope.of(this); + /// The brightness the defaults below follow when there is no + /// [QueryaThemeScope]: the shadcn theme's, else Material's. [colors] asserts + /// without a shadcn theme, and these defaults exist for exactly that case + /// (a widget under a bare app, as in tests). + Brightness get _ambientBrightness => + dependOnInheritedWidgetOfExactType() + ?.data + .colorScheme + .brightness ?? + material.Theme.of(this).brightness; + QueryaWorkbenchTheme get workbench => workbenchOrDefault; /// The workbench tokens of the nearest [QueryaThemeScope], or the defaults @@ -67,20 +78,20 @@ 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 => QueryaThemeScope.maybeOf(this)?.editor ?? - (colors.brightness == Brightness.light + (_ambientBrightness == Brightness.light ? QueryaEditorTheme.lightDefault : QueryaEditorTheme.darkDefault); QueryaSemanticPalette get semanticPalette => QueryaSemanticPalette.fromTheme( QueryaThemeScope.maybeOf(this) ?? - (colors.brightness == Brightness.light + (_ambientBrightness == Brightness.light ? QueryaTheme.lightDefault : QueryaTheme.darkDefault), ); 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), From e62dfa745ce6b0cb74d4027e0b318831fabd9503 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:23:47 +0300 Subject: [PATCH 087/157] fix(mongodb): one-pass normalisation of mongosh object literals (#1513, #1539) (#1661) MqlParser._normalizeJsObject swapped string literals for @@@STR_n@@@ markers and put them back with one replaceAll over the whole query per string. That was quadratic (an insertMany of 1,000 documents took ~5 s) and changed data: escapes stayed in the value ("a\"b" became a\"b, "C:\\\\dir" stayed doubled, \n was not a newline), and a string that contained ISODate(...), ObjectId(...) or a marker broke the query. It is now one forward scan: strings are read with their JavaScript escapes decoded and written as JSON strings; ObjectId / ISODate / NumberLong / NumberInt calls are converted only outside strings (NumberLong(-5) now works too); unquoted keys after { [ , are quoted. Same JSON as before on escape-free input. insertMany: 100 documents 43 ms -> 0.5 ms, 1,000 documents 4.9 s -> 6.6 ms (standalone Dart AOT). --- lib/features/mongodb/mql_parser.dart | 263 +++++++++++++++------ test/features/mongodb/mql_parser_test.dart | 49 ++++ 2 files changed, 234 insertions(+), 78 deletions(-) diff --git a/lib/features/mongodb/mql_parser.dart b/lib/features/mongodb/mql_parser.dart index 116d4bf1..3068c8ca 100644 --- a/lib/features/mongodb/mql_parser.dart +++ b/lib/features/mongodb/mql_parser.dart @@ -580,98 +580,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/test/features/mongodb/mql_parser_test.dart b/test/features/mongodb/mql_parser_test.dart index 4791fea0..4f51c70f 100644 --- a/test/features/mongodb/mql_parser_test.dart +++ b/test/features/mongodb/mql_parser_test.dart @@ -120,4 +120,53 @@ 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)); + }); + }); } From 865f7f028516dd83a38434322a7f407802714a86 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:23:56 +0300 Subject: [PATCH 088/157] perf(catalog): ERD neighbourhood and MCP lookups read only the tables they need (#1572) (#1662) * perf(catalog): ERD neighbourhood and MCP lookups read only the tables they need (#1572) loadNeighbourhood ran the column catalog query for every column of each schema in the neighbourhood and filtered in Dart; describe_table and sample_rows loaded the columns, keys and view columns of the whole schema to find one table. The cost followed the schema, not the request, and a schema over the 200,000-row limit was cut before filtering, so a table could come back without its columns. - columnsSql / viewColumnsSql take tables: an IN filter on the table name (relname, table_name, m.name), literal-escaped. - loadNeighbourhood walks the keys first, then asks each schema only for its tables in the neighbourhood. - MCP resolves the name with tableNamesSql (names only, exact match else the single case-insensitive one, as before), then describe_table loads that table's columns and view columns; sample_rows needs only the name. list_tables and the schema:// resource still read the whole catalog. SQLite, 3,000 tables x 17 columns: the neighbourhood of one table read 51,000 column rows in 225 ms; filtered, 51 rows in 0.7 ms. * fix(mcp): a described table keeps its references; tests follow the filtered catalog (#1572) describe_table loaded the columns of the asked table only, and ErdSchema drops a relation to a table it has no columns for, so orders.user_id lost its users.id reference. The keys are read first and the tables they point to are loaded with it. Tests: the MCP check now picks column queries by format_type (the key query also joins pg_attribute), the ERD layout test's fake recognises the filtered column query, and an unused import is gone. * test(workspace): the relations view fake recognises the filtered column query (#1572) --- lib/core/erd/erd_catalog.dart | 72 ++++++++++++--- lib/core/mcp/mcp_query_service.dart | 77 ++++++++++++---- test/core/mcp/mcp_query_service_test.dart | 33 ++++++- .../erd/erd_layout_persistence_test.dart | 6 +- test/features/erd/erd_test.dart | 90 ++++++++++++++++--- .../generic_table_view_relations_test.dart | 9 +- 6 files changed, 239 insertions(+), 48 deletions(-) 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/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/test/core/mcp/mcp_query_service_test.dart b/test/core/mcp/mcp_query_service_test.dart index d51023e4..3449c010 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)'], @@ -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/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_test.dart b/test/features/erd/erd_test.dart index b5b140a7..8963656d 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -33,6 +33,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 +153,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 +186,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 +210,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 +235,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 +258,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 +717,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 +964,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'], @@ -1051,7 +1113,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 +1149,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 +1242,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 +1306,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 +1397,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 +1476,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/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()); From 8d33488673db7a494560d8834834ebdbd7e95f25 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:24:05 +0300 Subject: [PATCH 089/157] perf(grid): sort timestamp columns without a local DateTime per row (#1533) (#1664) * perf(grid): sort timestamp columns without a local DateTime per row (#1533) Sorting by a date / timestamp column parsed each cell with DateTime.tryParse. Nearly all of its cost is the local constructor it ends in, which asks the system for the zone offset on every call (and re-reads the zone file when TZ is not set): 50,000 timestamps took ~400 ms. isoMicrosSinceEpoch reads the shapes drivers print (YYYY-MM-DD, then optional [T ]HH:MM, :SS, .fraction, Z / +-HH[[:]MM]) from the code units and returns the same instant: zoned values through DateTime.utc, local ones through DateTime.utc plus the day's offset, looked up once per day per sort; a day whose offset changes (DST) still uses the local constructor. Other shapes go to DateTime.tryParse as before. Keys are ints instead of DateTime objects. Standalone Dart AOT, 50,000 timestamps, TZ unset: ~400 ms -> ~60 ms. Same instants as DateTime.tryParse on 200,000 values in four zones (Moscow, Berlin, New York, Lord Howe); same order on mixed columns. * fix(grid): a lower-case t is not a date-time separator for DateTime.parse (#1533) isoMicrosSinceEpoch took '2026-01-02t03:04:05' as a timestamp, which DateTime.parse refuses, so such a cell sorted as a date instead of as text. Only 'T' and a space are accepted now; the parity test caught it and the fuzz check now includes the shape. --- lib/features/workspace/grid/grid_sorting.dart | 141 +++++++++++++++++- .../workspace/grid_sort_dates_test.dart | 77 ++++++++++ 2 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 test/features/workspace/grid_sort_dates_test.dart 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/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}); + }); +} From 70d98d981b52fcd234fd0f3d6c3655c4fd7ec5d6 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:24:41 +0300 Subject: [PATCH 090/157] ui(settings): adapt left category rail width to UI scale and make theme editor responsive (#1653) (#1672) --- lib/features/settings/preferences_dialog.dart | 3 +- .../settings/theme_editor_dialog.dart | 21 +++++++----- lib/shared/widgets/querya_modal_dialog.dart | 4 ++- .../preferences_master_detail_test.dart | 23 +++++++++++++ .../settings/theme_editor_dialog_test.dart | 32 +++++++++++++++++++ test/support/querya_theme_test_shell.dart | 3 +- 6 files changed, 75 insertions(+), 11 deletions(-) diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 71ac9cd4..8e35828b 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; +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/update_manifest.dart'; @@ -374,7 +375,7 @@ class PreferencesDialogContentState children: [ // Left Category Rail material.Container( - width: 184, + width: context.scaled(184), decoration: material.BoxDecoration( border: material.Border( right: material.BorderSide( diff --git a/lib/features/settings/theme_editor_dialog.dart b/lib/features/settings/theme_editor_dialog.dart index b7ba96af..66420771 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'; @@ -149,19 +151,22 @@ class _ThemeEditorDialogState extends material.State { final cs = theme.colorScheme; 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 +258,7 @@ class _ThemeEditorDialogState extends material.State { ], ), ), - ), + ), actions: [ QueryaButton.secondary( label: 'Reset from current', 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/test/features/settings/preferences_master_detail_test.dart b/test/features/settings/preferences_master_detail_test.dart index 3d12141b..20ca31d9 100644 --- a/test/features/settings/preferences_master_detail_test.dart +++ b/test/features/settings/preferences_master_detail_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/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; @@ -226,5 +227,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/theme_editor_dialog_test.dart b/test/features/settings/theme_editor_dialog_test.dart index b23a9b61..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'; @@ -116,5 +117,36 @@ void main() { 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/support/querya_theme_test_shell.dart b/test/support/querya_theme_test_shell.dart index 16604034..dd3f26fc 100644 --- a/test/support/querya_theme_test_shell.dart +++ b/test/support/querya_theme_test_shell.dart @@ -8,6 +8,7 @@ Widget queryaThemeTestShell({ required Widget child, QueryaTheme data = QueryaTheme.darkDefault, ThemeData? theme, + double uiScale = 1.0, }) { final td = theme ?? data.toShadcnThemeData(); return ShadcnApp( @@ -18,7 +19,7 @@ Widget queryaThemeTestShell({ // binding reports, and a sheet needs a DrawerOverlay the app does not have. menuHandler: const PopoverOverlayHandler(), builder: (context, appChild) => QueryaUiScaleScope( - scale: 1.0, + scale: uiScale, child: QueryaThemeScope( data: data, child: appChild ?? const SizedBox.shrink(), From 71dc643d41a987bea0c95c8e0b805e2ea849bda2 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:31:09 +0300 Subject: [PATCH 091/157] fix(mongodb): nested ObjectId / ISODate / NumberLong in MQL become BSON values (#1667) (#1673) The relaxed parser turns ObjectId(...) and ISODate(...) inside an argument into {"$oid": ...} / {"$date": ...}, and nothing on the query-workspace path converted them: find({_id: ObjectId(...)}) sent {_id: {$oid: ...}} and matched nothing, insertOne stored a sub-document. Every parsed argument now has single-key {$oid: <24 hex>}, {$date: } and {$numberLong: } maps turned into ObjectId, UTC DateTime and int, at any depth, whether the relaxed syntax produced them or the user typed Extended JSON. Other maps, query operators such as $regex / $options included, stay as written. ISODate without a zone is UTC, as in mongosh, also for a top-level ISODate(...) argument (it was local time). --- lib/features/mongodb/mql_parser.dart | 59 +++++++++++++++++++++- test/features/mongodb/mql_parser_test.dart | 46 +++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/lib/features/mongodb/mql_parser.dart b/lib/features/mongodb/mql_parser.dart index 3068c8ca..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(')')) { diff --git a/test/features/mongodb/mql_parser_test.dart b/test/features/mongodb/mql_parser_test.dart index 4f51c70f..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() { @@ -169,4 +170,49 @@ void main() { 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}); + }); + }); } From 0e44d615cd3f41fe0fcd26243851fd90910dd65c Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:31:12 +0300 Subject: [PATCH 092/157] ui(grid): fix RenderFlex overflow during DataGridCalcBar collapse animation (#1641) (#1674) --- .../workspace/data_grid_calc_bar.dart | 38 ++--- .../workspace/data_grid_calc_bar_test.dart | 144 ++++++++++++++++++ 2 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 test/features/workspace/data_grid_calc_bar_test.dart diff --git a/lib/features/workspace/data_grid_calc_bar.dart b/lib/features/workspace/data_grid_calc_bar.dart index a85ba3e0..c3de21d4 100644 --- a/lib/features/workspace/data_grid_calc_bar.dart +++ b/lib/features/workspace/data_grid_calc_bar.dart @@ -1,7 +1,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: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; @@ -18,25 +18,23 @@ class DataGridCalcBar extends StatelessWidget { 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); - 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 +118,7 @@ class DataGridCalcBar extends StatelessWidget { ), ], ), + ), ); } } @@ -162,7 +161,8 @@ class _StatBadge extends StatelessWidget { fontSize: 11, fontWeight: FontWeight.w600, color: cs.foreground, - fontFamily: 'monospace', + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, ), ), ], 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()); + }); + }); +} From aa1804a4236fd767a991e23264c4b5af995af775 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:49:50 +0300 Subject: [PATCH 093/157] docs(changelog): today's entries; test: calc bar test after #1674 (dev red) (#1675) * docs(changelog): entries for the fixes and speed-ups merged on 2026-10-17 Redis sockets per database (#1569), macOS update and unsigned build (#1587, #1592), Format SQL backslashes (#1566), MongoDB parsing and Extended JSON (#1513, #1539, #1667), ERD / MCP catalog (#1572), README (#1500), and the grid / results / export speed-ups (#1520, #1533, #1537, #1548). The PRs left CHANGELOG.md alone so they would not conflict with each other. * test(workspace): the calc bar test selects a cell the bar is shown for #1674 collapses DataGridCalcBar with QueryaAnimatedExpand, which does not build the content while collapsed. The test selected one text cell, for which the bar is hidden by design, and found 'Count: ' only because the old zero-height container still built it. It now selects a numeric cell, and a second test pins that a single text cell keeps the bar collapsed. dev has been red on this since #1674. --- CHANGELOG.md | 12 ++++++++++++ .../generic_table_view_filter_calc_test.dart | 16 ++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 995e0edd..f1cf9621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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 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 +- **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) 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); + }); }); } From d3a79811c30e8faed6a4f745ffef4d6f1ce66e54 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:34:01 +0300 Subject: [PATCH 094/157] fix(extensions): a failed upgrade no longer deletes the working extension (#1562) (#1676) * fix(extensions): extract into a staging folder and swap, so a failed upgrade keeps the working extension (#1562) * test(extensions): fail the upgrade through a bad archive entry (#1562) * fix(extensions): reject traversal entries before the common root is stripped (#1562) --- .../extensions/local_extension_installer.dart | 98 +++++++++++++++---- .../extensions/local_extension_registry.dart | 2 +- .../market/http_marketplace_repository.dart | 61 ++---------- .../local_extension_installer_test.dart | 71 ++++++++++++++ 4 files changed, 156 insertions(+), 76 deletions(-) diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index e3578431..f2ac7112 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -119,56 +119,104 @@ 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 = ExtensionPaths.extensionDirectory(root, manifest.id); - if (await extDir.exists()) { - await extDir.delete(recursive: true); - } - await extDir.create(recursive: true); + // 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 (_) {} + } + 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 (_) {} if (e is MarketplaceException) rethrow; @@ -239,6 +287,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); } diff --git a/lib/core/extensions/local_extension_registry.dart b/lib/core/extensions/local_extension_registry.dart index 23ac974c..5c73a84c 100644 --- a/lib/core/extensions/local_extension_registry.dart +++ b/lib/core/extensions/local_extension_registry.dart @@ -50,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 { diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 0d2bb4c1..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'; @@ -202,61 +202,14 @@ class HttpMarketplaceRepository implements MarketplaceRepository { throw MarketplaceException(error.message); } - final dir = await ExtensionPaths.extensionsDirectory(); - final extDir = ExtensionPaths.extensionDirectory(dir, 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 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); - } - } - - 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(); diff --git a/test/core/extensions/local_extension_installer_test.dart b/test/core/extensions/local_extension_installer_test.dart index 0e27688a..a70f72e4 100644 --- a/test/core/extensions/local_extension_installer_test.dart +++ b/test/core/extensions/local_extension_installer_test.dart @@ -342,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); + }); }); } From b58dab2f9a5ce064faac328c9db8921ac020fdd6 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:40:17 +0300 Subject: [PATCH 095/157] fix(extensions): accept spaces after an engine range operator; add HostVersion operator > (#1635, #1636) (#1677) --- .../harness/extension_manifest_validator.dart | 10 +++++++--- .../harness/extension_protocol_profile.dart | 1 + .../extension_manifest_validator_test.dart | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/lib/core/extensions/harness/extension_manifest_validator.dart b/lib/core/extensions/harness/extension_manifest_validator.dart index aab84e1c..541475d4 100644 --- a/lib/core/extensions/harness/extension_manifest_validator.dart +++ b/lib/core/extensions/harness/extension_manifest_validator.dart @@ -168,7 +168,11 @@ final _semver = RegExp(r'^\d+\.\d+\.\d+(?:[+-][0-9A-Za-z.+-]+)?$'); /// 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) { @@ -182,8 +186,8 @@ bool? engineRangeAllows(HostVersion host, String range) { bool? _comparatorAllows(HostVersion host, String c) { try { if (c.startsWith('>=')) return host >= HostVersion.parse(c.substring(2)); - if (c.startsWith('<=')) return !(HostVersion.parse(c.substring(2)) < host); - if (c.startsWith('>')) return !(host <= HostVersion.parse(c.substring(1))); + 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('^')) { diff --git a/lib/core/extensions/harness/extension_protocol_profile.dart b/lib/core/extensions/harness/extension_protocol_profile.dart index 5b85ff80..b03010ee 100644 --- a/lib/core/extensions/harness/extension_protocol_profile.dart +++ b/lib/core/extensions/harness/extension_protocol_profile.dart @@ -29,6 +29,7 @@ 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; diff --git a/test/core/extensions/harness/extension_manifest_validator_test.dart b/test/core/extensions/harness/extension_manifest_validator_test.dart index dc72bd00..6e06b078 100644 --- a/test/core/extensions/harness/extension_manifest_validator_test.dart +++ b/test/core/extensions/harness/extension_manifest_validator_test.dart @@ -125,6 +125,25 @@ void main() { 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']) { From cae040de39096bf88df50a47932fb061595618e4 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:48:27 +0300 Subject: [PATCH 096/157] fix(sandbox): declarative engine JSONC, sql.format and sql.parse no longer regex over literals (#1567) (#1678) * fix(sandbox): declarative engine reads JSONC with a scanner, formats SQL outside literals, parses past leading comments (#1567) * fix(sandbox): keep the trailing newline in sql.format; tests for #1567 * test(sandbox): keep the trailing comma next to the brace, as stripJsonc reads it (#1567) --- .../embedded/declarative_embedded_engine.dart | 75 +++++++++---------- .../embedded_sandbox_runtime_test.dart | 62 +++++++++++++++ 2 files changed, 98 insertions(+), 39 deletions(-) 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/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', () { From 4465669e39765dbc1d1d658404baa69523635a35 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:52:40 +0300 Subject: [PATCH 097/157] fix: MCP guard, socket host, RPC client, sandbox runner, file launch hardening (#1565) (#1679) * fix: small hardening from review - MCP guard, socket host limits, RPC write, sandbox scratch, file launch (#1565) * fix: guard matches pg_try_advisory_*, keep the MCP timeout test off pg_sleep, drop an unused import (#1565) --- .../extensions/rpc/json_rpc_stdio_client.dart | 3 +- .../sandbox/sandbox_process_runner.dart | 39 +++++++------ lib/core/mcp/mcp_socket_host.dart | 50 +++++++++++++++- lib/core/mcp/mcp_sql_guard.dart | 8 ++- lib/core/platform/file_launch_service.dart | 12 ++-- lib/features/main_screen/main_screen.dart | 2 +- test/core/mcp/mcp_query_service_test.dart | 2 +- test/core/mcp/mcp_socket_host_test.dart | 57 +++++++++++++++++++ test/core/mcp/mcp_sql_guard_test.dart | 18 ++++++ .../platform/file_launch_service_test.dart | 4 +- .../main_screen_file_launch_test.dart | 2 +- 11 files changed, 165 insertions(+), 32 deletions(-) create mode 100644 test/core/mcp/mcp_socket_host_test.dart 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/sandbox_process_runner.dart b/lib/core/extensions/sandbox/sandbox_process_runner.dart index e793eac1..979e78ca 100644 --- a/lib/core/extensions/sandbox/sandbox_process_runner.dart +++ b/lib/core/extensions/sandbox/sandbox_process_runner.dart @@ -125,24 +125,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/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..e8fbf830 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. diff --git a/lib/core/platform/file_launch_service.dart b/lib/core/platform/file_launch_service.dart index 43117eea..22023644 100644 --- a/lib/core/platform/file_launch_service.dart +++ b/lib/core/platform/file_launch_service.dart @@ -54,9 +54,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(); @@ -153,7 +150,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 +171,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/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 8a86649f..ab203462 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -80,7 +80,7 @@ class _MainScreenState extends State { final connections = await LocalDb.instance.getConnections(); if (connections.isEmpty && mounted && - !FileLaunchService.instance.hasPendingTarget) { + !FileLaunchService.instance.hasPendingTargets) { _onOpenWelcomeTour(); } } diff --git a/test/core/mcp/mcp_query_service_test.dart b/test/core/mcp/mcp_query_service_test.dart index 3449c010..b515303a 100644 --- a/test/core/mcp/mcp_query_service_test.dart +++ b/test/core/mcp/mcp_query_service_test.dart @@ -212,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'))), ); 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..f2e140fe 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); 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/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); From affa8b295d0050c1ee2d4a62a501ddd8fe2dfe96 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:56:51 +0300 Subject: [PATCH 098/157] fix: cache JSON/XML detection in the cell inspector, check chmod of the TLS key file, log audit write failures (#1680) --- .../security/ssl_certificate_support.dart | 16 +++++++- lib/core/storage/local_db.dart | 9 +++-- .../grid_cell_popover_inspector.dart | 37 +++++++++++++------ 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/lib/core/security/ssl_certificate_support.dart b/lib/core/security/ssl_certificate_support.dart index ae6e3852..aa4f46d9 100644 --- a/lib/core/security/ssl_certificate_support.dart +++ b/lib/core/security/ssl_certificate_support.dart @@ -174,9 +174,21 @@ Future resolveMongoTlsCertificateKeyFile({ 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; } diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index ffae4447..109548cf 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -583,8 +583,9 @@ class LocalDb { [kMutationAuditCap], ); } - } catch (_) { - // Best effort. + } catch (e) { + // Best effort, but a missing audit trail should not be invisible. + debugPrint('LocalDb: could not record a mutation audit entry: $e'); } } @@ -624,8 +625,8 @@ class LocalDb { 'SELECT id FROM mcp_activity ORDER BY id DESC LIMIT 1 OFFSET ?)', [kMcpActivityCap], ); - } catch (_) { - // Best effort. + } catch (e) { + debugPrint('LocalDb: could not record MCP activity: $e'); } } diff --git a/lib/features/workspace/grid_cell_popover_inspector.dart b/lib/features/workspace/grid_cell_popover_inspector.dart index 6ff292b9..845f9c68 100644 --- a/lib/features/workspace/grid_cell_popover_inspector.dart +++ b/lib/features/workspace/grid_cell_popover_inspector.dart @@ -169,26 +169,39 @@ class _GridCellInspectorDialogState }); } - bool _isJson() { - final text = _controller.text.trim(); + // 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). + String? _kindText; + var _kindIsJson = false; + var _kindIsXml = false; + + void _refreshKind() { + final raw = _controller.text; + if (identical(raw, _kindText)) return; + _kindText = raw; + final text = raw.trim(); + _kindIsJson = false; + _kindIsXml = false; if ((text.startsWith('{') && text.endsWith('}')) || (text.startsWith('[') && text.endsWith(']'))) { try { jsonDecode(text); - return true; - } catch (_) { - return false; - } + _kindIsJson = true; + } catch (_) {} + } else if (text.startsWith('<') && text.endsWith('>')) { + _kindIsXml = XmlHtmlFormatter.validate(text) == null; } - return 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() { From c2c7aa711e6730c1e0bc7b0da9224b5a8ec9764f Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:05:27 +0300 Subject: [PATCH 099/157] perf: cache the Redis folder key count; groupings parse numbers without copying the cell (#1682) --- lib/features/redis/redis_key_tree_engine.dart | 10 +++++++++- .../workspace/grid_groupings_engine.dart | 12 ++++++++++-- .../workspace/grid_selection_calc_engine.dart | 1 - .../workspace/data_grid_engines_test.dart | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lib/features/redis/redis_key_tree_engine.dart b/lib/features/redis/redis_key_tree_engine.dart index 2185d7f6..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; 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 633c7923..6552a432 100644 --- a/lib/features/workspace/grid_selection_calc_engine.dart +++ b/lib/features/workspace/grid_selection_calc_engine.dart @@ -267,7 +267,6 @@ abstract final class GridSelectionCalcEngine { /// 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`. - @foundation.visibleForTesting static double? parseThousandsSeparated(String s) { final n = s.length; var i = 0; 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], From b16cb808b6d8391473f5db2b31d5867123b35aa3 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:13:31 +0300 Subject: [PATCH 100/157] perf(grid): cheaper per-row filter predicates, one selection test per row, cheaper clipboard export (#1681) * perf(grid): filter predicates resolve their operator once and parse a cell only for numeric targets; one selection test per row; cheaper clipboard export * fix(grid): drop redundant null assertions; number pre-check as a conditional --- .../workspace/grid/grid_row_renderer.dart | 73 ++++----- .../grid/grid_selection_manager.dart | 64 +++++--- .../workspace/grid_filter_engine.dart | 149 ++++++++++++------ .../workspace/grid_filter_predicate_test.dart | 14 ++ .../workspace/grid_selection_export_test.dart | 39 +++++ 5 files changed, 232 insertions(+), 107 deletions(-) diff --git a/lib/features/workspace/grid/grid_row_renderer.dart b/lib/features/workspace/grid/grid_row_renderer.dart index 9a711d69..f8986092 100644 --- a/lib/features/workspace/grid/grid_row_renderer.dart +++ b/lib/features/workspace/grid/grid_row_renderer.dart @@ -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], + colorScheme: colorScheme, + 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 9ceb4e23..c9a03fe9 100644 --- a/lib/features/workspace/grid/grid_selection_manager.dart +++ b/lib/features/workspace/grid/grid_selection_manager.dart @@ -127,13 +127,15 @@ class ResultGridSelection { } else if (_hasLeadingZeroDigits(val.trim())) { // Preserve numeric strings with leading zeros (e.g. '01234', '007') map[colName] = val; - } else if (int.tryParse(val) case final asInt?) { + } else if ((_mayBeNumber(val) ? int.tryParse(val) : null) + case final asInt?) { map[colName] = asInt; - } else if (double.tryParse(val) case final asDouble?) { + } else if ((_mayBeNumber(val) ? double.tryParse(val) : null) + case final asDouble?) { map[colName] = asDouble; - } else if (val.toLowerCase() == 'true') { + } 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; @@ -147,6 +149,34 @@ class ResultGridSelection { return const JsonEncoder.withIndent(' ').convert(result); } + /// 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 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; + } + /// `^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; @@ -157,22 +187,18 @@ class ResultGridSelection { return true; } - static String _escapeTsv(String val) { - if (val.contains('\t') || - val.contains('\n') || - val.contains('\r') || - val.contains('"')) { - return '"${val.replaceAll('"', '""')}"'; - } - return val; - } + static String _escapeTsv(String val) => _quoteIfAny(val, 0x09); + + static String _escapeCsv(String val) => _quoteIfAny(val, 0x2C); - static String _escapeCsv(String val) { - if (val.contains(',') || - val.contains('"') || - val.contains('\n') || - val.contains('\r')) { - return '"${val.replaceAll('"', '""')}"'; + /// 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_filter_engine.dart b/lib/features/workspace/grid_filter_engine.dart index ccbd94d6..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,6 +183,22 @@ 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 { /// 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 @@ -187,7 +211,8 @@ class _PredicateNode extends _FilterAstNode { this.betweenMin, this.betweenMax, this.likeRegex, - }) : _upperOp = op.toUpperCase().trim(), + }) : _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()}, @@ -205,7 +230,8 @@ class _PredicateNode extends _FilterAstNode { /// per-row scan does not rebuild the same RegExp for every row. final RegExp? likeRegex; - final String _upperOp; + final _PredicateKind _kind; + final _Comparison _cmp; final String _lowerTarget; final double? _numTarget; final Set _inSet; @@ -217,16 +243,16 @@ class _PredicateNode extends _FilterAstNode { if (colIndex < 0 || colIndex >= row.length) return false; final cellValue = row[colIndex]; - switch (_upperOp) { - case 'IS NULL': + switch (_kind) { + case _PredicateKind.isNull: return _isNull(cellValue); - case 'IS NOT NULL': + case _PredicateKind.isNotNull: return !_isNull(cellValue); - case 'IN': + case _PredicateKind.inList: return _inSet.contains(cellValue.toLowerCase().trim()); - case 'NOT IN': + case _PredicateKind.notInList: return !_inSet.contains(cellValue.toLowerCase().trim()); - case 'BETWEEN': + case _PredicateKind.between: final min = betweenMin; final max = betweenMax; if (min != null && max != null) { @@ -238,69 +264,95 @@ class _PredicateNode extends _FilterAstNode { } return cellValue.compareTo(min) >= 0 && cellValue.compareTo(max) <= 0; } - case 'LIKE': + case _PredicateKind.like: return (likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true)) .hasMatch(cellValue); - case 'NOT LIKE': + case _PredicateKind.notLike: return !(likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true)) .hasMatch(cellValue); - case 'ILIKE': + case _PredicateKind.ilike: return (likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false)) .hasMatch(cellValue); - case 'NOT ILIKE': + 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 - final numCell = double.tryParse(cellValue.trim()); + // 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 (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 (numTarget != null) { + final numCell = double.tryParse(cellValue.trim()); + 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 final lowerCell = cellValue.toLowerCase(); final lowerTarget = _lowerTarget; - switch (op) { - case '=': - case '==': + 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; @@ -322,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; @@ -771,7 +824,7 @@ class _FilterParser { return _FreeTextNode(token.value); } - return const _FreeTextNode(''); + return _FreeTextNode(''); } bool _match(_TokenType type) { diff --git a/test/features/workspace/grid_filter_predicate_test.dart b/test/features/workspace/grid_filter_predicate_test.dart index f8af9ac6..fe035b1e 100644 --- a/test/features/workspace/grid_filter_predicate_test.dart +++ b/test/features/workspace/grid_filter_predicate_test.dart @@ -50,6 +50,20 @@ void main() { 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_selection_export_test.dart b/test/features/workspace/grid_selection_export_test.dart index b2c904f3..d8b2af1f 100644 --- a/test/features/workspace/grid_selection_export_test.dart +++ b/test/features/workspace/grid_selection_export_test.dart @@ -91,6 +91,45 @@ void main() { }); }); + 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, From c56f041f6dc6e738e5bfc192999a57055dddea22 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:13:39 +0300 Subject: [PATCH 101/157] perf(grid): CJK font fallback for cells and the shared mono stack; cells share their text style and decoration (#1553, #1519) (#1684) --- lib/core/theme/querya_typography.dart | 31 ++++ .../workspace/data_grid_groupings_view.dart | 3 + .../workspace/grid/grid_cell_renderer.dart | 156 +++++++++++++----- lib/features/workspace/grid_cell_editor.dart | 2 + lib/features/workspace/result_grid_view.dart | 1 + test/core/theme/querya_typography_test.dart | 16 +- 6 files changed, 162 insertions(+), 47 deletions(-) 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/features/workspace/data_grid_groupings_view.dart b/lib/features/workspace/data_grid_groupings_view.dart index fa0722d7..a83284f2 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'; @@ -320,6 +321,8 @@ class _DataGridGroupingsViewState fontSize: 13, fontWeight: FontWeight.w600, fontFamily: 'monospace', + fontFamilyFallback: + QueryaTypography.cjkFontFamilyFallback, ), ), ), diff --git a/lib/features/workspace/grid/grid_cell_renderer.dart b/lib/features/workspace/grid/grid_cell_renderer.dart index 1fc339a0..4d9c889c 100644 --- a/lib/features/workspace/grid/grid_cell_renderer.dart +++ b/lib/features/workspace/grid/grid_cell_renderer.dart @@ -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( + colorScheme, + isNull: isNull, + isDeleted: isDeleted, + ); + final decoration = _CellLook.decoration( + colorScheme, + 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, @@ -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 ColorScheme? _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(ColorScheme scheme) { + if (identical(_scheme, scheme)) return; + _scheme = scheme; + _styles.fillRange(0, _styles.length, null); + _decorations.clear(); + } + + static material.TextStyle style( + ColorScheme 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( + ColorScheme 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_cell_editor.dart b/lib/features/workspace/grid_cell_editor.dart index 26196ac5..4ae8b71b 100644 --- a/lib/features/workspace/grid_cell_editor.dart +++ b/lib/features/workspace/grid_cell_editor.dart @@ -2,6 +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: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; @@ -188,6 +189,7 @@ class _GridCellEditorState extends material.State { style: const material.TextStyle( fontSize: 12, fontFamily: 'monospace', + fontFamilyFallback: QueryaTypography.cjkFontFamilyFallback, ), decoration: const material.InputDecoration( border: material.InputBorder.none, diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 13a7c306..bf1b8cdc 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -16,6 +16,7 @@ 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'; 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', () { From ab84e4c7eb9dbb6e85ac9cfb68abe90ec66399a8 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:20:52 +0300 Subject: [PATCH 102/157] perf(erd): relation painter reuses one Paint, the rounded paths and the laid-out cardinality labels (#1518) (#1685) --- .../erd/widgets/erd_relation_painter.dart | 52 +++++++--- .../erd/erd_relation_painter_test.dart | 98 +++++++++++++++++++ 2 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 test/features/erd/erd_relation_painter_test.dart 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/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); + }); + }); +} From c12e3831045740f48c6388805427092c467dd7f9 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:21:04 +0300 Subject: [PATCH 103/157] perf(editor): share token TextStyles between segments instead of copyWith per token (#1543) (#1686) --- lib/core/editor/syntax_highlight_isolate.dart | 37 +++++++--- .../editor/syntax_highlight_isolate_test.dart | 67 +++++++++++++++++++ 2 files changed, 96 insertions(+), 8 deletions(-) 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/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)); + } + }); + }); } From 1d6ee75863f538d1d678b0fd310be87862a56c79 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:31:15 +0300 Subject: [PATCH 104/157] fix(sql): statement splitter and destructive/mutation checks read backslashes as the dialect does; splitter built on SqlLexicalScanner (#1687) --- .../database/destructive_sql_detector.dart | 53 +++++-- lib/core/database/sql_lexical_scanner.dart | 4 +- .../database/sql_mutation_classifier.dart | 19 ++- lib/core/database/sql_statement_splitter.dart | 129 ++++-------------- lib/core/database/table_mutation_engine.dart | 7 + lib/core/mcp/mcp_sql_guard.dart | 22 ++- .../workspace/generic_sql_workspace.dart | 12 +- .../destructive_sql_detector_test.dart | 25 ++++ .../sql_mutation_classifier_test.dart | 10 ++ .../database/sql_statement_splitter_test.dart | 50 +++++++ test/core/mcp/mcp_sql_guard_test.dart | 19 +++ 11 files changed, 223 insertions(+), 127 deletions(-) diff --git a/lib/core/database/destructive_sql_detector.dart b/lib/core/database/destructive_sql_detector.dart index cbb94996..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 @@ -267,7 +268,12 @@ abstract final class DestructiveSqlDetector { /// 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) { + static int _skipStringLiteral( + String sql, + int start, + int len, { + required bool backslashEscapes, + }) { var i = start; while (i < len) { final c = sql.codeUnitAt(i); @@ -277,7 +283,7 @@ abstract final class DestructiveSqlDetector { } else { return i + 1; // closing quote } - } else if (c == _chBackslash && i + 1 < len) { + } else if (backslashEscapes && c == _chBackslash && i + 1 < len) { i += 2; // escaped char } else { i++; @@ -292,7 +298,14 @@ abstract final class DestructiveSqlDetector { /// 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). - static String stripCommentsAndStrings(String sql) { + /// + /// [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; @@ -322,7 +335,13 @@ abstract final class DestructiveSqlDetector { } replacement = "''"; } else if (c == _chQuote) { - after = _skipStringLiteral(sql, i + 1, len); + after = _skipStringLiteral( + sql, + i + 1, + len, + backslashEscapes: + backslashEscapes || SqlLexicalScanner.isEscapeStringPrefix(sql, i), + ); replacement = "''"; } else { i++; @@ -341,17 +360,33 @@ abstract final class DestructiveSqlDetector { /// 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 diff --git a/lib/core/database/sql_lexical_scanner.dart b/lib/core/database/sql_lexical_scanner.dart index f36296da..7125c0df 100644 --- a/lib/core/database/sql_lexical_scanner.dart +++ b/lib/core/database/sql_lexical_scanner.dart @@ -118,7 +118,7 @@ abstract final class SqlLexicalScanner { } if (code == _chSingleQuote) { - final escapes = backslashEscapes || _isEscapeStringPrefix(sql, i); + final escapes = backslashEscapes || isEscapeStringPrefix(sql, i); var j = i + 1; while (j < len) { final curr = sql.codeUnitAt(j); @@ -169,7 +169,7 @@ abstract final class SqlLexicalScanner { /// 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) { + 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; 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/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/mcp/mcp_sql_guard.dart b/lib/core/mcp/mcp_sql_guard.dart index e8fbf830..caa0da5f 100644 --- a/lib/core/mcp/mcp_sql_guard.dart +++ b/lib/core/mcp/mcp_sql_guard.dart @@ -74,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) { @@ -90,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(); @@ -113,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/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 7b00f606..6c65b58c 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -594,6 +594,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. @@ -623,7 +624,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( @@ -843,6 +847,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; @@ -851,7 +856,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]; 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/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/mcp/mcp_sql_guard_test.dart b/test/core/mcp/mcp_sql_guard_test.dart index f2e140fe..d9392012 100644 --- a/test/core/mcp/mcp_sql_guard_test.dart +++ b/test/core/mcp/mcp_sql_guard_test.dart @@ -151,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); + }); } From 26abaa26e7bf980ae7f4224433eb7107db512a9d Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:39:18 +0300 Subject: [PATCH 105/157] perf(inspector): detect, format and minify large JSON/XML cells on a worker isolate (#1688) --- .../grid_cell_popover_inspector.dart | 160 +++++++++++++----- .../workspace/grid_cell_editor_test.dart | 84 +++++++++ 2 files changed, 200 insertions(+), 44 deletions(-) diff --git a/lib/features/workspace/grid_cell_popover_inspector.dart b/lib/features/workspace/grid_cell_popover_inspector.dart index 845f9c68..d00f7de4 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(); @@ -171,27 +215,53 @@ class _GridCellInspectorDialogState // 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). + // 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; - final text = raw.trim(); _kindIsJson = false; _kindIsXml = false; - if ((text.startsWith('{') && text.endsWith('}')) || - (text.startsWith('[') && text.endsWith(']'))) { - try { - jsonDecode(text); - _kindIsJson = true; - } catch (_) {} - } else if (text.startsWith('<') && text.endsWith('>')) { - _kindIsXml = XmlHtmlFormatter.validate(text) == null; + final text = raw.trim(); + if (!_looksLikeJson(text) && !_looksLikeXml(text)) return; + if (raw.length >= inspectorIsolateThreshold) { + _detectInBackground(); + return; } + 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() { @@ -312,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), @@ -328,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/test/features/workspace/grid_cell_editor_test.dart b/test/features/workspace/grid_cell_editor_test.dart index 534952c7..20299ce2 100644 --- a/test/features/workspace/grid_cell_editor_test.dart +++ b/test/features/workspace/grid_cell_editor_test.dart @@ -6,6 +6,7 @@ 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'; @@ -196,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; From ec184ceeb549b7e5ce28fe3a88a32b757c521d11 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:20:51 +0300 Subject: [PATCH 106/157] fix(updater): the Beta channel offers the highest version, not the first release by date (#1493) (#1691) --- lib/core/updater/github_releases_client.dart | 36 +++++-- .../updater/github_releases_beta_test.dart | 93 +++++++++++++++++++ 2 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 test/core/updater/github_releases_beta_test.dart 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/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'); + }); + }); +} From 04714ac4cbf9ae0c6a91f386f0b9ea5cd4b96d62 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:20:59 +0300 Subject: [PATCH 107/157] fix(mcp): serialize McpServerController start/stop so overlapping calls cannot leak a socket host (#1563) (#1692) --- lib/core/mcp/mcp_server_controller.dart | 30 +++++++++++++----- test/core/mcp/mcp_server_test.dart | 41 +++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/lib/core/mcp/mcp_server_controller.dart b/lib/core/mcp/mcp_server_controller.dart index ba95bc83..428b1fcd 100644 --- a/lib/core/mcp/mcp_server_controller.dart +++ b/lib/core/mcp/mcp_server_controller.dart @@ -99,7 +99,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 +139,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()) { @@ -166,11 +182,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/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); From b9a337ccf2ca1ef97fe65e67e10f6344237d9742 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:21:06 +0300 Subject: [PATCH 108/157] fix(team): profile import skips and counts unreadable entries instead of throwing; scrub pwd, private keys, access keys, credentials and signatures (#1564) (#1693) --- lib/core/team/team_profile.dart | 76 +++++++++++++---- .../connections/team_profile_actions.dart | 17 ++-- test/core/team/team_profile_test.dart | 84 +++++++++++++++++++ 3 files changed, 155 insertions(+), 22 deletions(-) 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/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/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']); + }); + }); } From 58c3b63c6c93ec42445932f79bd06928878c6ec2 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:21:19 +0300 Subject: [PATCH 109/157] =?UTF-8?q?fix(status-bar):=20a=20result=20without?= =?UTF-8?q?=20a=20message=20clears=20the=20status=20text=20instead=20of=20?= =?UTF-8?q?keeping=20'Running=20query=E2=80=A6'=20(#1556)=20(#1694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/ui/querya_shell_status.dart | 5 ++- test/core/ui/querya_shell_status_test.dart | 36 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) 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/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).'); From 23cf3513d5b74b45ed1c2be6f9a5589246ebbae1 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:21:30 +0300 Subject: [PATCH 110/157] fix(settings): log why PreferencesDialog could not load the preferences (#1637) (#1695) * fix(settings): log why PreferencesDialog could not load the preferences (#1637) * fix(settings): drop the redundant foundation import (debugPrint comes through widgets.dart) --- lib/features/settings/preferences_dialog.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 8e35828b..c74dea06 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -127,7 +127,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 { From cfd6ac1eac5288bd2e9ae308550d2f784647f5c8 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:23:34 +0300 Subject: [PATCH 111/157] fix(mongodb): find never reads an unbounded result; .limit(0) is capped and the summary says so (#1357) (#1696) --- lib/core/database/mongodb_service.dart | 15 +++++++++--- .../mongodb/mongo_query_executor.dart | 14 ++++++++++- test/core/database/mongo_service_test.dart | 14 +++++++++++ .../mongodb/mongo_find_summary_test.dart | 24 +++++++++++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 test/features/mongodb/mongo_find_summary_test.dart 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/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/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/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'); + }); + }); +} From 5ef7723319005e3ea29d40ee4ebbd2c766d60923 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:23:55 +0300 Subject: [PATCH 112/157] docs(getting-started): never suggest flutter create on this repo; steer Linux users to run_linux.sh; list the build dependencies (#1501) (#1697) --- docs/getting-started.md | 43 ++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index d90e08ea..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 From 68f0d4a724f902b938f98191812718a27928e567 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:38:40 +0300 Subject: [PATCH 113/157] motion: toasts animate out and give their height back; the welcome tour slides in the direction of travel (#1645) (#1699) --- .../onboarding/welcome_tour_dialog.dart | 28 +++++++++-- lib/shared/widgets/app_toast.dart | 33 +++++++++---- .../onboarding/welcome_tour_dialog_test.dart | 40 ++++++++++++++++ test/shared/app_toast_test.dart | 46 +++++++++++++++++++ 4 files changed, 134 insertions(+), 13 deletions(-) diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index d2d624c2..1d36b2d4 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -146,15 +146,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 +173,10 @@ class _WelcomeTourDialogState extends State { void _previous() { if (_currentStep > 0) { - setState(() => _currentStep--); + setState(() { + _forward = false; + _currentStep--; + }); } } @@ -287,11 +301,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, 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/test/features/onboarding/welcome_tour_dialog_test.dart b/test/features/onboarding/welcome_tour_dialog_test.dart index e99497f0..374480ab 100644 --- a/test/features/onboarding/welcome_tour_dialog_test.dart +++ b/test/features/onboarding/welcome_tour_dialog_test.dart @@ -109,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; 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); + }); } From 82baa5ea1c32f260d7b03a4a0ca2b089028aaf27 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:45:17 +0300 Subject: [PATCH 114/157] fix(ui): destructive-query dialog fits small windows; long table names in DML preview are cut (#1642) (#1698) * fix(ui): destructive-query dialog fits small windows and UI scale; long table names in DML preview are cut (#1642) * test(ui): find the destructive dialog by its card * fix(ui): destructive-query dialog body scrolls when the window is shorter than it (#1642) --- .../workspace/destructive_query_dialog.dart | 421 +++++++++--------- .../workspace/dml_preview_dialog.dart | 17 +- .../destructive_query_dialog_test.dart | 32 ++ .../workspace/dml_preview_dialog_test.dart | 26 ++ 4 files changed, 291 insertions(+), 205 deletions(-) diff --git a/lib/features/workspace/destructive_query_dialog.dart b/lib/features/workspace/destructive_query_dialog.dart index 794cbd89..5b6cc08c 100644 --- a/lib/features/workspace/destructive_query_dialog.dart +++ b/lib/features/workspace/destructive_query_dialog.dart @@ -1,6 +1,8 @@ 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'; @@ -105,239 +107,260 @@ class _DestructiveQueryDialogState final isDark = theme.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: context.workbench.editorBackground, - 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: QueryaTypography.mono, - fontFamilyFallback: - QueryaTypography.monoFontFamilyFallback, - fontSize: 12.5, - height: 1.45, - color: cs.foreground, + 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 c741f740..4c1e777f 100644 --- a/lib/features/workspace/dml_preview_dialog.dart +++ b/lib/features/workspace/dml_preview_dialog.dart @@ -124,12 +124,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(), + ), ], ), ), diff --git a/test/features/workspace/destructive_query_dialog_test.dart b/test/features/workspace/destructive_query_dialog_test.dart index 748088d4..ee9eaddc 100644 --- a/test/features/workspace/destructive_query_dialog_test.dart +++ b/test/features/workspace/destructive_query_dialog_test.dart @@ -3,6 +3,7 @@ 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'; @@ -330,5 +331,36 @@ void main() { QueryaTheme.lightDefault.colorScheme.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 cfa09de9..24f465e2 100644 --- a/test/features/workspace/dml_preview_dialog_test.dart +++ b/test/features/workspace/dml_preview_dialog_test.dart @@ -184,5 +184,31 @@ void main() { 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); + }); }); } From 45bcbc8ed21eb2277dc08239e245a4a2d6b2635f Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:46:22 +0300 Subject: [PATCH 115/157] ux(sql-workspace): query errors show the driver message as a headline, details folded and without the app's wrapper, with Copy (#1557) (#1701) --- lib/core/database/query_error_text.dart | 72 ++++++++++ lib/features/workspace/results_tab.dart | 130 +++++++++++++----- test/core/database/query_error_text_test.dart | 73 ++++++++++ test/features/workspace/results_tab_test.dart | 83 ++++++++++- 4 files changed, 323 insertions(+), 35 deletions(-) create mode 100644 lib/core/database/query_error_text.dart create mode 100644 test/core/database/query_error_text_test.dart diff --git a/lib/core/database/query_error_text.dart b/lib/core/database/query_error_text.dart new file mode 100644 index 00000000..91a3488a --- /dev/null +++ b/lib/core/database/query_error_text.dart @@ -0,0 +1,72 @@ +/// 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)}…'; +} diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 57be122c..1c4dc1e4 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -4,6 +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/database/query_error_text.dart'; import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; import 'package:querya_desktop/features/results/charts/quick_chart_view.dart'; import 'package:querya_desktop/features/workspace/data_grid_calc_bar.dart'; @@ -84,6 +85,10 @@ class ResultsTab extends material.StatefulWidget { } class _ResultsTabState extends material.State { + /// The driver text of a query error is folded away until asked for (#1557). + bool _showErrorDetails = false; + bool _errorCopied = false; + /// 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. @@ -249,6 +254,10 @@ class _ResultsTabState extends material.State { @override void didUpdateWidget(covariant ResultsTab oldWidget) { + if (oldWidget.errorMessage != widget.errorMessage) { + _showErrorDetails = false; + _errorCopied = false; + } super.didUpdateWidget(oldWidget); _syncGridBridge(); if (!identical(oldWidget.rows, widget.rows) || @@ -379,56 +388,109 @@ class _ResultsTabState extends material.State { super.dispose(); } - @override - material.Widget build(material.BuildContext context) { - if (widget.isLoading) { - return const material.Center( - key: material.ValueKey('results_mode_loading'), - child: QueryaSpinner( - size: QueryaSpinnerSize.sm, - label: 'Executing query...', - ), - ); - } - - if (widget.errorMessage != null) { - return material.Center( - 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, + /// The driver's message as the headline, the rest (without the app's own + /// statement wrapping) behind "Show details", and a Copy button (#1557). + material.Widget _buildError( + material.BuildContext context, + QueryErrorText error, + ) { + final cs = Theme.of(context).colorScheme; + return material.Center( + 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: cs.destructive, + ), + const Gap(16), + const Text('Query Error').large().semiBold(), + const Gap(8), + material.SelectableText( + error.summary, + textAlign: 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: _showErrorDetails ? 'Hide details' : 'Show details', + icon: _showErrorDetails + ? material.Icons.expand_less_rounded + : material.Icons.expand_more_rounded, + size: QueryaControlSize.sm, + onPressed: () => setState( + () => _showErrorDetails = !_showErrorDetails, + ), + ), + const Gap(6), + QueryaButton.ghost( + label: _errorCopied ? 'Copied' : 'Copy error', + icon: _errorCopied + ? material.Icons.check_rounded + : material.Icons.copy_rounded, + size: QueryaControlSize.sm, + onPressed: () => unawaited(_copyError(error.detail)), + ), + ], ), - const Gap(16), - const Text('Query Error').large().semiBold(), + ], + if (error.hasDetail && _showErrorDetails) ...[ 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!, + text: error.detail, style: material.TextStyle( - color: Theme.of(context).colorScheme.mutedForeground, + color: cs.mutedForeground, fontSize: 13, ), ), ), - if (widget.errorAction != null) ...[ - const Gap(16), - widget.errorAction!, - ], ], - ), + if (widget.errorAction != null) ...[ + const Gap(16), + widget.errorAction!, + ], + ], + ), + ), + ); + } + + Future _copyError(String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (!mounted) return; + setState(() => _errorCopied = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _errorCopied = false); + } + + @override + material.Widget build(material.BuildContext context) { + if (widget.isLoading) { + return const material.Center( + key: material.ValueKey('results_mode_loading'), + child: QueryaSpinner( + size: QueryaSpinnerSize.sm, + label: 'Executing query...', ), ); } + if (widget.errorMessage != null) { + return _buildError(context, QueryErrorText.parse(widget.errorMessage!)); + } + if (widget.columns.isEmpty && widget.rows.isEmpty && widget.stagingBuffer == null) { if (widget.statusLine != null) { return material.Padding( 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..b289642e --- /dev/null +++ b/test/core/database/query_error_text_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; + +void main() { + 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); + }); + }); +} diff --git a/test/features/workspace/results_tab_test.dart b/test/features/workspace/results_tab_test.dart index 69615aa8..b2f4ef90 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'; @@ -1457,4 +1458,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); + }); + }); } From d4abb8275a7b3d7a70c4406aba2ab1430db67c6c Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:47:12 +0300 Subject: [PATCH 116/157] ux(button): a cut-short QueryaButton label shows in full as a tooltip (#1689) (#1700) * ux(button): a QueryaButton label cut short shows its full text as a tooltip (#1689) * ux(button): report a cut label from a render proxy, not a LayoutBuilder, so buttons keep working in intrinsic layouts * fix(button): import the render proxy types from rendering --- lib/shared/widgets/querya_button.dart | 85 +++++++++++++++++++-- test/shared/widgets/querya_button_test.dart | 72 +++++++++++++++++ 2 files changed, 152 insertions(+), 5 deletions(-) diff --git a/lib/shared/widgets/querya_button.dart b/lib/shared/widgets/querya_button.dart index 109670b0..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'; @@ -293,6 +294,40 @@ class _QueryaButtonState extends material.State { } + /// 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) { final w = widget; @@ -370,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) ...[ @@ -535,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/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); + }); + }); } From 294468180615eb598b330e18ed8e647bd256eede Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:59:51 +0300 Subject: [PATCH 117/157] fix(macos): detect the App Sandbox; the updater refuses to replace a sandboxed app, MCP and extensions say why they cannot work (#1593) (#1702) --- lib/core/platform/macos_sandbox.dart | 38 ++++++++++++++++ .../installers/update_install_context.dart | 7 +++ .../preferences_extensions_section.dart | 8 ++++ .../settings/preferences_mcp_section.dart | 8 ++++ test/core/platform/macos_sandbox_test.dart | 44 +++++++++++++++++++ .../macos_app_replace_script_test.dart | 12 +++++ .../preferences_extensions_section_test.dart | 32 ++++++++++++++ 7 files changed, 149 insertions(+) create mode 100644 lib/core/platform/macos_sandbox.dart create mode 100644 test/core/platform/macos_sandbox_test.dart 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/updater/installers/update_install_context.dart b/lib/core/updater/installers/update_install_context.dart index 0183cbe7..7cf0f622 100644 --- a/lib/core/updater/installers/update_install_context.dart +++ b/lib/core/updater/installers/update_install_context.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/platform/macos_sandbox.dart'; import '../app_updater_service.dart'; @@ -92,6 +93,12 @@ class UpdateInstallContext { /// 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 ' diff --git a/lib/features/settings/preferences_extensions_section.dart b/lib/features/settings/preferences_extensions_section.dart index cc0b8019..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'; @@ -106,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, diff --git a/lib/features/settings/preferences_mcp_section.dart b/lib/features/settings/preferences_mcp_section.dart index 31da9fb7..31f25730 100644 --- a/lib/features/settings/preferences_mcp_section.dart +++ b/lib/features/settings/preferences_mcp_section.dart @@ -6,6 +6,7 @@ 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'; @@ -177,6 +178,13 @@ class PreferencesMcpSectionState extends material.State { '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), if (!_loaded) const material.Padding( 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/updater/macos_app_replace_script_test.dart b/test/core/updater/macos_app_replace_script_test.dart index 9948650c..39bfbf57 100644 --- a/test/core/updater/macos_app_replace_script_test.dart +++ b/test/core/updater/macos_app_replace_script_test.dart @@ -162,6 +162,18 @@ void main() { ); }); + 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) diff --git a/test/features/settings/preferences_extensions_section_test.dart b/test/features/settings/preferences_extensions_section_test.dart index 3191d53e..93c152c1 100644 --- a/test/features/settings/preferences_extensions_section_test.dart +++ b/test/features/settings/preferences_extensions_section_test.dart @@ -5,6 +5,7 @@ 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'; @@ -47,6 +48,37 @@ void main() { 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( From 4a751579d0a5ba6edb57695bcb788497565a9bd3 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:00:00 +0300 Subject: [PATCH 118/157] fix(macos): Copy menu item works; Seatbelt profile names real paths and escapes them (#1590) (#1703) --- .../sandbox/sandbox_launch_command.dart | 32 +++++++++++++--- .../macos/querya_platform_menu_bar.dart | 13 ++++++- .../sandbox/sandbox_process_runner_test.dart | 38 +++++++++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) 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/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/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', () { From d362198fcc914015f3a333178d611522ebbd2cd3 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:00:08 +0300 Subject: [PATCH 119/157] fix(updater): a macOS update must carry the Team ID of the installed app, not just an intact seal (#1588) (#1704) --- .../installers/macos_sparkle_installer.dart | 61 ++++++++++++++++++- .../updater/macos_signer_identity_test.dart | 59 ++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 test/core/updater/macos_signer_identity_test.dart diff --git a/lib/core/updater/installers/macos_sparkle_installer.dart b/lib/core/updater/installers/macos_sparkle_installer.dart index f1be5f34..f731adb1 100644 --- a/lib/core/updater/installers/macos_sparkle_installer.dart +++ b/lib/core/updater/installers/macos_sparkle_installer.dart @@ -12,9 +12,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(); @@ -54,6 +59,7 @@ class MacosSparkleInstaller { ); } await _verifyCodesign(found); + await _verifySameSigner(installed: targetApp, update: found.path); newApp = found; } catch (_) { // The app keeps running: do not leave the extracted update behind. @@ -77,8 +83,32 @@ class MacosSparkleInstaller { 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], ); @@ -92,3 +122,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/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, + ); + }); + }); +} From 2678ce4dfe9b4c7582751ac8524f8e95a49ddc28 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:00:38 +0300 Subject: [PATCH 120/157] feat(macos): open files Finder hands over (Open With, double-click) at launch and while running; build the macOS app on PRs that touch it (#1589) (#1705) --- .github/workflows/macos-build.yml | 39 ++++++++++ lib/core/platform/file_launch_service.dart | 6 ++ .../platform/macos_file_open_channel.dart | 48 ++++++++++++ lib/features/main_screen/main_screen.dart | 8 ++ lib/main.dart | 2 + macos/Runner/AppDelegate.swift | 77 +++++++++++++++++++ macos/Runner/MainFlutterWindow.swift | 3 + .../macos_file_open_channel_test.dart | 43 +++++++++++ 8 files changed, 226 insertions(+) create mode 100644 .github/workflows/macos-build.yml create mode 100644 lib/core/platform/macos_file_open_channel.dart create mode 100644 test/core/platform/macos_file_open_channel_test.dart 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/lib/core/platform/file_launch_service.dart b/lib/core/platform/file_launch_service.dart index 22023644..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'; @@ -121,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. 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/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index ab203462..8ea1307c 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -74,6 +74,9 @@ 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) { @@ -90,8 +93,13 @@ class _MainScreenState extends State { }); } + void _onFilesQueued() { + if (mounted) unawaited(_handlePendingFileLaunch()); + } + @override void dispose() { + FileLaunchService.instance.pendingRevision.removeListener(_onFilesQueued); HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); UnsandboxedLaunchConsentGate.instance.handler = null; _safeModeRelockTimer?.cancel(); diff --git a/lib/main.dart b/lib/main.dart index 7cc1489c..3268a7d7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ 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'; @@ -26,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); 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/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/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); + }); + }); +} From c8119089423cb7c4090f26809cbeed9007d40782 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:01:30 +0300 Subject: [PATCH 121/157] docs(macos): installing the unsigned build on macOS 14 and 15+, App Translocation, and the minimum macOS version (#1594, #1591) (#1706) --- README.md | 20 ++++++++++++++------ docs/macos-signing.md | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f3f7fe29..88fedbe5 100644 --- a/README.md +++ b/README.md @@ -151,12 +151,20 @@ flatpak install --user ./Querya-Desktop-*-linux.flatpak 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 Desktop** into your `Applications` folder. - -> **Unsigned builds:** If Gatekeeper blocks the app after installation, run: -> ```bash -> xattr -cr "/Applications/Querya Desktop.app" -> ``` +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). --- diff --git a/docs/macos-signing.md b/docs/macos-signing.md index 15f1c827..f4cec0db 100644 --- a/docs/macos-signing.md +++ b/docs/macos-signing.md @@ -141,6 +141,28 @@ If you later add plugins that require microphone, camera, or other protected res --- +## 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 the vendored `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) From 20701cf9249598236848cdae88bdd7b15fb67526 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:10:22 +0300 Subject: [PATCH 122/157] ci(release): a beta version or tag is published as a GitHub pre-release, never latest, and skips AUR and APT / DNF (#1492) (#1707) --- .github/workflows/release.yml | 37 ++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68272670..4866682d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -490,6 +490,7 @@ jobs: outputs: version: ${{ needs.build-windows.outputs.version }} release_tag: ${{ steps.rel.outputs.tag }} + prerelease: ${{ steps.kind.outputs.prerelease }} steps: - uses: actions/checkout@v4 with: @@ -543,11 +544,39 @@ jobs: 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 "---" @@ -599,6 +628,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 @@ -617,6 +648,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 @@ -655,6 +688,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 From 037ee356aac8147843f7b6df64694f2434c4f932 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:10:30 +0300 Subject: [PATCH 123/157] fix(packaging): .deb and .rpm of a beta sort before the final release (0.5.0~beta.1) (#1494) (#1708) --- scripts/linux/build_deb.sh | 5 +- scripts/linux/build_rpm.sh | 7 +-- scripts/linux/package_version.sh | 22 +++++++++ test/packaging/package_version_test.dart | 60 ++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 4 deletions(-) create mode 100755 scripts/linux/package_version.sh create mode 100644 test/packaging/package_version_test.dart diff --git a/scripts/linux/build_deb.sh b/scripts/linux/build_deb.sh index 5427f546..3cdd273e 100755 --- a/scripts/linux/build_deb.sh +++ b/scripts/linux/build_deb.sh @@ -24,6 +24,9 @@ if ! command -v dpkg-deb >/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/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')); +} From 5eade2b8157e13cb29dfb01b8fc9496d2d679db4 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:18:16 +0300 Subject: [PATCH 124/157] ci(release): check_release.sh and prepare_beta.sh; version-bump keeps a beta version; release branches are checked in CI (#1498) (#1709) --- .github/workflows/ci.yml | 15 +++ .github/workflows/release.yml | 9 ++ .github/workflows/version-bump.yml | 28 ++++-- scripts/release/check_release.sh | 79 +++++++++++++++ scripts/release/prepare_beta.sh | 55 +++++++++++ test/packaging/release_scripts_test.dart | 119 +++++++++++++++++++++++ 6 files changed, 299 insertions(+), 6 deletions(-) create mode 100755 scripts/release/check_release.sh create mode 100755 scripts/release/prepare_beta.sh create mode 100644 test/packaging/release_scripts_test.dart 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/release.yml b/.github/workflows/release.yml index 4866682d..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: | 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/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 < 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); + }); + }); +} From adbb65f2becd592d6ae77939dfcc0161a883d918 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:20:04 +0300 Subject: [PATCH 125/157] feat(updater): back up the local database before an in-app update, refuse a newer schema without changing it, explain the channel row on package installs (#1497) (#1710) --- docs/security.md | 4 + docs/user-guide.md | 6 ++ lib/core/storage/local_db.dart | 53 +++++++++++++ lib/core/storage/update_backup.dart | 72 +++++++++++++++++ lib/core/updater/app_updater_service.dart | 6 +- .../installers/update_install_context.dart | 16 ++++ lib/features/settings/preferences_dialog.dart | 15 +++- lib/features/updater/update_dialog.dart | 78 +++++++++++++++++++ .../storage/local_db_newer_schema_test.dart | 64 +++++++++++++++ test/core/storage/update_backup_test.dart | 78 +++++++++++++++++++ .../updater/app_updater_service_test.dart | 27 +++++++ .../preferences_master_detail_test.dart | 27 +++++++ test/features/updater/update_dialog_test.dart | 76 ++++++++++++++++++ 13 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 lib/core/storage/update_backup.dart create mode 100644 test/core/storage/local_db_newer_schema_test.dart create mode 100644 test/core/storage/update_backup_test.dart 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/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/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 109548cf..a378e903 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -8,6 +8,7 @@ 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/update_backup.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const _dbName = 'querya.db'; @@ -40,6 +41,36 @@ String? _normalizeHistoryDatabaseName(String? databaseName) { return t; } +/// 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, + }); + + /// Schema version stored in the file. + final int found; + + /// Highest schema version this build reads. + final int supported; + + final String path; + + @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. /// File: [applicationSupport]/querya_desktop/querya.db class LocalDb { @@ -84,6 +115,13 @@ class LocalDb { version: _dbVersion, onCreate: _onCreate, onUpgrade: _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: (db) async { await db.execute('PRAGMA foreign_keys = ON'); await db.execute('PRAGMA journal_mode = WAL'); @@ -916,6 +954,21 @@ class LocalDb { } } + /// 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 { 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/updater/app_updater_service.dart b/lib/core/updater/app_updater_service.dart index fc462096..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) { diff --git a/lib/core/updater/installers/update_install_context.dart b/lib/core/updater/installers/update_install_context.dart index 7cf0f622..9cb4f579 100644 --- a/lib/core/updater/installers/update_install_context.dart +++ b/lib/core/updater/installers/update_install_context.dart @@ -1,5 +1,6 @@ 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'; @@ -114,6 +115,21 @@ class UpdateInstallContext { 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 || diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index c74dea06..78f52686 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey; 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'; @@ -576,11 +577,19 @@ class PreferencesDialogContentState ), 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)); }, diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index cde282ac..2b8c75dd 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -5,6 +5,7 @@ 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'; @@ -87,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({ @@ -100,6 +154,7 @@ class UpdateDialogContent extends material.StatefulWidget { this.selectAsset, this.download, this.install, + this.backupLocalData, this.confirmRestartIfUnsaved, this.showCopyToast = true, }); @@ -114,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; @@ -271,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); 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/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/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/features/settings/preferences_master_detail_test.dart b/test/features/settings/preferences_master_detail_test.dart index 20ca31d9..fa682f74 100644 --- a/test/features/settings/preferences_master_detail_test.dart +++ b/test/features/settings/preferences_master_detail_test.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/theme/app_theme.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; @@ -174,6 +175,32 @@ void main() { 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()); 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( From 081938e339643c24a47ab70dda08458effe94fbb Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:20:30 +0300 Subject: [PATCH 126/157] docs(release): Beta channel procedure, promote and rollback, the traps; betas are GitHub-only on package channels (#1496, #1495) (#1711) --- CONTRIBUTING.md | 5 ++++- docs/packaging.md | 6 ++++++ docs/release-checklist.md | 18 +++++++++++++++++ docs/tags-and-releases.md | 41 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 68 insertions(+), 2 deletions(-) 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/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 0cdadc7f..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,6 +101,8 @@ 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). 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. ## Что внутри релиза From 2d9f1cdd3989aaac5aa46417840bda044f4c46ec Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:36:42 +0300 Subject: [PATCH 127/157] release: 0.5.0-beta.1 (#1712) --- CHANGELOG.md | 23 +++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1cf9621..69b3faa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,17 @@ 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). @@ -19,10 +28,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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). diff --git a/pubspec.yaml b/pubspec.yaml index ee61ffc8..8eb429e8 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 From 1cd1b5e7edc14d6f8df9550e41b0f1943b4e8d85 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:54:27 +0300 Subject: [PATCH 128/157] fix(stats): one polling controller for the overview screens (#1570) (#1713) * fix(stats): one polling controller for the five overview screens: one request at a time, backoff on errors, a note when the numbers are stale (#1570) * fix(stats): import ValueListenable in the stale note (#1570) * test(stats): dispose polling controllers inside the test, a pending timer fails the invariants check (#1570) --- lib/core/motion/polling_controller.dart | 179 ++++++++++++++ lib/core/motion/ticker_gated_polling.dart | 24 -- .../extensions/extension_stats_view.dart | 114 +++++---- lib/features/mongodb/mongo_stats_view.dart | 105 ++++----- lib/features/mysql/mysql_stats_view.dart | 136 ++++++----- .../postgresql/postgres_stats_view.dart | 154 ++++++------ lib/features/redis/redis_view.dart | 83 ++++--- lib/shared/widgets/poll_stale_overlay.dart | 61 +++++ lib/shared/widgets/widgets.dart | 1 + test/core/motion/polling_controller_test.dart | 220 ++++++++++++++++++ .../motion/ticker_gated_polling_test.dart | 55 ----- 11 files changed, 741 insertions(+), 391 deletions(-) create mode 100644 lib/core/motion/polling_controller.dart delete mode 100644 lib/core/motion/ticker_gated_polling.dart create mode 100644 lib/shared/widgets/poll_stale_overlay.dart create mode 100644 test/core/motion/polling_controller_test.dart delete mode 100644 test/core/motion/ticker_gated_polling_test.dart 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/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/features/extensions/extension_stats_view.dart b/lib/features/extensions/extension_stats_view.dart index e274e5fd..dd9052e6 100644 --- a/lib/features/extensions/extension_stats_view.dart +++ b/lib/features/extensions/extension_stats_view.dart @@ -1,10 +1,9 @@ -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/shared/widgets/widgets.dart'; @@ -31,11 +30,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 +46,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 +61,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,35 +91,28 @@ 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; if (_loading) { @@ -168,39 +155,42 @@ 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), - ], + ), ), - ), - ); - }, + ); + }, + ), ), - ), + ), ); } diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index 8da265e3..eeb4dc88 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_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/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; @@ -47,12 +46,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 +69,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 +77,6 @@ class _MongoStatsViewState extends material.State { @override void dispose() { - _timer?.cancel(); _disconnectCurrent(); super.dispose(); } @@ -89,7 +87,7 @@ class _MongoStatsViewState extends material.State { } Future _load() async { - _timer?.cancel(); + stopPolling(); _disconnectCurrent(); if (!mounted) return; setState(() { @@ -112,13 +110,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 +145,7 @@ class _MongoStatsViewState extends material.State { _error = e.toString(); _loading = false; }); + syncPolling(); } } } @@ -165,30 +165,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,37 +196,26 @@ 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), - ), - ], - ), + 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), + ), + ], + ), + ), ); } @@ -464,7 +451,7 @@ class _MongoStatsViewState extends material.State { ], onSelected: (value) { setState(() => _autoRefreshInterval = value); - _startTimer(); + syncPolling(); }, ), ), diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index dcb4bfd8..68ccf297 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_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/mysql_service.dart'; @@ -31,7 +30,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 +39,6 @@ class _MysqlStatsViewState extends material.State { final _history = MetricHistory(); bool _loading = true; String? _error; - Timer? _timer; @override void initState() { @@ -51,7 +50,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 +58,6 @@ class _MysqlStatsViewState extends material.State { @override void dispose() { - _timer?.cancel(); _disconnect(); super.dispose(); } @@ -70,7 +68,7 @@ class _MysqlStatsViewState extends material.State { } Future _load() async { - _timer?.cancel(); + stopPolling(); _disconnect(); if (!mounted) return; setState(() { @@ -90,13 +88,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 +107,7 @@ class _MysqlStatsViewState extends material.State { _error = 'Not connected'; _loading = false; }); + syncPolling(); return; } try { @@ -124,6 +124,7 @@ class _MysqlStatsViewState extends material.State { _error = e.toString(); _loading = false; }); + syncPolling(); } } @@ -140,35 +141,29 @@ 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; if (_loading) { @@ -217,47 +212,50 @@ 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), + ], + ), ), - ), - ); - }, + ); + }, + ), ), - ), + ), ); } diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 1a5a8e01..0381db13 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'; @@ -56,7 +55,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 +66,6 @@ class _PostgresStatsViewState extends material.State { final _history = MetricHistory(); bool _loading = true; String? _error; - Timer? _timer; @override void initState() { @@ -78,7 +77,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 +85,6 @@ class _PostgresStatsViewState extends material.State { @override void dispose() { - _timer?.cancel(); _disconnectCurrent(); super.dispose(); } @@ -99,7 +97,7 @@ class _PostgresStatsViewState extends material.State { } Future _load() async { - _timer?.cancel(); + stopPolling(); _disconnectCurrent(); if (!mounted) return; setState(() { @@ -109,7 +107,7 @@ class _PostgresStatsViewState extends material.State { }); if (widget.statsSource != null) { await _fetch(); - if (mounted) _startTimer(); + if (mounted) syncPolling(); return; } try { @@ -124,13 +122,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 +154,7 @@ class _PostgresStatsViewState extends material.State { _error = 'Not connected'; _loading = false; }); + syncPolling(); return; } try { @@ -178,37 +178,30 @@ 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; if (_loading) { @@ -251,56 +244,59 @@ 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), + ], + ), ), - ), - ); - }, + ); + }, + ), ), - ), + ), ); } diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 40f46a16..5bb5a87f 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -2,7 +2,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/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; @@ -74,12 +74,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 +91,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 +99,6 @@ class _RedisViewState extends material.State { @override void dispose() { - _timer?.cancel(); _disconnectCurrent(); super.dispose(); } @@ -118,7 +117,7 @@ class _RedisViewState extends material.State { } Future _load({bool reconnect = false}) async { - _timer?.cancel(); + stopPolling(); if (!mounted) return; setState(() { _loading = true; @@ -160,13 +159,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,51 +193,48 @@ 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), - ), - ], - ), + 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), + ), + ], + ), + ), ); } diff --git a/lib/shared/widgets/poll_stale_overlay.dart b/lib/shared/widgets/poll_stale_overlay.dart new file mode 100644 index 00000000..a4d24e52 --- /dev/null +++ b/lib/shared/widgets/poll_stale_overlay.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/material.dart'; +import 'package:querya_desktop/core/motion/polling_controller.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 scheme = Theme.of(context).colorScheme; + return Tooltip( + message: '${s.lastError}', + child: DecoratedBox( + key: const ValueKey('poll_stale_hint'), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + message(s), + style: TextStyle( + fontSize: 12, + color: scheme.onErrorContainer, + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index caee584b..44aced82 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'; 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); - }); -} From 3eeae0fd1ed06239367045632311d8c24d0f8df9 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:54:36 +0300 Subject: [PATCH 129/157] ci(bench): erd_open_200 diagnoses itself; an aborted bench run fails the job (#1546) (#1714) * ci(bench): the 200-table script is checked, a failed scenario run fails the job and says why (#1546) * ci(bench): open the diagram through the workspace; the toolbar button is outside the window with seven tabs (#1546) --- .github/workflows/benchmark.yml | 10 ++++ benchmark/app_perf_bench.dart | 45 ++++++++++++++++-- .../app_bench_report.cpython-314.pyc | Bin 0 -> 5216 bytes scripts/ci/app_bench_report.py | 15 +++++- scripts/ci/test_app_bench_report.py | 10 ++++ 5 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 scripts/ci/__pycache__/app_bench_report.cpython-314.pyc 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/benchmark/app_perf_bench.dart b/benchmark/app_perf_bench.dart index 840de99f..484e1baa 100644 --- a/benchmark/app_perf_bench.dart +++ b/benchmark/app_perf_bench.dart @@ -81,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); }); } @@ -257,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)); @@ -271,6 +290,22 @@ 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')!; 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 0000000000000000000000000000000000000000..bbf27aa4839b3a6d209032ca02012309fdc552f4 GIT binary patch literal 5216 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 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") From 05ba44526f7614e7f1a98a712ea898bac67f2888 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:34:06 +0300 Subject: [PATCH 130/157] refactor(database): the SSH tunnel steps of the four drivers live in one mixin (#1508) (#1716) --- lib/core/database/mongodb_connection.dart | 44 +++--------- lib/core/database/mysql_connection.dart | 61 +++++------------ lib/core/database/postgres_connection.dart | 58 ++++------------ lib/core/database/redis_connection.dart | 43 +++--------- lib/core/database/ssh_tunnel_host.dart | 70 ++++++++++++++++++++ test/core/database/ssh_tunnel_host_test.dart | 29 ++++++++ 6 files changed, 146 insertions(+), 159 deletions(-) create mode 100644 lib/core/database/ssh_tunnel_host.dart create mode 100644 test/core/database/ssh_tunnel_host_test.dart diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 2bbc4a4e..8b3205dc 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -3,15 +3,15 @@ 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'; /// MongoDB connection configuration and state. -class MongoConnection { +class MongoConnection with SshTunnelHost { MongoConnection({ required this.id, required this.name, @@ -60,11 +60,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 +233,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 +246,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 +269,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 +342,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 { diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index e269fa8d..06f3c0d0 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'; @@ -93,7 +94,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 +138,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; @@ -234,27 +234,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 +256,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 +275,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, @@ -327,15 +309,12 @@ class MysqlConnection { try { await openedHandle?.release(); } catch (_) {} - if (identical(_sshTunnelHandle, openedHandle)) _sshTunnelHandle = null; + 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 +411,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 +433,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`). 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/redis_connection.dart b/lib/core/database/redis_connection.dart index 120483e6..316d52c3 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -4,9 +4,9 @@ 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'; @@ -20,7 +20,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, @@ -110,11 +110,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; @@ -212,28 +211,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 @@ -296,10 +277,7 @@ class RedisConnection { try { socket?.destroy(); } catch (_) {} - try { - await _sshTunnelHandle?.release(); - } catch (_) {} - _sshTunnelHandle = null; + await releaseSshTunnel(); rethrow; } } @@ -340,10 +318,7 @@ class RedisConnection { } } } - try { - await _sshTunnelHandle?.release(); - } catch (_) {} - _sshTunnelHandle = null; + await releaseSshTunnel(); } Future forceClose() => disconnect(); 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/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); + }); +} From b094845b216506fc36449da142f88e60fdfc0f67 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:53:48 +0300 Subject: [PATCH 131/157] refactor(core): swallowed errors in the extension, SSL, MCP, storage and driver cleanup paths leave a line in the debug log (#1507) (#1717) --- lib/core/database/mongodb_connection.dart | 13 ++++++-- lib/core/database/mysql_connection.dart | 17 +++++++--- lib/core/database/redis_connection.dart | 21 +++++++++--- lib/core/database/sqlite_connection.dart | 13 ++++++-- .../extensions/extension_driver_session.dart | 13 ++++++-- .../extensions/harness/extension_harness.dart | 9 +++-- .../extensions/local_extension_installer.dart | 9 +++-- .../sandbox/sandbox_process_runner.dart | 9 +++-- lib/core/mcp/mcp_access_store.dart | 5 ++- lib/core/mcp/mcp_mongo_service.dart | 5 ++- lib/core/mcp/mcp_server_controller.dart | 5 ++- lib/core/security/connection_environment.dart | 9 +++-- lib/core/security/ssh_tunnel_manager.dart | 33 ++++++++++++++----- .../security/ssl_certificate_support.dart | 20 ++++++++--- .../storage/connection_secrets_store.dart | 13 ++++++-- lib/core/storage/local_db.dart | 33 ++++++++++++++----- .../installers/macos_sparkle_installer.dart | 5 ++- lib/core/util/log_ignored.dart | 10 ++++++ 18 files changed, 188 insertions(+), 54 deletions(-) create mode 100644 lib/core/util/log_ignored.dart diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 8b3205dc..b20ca1a9 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -9,6 +9,7 @@ import 'package:querya_desktop/core/security/ssh_tunnel_config.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 with SshTunnelHost { @@ -368,7 +369,9 @@ class MongoConnection with SshTunnelHost { _openedDbs.remove(databaseName); try { await existing.close(); - } catch (_) {} + } catch (e) { + logIgnored('mongodb_connection', e); + } } return _openingDbs.putIfAbsent(databaseName, () async { try { @@ -379,7 +382,9 @@ class MongoConnection with SshTunnelHost { } catch (_) { try { await db.close(); - } catch (_) {} + } catch (e) { + logIgnored('mongodb_connection', e); + } rethrow; } _openedDbs[databaseName] = db; @@ -471,7 +476,9 @@ class MongoConnection with SshTunnelHost { _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/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 06f3c0d0..afd23671 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -13,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 { @@ -212,7 +213,9 @@ class MysqlConnection with SshTunnelHost { if (_conn != null && !_conn!.connected) { try { await _conn?.close(); - } catch (_) {} + } catch (e) { + logIgnored('mysql_connection', e); + } _conn = null; } final generation = _generation; @@ -305,10 +308,14 @@ class MysqlConnection with SshTunnelHost { 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 (_) {} + } catch (e) { + logIgnored('mysql_connection', e); + } forgetSshTunnel(openedHandle); throw StateError('The connection was closed while it was being opened'); } @@ -560,7 +567,9 @@ class MysqlConnection with SshTunnelHost { if (!join) { try { await execute('ROLLBACK'); - } catch (_) {} + } catch (e) { + logIgnored('mysql_connection', e); + } } rethrow; } diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index 316d52c3..fe8c1bd6 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -12,6 +12,7 @@ import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/database/redis_bulk.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; @@ -181,7 +182,9 @@ class RedisConnection with SshTunnelHost { if (_socket != null) { try { _socket?.destroy(); - } catch (_) {} + } catch (e) { + logIgnored('redis_connection', e); + } _socket = null; _command = null; _conn = null; @@ -276,7 +279,9 @@ class RedisConnection with SshTunnelHost { // a silent server): close it, or every failed try leaves one behind. try { socket?.destroy(); - } catch (_) {} + } catch (e) { + logIgnored('redis_connection', e); + } await releaseSshTunnel(); rethrow; } @@ -306,7 +311,9 @@ class RedisConnection with SshTunnelHost { _socket = null; try { sock?.destroy(); - } catch (_) {} + } catch (e) { + logIgnored('redis_connection', e); + } final c = _conn; _conn = null; if (c != null && wasConnected) { @@ -363,7 +370,9 @@ class RedisConnection with SshTunnelHost { if (!isConnected) return; try { await sendCommand([readOnly ? 'READONLY' : 'READWRITE']); - } catch (_) {} + } catch (e) { + logIgnored('redis_connection', e); + } } void _assertWritable() { @@ -927,7 +936,9 @@ class RedisConnectionTestFake extends RedisConnection { if (c != null) { try { await c.close(); - } catch (_) {} + } catch (e) { + logIgnored('redis_connection', e); + } } } 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/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/harness/extension_harness.dart b/lib/core/extensions/harness/extension_harness.dart index 7244abb8..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 @@ -537,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/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index f2ac7112..2a4df220 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -14,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). /// @@ -206,7 +207,9 @@ class LocalExtensionInstaller { // The new version is in place; a leftover backup is only clutter. try { await backup.delete(recursive: true); - } catch (_) {} + } catch (e) { + logIgnored('local_extension_installer', e); + } } await _reloadRegistry(); @@ -218,7 +221,9 @@ class LocalExtensionInstaller { 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'); } diff --git a/lib/core/extensions/sandbox/sandbox_process_runner.dart b/lib/core/extensions/sandbox/sandbox_process_runner.dart index 979e78ca..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'); } 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_server_controller.dart b/lib/core/mcp/mcp_server_controller.dart index 428b1fcd..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. @@ -163,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( 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/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 aa4f46d9..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,7 +171,9 @@ 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); @@ -211,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. @@ -226,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/connection_secrets_store.dart b/lib/core/storage/connection_secrets_store.dart index 7fa31211..bb49b1bd 100644 --- a/lib/core/storage/connection_secrets_store.dart +++ b/lib/core/storage/connection_secrets_store.dart @@ -1,5 +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. @@ -226,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; @@ -253,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/local_db.dart b/lib/core/storage/local_db.dart index a378e903..516e5590 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -10,6 +10,7 @@ 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/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; @@ -940,17 +941,23 @@ 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); + } } } @@ -986,7 +993,9 @@ class LocalDb { if (await walFile.exists()) { try { await walFile.copy('$backupPath-wal'); - } catch (_) {} + } catch (e) { + logIgnored('local_db', e); + } } return backupPath; } @@ -1000,10 +1009,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(); } @@ -1229,7 +1242,9 @@ class ConnectionRow { return SshTunnelConfig.fromMap(sshMap); } } - } catch (_) {} + } catch (e) { + logIgnored('local_db', e); + } return null; } @@ -1256,7 +1271,9 @@ class ConnectionRow { if (decoded is Map) { opts = Map.from(decoded); } - } catch (_) {} + } catch (e) { + logIgnored('local_db', e); + } } if (config == null || !config.enabled) { opts.remove('ssh_tunnel'); diff --git a/lib/core/updater/installers/macos_sparkle_installer.dart b/lib/core/updater/installers/macos_sparkle_installer.dart index f731adb1..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'; @@ -65,7 +66,9 @@ class MacosSparkleInstaller { // The app keeps running: do not leave the extracted update behind. try { await extractDir.delete(recursive: true); - } catch (_) {} + } catch (e) { + logIgnored('macos_sparkle_installer', e); + } rethrow; } 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'); +} From 7055606bcd2af69e26caa1f4d940c090665bd752 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:54:09 +0300 Subject: [PATCH 132/157] perf(erd): opening a 200-table diagram no longer builds every card in one frame (#1546) (#1715) * ci(bench): the 200-table script is checked, a failed scenario run fails the job and says why (#1546) * ci(bench): open the diagram through the workspace; the toolbar button is outside the window with seven tabs (#1546) * perf(erd): a big diagram mounts its cards a chunk per frame and a rebuild reuses cards whose inputs did not change (#1546) * test(erd): a rebuild without a look change reuses every card (search panel), picking fades the others so it cannot show that * fix(erd): the card cache compared a palette built anew on each read, so it never hit; compare its slot colours (#1546) --- lib/features/erd/erd_view.dart | 243 +++++++++++++++++++++++++------- test/features/erd/erd_test.dart | 62 ++++++++ 2 files changed, 253 insertions(+), 52 deletions(-) diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 00fd4946..5feeb843 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -292,6 +292,9 @@ class _ErdViewState extends material.State _marked.clear(); _setLayout(ErdLayoutEngine.withSaved(_compute(_visibleOf(schema)), saved)); _loading = false; + _cardCache = {}; + _mountedTables = + schema.tables.length > _progressiveFrom ? _mountChunk : 1 << 30; }); if (schema.truncated) { showAppToast( @@ -1397,6 +1400,129 @@ class _ErdViewState extends material.State ); } + /// Cards built so far for the open diagram: a big one mounts a chunk per + /// frame (see [_mountedTables]) instead of in one long frame. + static const _progressiveFrom = 60; + static const _mountChunk = 24; + int _mountedTables = 1 << 30; + bool _mountPending = false; + + /// Cards by table, with what they were built from. A rebuild of the diagram + /// (a pick, a drag, a toolbar change) reuses every card whose inputs did not + /// change, so Flutter skips its subtree instead of rebuilding 200 of them. + var _cardCache = {}; + + List _cards(ErdSchema schema, ErdLayout layout) { + final tables = schema.tables; + final shown = min(tables.length, _mountedTables); + final menuKey = Object.hash( + Object.hashAll(_marked), + Object.hashAll([ + for (final g in _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); + final next = {}; + final cards = []; + for (var i = 0; i < shown; i++) { + final t = tables[i]; + final inputs = _CardInputs( + columns: t.columns, + comment: t.comment, + position: layout.positions[t.name]!, + width: layout.widthFor(t.name), + headerColor: _headerColor(t.name), + collapsed: _collapsed.contains(t.name), + marked: _marked.contains(t.name), + dragging: _dragging == t.name, + junctionOf: _junctions[t.name], + menuKey: menuKey, + palette: palette, + onOpenTable: widget.onOpenTable, + onOpenInSql: widget.onOpenInSql, + onShowRelations: widget.onShowRelations, + ); + final cached = _cardCache[t.name]; + final card = cached != null && cached.$1 == inputs + ? cached.$2 + : _card(t, layout); + next[t.name] = (inputs, card); + cards.add(card); + } + _cardCache = next; + if (shown < tables.length) _mountMore(); + return cards; + } + + /// Mounts the next chunk of cards after this frame. + void _mountMore() { + if (_mountPending) return; + _mountPending = true; + material.WidgetsBinding.instance.addPostFrameCallback((_) { + _mountPending = false; + if (!mounted) return; + setState(() => _mountedTables += _mountChunk); + }); + } + + 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: 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, + ), + ), + ), + ), + ); + } + @override material.Widget build(material.BuildContext context) { final wb = context.workbench; @@ -1481,58 +1607,7 @@ class _ErdViewState extends material.State ), ), ), - 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, - ), - ), - ), - ), - ), + ..._cards(schema, layout), // Sticky notes above the cards (#1283). for (final n in _notes) _noteWidget(n), ], @@ -1703,3 +1778,67 @@ class _ErdViewState extends material.State ); } } + +/// 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 _CardInputs { + const _CardInputs({ + 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! _CardInputs) 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/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index 8963656d..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'; @@ -1004,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; From 35996da940dcc870964674c23775c2bcd140b640 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:54:19 +0300 Subject: [PATCH 133/157] refactor(shell): split MainScreen into a layout, a navigator, dialogs and a pure shortcut table (#1503) (#1718) * refactor(shell): the content split and the connections panel slot move out of main_screen.dart (#1503) * refactor(shell): global shortcuts resolve in a pure MainScreenShortcuts, tested per key and layout (#1503) * refactor(shell): picks in the tree and quick switcher move to MainScreenNavigator (#1503) * test(shell): MainScreenNavigator picks, repeats and the pending Mongo collection (#1503) * refactor(shell): modal flows and the read-only lock move to MainScreenDialogs (#1503) * refactor(shell): title bar and status bar become widgets; main_screen.dart is the structural layout (#1503) --- benchmark/app_perf_bench.dart | 2 +- .../main_screen/main_content_split.dart | 596 ++++++ lib/features/main_screen/main_screen.dart | 1679 ++--------------- .../main_screen/main_screen_chrome.dart | 113 ++ .../main_screen/main_screen_dialogs.dart | 204 ++ .../main_screen/main_screen_navigator.dart | 468 +++++ .../main_screen/main_screen_shortcuts.dart | 129 ++ .../main_screen_navigator_test.dart | 86 + .../main_screen_shortcuts_test.dart | 130 ++ 9 files changed, 1875 insertions(+), 1532 deletions(-) create mode 100644 lib/features/main_screen/main_content_split.dart create mode 100644 lib/features/main_screen/main_screen_chrome.dart create mode 100644 lib/features/main_screen/main_screen_dialogs.dart create mode 100644 lib/features/main_screen/main_screen_navigator.dart create mode 100644 lib/features/main_screen/main_screen_shortcuts.dart create mode 100644 test/features/main_screen/main_screen_navigator_test.dart create mode 100644 test/features/main_screen/main_screen_shortcuts_test.dart diff --git a/benchmark/app_perf_bench.dart b/benchmark/app_perf_bench.dart index 484e1baa..5212e560 100644 --- a/benchmark/app_perf_bench.dart +++ b/benchmark/app_perf_bench.dart @@ -121,7 +121,7 @@ Future _runAll() async { 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); 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 8ea1307c..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() { @@ -84,17 +76,17 @@ class _MainScreenState extends State { if (connections.isEmpty && mounted && !FileLaunchService.instance.hasPendingTargets) { - _onOpenWelcomeTour(); + _dialogs.onOpenWelcomeTour(); } } if (mounted) { - await _handlePendingFileLaunch(); + await _dialogs.handlePendingFileLaunch(); } }); } void _onFilesQueued() { - if (mounted) unawaited(_handlePendingFileLaunch()); + if (mounted) unawaited(_dialogs.handlePendingFileLaunch()); } @override @@ -102,779 +94,76 @@ class _MainScreenState extends State { 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, - ); - } - - 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, + final keyboard = HardwareKeyboard.instance; + final shortcut = MainScreenShortcuts.resolve( + event, + cmdOrCtrl: Platform.isMacOS + ? (keyboard.isMetaPressed || keyboard.isControlPressed) + : keyboard.isControlPressed, + shift: keyboard.isShiftPressed, + alt: keyboard.isAltPressed, ); - } - - 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) { @@ -884,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 @@ -914,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, @@ -926,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( @@ -1009,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( @@ -1076,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(), ), ], ), @@ -1195,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/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); + }); +} From 868c7b1e68cadbc17fe444a7b73b349f147fee5f Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:23:08 +0300 Subject: [PATCH 134/157] perf(results): the SQL workspaces pack typed columns as rows arrive instead of collecting List> first (#1552) (#1719) --- lib/core/database/compact_result_dataset.dart | 173 +---------- lib/core/database/result_columns_builder.dart | 291 ++++++++++++++++++ lib/core/database/stream_take_drain.dart | 27 +- lib/features/mysql/mysql_sql_workspace.dart | 36 +-- .../postgresql/postgres_sql_workspace.dart | 20 +- lib/features/sqlite/sqlite_sql_workspace.dart | 19 +- .../database/result_columns_builder_test.dart | 167 ++++++++++ 7 files changed, 530 insertions(+), 203 deletions(-) create mode 100644 lib/core/database/result_columns_builder.dart create mode 100644 test/core/database/result_columns_builder_test.dart diff --git a/lib/core/database/compact_result_dataset.dart b/lib/core/database/compact_result_dataset.dart index 13627607..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,173 +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). - /// Non-null cells per column that go through the cell formatter while - /// [fromRawRows] decides the column's kind; the rest are type-tested. - static const int _formattedSamples = 60; - + /// 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++; - - // The driver formatters print a column's ints, numbers and strings - // by the column's type (bytea, binary, boolean...), not by the value, - // so the first [_formattedSamples] cells show whether the column - // prints as its values do. Formatting every cell only to decide the - // kind cost more than the packing itself (#1548). - if (cellFormatter != null && sampleCount <= _formattedSamples) { - 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; - } - } - - // Nothing left to learn: the column is generic. - if (!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/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/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/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/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/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/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'); + } + } + }); +} From 16ff66b52591d42e5e36f9f875cab569808ec7b6 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:39:59 +0300 Subject: [PATCH 135/157] bench(results): sort, filter and export over the lazy dataset rows vs materialized strings; consumers keep List> (#1549) (#1720) --- benchmark/result_consumers_bench.dart | 78 +++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 benchmark/result_consumers_bench.dart 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)); + }); +} From 7869ce165ddca906ec05fb0f85db4317f5602c40 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:40:36 +0300 Subject: [PATCH 136/157] refactor(storage): split LocalDb into a schema and per-area repositories (#1502) (#1721) * refactor(storage): the schema, its upgrade path and the open pragmas move to LocalDbSchema, with upgrade tests (#1502) * refactor(storage): the row and entry models move to local_db_models.dart (re-exported from local_db.dart) (#1502) * refactor(storage): LocalDb delegates folders, SQL history, audit trails, settings and connections to repositories (#1502) * test(storage): import the models the activity log test builds --- lib/core/storage/activity_log_repository.dart | 105 ++ lib/core/storage/app_settings_repository.dart | 60 + lib/core/storage/connection_repository.dart | 196 +++ lib/core/storage/folder_repository.dart | 65 + lib/core/storage/local_db.dart | 1244 ++--------------- lib/core/storage/local_db_models.dart | 405 ++++++ lib/core/storage/local_db_schema.dart | 295 ++++ .../storage/sql_query_history_repository.dart | 148 ++ test/core/storage/local_db_schema_test.dart | 155 ++ .../storage/storage_repositories_test.dart | 126 ++ 10 files changed, 1651 insertions(+), 1148 deletions(-) create mode 100644 lib/core/storage/activity_log_repository.dart create mode 100644 lib/core/storage/app_settings_repository.dart create mode 100644 lib/core/storage/connection_repository.dart create mode 100644 lib/core/storage/folder_repository.dart create mode 100644 lib/core/storage/local_db_models.dart create mode 100644 lib/core/storage/local_db_schema.dart create mode 100644 lib/core/storage/sql_query_history_repository.dart create mode 100644 test/core/storage/local_db_schema_test.dart create mode 100644 test/core/storage/storage_repositories_test.dart 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_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/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 516e5590..323b477e 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -1,46 +1,22 @@ -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'; - -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(); -} - -/// Fallback when [recordSqlQueryHistory] is called without `maxEntries`. -/// Keep in sync with [kDefaultSqlHistoryMaxEntries] in `app_settings.dart`. -const int kDefaultSqlHistoryCap = 100; - -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()); -} - -String? _normalizeHistoryDatabaseName(String? databaseName) { - final t = databaseName?.trim(); - if (t == null || t.isEmpty) return null; - return t; -} /// The local database was written by a newer Querya Desktop: its schema /// version is above the one this build knows (#1497). @@ -113,22 +89,18 @@ class LocalDb { final db = await databaseFactoryFfi.openDatabase( _cachedDbPath!, options: OpenDatabaseOptions( - version: _dbVersion, - onCreate: _onCreate, - onUpgrade: _onUpgrade, + 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( + onDowngrade: (db, found, supported) => + throw LocalDbNewerSchemaException( found: found, supported: supported, path: _cachedDbPath!, ), - 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'); - }, + onOpen: LocalDbSchema.onOpen, ), ); _db = db; @@ -136,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; - } - - /// 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 _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. - 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. - 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 _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. - 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) - ) - '''); - } + // 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, + ); - 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> getFolders() => folders.getFolders(); - 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 addFolder(String name) => folders.addFolder(name); - 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 removeFolder(String name) => folders.removeFolder(name); - 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 setConnectionFolder(int connectionId, int? folderId) => + folders.setConnectionFolder(connectionId, folderId); - Future deleteAppSetting(String key) async { - final db = await _open(); - await db.delete('app_settings', where: 'key = ?', whereArgs: [key]); - } + Future clearFolders() => folders.clearFolders(); - final Map _historyInsertCounts = {}; + Future getFolderIdByName(String name) => + folders.getFolderIdByName(name); Future recordSqlQueryHistory({ required int connectionId, @@ -464,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, @@ -598,324 +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 (e) { - // Best effort, but a missing audit trail should not be invisible. - debugPrint('LocalDb: could not record a mutation audit entry: $e'); - } - } - - 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; - } - - /// 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 clearMutationAudit() => activity.clearMutationAudit(); - Future clearMcpActivity() async { - final db = await _open(); - await db.delete('mcp_activity'); - } + Future recordMcpActivity(McpActivityEntry entry) => + activity.recordMcpActivity(entry); - 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); - } - - /// 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; - } + {bool hydrateSecrets = false}) => + connections.getConnectionById(id, hydrateSecrets: hydrateSecrets); - /// 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 addConnection(ConnectionRow row) => + connections.addConnection(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 updateConnection(ConnectionRow row) => + connections.updateConnection(row); - 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; - } - } + Future removeConnection(int id) => connections.removeConnection(id); - /// 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`. @@ -1028,386 +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 (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_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/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); + }); +} From 25bdf099fb6075570e149075a7df77344822593f Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:59:02 +0300 Subject: [PATCH 137/157] refactor(redis): replies are parsed once into a sealed RedisReply, command arguments are typed, RedisConnectionTestFake moves to test/ (#1571) (#1722) --- lib/core/database/redis_connection.dart | 478 +++--------------- lib/core/database/redis_reply.dart | 157 ++++++ test/core/database/redis_connection_test.dart | 1 + .../database/redis_database_socket_test.dart | 1 + test/core/database/redis_reply_test.dart | 88 ++++ .../core/database/redis_set_keepttl_test.dart | 1 + .../database/redis_types_and_ttls_test.dart | 2 +- .../e2e_redis_cli_terminal_test.dart | 2 +- .../e2e_redis_keys_tree_and_types_test.dart | 1 + .../redis/redis_cli_workspace_test.dart | 2 +- .../features/redis/redis_key_editor_test.dart | 2 +- test/features/redis/redis_keys_view_test.dart | 2 +- test/support/redis_connection_test_fake.dart | 277 ++++++++++ 13 files changed, 613 insertions(+), 401 deletions(-) create mode 100644 lib/core/database/redis_reply.dart create mode 100644 test/core/database/redis_reply_test.dart create mode 100644 test/support/redis_connection_test_fake.dart diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index fe8c1bd6..37acffef 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/security/ssh_tunnel_config.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'; @@ -400,25 +401,38 @@ class RedisConnection with SshTunnelHost { } } + /// 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. @@ -433,31 +447,42 @@ class RedisConnection with SshTunnelHost { 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. @@ -477,15 +502,15 @@ class RedisConnection with SshTunnelHost { 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, ), ]; @@ -501,9 +526,7 @@ class RedisConnection with SshTunnelHost { /// 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]. @@ -556,15 +579,7 @@ class RedisConnection with SshTunnelHost { /// 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). @@ -573,27 +588,18 @@ class RedisConnection with SshTunnelHost { 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. @@ -610,18 +616,13 @@ class RedisConnection with SshTunnelHost { /// 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. @@ -632,12 +633,7 @@ class RedisConnection with SshTunnelHost { /// 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. @@ -649,18 +645,13 @@ class RedisConnection with SshTunnelHost { /// 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). @@ -669,21 +660,13 @@ class RedisConnection with SshTunnelHost { 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. @@ -701,24 +684,17 @@ class RedisConnection with SshTunnelHost { /// 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. @@ -736,8 +712,7 @@ class RedisConnection with SshTunnelHost { /// DEL key. Future del(Object key) async { _assertWritable(); - final result = await sendCommand(['DEL', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['DEL', key])).asInt(); } /// False once the server answered `UNLINK` with an unknown command (< 4.0). @@ -758,7 +733,7 @@ class RedisConnection with SshTunnelHost { for (var i = 0; i < list.length; i += chunkSize) [ for (final key in list.sublist(i, min(i + chunkSize, list.length))) - redisCommandArg(key), + key, ], ]; @@ -766,11 +741,11 @@ class RedisConnection with SshTunnelHost { var total = 0; var verb = _unlinkSupported == false ? 'DEL' : 'UNLINK'; try { - total += redisReplyInt(await sendCommand([verb, ...chunks.first])); + total += (await command([verb, ...chunks.first])).asInt(); } catch (e) { if (verb != 'UNLINK' || !_isUnknownCommand(e)) rethrow; verb = 'DEL'; - total += redisReplyInt(await sendCommand([verb, ...chunks.first])); + total += (await command([verb, ...chunks.first])).asInt(); } _unlinkSupported ??= verb == 'UNLINK'; if (chunks.length == 1) return total; @@ -782,8 +757,8 @@ class RedisConnection with SshTunnelHost { try { final replies = await Future.wait([ for (final chunk in chunks.skip(1)) - sendCommand([verb, ...chunk]).then( - (reply) => redisReplyInt(reply), + command([verb, ...chunk]).then( + (reply) => reply.asInt(), onError: (Object e, StackTrace st) { firstError ??= e; firstStack ??= st; @@ -815,15 +790,12 @@ class RedisConnection with SshTunnelHost { /// 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. @@ -858,292 +830,6 @@ class RedisConnection with SshTunnelHost { } } -/// 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 = []; - - 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 (e) { - logIgnored('redis_connection', e); - } - } - } - - @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 '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, []]; - } -} - 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/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 index f43540de..e728c81c 100644 --- a/test/core/database/redis_database_socket_test.dart +++ b/test/core/database/redis_database_socket_test.dart @@ -5,6 +5,7 @@ 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 { 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/e2e/04_nosql_workspaces/e2e_redis_cli_terminal_test.dart b/test/e2e/04_nosql_workspaces/e2e_redis_cli_terminal_test.dart index 213f6174..37bd1c98 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/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)); 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..537d9386 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)); diff --git a/test/features/redis/redis_cli_workspace_test.dart b/test/features/redis/redis_cli_workspace_test.dart index e69b0bff..deab2b64 100644 --- a/test/features/redis/redis_cli_workspace_test.dart +++ b/test/features/redis/redis_cli_workspace_test.dart @@ -3,12 +3,12 @@ import 'dart:typed_data'; 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/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 diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart index 9086cdec..f0dcfc14 100644 --- a/test/features/redis/redis_key_editor_test.dart +++ b/test/features/redis/redis_key_editor_test.dart @@ -3,13 +3,13 @@ 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(); 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/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, []]; + } +} From b09fdf6b87ba13f9cac05864f22193df29032b49 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:03:27 +0300 Subject: [PATCH 138/157] refactor(erd): ErdViewController, toolbar, group menu and card cache out of _ErdViewState (#1690) (#1723) * refactor(erd): ErdViewController holds the diagram's user state; the view reads it from there (#1690) * refactor(erd): groups, marks, density, notes, views, picks and Esc live in ErdViewController (#1690) * refactor(erd): the toolbar is a widget; controller tests (#1690) * refactor(erd): group menu, card inputs and the card cache move out of the view (#1690) --- lib/features/erd/erd_view.dart | 810 +++++------------- lib/features/erd/erd_view_controller.dart | 336 ++++++++ lib/features/erd/widgets/erd_card_cache.dart | 66 ++ lib/features/erd/widgets/erd_card_inputs.dart | 67 ++ lib/features/erd/widgets/erd_group_menu.dart | 50 ++ lib/features/erd/widgets/erd_toolbar.dart | 134 +++ .../erd/erd_view_controller_test.dart | 277 ++++++ 7 files changed, 1154 insertions(+), 586 deletions(-) create mode 100644 lib/features/erd/erd_view_controller.dart create mode 100644 lib/features/erd/widgets/erd_card_cache.dart create mode 100644 lib/features/erd/widgets/erd_card_inputs.dart create mode 100644 lib/features/erd/widgets/erd_group_menu.dart create mode 100644 lib/features/erd/widgets/erd_toolbar.dart create mode 100644 test/features/erd/erd_view_controller_test.dart diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 5feeb843..25562d69 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -18,6 +18,7 @@ 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_view_name_dialog.dart'; import 'package:querya_desktop/core/erd/erd_group_ops.dart'; import 'package:querya_desktop/core/erd/erd_layout.dart'; @@ -26,6 +27,8 @@ 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_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'; @@ -33,15 +36,13 @@ 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; // Moved out of this file (#1362); re-exported for existing importers. @@ -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,50 +141,33 @@ 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(); @@ -245,13 +228,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 +255,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,16 +268,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 = {}; - _mountedTables = - schema.tables.length > _progressiveFrom ? _mountChunk : 1 << 30; + _cardCache.reset(schema.tables.length); }); if (schema.truncated) { showAppToast( @@ -331,30 +312,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( @@ -363,47 +320,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(); } @@ -411,26 +347,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(); } @@ -447,19 +380,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(); } @@ -469,12 +401,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); } @@ -484,11 +414,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(); @@ -511,17 +441,17 @@ 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); + _showState(null, _c.base); return; } - final view = _viewById(id); + final view = _c.viewById(id); if (view == null) return; _showState( id, - view.asLayout({for (final t in schema.tables) t.name}, _headerColors)); + view.asLayout({for (final t in schema.tables) t.name}, _c.headerColors)); } /// Saves what the diagram shows as a new view and moves into it. @@ -531,66 +461,46 @@ 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; + final fromAll = _c.viewById(_c.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++; - } + if (fromAll) _c.base = _c.base.showingAll(); + final id = _c.nextViewId(); setState(() { - _views.add(ErdSavedView( - id: 'v$n', + _c.views.add(ErdSavedView( + id: id, name: name, tables: { for (final t in schema.tables) - if (!_hidden.contains(t.name)) t.name, + if (!_c.hidden.contains(t.name)) t.name, }, layout: _current(), )); - _activeView = 'v$n'; + _c.activeView = id; }); _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); - } + _c.removeView(id); + _showState(null, _c.base); } /// Canvas point at the middle of what the viewport shows. @@ -608,8 +518,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: _viewCentre()); + setState(() => _c.addNote(note)); _scheduleSave(); } @@ -621,29 +531,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, _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, _scale)); } /// The table nearest to a note's middle, to attach it to. @@ -654,16 +557,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; @@ -672,15 +565,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: () { @@ -694,13 +587,13 @@ 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; @@ -712,14 +605,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(); } @@ -750,12 +643,12 @@ class _ErdViewState extends material.State 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), + collapsed: Set.of(_c.collapsed), + hidden: Set.of(_c.hidden), + detail: _c.detail, + headerColors: Map.of(_c.headerColors), + groups: List.of(_c.groups), + notes: List.of(_c.notes), scale: m.getMaxScaleOnAxis(), translation: material.Offset(t.x, t.y), ); @@ -767,29 +660,22 @@ class _ErdViewState extends material.State final schema = _schema; if (schema == null || _layout == null) return; final cur = _current(); - final i = _views.indexWhere((v) => v.id == _activeView); + final i = _c.views.indexWhere((v) => v.id == _c.activeView); if (i < 0) { - _base = cur; + _c.base = cur; return; } - _views[i] = ErdSavedView( - id: _views[i].id, - name: _views[i].name, + _c.views[i] = ErdSavedView( + id: _c.views[i].id, + name: _c.views[i].name, tables: { for (final t in schema.tables) - if (!_hidden.contains(t.name)) t.name, + if (!_c.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; - } - Future _saveNow() async { final layout = _layout; if (!_persists || layout == null) return; @@ -798,8 +684,8 @@ class _ErdViewState extends material.State 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)), + _c.base.withViews(List.of(_c.views), _c.activeView, + headerColors: Map.of(_c.headerColors)), ); } catch (_) { // Keeping the layout is a convenience: a failed write is not an error @@ -818,9 +704,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. @@ -837,7 +723,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(); } @@ -845,44 +731,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; + if (_c.dragging != table || layout == null) return; final scale = _transform.value.getMaxScaleOnAxis(); 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(); } @@ -902,8 +781,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(); } @@ -943,7 +822,7 @@ class _ErdViewState extends material.State final vs = _viewportSize(); if (layout == null || vs == null) return; // The cards and every note: a note placed away from the cards counts. - final size = _canvasSize(layout); + final size = _c.canvasSize(layout); final s = ErdLayout.fitScale(size, vs); final floor = min(_minScale, s); if (floor != _zoomFloor) setState(() => _zoomFloor = floor); @@ -958,8 +837,8 @@ class _ErdViewState extends material.State _selectTable(schema, name); _searchController.clear(); setState(() { - _searchOpen = false; - _query = ''; + _c.searchOpen = false; + _c.query = ''; }); _canvasFocus.requestFocus(); } @@ -977,7 +856,7 @@ class _ErdViewState extends material.State final t = vs.center(material.Offset.zero) - centre * s; _transform.value = Matrix4.identity()..translate(t.dx, t.dy)..scale(s); } - setState(() => _selected = name); + setState(() => _c.selected = name); _syncFocus(); } @@ -985,14 +864,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(); } @@ -1006,10 +879,7 @@ class _ErdViewState extends material.State return; } _canvasFocus.requestFocus(); - setState(() { - _pickedRelation = hit; - _selected = null; - }); + setState(() => _c.pickRelation(hit)); _edgeTipNotifier.value = (hit, p); _syncFocus(); } @@ -1017,50 +887,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() { @@ -1084,26 +946,10 @@ class _ErdViewState extends material.State 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; + String? _currentFocus() => _c.currentFocus(_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, - ); - } + 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) => @@ -1114,7 +960,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; @@ -1130,7 +976,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); } @@ -1147,13 +993,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(); } @@ -1162,15 +1002,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()), @@ -1178,49 +1018,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, @@ -1229,7 +1029,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, @@ -1242,7 +1042,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), @@ -1276,19 +1076,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(); @@ -1312,9 +1112,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; @@ -1325,9 +1125,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(); } @@ -1339,11 +1139,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(); } @@ -1357,7 +1157,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); @@ -1379,7 +1179,7 @@ class _ErdViewState extends material.State } void _leaveCanvas() { - if (_pickedRelation != null) return; + if (_c.pickedRelation != null) return; _edgeTipNotifier.value = null; } @@ -1389,36 +1189,27 @@ class _ErdViewState extends material.State /// 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), ); } - /// Cards built so far for the open diagram: a big one mounts a chunk per - /// frame (see [_mountedTables]) instead of in one long frame. - static const _progressiveFrom = 60; - static const _mountChunk = 24; - int _mountedTables = 1 << 30; - bool _mountPending = false; - - /// Cards by table, with what they were built from. A rebuild of the diagram - /// (a pick, a drag, a toolbar change) reuses every card whose inputs did not - /// change, so Flutter skips its subtree instead of rebuilding 200 of them. - var _cardCache = {}; + late final _cardCache = ErdCardCache( + isMounted: () => mounted, + rebuild: () => setState(() {}), + ); List _cards(ErdSchema schema, ErdLayout layout) { - final tables = schema.tables; - final shown = min(tables.length, _mountedTables); final menuKey = Object.hash( - Object.hashAll(_marked), + Object.hashAll(_c.marked), Object.hashAll([ - for (final g in _groups) + for (final g in _c.groups) Object.hash(g.id, g.name, Object.hashAll(g.tables)), ]), ); @@ -1426,47 +1217,26 @@ class _ErdViewState extends material.State // cards and the menu use. final p = context.semanticPalette; final palette = (p.type1, p.type2, p.type3, p.type4, p.type5); - final next = {}; - final cards = []; - for (var i = 0; i < shown; i++) { - final t = tables[i]; - final inputs = _CardInputs( + 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: _collapsed.contains(t.name), - marked: _marked.contains(t.name), - dragging: _dragging == t.name, - junctionOf: _junctions[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, - ); - final cached = _cardCache[t.name]; - final card = cached != null && cached.$1 == inputs - ? cached.$2 - : _card(t, layout); - next[t.name] = (inputs, card); - cards.add(card); - } - _cardCache = next; - if (shown < tables.length) _mountMore(); - return cards; - } - - /// Mounts the next chunk of cards after this frame. - void _mountMore() { - if (_mountPending) return; - _mountPending = true; - material.WidgetsBinding.instance.addPostFrameCallback((_) { - _mountPending = false; - if (!mounted) return; - setState(() => _mountedTables += _mountChunk); - }); + ), + build: (t) => _card(t, layout), + ); } material.Widget _card(ErdTable t, ErdLayout layout) { @@ -1486,11 +1256,20 @@ class _ErdViewState extends material.State onOpenData: widget.onOpenTable, onOpenInSql: widget.onOpenInSql, onShowRelations: widget.onShowRelations, - collapsed: _collapsed.contains(t.name), + collapsed: _c.collapsed.contains(t.name), onToggleCollapsed: () => _toggleCollapsed(t.name), onHide: () => _hide(t.name), - groupItems: _groupMenu(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), @@ -1501,12 +1280,12 @@ class _ErdViewState extends material.State headerColor: _headerColor(t.name), highlighted: focus.highlighted, focusColumn: focus.column, - junctionOf: _junctions[t.name], - dragging: _dragging == t.name, + junctionOf: _c.junctions[t.name], + dragging: _c.dragging == t.name, onOpen: widget.onOpenTable == null ? null : () => widget.onOpenTable!(t.name), - marked: _marked.contains(t.name), + marked: _c.marked.contains(t.name), onSelect: () => _tapCard(t.name), onHover: (inside) => _hovered.value = inside ? t.name @@ -1539,7 +1318,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); @@ -1562,7 +1341,7 @@ class _ErdViewState extends material.State constrained: false, transformationController: _transform, // A card or group drag must not pan the canvas. - panEnabled: _dragging == null && _draggingGroup == null, + panEnabled: _c.dragging == null && _c.draggingGroup == null, minScale: _zoomFloor, maxScale: _maxScale, boundaryMargin: const material.EdgeInsets.all(400), @@ -1570,8 +1349,8 @@ class _ErdViewState extends material.State key: _boundaryKey, child: material.Container( color: wb.surface, - width: _canvasSize(layout).width, - height: _canvasSize(layout).height, + width: _c.canvasSize(layout).width, + height: _c.canvasSize(layout).height, child: material.GestureDetector( // A tap on an edge picks its relation, elsewhere it clears // the selection (#1281). @@ -1590,7 +1369,7 @@ class _ErdViewState extends material.State ), ), // Group frames under the edges and cards (#1282). - for (final g in _allGroups()) + for (final g in _c.allGroups(_schema)) ..._groupFrame(g, layout), material.Positioned.fill( child: material.ValueListenableBuilder( @@ -1602,14 +1381,14 @@ class _ErdViewState extends material.State color: wb.mutedForeground, highlight: wb.accent, focus: focus, - picked: _pickedRelation, + picked: _c.pickedRelation, ), ), ), ), ..._cards(schema, layout), // Sticky notes above the cards (#1283). - for (final n in _notes) _noteWidget(n), + for (final n in _c.notes) _noteWidget(n), ], ), ), @@ -1670,175 +1449,34 @@ class _ErdViewState extends material.State 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)), ], ); } } - -/// 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 _CardInputs { - const _CardInputs({ - 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! _CardInputs) 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/erd_view_controller.dart b/lib/features/erd/erd_view_controller.dart new file mode 100644 index 00000000..30e74b3d --- /dev/null +++ b/lib/features/erd/erd_view_controller.dart @@ -0,0 +1,336 @@ +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); + + // ---- 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/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_group_menu.dart b/lib/features/erd/widgets/erd_group_menu.dart new file mode 100644 index 00000000..a8c718d7 --- /dev/null +++ b/lib/features/erd/widgets/erd_group_menu.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; + +/// 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 [ + MenuButton( + 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)) + MenuButton( + subMenu: [ + for (final g in groups) + if (g.id != current?.id) + MenuButton( + onPressed: (_) => onAddToGroup(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: (_) => onRemoveFromGroup(table), + child: Text('Remove from ${current.name}'), + ), + ]; +} 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/test/features/erd/erd_view_controller_test.dart b/test/features/erd/erd_view_controller_test.dart new file mode 100644 index 00000000..d213146b --- /dev/null +++ b/test/features/erd/erd_view_controller_test.dart @@ -0,0 +1,277 @@ + +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); + }); + }); +} From 4eb96f584fedc63554d80dcaeae0cf6f621699cb Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:20:59 +0300 Subject: [PATCH 139/157] refactor(erd): ErdViewport and ErdCanvas out of _ErdViewState, saved-view rules into the controller (#1690) (#1724) * refactor(erd): ErdViewport holds zoom and pan, ErdCanvas puts the layers in order (#1690) * refactor(erd): saving and view switching rules move to the controller; viewport and controller tests (#1690) --- lib/features/erd/erd_view.dart | 329 ++++-------------- lib/features/erd/erd_view_controller.dart | 77 ++++ lib/features/erd/erd_viewport.dart | 109 ++++++ lib/features/erd/widgets/erd_canvas.dart | 188 ++++++++++ .../erd/erd_view_controller_test.dart | 85 +++++ test/features/erd/erd_viewport_test.dart | 99 ++++++ 6 files changed, 624 insertions(+), 263 deletions(-) create mode 100644 lib/features/erd/erd_viewport.dart create mode 100644 lib/features/erd/widgets/erd_canvas.dart create mode 100644 test/features/erd/erd_viewport_test.dart diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 25562d69..adc5e310 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'; @@ -19,6 +18,7 @@ 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'; @@ -27,13 +27,13 @@ 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'; @@ -171,17 +171,7 @@ class _ErdViewState extends material.State 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; @@ -198,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) { @@ -217,7 +207,7 @@ class _ErdViewState extends material.State super.initState(); _searchFocus.addListener(_onSearchFocus); _hovered.addListener(_syncFocus); - _transform.addListener(_scheduleSave); + _viewport.transform.addListener(_scheduleSave); _load(); } @@ -293,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(); @@ -427,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(); @@ -443,15 +429,9 @@ class _ErdViewState extends material.State final schema = _schema; if (schema == null || id == _c.activeView) return; _captureCurrent(); - if (id == null) { - _showState(null, _c.base); - return; - } - final view = _c.viewById(id); - if (view == null) return; - _showState( - id, - view.asLayout({for (final t in schema.tables) t.name}, _c.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. @@ -463,24 +443,12 @@ class _ErdViewState extends material.State action: 'Save', name: 'View ${_c.views.length + 1}'); if (name == null || !mounted) return; - final fromAll = _c.viewById(_c.activeView) == null; _captureCurrent(); - // The tables hidden by hand now belong to the view; All tables shows - // every table, always. - if (fromAll) _c.base = _c.base.showingAll(); - final id = _c.nextViewId(); - setState(() { - _c.views.add(ErdSavedView( - id: id, - name: name, - tables: { - for (final t in schema.tables) - if (!_c.hidden.contains(t.name)) t.name, - }, - layout: _current(), - )); - _c.activeView = id; - }); + setState(() => _c.addViewFromCurrent( + name: name, + current: _current(), + tableNames: [for (final t in schema.tables) t.name], + )); _scheduleSave(); } @@ -503,22 +471,12 @@ class _ErdViewState extends material.State _showState(null, _c.base); } - /// 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; - } - Future _addNote() async { final text = await showErdNoteDialog(context, title: 'New note', action: 'Add'); if (text == null || !mounted) return; final note = - ErdNoteOps.create(_c.notes, text: text, centre: _viewCentre()); + ErdNoteOps.create(_c.notes, text: text, centre: _viewport.centre()); setState(() => _c.addNote(note)); _scheduleSave(); } @@ -542,11 +500,11 @@ class _ErdViewState extends material.State } void _noteDragMove(String id, material.Offset screenDelta) { - setState(() => _c.moveNote(id, screenDelta, _scale)); + setState(() => _c.moveNote(id, screenDelta, _viewport.scale)); } void _noteResize(String id, material.Offset screenDelta) { - setState(() => _c.resizeNote(id, screenDelta, _scale)); + setState(() => _c.resizeNote(id, screenDelta, _viewport.scale)); } /// The table nearest to a note's middle, to attach it to. @@ -596,7 +554,7 @@ class _ErdViewState extends material.State 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)); @@ -638,42 +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(_c.collapsed), - hidden: Set.of(_c.hidden), - detail: _c.detail, - headerColors: Map.of(_c.headerColors), - groups: List.of(_c.groups), - notes: List.of(_c.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 = _c.views.indexWhere((v) => v.id == _c.activeView); - if (i < 0) { - _c.base = cur; - return; - } - _c.views[i] = ErdSavedView( - id: _c.views[i].id, - name: _c.views[i].name, - tables: { - for (final t in schema.tables) - if (!_c.hidden.contains(t.name)) t.name, - }, - layout: cur, - ); + _c.capture(_current(), [for (final t in schema.tables) t.name]); } Future _saveNow() async { @@ -681,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. - _c.base.withViews(List.of(_c.views), _c.activeView, - headerColors: Map.of(_c.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. @@ -755,7 +684,7 @@ class _ErdViewState extends material.State void _dragMove(String table, material.Offset screenDelta) { final layout = _layout; if (_c.dragging != table || layout == null) return; - final scale = _transform.value.getMaxScaleOnAxis(); + 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(() { @@ -793,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 = _c.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 @@ -846,15 +748,11 @@ 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(() => _c.selected = name); _syncFocus(); @@ -933,13 +831,13 @@ 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; @@ -1166,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); @@ -1183,10 +1081,6 @@ class _ErdViewState extends material.State _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 (!_c.searchOpen) return const material.SizedBox.shrink(); @@ -1304,7 +1198,6 @@ class _ErdViewState extends material.State @override material.Widget build(material.BuildContext context) { - final wb = context.workbench; final full = _schema; final schema = full == null ? null : _visibleOf(full); final layout = _layout; @@ -1329,120 +1222,30 @@ 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: _c.dragging == null && _c.draggingGroup == null, - minScale: _zoomFloor, - maxScale: _maxScale, - boundaryMargin: const material.EdgeInsets.all(400), - child: material.RepaintBoundary( - key: _boundaryKey, - child: material.Container( - color: wb.surface, - width: _c.canvasSize(layout).width, - height: _c.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 _c.allGroups(_schema)) - ..._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: _c.pickedRelation, - ), - ), - ), - ), - ..._cards(schema, layout), - // Sticky notes above the cards (#1283). - for (final n in _c.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; diff --git a/lib/features/erd/erd_view_controller.dart b/lib/features/erd/erd_view_controller.dart index 30e74b3d..2410fcc9 100644 --- a/lib/features/erd/erd_view_controller.dart +++ b/lib/features/erd/erd_view_controller.dart @@ -251,6 +251,83 @@ class ErdViewController { 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. 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/test/features/erd/erd_view_controller_test.dart b/test/features/erd/erd_view_controller_test.dart index d213146b..c4109c33 100644 --- a/test/features/erd/erd_view_controller_test.dart +++ b/test/features/erd/erd_view_controller_test.dart @@ -274,4 +274,89 @@ void main() { 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)); + }); + }); +} From 64f7bad2bce65a2d7abae8031e8af58ae05c40b9 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:26:37 +0300 Subject: [PATCH 140/157] deps: refresh_rate 2.0.1 from pub.dev replaces the vendored 1.0.2 fork (#1584) (#1725) --- docs/macos-signing.md | 2 +- docs/motion-and-high-refresh.md | 2 +- lib/core/motion/display_refresh_service.dart | 9 +- pubspec.yaml | 12 +- third_party/refresh_rate/CHANGELOG.md | 60 -- third_party/refresh_rate/LICENSE | 24 - third_party/refresh_rate/README.md | 230 ----- .../refresh_rate/analysis_options.yaml | 4 - third_party/refresh_rate/android/build.gradle | 51 -- .../refresh_rate/android/settings.gradle | 1 - .../android/src/main/AndroidManifest.xml | 3 - .../qoder/refresh_rate/RefreshRatePlugin.kt | 248 ------ .../refresh_rate/generated/RefreshRateApi.kt | 345 -------- third_party/refresh_rate/example/README.md | 17 - .../example/analysis_options.yaml | 28 - .../example/android/app/build.gradle.kts | 44 - .../android/app/src/debug/AndroidManifest.xml | 7 - .../android/app/src/main/AndroidManifest.xml | 45 - .../refresh_rate_example/MainActivity.kt | 5 - .../res/drawable-v21/launch_background.xml | 12 - .../main/res/drawable/launch_background.xml | 12 - .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 544 -> 0 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 442 -> 0 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 721 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 1031 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 1443 -> 0 bytes .../app/src/main/res/values-night/styles.xml | 18 - .../app/src/main/res/values/styles.xml | 18 - .../app/src/profile/AndroidManifest.xml | 7 - .../example/android/build.gradle.kts | 24 - .../example/android/gradle.properties | 2 - .../gradle/wrapper/gradle-wrapper.properties | 5 - .../example/android/settings.gradle.kts | 26 - .../integration_test/screenshot_test.dart | 150 ---- .../ios/Flutter/AppFrameworkInfo.plist | 24 - .../example/ios/Flutter/Debug.xcconfig | 2 - .../example/ios/Flutter/Release.xcconfig | 2 - third_party/refresh_rate/example/ios/Podfile | 43 - .../refresh_rate/example/ios/Podfile.lock | 28 - .../ios/Runner.xcodeproj/project.pbxproj | 738 ---------------- .../contents.xcworkspacedata | 7 - .../xcshareddata/IDEWorkspaceChecks.plist | 8 - .../xcshareddata/WorkspaceSettings.xcsettings | 8 - .../xcshareddata/xcschemes/Runner.xcscheme | 101 --- .../contents.xcworkspacedata | 10 - .../xcshareddata/IDEWorkspaceChecks.plist | 8 - .../xcshareddata/WorkspaceSettings.xcsettings | 8 - .../example/ios/Runner/AppDelegate.swift | 16 - .../AppIcon.appiconset/Contents.json | 122 --- .../Icon-App-1024x1024@1x.png | Bin 10932 -> 0 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 295 -> 0 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 406 -> 0 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 450 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 282 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 462 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 704 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 406 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 586 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 862 -> 0 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 862 -> 0 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 1674 -> 0 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 762 -> 0 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 1226 -> 0 bytes .../Icon-App-83.5x83.5@2x.png | Bin 1418 -> 0 bytes .../LaunchImage.imageset/Contents.json | 23 - .../LaunchImage.imageset/LaunchImage.png | Bin 68 -> 0 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 68 -> 0 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 68 -> 0 bytes .../LaunchImage.imageset/README.md | 5 - .../Runner/Base.lproj/LaunchScreen.storyboard | 37 - .../ios/Runner/Base.lproj/Main.storyboard | 26 - .../ios/Runner/DebugProfile.entitlements | 12 - .../example/ios/Runner/Info.plist | 70 -- .../example/ios/Runner/Release.entitlements | 8 - .../ios/Runner/Runner-Bridging-Header.h | 1 - .../example/ios/Runner/SceneDelegate.swift | 6 - .../example/ios/RunnerTests/RunnerTests.swift | 12 - .../refresh_rate/example/lib/main.dart | 11 - .../example/lib/src/example_app.dart | 18 - .../example/lib/src/example_home.dart | 422 --------- .../example/lib/src/example_theme.dart | 82 -- .../example/lib/src/models/action_spec.dart | 15 - .../example/lib/src/models/info_item.dart | 6 - .../lib/src/sections/action_panel.dart | 48 -- .../lib/src/sections/benchmark_panel.dart | 46 - .../lib/src/sections/display_info_panel.dart | 52 -- .../example/lib/src/sections/hero_panel.dart | 109 --- .../lib/src/sections/scroll_test_panel.dart | 108 --- .../lib/src/sections/status_panel.dart | 47 - .../example/lib/src/ui/panel_widgets.dart | 377 --------- .../refresh_rate/example/linux/CMakeLists.txt | 128 --- .../example/linux/flutter/CMakeLists.txt | 88 -- .../flutter/generated_plugin_registrant.cc | 15 - .../flutter/generated_plugin_registrant.h | 15 - .../linux/flutter/generated_plugins.cmake | 24 - .../example/linux/runner/CMakeLists.txt | 26 - .../refresh_rate/example/linux/runner/main.cc | 6 - .../example/linux/runner/my_application.cc | 148 ---- .../example/linux/runner/my_application.h | 21 - .../macos/Flutter/Flutter-Debug.xcconfig | 2 - .../macos/Flutter/Flutter-Release.xcconfig | 2 - .../refresh_rate/example/macos/Podfile | 42 - .../refresh_rate/example/macos/Podfile.lock | 22 - .../macos/Runner.xcodeproj/project.pbxproj | 801 ------------------ .../xcshareddata/IDEWorkspaceChecks.plist | 8 - .../xcshareddata/xcschemes/Runner.xcscheme | 99 --- .../contents.xcworkspacedata | 10 - .../xcshareddata/IDEWorkspaceChecks.plist | 8 - .../example/macos/Runner/AppDelegate.swift | 13 - .../AppIcon.appiconset/Contents.json | 68 -- .../AppIcon.appiconset/app_icon_1024.png | Bin 102994 -> 0 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 5680 -> 0 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 520 -> 0 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 14142 -> 0 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 1066 -> 0 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 36406 -> 0 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 2218 -> 0 bytes .../macos/Runner/Base.lproj/MainMenu.xib | 343 -------- .../macos/Runner/Configs/AppInfo.xcconfig | 14 - .../macos/Runner/Configs/Debug.xcconfig | 2 - .../macos/Runner/Configs/Release.xcconfig | 2 - .../macos/Runner/Configs/Warnings.xcconfig | 13 - .../macos/Runner/DebugProfile.entitlements | 12 - .../example/macos/Runner/Info.plist | 32 - .../macos/Runner/MainFlutterWindow.swift | 15 - .../example/macos/Runner/Release.entitlements | 8 - .../macos/RunnerTests/RunnerTests.swift | 12 - third_party/refresh_rate/example/pubspec.yaml | 23 - .../example/test/widget_test.dart | 11 - .../example/test_driver/integration_test.dart | 30 - .../refresh_rate/example/web/favicon.png | Bin 917 -> 0 bytes .../example/web/icons/Icon-192.png | Bin 5292 -> 0 bytes .../example/web/icons/Icon-512.png | Bin 8252 -> 0 bytes .../example/web/icons/Icon-maskable-192.png | Bin 5594 -> 0 bytes .../example/web/icons/Icon-maskable-512.png | Bin 20998 -> 0 bytes .../refresh_rate/example/web/index.html | 46 - .../refresh_rate/example/web/manifest.json | 35 - .../example/windows/CMakeLists.txt | 108 --- .../example/windows/flutter/CMakeLists.txt | 109 --- .../flutter/generated_plugin_registrant.cc | 14 - .../flutter/generated_plugin_registrant.h | 15 - .../windows/flutter/generated_plugins.cmake | 24 - .../example/windows/runner/CMakeLists.txt | 40 - .../example/windows/runner/Runner.rc | 121 --- .../example/windows/runner/flutter_window.cpp | 71 -- .../example/windows/runner/flutter_window.h | 33 - .../example/windows/runner/main.cpp | 43 - .../example/windows/runner/resource.h | 16 - .../windows/runner/resources/app_icon.ico | Bin 33772 -> 0 bytes .../windows/runner/runner.exe.manifest | 14 - .../example/windows/runner/utils.cpp | 65 -- .../example/windows/runner/utils.h | 19 - .../example/windows/runner/win32_window.cpp | 288 ------- .../example/windows/runner/win32_window.h | 102 --- .../ios/Classes/DisplayLinkSwizzle.h | 14 - .../ios/Classes/DisplayLinkSwizzle.m | 186 ---- .../ios/Classes/RefreshRatePlugin.swift | 210 ----- .../Classes/generated/RefreshRateApi.swift | 369 -------- .../refresh_rate/ios/refresh_rate.podspec | 22 - .../ios/refresh_rate/Package.swift | 32 - .../refresh_rate/RefreshRatePlugin.swift | 214 ----- .../generated/RefreshRateApi.swift | 369 -------- .../refresh_rate_objc/DisplayLinkSwizzle.m | 188 ---- .../include/DisplayLinkSwizzle.h | 14 - .../refresh_rate/lib/refresh_rate.dart | 5 - .../refresh_rate/lib/refresh_rate_web.dart | 27 - .../lib/src/generated/refresh_rate_api.g.dart | 446 ---------- .../lib/src/models/display_info.dart | 129 --- .../refresh_rate/lib/src/models/enums.dart | 151 ---- .../lib/src/models/session_report.dart | 187 ---- .../refresh_rate/lib/src/refresh_rate.dart | 244 ------ .../lib/src/refresh_rate_api_adapter.dart | 79 -- .../lib/src/verification/fps_tracker.dart | 145 ---- .../src/verification/overlay_controller.dart | 95 --- .../lib/src/verification/overlay_widgets.dart | 177 ---- .../verification/refresh_rate_session.dart | 135 --- .../lib/src/verification/session_scorer.dart | 97 --- .../lib/src/web/raf_hz_detector.dart | 74 -- .../lib/src/web/web_refresh_rate_adapter.dart | 73 -- third_party/refresh_rate/linux/CMakeLists.txt | 18 - .../refresh_rate/refresh_rate_plugin.h | 22 - .../refresh_rate/linux/refresh_rate_api.g.cc | 651 -------------- .../refresh_rate/linux/refresh_rate_api.g.h | 233 ----- .../refresh_rate/linux/refresh_rate_plugin.cc | 274 ------ .../macos/Classes/RefreshRatePlugin.swift | 167 ---- .../Classes/generated/RefreshRateApi.swift | 369 -------- .../refresh_rate/macos/refresh_rate.podspec | 16 - .../macos/refresh_rate/Package.swift | 35 - .../refresh_rate/pigeons/copyright.txt | 2 - .../pigeons/refresh_rate_api.dart | 64 -- .../pigeons/refresh_rate_api_linux.dart | 63 -- third_party/refresh_rate/pubspec.yaml | 61 -- third_party/refresh_rate/screenshots/fps.png | Bin 300828 -> 0 bytes third_party/refresh_rate/screenshots/hz.png | Bin 299789 -> 0 bytes .../refresh_rate/test/fps_tracker_test.dart | 226 ----- .../refresh_rate/test/models_test.dart | 86 -- .../test/refresh_rate_api_test.dart | 98 --- .../refresh_rate/windows/CMakeLists.txt | 18 - .../refresh_rate/refresh_rate_plugin_c_api.h | 23 - .../windows/refresh_rate_api.g.cpp | 651 -------------- .../refresh_rate/windows/refresh_rate_api.g.h | 233 ----- .../windows/refresh_rate_plugin_c_api.cpp | 155 ---- 202 files changed, 14 insertions(+), 14436 deletions(-) delete mode 100644 third_party/refresh_rate/CHANGELOG.md delete mode 100644 third_party/refresh_rate/LICENSE delete mode 100644 third_party/refresh_rate/README.md delete mode 100644 third_party/refresh_rate/analysis_options.yaml delete mode 100644 third_party/refresh_rate/android/build.gradle delete mode 100644 third_party/refresh_rate/android/settings.gradle delete mode 100644 third_party/refresh_rate/android/src/main/AndroidManifest.xml delete mode 100644 third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/RefreshRatePlugin.kt delete mode 100644 third_party/refresh_rate/android/src/main/kotlin/in/qoder/refresh_rate/generated/RefreshRateApi.kt delete mode 100644 third_party/refresh_rate/example/README.md delete mode 100644 third_party/refresh_rate/example/analysis_options.yaml delete mode 100644 third_party/refresh_rate/example/android/app/build.gradle.kts delete mode 100644 third_party/refresh_rate/example/android/app/src/debug/AndroidManifest.xml delete mode 100644 third_party/refresh_rate/example/android/app/src/main/AndroidManifest.xml delete mode 100644 third_party/refresh_rate/example/android/app/src/main/kotlin/in/qoder/refresh_rate_example/MainActivity.kt delete mode 100644 third_party/refresh_rate/example/android/app/src/main/res/drawable-v21/launch_background.xml delete mode 100644 third_party/refresh_rate/example/android/app/src/main/res/drawable/launch_background.xml delete mode 100644 third_party/refresh_rate/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png delete mode 100644 third_party/refresh_rate/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png delete mode 100644 third_party/refresh_rate/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png delete mode 100644 third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png delete mode 100644 third_party/refresh_rate/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 third_party/refresh_rate/example/android/app/src/main/res/values-night/styles.xml delete mode 100644 third_party/refresh_rate/example/android/app/src/main/res/values/styles.xml delete mode 100644 third_party/refresh_rate/example/android/app/src/profile/AndroidManifest.xml delete mode 100644 third_party/refresh_rate/example/android/build.gradle.kts delete mode 100644 third_party/refresh_rate/example/android/gradle.properties delete mode 100644 third_party/refresh_rate/example/android/gradle/wrapper/gradle-wrapper.properties delete mode 100644 third_party/refresh_rate/example/android/settings.gradle.kts delete mode 100644 third_party/refresh_rate/example/integration_test/screenshot_test.dart delete mode 100644 third_party/refresh_rate/example/ios/Flutter/AppFrameworkInfo.plist delete mode 100644 third_party/refresh_rate/example/ios/Flutter/Debug.xcconfig delete mode 100644 third_party/refresh_rate/example/ios/Flutter/Release.xcconfig delete mode 100644 third_party/refresh_rate/example/ios/Podfile delete mode 100644 third_party/refresh_rate/example/ios/Podfile.lock delete mode 100644 third_party/refresh_rate/example/ios/Runner.xcodeproj/project.pbxproj delete mode 100644 third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 third_party/refresh_rate/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 third_party/refresh_rate/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme delete mode 100644 third_party/refresh_rate/example/ios/Runner.xcworkspace/contents.xcworkspacedata delete mode 100644 third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 third_party/refresh_rate/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 third_party/refresh_rate/example/ios/Runner/AppDelegate.swift delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png delete mode 100644 third_party/refresh_rate/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md delete mode 100644 third_party/refresh_rate/example/ios/Runner/Base.lproj/LaunchScreen.storyboard delete mode 100644 third_party/refresh_rate/example/ios/Runner/Base.lproj/Main.storyboard delete mode 100644 third_party/refresh_rate/example/ios/Runner/DebugProfile.entitlements delete mode 100644 third_party/refresh_rate/example/ios/Runner/Info.plist delete mode 100644 third_party/refresh_rate/example/ios/Runner/Release.entitlements delete mode 100644 third_party/refresh_rate/example/ios/Runner/Runner-Bridging-Header.h delete mode 100644 third_party/refresh_rate/example/ios/Runner/SceneDelegate.swift delete mode 100644 third_party/refresh_rate/example/ios/RunnerTests/RunnerTests.swift delete mode 100644 third_party/refresh_rate/example/lib/main.dart delete mode 100644 third_party/refresh_rate/example/lib/src/example_app.dart delete mode 100644 third_party/refresh_rate/example/lib/src/example_home.dart delete mode 100644 third_party/refresh_rate/example/lib/src/example_theme.dart delete mode 100644 third_party/refresh_rate/example/lib/src/models/action_spec.dart delete mode 100644 third_party/refresh_rate/example/lib/src/models/info_item.dart delete mode 100644 third_party/refresh_rate/example/lib/src/sections/action_panel.dart delete mode 100644 third_party/refresh_rate/example/lib/src/sections/benchmark_panel.dart delete mode 100644 third_party/refresh_rate/example/lib/src/sections/display_info_panel.dart delete mode 100644 third_party/refresh_rate/example/lib/src/sections/hero_panel.dart delete mode 100644 third_party/refresh_rate/example/lib/src/sections/scroll_test_panel.dart delete mode 100644 third_party/refresh_rate/example/lib/src/sections/status_panel.dart delete mode 100644 third_party/refresh_rate/example/lib/src/ui/panel_widgets.dart delete mode 100644 third_party/refresh_rate/example/linux/CMakeLists.txt delete mode 100644 third_party/refresh_rate/example/linux/flutter/CMakeLists.txt delete mode 100644 third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.cc delete mode 100644 third_party/refresh_rate/example/linux/flutter/generated_plugin_registrant.h delete mode 100644 third_party/refresh_rate/example/linux/flutter/generated_plugins.cmake delete mode 100644 third_party/refresh_rate/example/linux/runner/CMakeLists.txt delete mode 100644 third_party/refresh_rate/example/linux/runner/main.cc delete mode 100644 third_party/refresh_rate/example/linux/runner/my_application.cc delete mode 100644 third_party/refresh_rate/example/linux/runner/my_application.h delete mode 100644 third_party/refresh_rate/example/macos/Flutter/Flutter-Debug.xcconfig delete mode 100644 third_party/refresh_rate/example/macos/Flutter/Flutter-Release.xcconfig delete mode 100644 third_party/refresh_rate/example/macos/Podfile delete mode 100644 third_party/refresh_rate/example/macos/Podfile.lock delete mode 100644 third_party/refresh_rate/example/macos/Runner.xcodeproj/project.pbxproj delete mode 100644 third_party/refresh_rate/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 third_party/refresh_rate/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme delete mode 100644 third_party/refresh_rate/example/macos/Runner.xcworkspace/contents.xcworkspacedata delete mode 100644 third_party/refresh_rate/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 third_party/refresh_rate/example/macos/Runner/AppDelegate.swift delete mode 100644 third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png delete mode 100644 third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png delete mode 100644 third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png delete mode 100644 third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png delete mode 100644 third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png delete mode 100644 third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png delete mode 100644 third_party/refresh_rate/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png delete mode 100644 third_party/refresh_rate/example/macos/Runner/Base.lproj/MainMenu.xib delete mode 100644 third_party/refresh_rate/example/macos/Runner/Configs/AppInfo.xcconfig delete mode 100644 third_party/refresh_rate/example/macos/Runner/Configs/Debug.xcconfig delete mode 100644 third_party/refresh_rate/example/macos/Runner/Configs/Release.xcconfig delete mode 100644 third_party/refresh_rate/example/macos/Runner/Configs/Warnings.xcconfig delete mode 100644 third_party/refresh_rate/example/macos/Runner/DebugProfile.entitlements delete mode 100644 third_party/refresh_rate/example/macos/Runner/Info.plist delete mode 100644 third_party/refresh_rate/example/macos/Runner/MainFlutterWindow.swift delete mode 100644 third_party/refresh_rate/example/macos/Runner/Release.entitlements delete mode 100644 third_party/refresh_rate/example/macos/RunnerTests/RunnerTests.swift delete mode 100644 third_party/refresh_rate/example/pubspec.yaml delete mode 100644 third_party/refresh_rate/example/test/widget_test.dart delete mode 100644 third_party/refresh_rate/example/test_driver/integration_test.dart delete mode 100644 third_party/refresh_rate/example/web/favicon.png delete mode 100644 third_party/refresh_rate/example/web/icons/Icon-192.png delete mode 100644 third_party/refresh_rate/example/web/icons/Icon-512.png delete mode 100644 third_party/refresh_rate/example/web/icons/Icon-maskable-192.png delete mode 100644 third_party/refresh_rate/example/web/icons/Icon-maskable-512.png delete mode 100644 third_party/refresh_rate/example/web/index.html delete mode 100644 third_party/refresh_rate/example/web/manifest.json delete mode 100644 third_party/refresh_rate/example/windows/CMakeLists.txt delete mode 100644 third_party/refresh_rate/example/windows/flutter/CMakeLists.txt delete mode 100644 third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.cc delete mode 100644 third_party/refresh_rate/example/windows/flutter/generated_plugin_registrant.h delete mode 100644 third_party/refresh_rate/example/windows/flutter/generated_plugins.cmake delete mode 100644 third_party/refresh_rate/example/windows/runner/CMakeLists.txt delete mode 100644 third_party/refresh_rate/example/windows/runner/Runner.rc delete mode 100644 third_party/refresh_rate/example/windows/runner/flutter_window.cpp delete mode 100644 third_party/refresh_rate/example/windows/runner/flutter_window.h delete mode 100644 third_party/refresh_rate/example/windows/runner/main.cpp delete mode 100644 third_party/refresh_rate/example/windows/runner/resource.h delete mode 100644 third_party/refresh_rate/example/windows/runner/resources/app_icon.ico delete mode 100644 third_party/refresh_rate/example/windows/runner/runner.exe.manifest delete mode 100644 third_party/refresh_rate/example/windows/runner/utils.cpp delete mode 100644 third_party/refresh_rate/example/windows/runner/utils.h delete mode 100644 third_party/refresh_rate/example/windows/runner/win32_window.cpp delete mode 100644 third_party/refresh_rate/example/windows/runner/win32_window.h delete mode 100644 third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.h delete mode 100644 third_party/refresh_rate/ios/Classes/DisplayLinkSwizzle.m delete mode 100644 third_party/refresh_rate/ios/Classes/RefreshRatePlugin.swift delete mode 100644 third_party/refresh_rate/ios/Classes/generated/RefreshRateApi.swift delete mode 100644 third_party/refresh_rate/ios/refresh_rate.podspec delete mode 100644 third_party/refresh_rate/ios/refresh_rate/Package.swift delete mode 100644 third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/RefreshRatePlugin.swift delete mode 100644 third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate/generated/RefreshRateApi.swift delete mode 100644 third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/DisplayLinkSwizzle.m delete mode 100644 third_party/refresh_rate/ios/refresh_rate/Sources/refresh_rate_objc/include/DisplayLinkSwizzle.h delete mode 100644 third_party/refresh_rate/lib/refresh_rate.dart delete mode 100644 third_party/refresh_rate/lib/refresh_rate_web.dart delete mode 100644 third_party/refresh_rate/lib/src/generated/refresh_rate_api.g.dart delete mode 100644 third_party/refresh_rate/lib/src/models/display_info.dart delete mode 100644 third_party/refresh_rate/lib/src/models/enums.dart delete mode 100644 third_party/refresh_rate/lib/src/models/session_report.dart delete mode 100644 third_party/refresh_rate/lib/src/refresh_rate.dart delete mode 100644 third_party/refresh_rate/lib/src/refresh_rate_api_adapter.dart delete mode 100644 third_party/refresh_rate/lib/src/verification/fps_tracker.dart delete mode 100644 third_party/refresh_rate/lib/src/verification/overlay_controller.dart delete mode 100644 third_party/refresh_rate/lib/src/verification/overlay_widgets.dart delete mode 100644 third_party/refresh_rate/lib/src/verification/refresh_rate_session.dart delete mode 100644 third_party/refresh_rate/lib/src/verification/session_scorer.dart delete mode 100644 third_party/refresh_rate/lib/src/web/raf_hz_detector.dart delete mode 100644 third_party/refresh_rate/lib/src/web/web_refresh_rate_adapter.dart delete mode 100644 third_party/refresh_rate/linux/CMakeLists.txt delete mode 100644 third_party/refresh_rate/linux/include/refresh_rate/refresh_rate_plugin.h delete mode 100644 third_party/refresh_rate/linux/refresh_rate_api.g.cc delete mode 100644 third_party/refresh_rate/linux/refresh_rate_api.g.h delete mode 100644 third_party/refresh_rate/linux/refresh_rate_plugin.cc delete mode 100644 third_party/refresh_rate/macos/Classes/RefreshRatePlugin.swift delete mode 100644 third_party/refresh_rate/macos/Classes/generated/RefreshRateApi.swift delete mode 100644 third_party/refresh_rate/macos/refresh_rate.podspec delete mode 100644 third_party/refresh_rate/macos/refresh_rate/Package.swift delete mode 100644 third_party/refresh_rate/pigeons/copyright.txt delete mode 100644 third_party/refresh_rate/pigeons/refresh_rate_api.dart delete mode 100644 third_party/refresh_rate/pigeons/refresh_rate_api_linux.dart delete mode 100644 third_party/refresh_rate/pubspec.yaml delete mode 100644 third_party/refresh_rate/screenshots/fps.png delete mode 100644 third_party/refresh_rate/screenshots/hz.png delete mode 100644 third_party/refresh_rate/test/fps_tracker_test.dart delete mode 100644 third_party/refresh_rate/test/models_test.dart delete mode 100644 third_party/refresh_rate/test/refresh_rate_api_test.dart delete mode 100644 third_party/refresh_rate/windows/CMakeLists.txt delete mode 100644 third_party/refresh_rate/windows/include/refresh_rate/refresh_rate_plugin_c_api.h delete mode 100644 third_party/refresh_rate/windows/refresh_rate_api.g.cpp delete mode 100644 third_party/refresh_rate/windows/refresh_rate_api.g.h delete mode 100644 third_party/refresh_rate/windows/refresh_rate_plugin_c_api.cpp diff --git a/docs/macos-signing.md b/docs/macos-signing.md index f4cec0db..30543e6d 100644 --- a/docs/macos-signing.md +++ b/docs/macos-signing.md @@ -157,7 +157,7 @@ Related: secrets are stored in the login keychain, so they work without `keychai ## 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 the vendored `refresh_rate` need 10.14). Raising it needs no plugin change, lowering it is not possible. +`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. 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/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/pubspec.yaml b/pubspec.yaml index 8eb429e8..d6119139 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ 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 bitsdojo_window: ^0.1.6 path: ^1.9.0 @@ -56,16 +56,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/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 db77bb4b7b0906d62b1847e87f15cdcacf6a4f29..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ 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 17987b79bb8a35cc66c3c1fd44f5a5526c1b78be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ 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 d5f1c8d34e7a88e3f88bea192c3a370d44689c3c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof 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 4d6372eebdb28e45604e46eeda8dd24651419bc0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` 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 dc9ada4725e9b0ddb1deab583e5b5102493aa332..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10932 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 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 797d452e458972bab9d994556c8305db4c827017..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c 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 6ed2d933e1120817fe9182483a228007b18ab6ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 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 4cd7b0099ca80c806f8fe495613e8d6c69460d76..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX 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 fe730945a01f64a61e2235dbe3f45b08f7729182..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c 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 502f463a9bc882b461c96aadf492d1729e49e725..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU 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 0ec303439225b78712f49115768196d8d76f6790..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU 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 e9f5fea27c705180eb716271f41b582e76dcbd90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1674 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 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 0467bf12aa4d28f374bb26596605a46dcbb3e7c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H 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 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v 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 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v 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 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v 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 82b6f9d9a33e198f5747104729e1fcef999772a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 102994 zcmeEugo5nb1G~3xi~y`}h6XHx5j$(L*3|5S2UfkG$|UCNI>}4f?MfqZ+HW-sRW5RKHEm z^unW*Xx{AH_X3Xdvb%C(Bh6POqg==@d9j=5*}oEny_IS;M3==J`P0R!eD6s~N<36C z*%-OGYqd0AdWClO!Z!}Y1@@RkfeiQ$Ib_ z&fk%T;K9h`{`cX3Hu#?({4WgtmkR!u3ICS~|NqH^fdNz>51-9)OF{|bRLy*RBv#&1 z3Oi_gk=Y5;>`KbHf~w!`u}!&O%ou*Jzf|Sf?J&*f*K8cftMOKswn6|nb1*|!;qSrlw= zr-@X;zGRKs&T$y8ENnFU@_Z~puu(4~Ir)>rbYp{zxcF*!EPS6{(&J}qYpWeqrPWW< zfaApz%<-=KqxrqLLFeV3w0-a0rEaz9&vv^0ZfU%gt9xJ8?=byvNSb%3hF^X_n7`(fMA;C&~( zM$cQvQ|g9X)1AqFvbp^B{JEX$o;4iPi?+v(!wYrN{L}l%e#5y{j+1NMiT-8=2VrCP zmFX9=IZyAYA5c2!QO96Ea-6;v6*$#ZKM-`%JCJtrA3d~6h{u+5oaTaGE)q2b+HvdZ zvHlY&9H&QJ5|uG@wDt1h99>DdHy5hsx)bN`&G@BpxAHh$17yWDyw_jQhhjSqZ=e_k z_|r3=_|`q~uA47y;hv=6-o6z~)gO}ZM9AqDJsR$KCHKH;QIULT)(d;oKTSPDJ}Jx~G#w-(^r<{GcBC*~4bNjfwHBumoPbU}M)O za6Hc2ik)2w37Yyg!YiMq<>Aov?F2l}wTe+>h^YXcK=aesey^i)QC_p~S zp%-lS5%)I29WfywP(r4@UZ@XmTkqo51zV$|U|~Lcap##PBJ}w2b4*kt7x6`agP34^ z5fzu_8rrH+)2u*CPcr6I`gL^cI`R2WUkLDE5*PX)eJU@H3HL$~o_y8oMRoQ0WF9w| z6^HZDKKRDG2g;r8Z4bn+iJNFV(CG;K-j2>aj229gl_C6n12Jh$$h!}KVhn>*f>KcH z;^8s3t(ccVZ5<{>ZJK@Z`hn_jL{bP8Yn(XkwfRm?GlEHy=T($8Z1Mq**IM`zxN9>-yXTjfB18m_$E^JEaYn>pj`V?n#Xu;Z}#$- zw0Vw;T*&9TK$tKI7nBk9NkHzL++dZ^;<|F6KBYh2+XP-b;u`Wy{~79b%IBZa3h*3^ zF&BKfQ@Ej{7ku_#W#mNJEYYp=)bRMUXhLy2+SPMfGn;oBsiG_6KNL8{p1DjuB$UZB zA)a~BkL)7?LJXlCc}bB~j9>4s7tlnRHC5|wnycQPF_jLl!Avs2C3^lWOlHH&v`nGd zf&U!fn!JcZWha`Pl-B3XEe;(ks^`=Z5R zWyQR0u|do2`K3ec=YmWGt5Bwbu|uBW;6D8}J3{Uep7_>L6b4%(d=V4m#(I=gkn4HT zYni3cnn>@F@Wr<hFAY3Y~dW+3bte;70;G?kTn4Aw5nZ^s5|47 z4$rCHCW%9qa4)4vE%^QPMGf!ET!^LutY$G zqdT(ub5T5b+wi+OrV}z3msoy<4)`IPdHsHJggmog0K*pFYMhH!oZcgc5a)WmL?;TPSrerTVPp<#s+imF3v#!FuBNNa`#6 z!GdTCF|IIpz#(eV^mrYKThA4Bnv&vQet@%v9kuRu3EHx1-2-it@E`%9#u`)HRN#M? z7aJ{wzKczn#w^`OZ>Jb898^Xxq)0zd{3Tu7+{-sge-rQ z&0PME&wIo6W&@F|%Z8@@N3)@a_ntJ#+g{pUP7i?~3FirqU`rdf8joMG^ld?(9b7Iv z>TJgBg#)(FcW)h!_if#cWBh}f+V08GKyg|$P#KTS&%=!+0a%}O${0$i)kn9@G!}En zv)_>s?glPiLbbx)xk(lD-QbY(OP3;MSXM5E*P&_`Zks2@46n|-h$Y2L7B)iH{GAAq19h5-y0q>d^oy^y+soJu9lXxAe%jcm?=pDLFEG2kla40e!5a}mpe zdL=WlZ=@U6{>g%5a+y-lx)01V-x;wh%F{=qy#XFEAqcd+m}_!lQ)-9iiOL%&G??t| z?&NSdaLqdPdbQs%y0?uIIHY7rw1EDxtQ=DU!i{)Dkn~c$LG5{rAUYM1j5*G@oVn9~ zizz{XH(nbw%f|wI=4rw^6mNIahQpB)OQy10^}ACdLPFc2@ldVi|v@1nWLND?)53O5|fg`RZW&XpF&s3@c-R?aad!$WoH6u0B|}zt)L($E^@U- zO#^fxu9}Zw7Xl~nG1FVM6DZSR0*t!4IyUeTrnp@?)Z)*!fhd3)&s(O+3D^#m#bAem zpf#*aiG_0S^ofpm@9O7j`VfLU0+{$x!u^}3!zp=XST0N@DZTp!7LEVJgqB1g{psNr za0uVmh3_9qah14@M_pi~vAZ#jc*&aSm$hCNDsuQ-zPe&*Ii#2=2gP+DP4=DY z_Y0lUsyE6yaV9)K)!oI6+*4|spx2at*30CAx~6-5kfJzQ`fN8$!lz%hz^J6GY?mVH zbYR^JZ(Pmj6@vy-&!`$5soyy-NqB^8cCT40&R@|6s@m+ZxPs=Bu77-+Os7+bsz4nA3DrJ8#{f98ZMaj-+BD;M+Jk?pgFcZIb}m9N z{ct9T)Kye&2>l^39O4Q2@b%sY?u#&O9PO4@t0c$NUXG}(DZJ<;_oe2~e==3Z1+`Zo zFrS3ns-c}ZognVBHbg#e+1JhC(Yq7==rSJQ8J~}%94(O#_-zJKwnBXihl#hUd9B_>+T& z7eHHPRC?5ONaUiCF7w|{J`bCWS7Q&xw-Sa={j-f)n5+I=9s;E#fBQB$`DDh<^mGiF zu-m_k+)dkBvBO(VMe2O4r^sf3;sk9K!xgXJU>|t9Vm8Ty;fl5pZzw z9j|}ZD}6}t;20^qrS?YVPuPRS<39d^y0#O1o_1P{tN0?OX!lc-ICcHI@2#$cY}_CY zev|xdFcRTQ_H)1fJ7S0*SpPs8e{d+9lR~IZ^~dKx!oxz?=Dp!fD`H=LH{EeC8C&z-zK$e=!5z8NL=4zx2{hl<5z*hEmO=b-7(k5H`bA~5gT30Sjy`@-_C zKM}^so9Ti1B;DovHByJkTK87cfbF16sk-G>`Q4-txyMkyQS$d}??|Aytz^;0GxvOs zPgH>h>K+`!HABVT{sYgzy3CF5ftv6hI-NRfgu613d|d1cg^jh+SK7WHWaDX~hlIJ3 z>%WxKT0|Db1N-a4r1oPKtF--^YbP=8Nw5CNt_ZnR{N(PXI>Cm$eqi@_IRmJ9#)~ZHK_UQ8mi}w^`+4$OihUGVz!kW^qxnCFo)-RIDbA&k-Y=+*xYv5y4^VQ9S)4W5Pe?_RjAX6lS6Nz#!Hry=+PKx2|o_H_3M`}Dq{Bl_PbP(qel~P@=m}VGW*pK96 zI@fVag{DZHi}>3}<(Hv<7cVfWiaVLWr@WWxk5}GDEbB<+Aj;(c>;p1qmyAIj+R!`@#jf$ zy4`q23L-72Zs4j?W+9lQD;CYIULt%;O3jPWg2a%Zs!5OW>5h1y{Qof!p&QxNt5=T( zd5fy&7=hyq;J8%86YBOdc$BbIFxJx>dUyTh`L z-oKa=OhRK9UPVRWS`o2x53bAv+py)o)kNL6 z9W1Dlk-g6Ht@-Z^#6%`9S9`909^EMj?9R^4IxssCY-hYzei^TLq7Cj>z$AJyaU5=z zl!xiWvz0U8kY$etrcp8mL;sYqGZD!Hs-U2N{A|^oEKA482v1T%cs%G@X9M?%lX)p$ zZoC7iYTPe8yxY0Jne|s)fCRe1mU=Vb1J_&WcIyP|x4$;VSVNC`M+e#oOA`#h>pyU6 z?7FeVpk`Hsu`~T3i<_4<5fu?RkhM;@LjKo6nX>pa%8dSdgPO9~Jze;5r>Tb1Xqh5q z&SEdTXevV@PT~!O6z|oypTk7Qq+BNF5IQ(8s18c=^0@sc8Gi|3e>VKCsaZ?6=rrck zl@oF5Bd0zH?@15PxSJIRroK4Wa?1o;An;p0#%ZJ^tI=(>AJ2OY0GP$E_3(+Zz4$AQ zW)QWl<4toIJ5TeF&gNXs>_rl}glkeG#GYbHHOv-G!%dJNoIKxn)FK$5&2Zv*AFic! z@2?sY&I*PSfZ8bU#c9fdIJQa_cQijnj39-+hS@+~e*5W3bj%A}%p9N@>*tCGOk+cF zlcSzI6j%Q|2e>QG3A<86w?cx6sBtLNWF6_YR?~C)IC6_10SNoZUHrCpp6f^*+*b8` zlx4ToZZuI0XW1W)24)92S)y0QZa);^NRTX6@gh8@P?^=#2dV9s4)Q@K+gnc{6|C}& zDLHr7nDOLrsH)L@Zy{C_2UrYdZ4V{|{c8&dRG;wY`u>w%$*p>PO_}3`Y21pk?8Wtq zGwIXTulf7AO2FkPyyh2TZXM1DJv>hI`}x`OzQI*MBc#=}jaua&czSkI2!s^rOci|V zFkp*Vbiz5vWa9HPFXMi=BV&n3?1?%8#1jq?p^3wAL`jgcF)7F4l<(H^!i=l-(OTDE zxf2p71^WRIExLf?ig0FRO$h~aA23s#L zuZPLkm>mDwBeIu*C7@n@_$oSDmdWY7*wI%aL73t~`Yu7YwE-hxAATmOi0dmB9|D5a zLsR7OQcA0`vN9m0L|5?qZ|jU+cx3_-K2!K$zDbJ$UinQy<9nd5ImWW5n^&=Gg>Gsh zY0u?m1e^c~Ug39M{{5q2L~ROq#c{eG8Oy#5h_q=#AJj2Yops|1C^nv0D1=fBOdfAG z%>=vl*+_w`&M7{qE#$xJJp_t>bSh7Mpc(RAvli9kk3{KgG5K@a-Ue{IbU{`umXrR3ra5Y7xiX42+Q%N&-0#`ae_ z#$Y6Wa++OPEDw@96Zz##PFo9sADepQe|hUy!Zzc2C(L`k9&=a8XFr+!hIS>D2{pdGP1SzwyaGLiH3j--P>U#TWw90t8{8Bt%m7Upspl#=*hS zhy|(XL6HOqBW}Og^tLX7 z+`b^L{O&oqjwbxDDTg2B;Yh2(fW>%S5Pg8^u1p*EFb z`(fbUM0`afawYt%VBfD&b3MNJ39~Ldc@SAuzsMiN%E}5{uUUBc7hc1IUE~t-Y9h@e7PC|sv$xGx=hZiMXNJxz5V(np%6u{n24iWX#!8t#>Ob$in<>dw96H)oGdTHnU zSM+BPss*5)Wz@+FkooMxxXZP1{2Nz7a6BB~-A_(c&OiM)UUNoa@J8FGxtr$)`9;|O z(Q?lq1Q+!E`}d?KemgC!{nB1JJ!B>6J@XGQp9NeQvtbM2n7F%v|IS=XWPVZY(>oq$ zf=}8O_x`KOxZoGnp=y24x}k6?gl_0dTF!M!T`={`Ii{GnT1jrG9gPh)R=RZG8lIR| z{ZJ6`x8n|y+lZuy${fuEDTAf`OP!tGySLXD}ATJO5UoZv|Xo3%7O~L63+kw}v)Ci=&tWx3bQJfL@5O18CbPlkR^IcKA zy1=^Vl-K-QBP?9^R`@;czcUw;Enbbyk@vJQB>BZ4?;DM%BUf^eZE+sOy>a){qCY6Y znYy;KGpch-zf=5|p#SoAV+ie8M5(Xg-{FoLx-wZC9IutT!(9rJ8}=!$!h%!J+vE2e z(sURwqCC35v?1>C1L)swfA^sr16{yj7-zbT6Rf26-JoEt%U?+|rQ zeBuGohE?@*!zR9)1P|3>KmJSgK*fOt>N>j}LJB`>o(G#Dduvx7@DY7};W7K;Yj|8O zGF<+gTuoIKe7Rf+LQG3-V1L^|E;F*}bQ-{kuHq}| ze_NwA7~US19sAZ)@a`g*zkl*ykv2v3tPrb4Og2#?k6Lc7@1I~+ew48N&03hW^1Cx+ zfk5Lr4-n=#HYg<7ka5i>2A@ZeJ60gl)IDX!!p zzfXZQ?GrT>JEKl7$SH!otzK6=0dIlqN)c23YLB&Krf9v-{@V8p+-e2`ujFR!^M%*; ze_7(Jh$QgoqwB!HbX=S+^wqO15O_TQ0-qX8f-|&SOuo3ZE{{9Jw5{}>MhY}|GBhO& zv48s_B=9aYQfa;d>~1Z$y^oUUaDer>7ve5+Gf?rIG4GZ!hRKERlRNgg_C{W_!3tsI2TWbX8f~MY)1Q`6Wj&JJ~*;ay_0@e zzx+mE-pu8{cEcVfBqsnm=jFU?H}xj@%CAx#NO>3 z_re3Rq%d1Y7VkKy{=S73&p;4^Praw6Y59VCP6M?!Kt7{v#DG#tz?E)`K95gH_mEvb z%$<~_mQ$ad?~&T=O0i0?`YSp?E3Dj?V>n+uTRHAXn`l!pH9Mr}^D1d@mkf+;(tV45 zH_yfs^kOGLXlN*0GU;O&{=awxd?&`{JPRr$z<1HcAO2K`K}92$wC}ky&>;L?#!(`w z68avZGvb728!vgw>;8Z8I@mLtI`?^u6R>sK4E7%=y)jpmE$fH!Dj*~(dy~-2A5Cm{ zl{1AZw`jaDmfvaB?jvKwz!GC}@-Dz|bFm1OaPw(ia#?>vF7Y5oh{NVbyD~cHB1KFn z9C@f~X*Wk3>sQH9#D~rLPslAd26@AzMh=_NkH_yTNXx6-AdbAb z{Ul89YPHslD?xAGzOlQ*aMYUl6#efCT~WI zOvyiewT=~l1W(_2cEd(8rDywOwjM-7P9!8GCL-1<9KXXO=6%!9=W++*l1L~gRSxLVd8K=A7&t52ql=J&BMQu{fa6y zXO_e>d?4X)xp2V8e3xIQGbq@+vo#&n>-_WreTTW0Yr?|YRPP43cDYACMQ(3t6(?_k zfgDOAU^-pew_f5U#WxRXB30wcfDS3;k~t@b@w^GG&<5n$Ku?tT(%bQH(@UHQGN)N|nfC~7?(etU`}XB)$>KY;s=bYGY#kD%i9fz= z2nN9l?UPMKYwn9bX*^xX8Y@%LNPFU>s#Ea1DaP%bSioqRWi9JS28suTdJycYQ+tW7 zrQ@@=13`HS*dVKaVgcem-45+buD{B;mUbY$YYULhxK)T{S?EB<8^YTP$}DA{(&)@S zS#<8S96y9K2!lG^VW-+CkfXJIH;Vo6wh)N}!08bM$I7KEW{F6tqEQ?H@(U zAqfi%KCe}2NUXALo;UN&k$rU0BLNC$24T_mcNY(a@lxR`kqNQ0z%8m>`&1ro40HX} z{{3YQ;2F9JnVTvDY<4)x+88i@MtXE6TBd7POk&QfKU-F&*C`isS(T_Q@}K)=zW#K@ zbXpcAkTT-T5k}Wj$dMZl7=GvlcCMt}U`#Oon1QdPq%>9J$rKTY8#OmlnNWBYwafhx zqFnym@okL#Xw>4SeRFejBnZzY$jbO)e^&&sHBgMP%Ygfi!9_3hp17=AwLBNFTimf0 zw6BHNXw19Jg_Ud6`5n#gMpqe%9!QB^_7wAYv8nrW94A{*t8XZu0UT&`ZHfkd(F{Px zD&NbRJP#RX<=+sEeGs2`9_*J2OlECpR;4uJie-d__m*(aaGE}HIo+3P{my@;a~9Y$ zHBXVJ83#&@o6{M+pE9^lI<4meLLFN_3rwgR4IRyp)~OF0n+#ORrcJ2_On9-78bWbG zuCO0esc*n1X3@p1?lN{qWS?l7J$^jbpeel{w~51*0CM+q9@9X=>%MF(ce~om(}?td zjkUmdUR@LOn-~6LX#=@a%rvj&>DFEoQscOvvC@&ZB5jVZ-;XzAshwx$;Qf@U41W=q zOSSjQGQV8Qi3*4DngNMIM&Cxm7z*-K`~Bl(TcEUxjQ1c=?)?wF8W1g;bAR%sM#LK( z_Op?=P%)Z+J!>vpN`By0$?B~Out%P}kCriDq@}In&fa_ZyKV+nLM0E?hfxuu%ciUz z>yAk}OydbWNl7{)#112j&qmw;*Uj&B;>|;Qwfc?5wIYIHH}s6Mve@5c5r+y)jK9i( z_}@uC(98g)==AGkVN?4>o@w=7x9qhW^ zB(b5%%4cHSV?3M?k&^py)j*LK16T^Ef4tb05-h-tyrjt$5!oo4spEfXFK7r_Gfv7#x$bsR7T zs;dqxzUg9v&GjsQGKTP*=B(;)be2aN+6>IUz+Hhw-n>^|`^xu*xvjGPaDoFh2W4-n z@Wji{5Y$m>@Vt7TE_QVQN4*vcfWv5VY-dT0SV=l=8LAEq1go*f zkjukaDV=3kMAX6GAf0QOQHwP^{Z^=#Lc)sh`QB)Ftl&31jABvq?8!3bt7#8vxB z53M{4{GR4Hl~;W3r}PgXSNOt477cO62Yj(HcK&30zsmWpvAplCtpp&mC{`2Ue*Bwu zF&UX1;w%`Bs1u%RtGPFl=&sHu@Q1nT`z={;5^c^^S~^?2-?<|F9RT*KQmfgF!7=wD@hytxbD;=9L6PZrK*1<4HMObNWehA62DtTy)q5H|57 z9dePuC!1;0MMRRl!S@VJ8qG=v^~aEU+}2Qx``h1LII!y{crP2ky*R;Cb;g|r<#ryo zju#s4dE?5CTIZKc*O4^3qWflsQ(voX>(*_JP7>Q&$%zCAIBTtKC^JUi@&l6u&t0hXMXjz_y!;r@?k|OU9aD%938^TZ>V? zqJmom_6dz4DBb4Cgs_Ef@}F%+cRCR%UMa9pi<-KHN;t#O@cA%(LO1Rb=h?5jiTs93 zPLR78p+3t>z4|j=<>2i4b`ketv}9Ax#B0)hn7@bFl;rDfP8p7u9XcEb!5*PLKB(s7wQC2kzI^@ae)|DhNDmSy1bOLid%iIap@24A(q2XI!z_hkl-$1T10 z+KKugG4-}@u8(P^S3PW4x>an;XWEF-R^gB{`t8EiP{ZtAzoZ!JRuMRS__-Gg#Qa3{<;l__CgsF+nfmFNi}p z>rV!Y6B@cC>1up)KvaEQiAvQF!D>GCb+WZsGHjDeWFz?WVAHP65aIA8u6j6H35XNYlyy8>;cWe3ekr};b;$9)0G`zsc9LNsQ&D?hvuHRpBxH)r-1t9|Stc*u<}Ol&2N+wPMom}d15_TA=Aprp zjN-X3*Af$7cDWMWp##kOH|t;c2Pa9Ml4-)o~+7P;&q8teF-l}(Jt zTGKOQqJTeT!L4d}Qw~O0aanA$Vn9Rocp-MO4l*HK)t%hcp@3k0%&_*wwpKD6ThM)R z8k}&7?)YS1ZYKMiy?mn>VXiuzX7$Ixf7EW8+C4K^)m&eLYl%#T=MC;YPvD&w#$MMf zQ=>`@rh&&r!@X&v%ZlLF42L_c=5dSU^uymKVB>5O?AouR3vGv@ei%Z|GX5v1GK2R* zi!!}?+-8>J$JH^fPu@)E6(}9$d&9-j51T^n-e0Ze%Q^)lxuex$IL^XJ&K2oi`wG}QVGk2a7vC4X?+o^z zsCK*7`EUfSuQA*K@Plsi;)2GrayQOG9OYF82Hc@6aNN5ulqs1Of-(iZQdBI^U5of^ zZg2g=Xtad7$hfYu6l~KDQ}EU;oIj(3nO#u9PDz=eO3(iax7OCmgT2p_7&^3q zg7aQ;Vpng*)kb6=sd5?%j5Dm|HczSChMo8HHq_L8R;BR5<~DVyU$8*Tk5}g0eW5x7 z%d)JFZ{(Y<#OTKLBA1fwLM*fH7Q~7Sc2Ne;mVWqt-*o<;| z^1@vo_KTYaMnO$7fbLL+qh#R$9bvnpJ$RAqG+z8h|} z3F5iwG*(sCn9Qbyg@t0&G}3fE0jGq3J!JmG2K&$urx^$z95) z7h?;4vE4W=v)uZ*Eg3M^6f~|0&T)2D;f+L_?M*21-I1pnK(pT$5l#QNlT`SidYw~o z{`)G)Asv#cue)Ax1RNWiRUQ(tQ(bzd-f2U4xlJK+)ZWBxdq#fp=A>+Qc%-tl(c)`t z$e2Ng;Rjvnbu7((;v4LF9Y1?0el9hi!g>G{^37{ z`^s-03Z5jlnD%#Mix19zkU_OS|86^_x4<0(*YbPN}mi-$L?Z4K(M|2&VV*n*ZYN_UqI?eKZi3!b)i z%n3dzUPMc-dc|q}TzvPy!VqsEWCZL(-eURDRG4+;Eu!LugSSI4Fq$Ji$Dp08`pfP_C5Yx~`YKcywlMG;$F z)R5!kVml_Wv6MSpeXjG#g?kJ0t_MEgbXlUN3k|JJ%N>|2xn8yN>>4qxh!?dGI}s|Y zDTKd^JCrRSN+%w%D_uf=Tj6wIV$c*g8D96jb^Kc#>5Fe-XxKC@!pIJw0^zu;`_yeb zhUEm-G*C=F+jW%cP(**b61fTmPn2WllBr4SWNdKe*P8VabZsh0-R|?DO=0x`4_QY) zR7sthW^*BofW7{Sak&S1JdiG?e=SfL24Y#w_)xrBVhGB-13q$>mFU|wd9Xqe-o3{6 zSn@@1@&^)M$rxb>UmFuC+pkio#T;mSnroMVZJ%nZ!uImi?%KsIX#@JU2VY(`kGb1A z7+1MEG)wd@)m^R|a2rXeviv$!emwcY(O|M*xV!9%tBzarBOG<4%gI9SW;Um_gth4=gznYzOFd)y8e+3APCkL)i-OI`;@7-mCJgE`js(M} z;~ZcW{{FMVVO)W>VZ}ILouF#lWGb%Couu}TI4kubUUclW@jEn6B_^v!Ym*(T*4HF9 zWhNKi8%sS~viSdBtnrq!-Dc5(G^XmR>DFx8jhWvR%*8!m*b*R8e1+`7{%FACAK`7 zzdy8TmBh?FVZ0vtw6npnWwM~XjF2fNvV#ZlGG z?FxHkXHN>JqrBYoPo$)zNC7|XrQfcqmEXWud~{j?La6@kbHG@W{xsa~l1=%eLly8B z4gCIH05&Y;6O2uFSopNqP|<$ml$N40^ikxw0`o<~ywS1(qKqQN!@?Ykl|bE4M?P+e zo$^Vs_+x)iuw?^>>`$&lOQOUkZ5>+OLnRA)FqgpDjW&q*WAe(_mAT6IKS9;iZBl8M z<@=Y%zcQUaSBdrs27bVK`c$)h6A1GYPS$y(FLRD5Yl8E3j0KyH08#8qLrsc_qlws; znMV%Zq8k+&T2kf%6ZO^2=AE9>?a587g%-={X}IS~P*I(NeCF9_9&`)|ok0iiIun zo+^odT0&Z4k;rn7I1v87=z!zKU(%gfB$(1mrRYeO$sbqM22Kq68z9wgdg8HBxp>_< zn9o%`f?sVO=IN#5jSX&CGODWlZfQ9A)njK2O{JutYwRZ?n0G_p&*uwpE`Md$iQxrd zoQfF^b8Ou)+3BO_3_K5y*~?<(BF@1l+@?Z6;^;U>qlB)cdro;rxOS1M{Az$s^9o5sXDCg8yD<=(pKI*0e zLk>@lo#&s0)^*Q+G)g}C0IErqfa9VbL*Qe=OT@&+N8m|GJF7jd83vY#SsuEv2s{Q> z>IpoubNs>D_5?|kXGAPgF@mb_9<%hjU;S0C8idI)a=F#lPLuQJ^7OnjJlH_Sks9JD zMl1td%YsWq3YWhc;E$H1<0P$YbSTqs`JKY%(}svsifz|h8BHguL82dBl+z0^YvWk8 zGy;7Z0v5_FJ2A$P0wIr)lD?cPR%cz>kde!=W%Ta^ih+Dh4UKdf7ip?rBz@%y2&>`6 zM#q{JXvW9ZlaSk1oD!n}kSmcDa2v6T^Y-dy+#fW^y>eS8_%<7tWXUp8U@s$^{JFfKMjDAvR z$YmVB;n3ofl!ro9RNT!TpQpcycXCR}$9k5>IPWDXEenQ58os?_weccrT+Bh5sLoiH zZ_7~%t(vT)ZTEO= zb0}@KaD{&IyK_sd8b$`Qz3%UA`nSo zn``!BdCeN!#^G;lK@G2ron*0jQhbdw)%m$2;}le@z~PSLnU-z@tL)^(p%P>OO^*Ff zNRR9oQ`W+x^+EU+3BpluwK77|B3=8QyT|$V;02bn_LF&3LhLA<#}{{)jE)}CiW%VEU~9)SW+=F%7U-iYlQ&q!#N zwI2{(h|Pi&<8_fqvT*}FLN^0CxN}#|3I9G_xmVg$gbn2ZdhbmGk7Q5Q2Tm*ox8NMo zv`iaZW|ZEOMyQga5fts?&T-eCCC9pS0mj7v0SDkD=*^MxurP@89v&Z#3q{FM!a_nr zb?KzMv`BBFOew>4!ft@A&(v-kWXny-j#egKef|#!+3>26Qq0 zv!~8ev4G`7Qk>V1TaMT-&ziqoY3IJp8_S*%^1j73D|=9&;tDZH^!LYFMmME4*Wj(S zRt~Q{aLb_O;wi4u&=}OYuj}Lw*j$@z*3>4&W{)O-oi@9NqdoU!=U%d|se&h?^$Ip# z)BY+(1+cwJz!yy4%l(aLC;T!~Ci>yAtXJb~b*yr&v7f{YCU8P|N1v~H`xmGsG)g)y z4%mv=cPd`s7a*#OR7f0lpD$ueP>w8qXj0J&*7xX+U!uat5QNk>zwU$0acn5p=$88L=jn_QCSYkTV;1~(yUem#0gB`FeqY98sf=>^@ z_MCdvylv~WL%y_%y_FE1)j;{Szj1+K7Lr_y=V+U zk6Tr;>XEqlEom~QGL!a+wOf(@ZWoxE<$^qHYl*H1a~kk^BLPn785%nQb$o;Cuz0h& za9LMx^bKEbPS%e8NM33Jr|1T|ELC(iE!FUci38xW_Y7kdHid#2ie+XZhP;2!Z;ZAM zB_cXKm)VrPK!SK|PY00Phwrpd+x0_Aa;}cDQvWKrwnQrqz##_gvHX2ja?#_{f#;bz`i>C^^ zTLDy;6@HZ~XQi7rph!mz9k!m;KchA)uMd`RK4WLK7)5Rl48m#l>b(#`WPsl<0j z-sFkSF6>Nk|LKnHtZ`W_NnxZP62&w)S(aBmmjMDKzF%G;3Y?FUbo?>b5;0j8Lhtc4 zr*8d5Y9>g@FFZaViw7c16VsHcy0u7M%6>cG1=s=Dtx?xMJSKIu9b6GU8$uSzf43Y3 zYq|U+IWfH;SM~*N1v`KJo!|yfLxTFS?oHsr3qvzeVndVV^%BWmW6re_S!2;g<|Oao z+N`m#*i!)R%i1~NO-xo{qpwL0ZrL7hli;S z3L0lQ_z}z`fdK39Mg~Zd*%mBdD;&5EXa~@H(!###L`ycr7gW`f)KRuqyHL3|uyy3h zSS^td#E&Knc$?dXs*{EnPYOp^-vjAc-h4z#XkbG&REC7;0>z^^Z}i8MxGKerEY z>l?(wReOlXEsNE5!DO&ZWyxY)gG#FSZs%fXuzA~XIAPVp-%yb2XLSV{1nH6{)5opg z(dZKckn}Q4Li-e=eUDs1Psg~5zdn1>ql(*(nn6)iD*OcVkwmKL(A{fix(JhcVB&}V zVt*Xb!{gzvV}dc446>(D=SzfCu7KB`oMjv6kPzSv&B>>HLSJP|wN`H;>oRw*tl#N) z*zZ-xwM7D*AIsBfgqOjY1Mp9aq$kRa^dZU_xw~KxP;|q(m+@e+YSn~`wEJzM|Ippb zzb@%;hB7iH4op9SqmX?j!KP2chsb79(mFossBO-Zj8~L}9L%R%Bw<`^X>hjkCY5SG z7lY!8I2mB#z)1o;*3U$G)3o0A&{0}#B;(zPd2`OF`Gt~8;0Re8nIseU z_yzlf$l+*-wT~_-cYk$^wTJ@~7i@u(CZs9FVkJCru<*yK8&>g+t*!JqCN6RH%8S-P zxH8+Cy#W?!;r?cLMC(^BtAt#xPNnwboI*xWw#T|IW^@3|q&QYY6Ehxoh@^URylR|T zne-Y6ugE^7p5bkRDWIh)?JH5V^ub82l-LuVjDr7UT^g`q4dB&mBFRWGL_C?hoeL(% zo}ocH5t7|1Mda}T!^{Qt9vmA2ep4)dQSZO>?Eq8}qRp&ZJ?-`Tnw+MG(eDswP(L*X3ahC2Ad0_wD^ff9hfzb%Jd`IXx5 zae@NMzBXJDwJS?7_%!TB^E$N8pvhOHDK$7YiOelTY`6KX8hK6YyT$tk*adwN>s^Kp zwM3wGVPhwKU*Yq-*BCs}l`l#Tej(NQ>jg*S0TN%D+GcF<14Ms6J`*yMY;W<-mMN&-K>((+P}+t+#0KPGrzjP zJ~)=Bcz%-K!L5ozIWqO(LM)l_9lVOc4*S65&DKM#TqsiWNG{(EZQw!bc>qLW`=>p-gVJ;T~aN2D_- z{>SZC=_F+%hNmH6ub%Ykih0&YWB!%sd%W5 zHC2%QMP~xJgt4>%bU>%6&uaDtSD?;Usm}ari0^fcMhi_)JZgb1g5j zFl4`FQ*%ROfYI}e7RIq^&^a>jZF23{WB`T>+VIxj%~A-|m=J7Va9FxXV^%UwccSZd zuWINc-g|d6G5;95*%{e;9S(=%yngpfy+7ao|M7S|Jb0-4+^_q-uIqVS&ufU880UDH*>(c)#lt2j zzvIEN>>$Y(PeALC-D?5JfH_j+O-KWGR)TKunsRYKLgk7eu4C{iF^hqSz-bx5^{z0h ze2+u>Iq0J4?)jIo)}V!!m)%)B;a;UfoJ>VRQ*22+ncpe9f4L``?v9PH&;5j{WF?S_C>Lq>nkChZB zjF8(*v0c(lU^ZI-)_uGZnnVRosrO4`YinzI-RSS-YwjYh3M`ch#(QMNw*)~Et7Qpy z{d<3$4FUAKILq9cCZpjvKG#yD%-juhMj>7xIO&;c>_7qJ%Ae8Z^m)g!taK#YOW3B0 zKKSMOd?~G4h}lrZbtPk)n*iOC1~mDhASGZ@N{G|dF|Q^@1ljhe=>;wusA&NvY*w%~ zl+R6B^1yZiF)YN>0ms%}qz-^U-HVyiN3R9k1q4)XgDj#qY4CE0)52%evvrrOc898^ z*^)XFR?W%g0@?|6Mxo1ZBp%(XNv_RD-<#b^?-Fs+NL^EUW=iV|+Vy*F%;rBz~pN7%-698U-VMfGEVnmEz7fL1p)-5sLT zL;Iz>FCLM$p$c}g^tbkGK1G$IALq1Gd|We@&TtW!?4C7x4l*=4oF&&sr0Hu`x<5!m zhX&&Iyjr?AkNXU_5P_b^Q3U9sy#f6ZF@2C96$>1k*E-E%DjwvA{VL0PdU~suN~DZo zm{T!>sRdp`Ldpp9olrH@(J$QyGq!?#o1bUo=XP2OEuT3`XzI>s^0P{manUaE4pI%! zclQq;lbT;nx7v3tR9U)G39h?ryrxzd0xq4KX7nO?piJZbzT_CU&O=T(Vt;>jm?MgC z2vUL#*`UcMsx%w#vvjdamHhmN!(y-hr~byCA-*iCD};#l+bq;gkwQ0oN=AyOf@8ow>Pj<*A~2*dyjK}eYdN);%!t1 z6Y=|cuEv-|5BhA?n2Db@4s%y~(%Wse4&JXw=HiO48%c6LB~Z0SL1(k^9y?ax%oj~l zf7(`iAYLdPRq*ztFC z7VtAb@s{as%&Y;&WnyYl+6Wm$ru*u!MKIg_@01od-iQft0rMjIj8e7P9eKvFnx_X5 zd%pDg-|8<>T2Jdqw>AII+fe?CgP+fL(m0&U??QL8YzSjV{SFi^vW~;wN@or_(q<0Y zRt~L}#JRcHOvm$CB)T1;;7U>m%)QYBLTR)KTARw%zoDxgssu5#v{UEVIa<>{8dtkm zXgbCGp$tfue+}#SD-PgiNT{Zu^YA9;4BnM(wZ9-biRo_7pN}=aaimjYgC=;9@g%6< zxol5sT_$<8{LiJ6{l1+sV)Z_QdbsfEAEMw!5*zz6)Yop?T0DMtR_~wfta)E6_G@k# zZRP11D}$ir<`IQ`<(kGfAS?O-DzCyuzBq6dxGTNNTK?r^?zT30mLY!kQ=o~Hv*k^w zvq!LBjW=zzIi%UF@?!g9vt1CqdwV(-2LYy2=E@Z?B}JDyVkluHtzGsWuI1W5svX~K z&?UJ45$R7g>&}SFnLnmw09R2tUgmr_w6mM9C}8GvQX>nL&5R#xBqnp~Se(I>R42`T zqZe9p6G(VzNB3QD><8+y%{e%6)sZDRXTR|MI zM#eZmao-~_`N|>Yf;a;7yvd_auTG#B?Vz5D1AHx=zpVUFe7*hME z+>KH5h1In8hsVhrstc>y0Q!FHR)hzgl+*Q&5hU9BVJlNGRkXiS&06eOBV^dz3;4d5 zeYX%$62dNOprZV$px~#h1RH?_E%oD6y;J;pF%~y8M)8pQ0olYKj6 zE+hd|7oY3ot=j9ZZ))^CCPADL6Jw%)F@A{*coMApcA$7fZ{T@3;WOQ352F~q6`Mgi z$RI6$8)a`Aaxy<8Bc;{wlDA%*%(msBh*xy$L-cBJvQ8hj#FCyT^%+Phw1~PaqyDou^JR0rxDkSrmAdjeYDFDZ`E z)G3>XtpaSPDlydd$RGHg;#4|4{aP5c_Om z2u5xgnhnA)K%8iU==}AxPxZCYC)lyOlj9as#`5hZ=<6<&DB%i_XCnt5=pjh?iusH$ z>)E`@HNZcAG&RW3Ys@`Ci{;8PNzE-ZsPw$~Wa!cP$ye+X6;9ceE}ah+3VY7Mx}#0x zbqYa}eO*FceiY2jNS&2cH9Y}(;U<^^cWC5Ob&)dZedvZA9HewU3R;gRQ)}hUdf+~Q zS_^4ds*W1T#bxS?%RH&<739q*n<6o|mV;*|1s>ly-Biu<2*{!!0#{_234&9byvn0* z5=>{95Zfb{(?h_Jk#ocR$FZ78O*UTOxld~0UF!kyGM|nH%B*qf)Jy}N!uT9NGeM19 z-@=&Y0yGGo_dw!FD>juk%P$6$qJkj}TwLBoefi;N-$9LAeV|)|-ET&culW9Sb_pc_ zp{cXI0>I0Jm_i$nSvGnYeLSSj{ccVS2wyL&0x~&5v;3Itc82 z5lIAkfn~wcY-bQB$G!ufWt%qO;P%&2B_R5UKwYxMemIaFm)qF1rA zc>gEihb=jBtsXCi0T%J37s&kt*3$s7|6)L(%UiY)6axuk{6RWIS8^+u;)6!R?Sgap z9|6<0bx~AgVi|*;zL@2x>Pbt2Bz*uv4x-`{F)XatTs`S>unZ#P^ZiyjpfL_q2z^fqgR-fbOcG=Y$q>ozkw1T6dH8-)&ww+z?E0 zR|rV(9bi6zpX3Ub>PrPK!{X>e$C66qCXAeFm)Y+lX8n2Olt7PNs*1^si)j!QmFV#t z0P2fyf$N^!dyTot&`Ew5{i5u<8D`8U`qs(KqaWq5iOF3x2!-z65-|HsyYz(MAKZ?< zCpQR;E)wn%s|&q(LVm0Ab>gdmCFJeKwVTnv@Js%!At;I=A>h=l=p^&<4;Boc{$@h< z38v`3&2wJtka@M}GS%9!+SpJ}sdtoYzMevVbnH+d_eMxN@~~ zZq@k)7V5f8u!yAX2qF3qjS7g%n$JuGrMhQF!&S^7(%Y{rP*w2FWj(v_J{+Hg*}wdWOd~pHQ19&n3RWeljK9W%sz&Y3Tm3 zR`>6YR54%qBHGa)2xbs`9cs_EsNHxsfraEgZ)?vrtooeA0sPKJK7an){ngtV@{SBa zkO6ORr1_Xqp+`a0e}sC*_y(|RKS13ikmHp3C^XkE@&wjbGWrt^INg^9lDz#B;bHiW zkK4{|cg08b!yHFSgPca5)vF&gqCgeu+c82%&FeM^Bb}GUxLy-zo)}N;#U?sJ2?G2BNe*9u_7kE5JeY!it=f`A_4gV3} z`M!HXZy#gN-wS!HvHRqpCHUmjiM;rVvpkC!voImG%OFVN3k(QG@X%e``VJSJ@Z7tb z*Onlf>z^D+&$0!4`IE$;2-NSO9HQWd+UFW(r;4hh;(j^p4H-~6OE!HQp^96v?{9Zt z;@!ZcccV%C2s6FMP#qvo4kG6C04A>XILt>JW}%0oE&HM5f6 zYLD!;My>CW+j<~=Wzev{aYtx2ZNw|ptTFV(4;9`6Tmbz6K1)fv4qPXa2mtoPt&c?P zhmO+*o8uP3ykL6E$il00@TDf6tOW7fmo?Oz_6GU^+5J=c22bWyuH#aNj!tT-^IHrJ zu{aqTYw@q;&$xDE*_kl50Jb*dp`(-^p={z}`rqECTi~3 z>0~A7L6X)=L5p#~$V}gxazgGT7$3`?a)zen>?TvAuQ+KAIAJ-s_v}O6@`h9n-sZk> z`3{IJeb2qu9w=P*@q>iC`5wea`KxCxrx{>(4{5P+!cPg|pn~;n@DiZ0Y>;k5mnKeS z!LIfT4{Lgd=MeysR5YiQKCeNhUQ;Os1kAymg6R!u?j%LF z4orCszIq_n52ulpes{(QN|zirdtBsc{9^Z72Ycb2ht?G^opkT_#|4$wa9`)8k3ilU z%ntAi`nakS1r10;#k^{-ZGOD&Z2|k=p40hRh5D7(&JG#Cty|ECOvwsSHkkSa)36$4 z?;v#%@D(=Raw(HP5s>#4Bm?f~n1@ebH}2tv#7-0l-i^H#H{PC|F@xeNS+Yw{F-&wH z07)bj8MaE6`|6NoqKM~`4%X> zKFl&7g1$Z3HB>lxn$J`P`6GSb6CE6_^NA1V%=*`5O!zP$a7Vq)IwJAki~XBLf=4TF zPYSL}>4nOGZ`fyHChq)jy-f{PKFp6$plHB2=;|>%Z^%)ecVue(*mf>EH_uO^+_zm? zJATFa9SF~tFwR#&0xO{LLf~@}s_xvCPU8TwIJgBs%FFzjm`u?1699RTui;O$rrR{# z1^MqMl5&6)G%@_k*$U5Kxq84!AdtbZ!@8FslBML}<`(Jr zenXrC6bFJP=R^FMBg7P?Pww-!a%G@kJH_zezKvuWU0>m1uyy}#Vf<$>u?Vzo3}@O% z1JR`B?~Tx2)Oa|{DQ_)y9=oY%haj!80GNHw3~qazgU-{|q+Bl~H94J!a%8UR?XsZ@ z0*ZyQugyru`V9b(0OrJOKISfi89bSVR zQy<+i_1XY}4>|D%X_`IKZUPz6=TDb)t1mC9eg(Z=tv zq@|r37AQM6A%H%GaH3szv1L^ku~H%5_V*fv$UvHl*yN4iaqWa69T2G8J2f3kxc7UE zOia@p0YNu_q-IbT%RwOi*|V|&)e5B-u>4=&n@`|WzH}BK4?33IPpXJg%`b=dr_`hU z8JibW_3&#uIN_#D&hX<)x(__jUT&lIH$!txEC@cXv$7yB&Rgu){M`9a`*PH} zRcU)pMWI2O?x;?hzR{WdzKt^;_pVGJAKKd)F$h;q=Vw$MP1XSd<;Mu;EU5ffyKIg+ z&n-Nb?h-ERN7(fix`htopPIba?0Gd^y(4EHvfF_KU<4RpN0PgVxt%7Yo99X*Pe|zR z?ytK&5qaZ$0KSS$3ZNS$$k}y(2(rCl=cuYZg{9L?KVgs~{?5adxS))Upm?LDo||`H zV)$`FF3icFmxcQshXX*1k*w3O+NjBR-AuE70=UYM*7>t|I-oix=bzDwp2*RoIwBp@r&vZukG; zyi-2zdyWJ3+E?{%?>e2Ivk`fAn&Ho(KhGSVE4C-zxM-!j01b~mTr>J|5={PrZHOgO zw@ND3=z(J7D>&C7aw{zT>GHhL2BmUX0GLt^=31RRPSnjoUO9LYzh_yegyPoAKhAQE z>#~O27dR4&LdQiak6={9_{LN}Z>;kyVYKH^d^*!`JVSXJlx#&r4>VnP$zb{XoTb=> zZsLvh>keP3fkLTIDdpf-@(ADfq4=@X=&n>dyU0%dwD{zsjCWc;r`-e~X$Q3NTz_TJ zOXG|LMQQIjGXY3o5tBm9>k6y<6XNO<=9H@IXF;63rzsC=-VuS*$E{|L_i;lZmHOD< zY92;>4spdeRn4L6pY4oUKZG<~+8U-q7ZvNOtW0i*6Q?H`9#U3M*k#4J;ek(MwF02x zUo1wgq9o6XG#W^mxl>pAD)Ll-V5BNsdVQ&+QS0+K+?H-gIBJ-ccB1=M_hxB6qcf`C zJ?!q!J4`kLhAMry4&a_0}up{CFevcjBl|N(uDM^N5#@&-nQt2>z*U}eJGi}m5f}l|IRVj-Q;a>wcLpK5RRWJ> zysdd$)Nv0tS?b~bw1=gvz3L_ZAIdDDPj)y|bp1;LE`!av!rODs-tlc}J#?erTgXRX z$@ph%*~_wr^bQYHM7<7=Q=45v|Hk7T=mDpW@OwRy3A_v`ou@JX5h!VI*e((v*5Aq3 zVYfB4<&^Dq5%^?~)NcojqK`(VXP$`#w+&VhQOn%;4pCkz;NEH6-FPHTQ+7I&JE1+Ozq-g43AEZV>ceQ^9PCx zZG@OlEF~!Lq@5dttlr%+gNjRyMwJdJU(6W_KpuVnd{3Yle(-p#6erIRc${l&qx$HA z89&sp=rT7MJ=DuTL1<5{)wtUfpPA|Gr6Q2T*=%2RFm@jyo@`@^*{5{lFPgv>84|pv z%y{|cVNz&`9C*cUely>-PRL)lHVErAKPO!NQ3<&l5(>Vp(MuJnrOf^4qpIa!o3D7( z1bjn#Vv$#or|s7Hct5D@%;@48mM%ISY7>7@ft8f?q~{s)@BqGiupoK1BAg?PyaDQ1 z`YT8{0Vz{zBwJ={I4)#ny{RP{K1dqzAaQN_aaFC%Z>OZ|^VhhautjDavGtsQwx@WH zr|1UKk^+X~S*RjCY_HN!=Jx>b6J8`Q(l4y|mc<6jnkHVng^Wk(A13-;AhawATsmmE#H%|8h}f1frs2x@Fwa_|ea+$tdG2Pz{7 z!ox^w^>^Cv4e{Xo7EQ7bxCe8U+LZG<_e$RnR?p3t?s^1Mb!ieB z#@45r*PTc_yjh#P=O8Zogo+>1#|a2nJvhOjIqKK1U&6P)O%5s~M;99O<|Y9zomWTL z666lK^QW`)cXV_^Y05yQZH3IRCW%25BHAM$c0>w`x!jh^15Zp6xYb!LoQ zr+RukTw0X2mxN%K0%=8|JHiaA3pg5+GMfze%9o5^#upx0M?G9$+P^DTx7~qq9$Qoi zV$o)yy zuUq>3c{_q+HA5OhdN*@*RkxRuD>Bi{Ttv_hyaaB;XhB%mJ2Cb{yL;{Zu@l{N?!GKE7es6_9J{9 zO(tmc0ra2;@oC%SS-8|D=omQ$-Dj>S)Utkthh{ovD3I%k}HoranSepC_yco2Q8 zY{tAuPIhD{X`KbhQIr%!t+GeH%L%q&p z3P%<-S0YY2Emjc~Gb?!su85}h_qdu5XN2XJUM}X1k^!GbwuUPT(b$Ez#LkG6KEWQB z7R&IF4srHe$g2R-SB;inW9T{@+W+~wi7VQd?}7||zi!&V^~o0kM^aby7YE_-B63^d zf_uo8#&C77HBautt_YH%v6!Q>H?}(0@4pv>cM6_7dHJ)5JdyV0Phi!)vz}dv{*n;t zf(+#Hdr=f8DbJqbMez)(n>@QT+amJ7g&w6vZ-vG^H1v~aZqG~u!1D(O+jVAG0EQ*aIsr*bsBdbD`)i^FNJ z&B@yxqPFCRGT#}@dmu-{0vp47xk(`xNM6E=7QZ5{tg6}#zFrd8Pb_bFg7XP{FsYP8 zbvWqG6#jfg*4gvY9!gJxJ3l2UjP}+#QMB(*(?Y&Q4PO`EknE&Cb~Yb@lCbk;-KY)n zzbjS~W5KZ3FV%y>S#$9Sqi$FIBCw`GfPDP|G=|y32VV-g@a1D&@%_oAbB@cAUx#aZ zlAPTJ{iz#Qda8(aNZE&0q+8r3&z_Ln)b=5a%U|OEcc3h1f&8?{b8ErEbilrun}mh3 z$1o^$-XzIiH|iGoJA`w`o|?w3m*NX|sd$`Mt+f*!hyJvQ2fS*&!SYn^On-M|pHGlu z4SC5bM7f6BAkUhGuN*w`97LLkbCx=p@K5RL2p>YpDtf{WTD|d3ucb6iVZ-*DRtoEA zCC5(x)&e=giR_id>5bE^l%Mxx>0@FskpCD4oq@%-Fg$8IcdRwkfn;DsjoX(v;mt3d z_4Mnf#Ft4x!bY!7Hz?RRMq9;5FzugD(sbt4up~6j?-or+ch~y_PqrM2hhTToJjR_~ z)E1idgt7EW>G*9%Q^K;o_#uFjX!V2pwfpgi>}J&p_^QlZki!@#dkvR`p?bckC`J*g z=%3PkFT3HAX2Q+dShHUbb1?ZcK8U7oaufLTCB#1W{=~k0Jabgv>q|H+GU=f-y|{p4 zwN|AE+YbCgx=7vlXE?@gkXW9PaqbO#GB=4$o0FkNT#EI?aLVd2(qnPK$Yh%YD%v(mdwn}bgsxyIBI^)tY?&G zi^2JfClZ@4b{xFjyTY?D61w@*ez2@5rWLpG#34id?>>oPg{`4F-l`7Lg@D@Hc}On} zx%BO4MsLYosLGACJ-d?ifZ35r^t*}wde>AAWO*J-X%jvD+gL9`u`r=kP zyeJ%FqqKfz8e_3K(M1RmB?gIYi{W7Z<THP2ihue0mbpu5n(x_l|e1tw(q!#m5lmef6ktqIb${ zV+ee#XRU}_dDDUiV@opHZ@EbQ<9qIZJMDsZDkW0^t3#j`S)G#>N^ZBs8k+FJhAfu< z%u!$%dyP3*_+jUvCf-%{x#MyDAK?#iPfE<(@Q0H7;a125eD%I(+!x1f;Sy`e<9>nm zQH4czZDQmW7^n>jL)@P@aAuAF$;I7JZE5a8~AJI5CNDqyf$gjloKR7C?OPt9yeH}n5 zNF8Vhmd%1O>T4EZD&0%Dt7YWNImmEV{7QF(dy!>q5k>Kh&Xy8hcBMUvVV~Xn8O&%{ z&q=JCYw#KlwM8%cu-rNadu(P~i3bM<_a{3!J*;vZhR6dln6#eW0^0kN)Vv3!bqM`w z{@j*eyzz=743dgFPY`Cx3|>ata;;_hQ3RJd+kU}~p~aphRx`03B>g4*~f%hUV+#D9rYRbsGD?jkB^$3XcgB|3N1L& zrmk9&Dg450mAd=Q_p?gIy5Zx7vRL?*rpNq76_rysFo)z)tp0B;7lSb9G5wX1vC9Lc z5Q8tb-alolVNWFsxO_=12o}X(>@Mwz1mkYh1##(qQwN=7VKz?61kay8A9(94Ky(4V zq6qd2+4a20Z0QRrmp6C?4;%U?@MatfXnkj&U6bP_&2Ny}BF%4{QhNx*Tabik9Y-~Z z@0WV6XD}aI(%pN}oW$X~Qo_R#+1$@J8(31?zM`#e`#(0f<-AZ^={^NgH#lc?oi(Mu zMk|#KR^Q;V@?&(sh5)D;-fu)rx%gXZ1&5)MR+Mhssy+W>V%S|PRNyTAd}74<(#J>H zR(1BfM%eIv0+ngHH6(i`?-%_4!6PpK*0X)79SX0X$`lv_q>9(E2kkkP;?c@rW2E^Q zs<;`9dg|lDMNECFrD3jTM^Mn-C$44}9d9Kc z#>*k&e#25;D^%82^1d@Yt{Y91MbEu0C}-;HR4+IaCeZ`l?)Q8M2~&E^FvJ?EBJJ(% zz1>tCW-E~FB}DI}z#+fUo+=kQME^=eH>^%V8w)dh*ugPFdhMUi3R2Cg}Zak4!k_8YW(JcR-)hY8C zXja}R7@%Q0&IzQTk@M|)2ViZDNCDRLNI)*lH%SDa^2TG4;%jE4n`8`aQAA$0SPH2@ z)2eWZuP26+uGq+m8F0fZn)X^|bNe z#f{qYZS!(CdBdM$N2(JH_a^b#R2=>yVf%JI_ieRFB{w&|o9txwMrVxv+n78*aXFGb z>Rkj2yq-ED<)A46T9CL^$iPynv`FoEhUM10@J+UZ@+*@_gyboQ>HY9CiwTUo7OM=w zd~$N)1@6U8H#Zu(wGLa_(Esx%h@*pmm5Y9OX@CY`3kPYPQx@z8yAgtm(+agDU%4?c zy8pR4SYbu8vY?JX6HgVq7|f=?w(%`m-C+a@E{euXo>XrGmkmFGzktI*rj*8D z)O|CHKXEzH{~iS+6)%ybRD|JRQ6j<+u_+=SgnJP%K+4$st+~XCVcAjI9e5`RYq$n{ zzy!X9Nv7>T4}}BZpSj9G9|(4ei-}Du<_IZw+CB`?fd$w^;=j8?vlp(#JOWiHaXJjB0Q00RHJ@sG6N#y^H7t^&V} z;VrDI4?75G$q5W9mV=J2iP24NHJy&d|HWHva>FaS#3AO?+ohh1__FMx;?`f{HG3v0 ztiO^Wanb>U4m9eLhoc_2B(ca@YdnHMB*~aYO+AE(&qh@?WukLbf_y z>*3?Xt-lxr?#}y%kTv+l8;!q?Hq8XSU+1E8x~o@9$)zO2z9K#(t`vPDri`mKhv|sh z{KREcy`#pnV>cTT7dm7M9B@9qJRt3lfo(C`CNkIq@>|2<(yn!AmVN?ST zbX_`JjtWa3&N*U{K7FYX8})*D#2@KBae` zhKS~s!r%SrXdhCsv~sF}7?ocyS?afya6%rDBu6g^b2j#TOGp^1zrMR}|70Z>CeYq- z1o|-=FBKlu{@;pm@QQJ_^!&hzi;0Z_Ho){x3O1KQ#TYk=rAt9`YKC0Y^}8GWIN{QW znYJyVTrmNvl!L=YS1G8BAxGmMUPi+Q7yb0XfG`l+L1NQVSbe^BICYrD;^(rke{jWCEZOtVv3xFze!=Z&(7}!)EcN;v0Dbit?RJ6bOr;N$ z=nk8}H<kCEE+IK3z<+3mkn4q!O7TMWpKShWWWM)X*)m6k%3luF6c>zOsFccvfLWf zH+mNkh!H@vR#~oe=ek}W3!71z$Dlj0c(%S|sJr>rvw!x;oCek+8f8s!U{DmfHcNpO z9>(IKOMfJwv?ey`V2ysSx2Npeh_x#bMh)Ngdj$al;5~R7Ac5R2?*f{hI|?{*$0qU- zY$6}ME%OGh^zA^z9zJUs-?a4ni8cw_{cYED*8x{bWg!Fn9)n;E9@B+t;#k}-2_j@# zg#b%R(5_SJAOtfgFCBZc`n<&z6)%nOIu@*yo!a% zpLg#36KBN$01W{b;qWN`Tp(T#jh%;Zp_zpS64lvBVY2B#UK)p`B4Oo)IO3Z&D6<3S zfF?ZdeNEnzE{}#gyuv)>;z6V{!#bx)` zY;hL*f(WVD*D9A4$WbRKF2vf;MoZVdhfWbWhr{+Db5@M^A4wrFReuWWimA4qp`GgoL2`W4WPUL5A=y3Y3P z%G?8lLUhqo@wJW8VDT`j&%YY7xh51NpVYlsrk_i4J|pLO(}(b8_>%U2M`$iVRDc-n zQiOdJbroQ%*vhN{!{pL~N|cfGooK_jTJCA3g_qs4c#6a&_{&$OoSQr_+-O^mKP=Fu zGObEx`7Qyu{nHTGNj(XSX*NPtAILL(0%8Jh)dQh+rtra({;{W2=f4W?Qr3qHi*G6B zOEj7%nw^sPy^@05$lOCjAI)?%B%&#cZ~nC|=g1r!9W@C8T0iUc%T*ne z)&u$n>Ue3FN|hv+VtA+WW)odO-sdtDcHfJ7s&|YCPfWaVHpTGN46V7Lx@feE#Od%0XwiZy40plD%{xl+K04*se zw@X4&*si2Z_0+FU&1AstR)7!Th(fdaOlsWh`d!y=+3m!QC$Zlkg8gnz!}_B7`+wSz z&kD?6{zPnE3uo~Tv8mLP%RaNt2hcCJBq=0T>%MW~Q@Tpt2pPP1?KcywH>in5@ zx+5;xu-ltFfo5vLU;2>r$-KCHjwGR&1XZ0YNyrXXAUK!FLM_7mV&^;;X^*YH(FLRr z`0Jjg7wiq2bisa`CG%o9i)o1`uG?oFjU_Zrv1S^ipz$G-lc^X@~6*)#%nn+RbgksJfl{w=k31(q>7a!PCMp5YY{+Neh~mo zG-3dd!0cy`F!nWR?=9f_KP$X?Lz&cLGm_ohy-|u!VhS1HG~e7~xKpYOh=GmiiU;nu zrZ5tWfan3kp-q_vO)}vY6a$19Q6UL0r znJ+iSHN-&w@vDEZ0V%~?(XBr|jz&vrBNLOngULxtH(Rp&U*rMY42n;05F11xh?k;n_DX2$4|vWIkXnbwfC z=ReH=(O~a;VEgVO?>qsP*#eOC9Y<_9Yt<6X}X{PyF7UXIA$f)>NR5P&4G_Ygq(9TwwQH*P>Rq>3T4I+t2X(b5ogXBAfNf!xiF#Gilm zp2h{&D4k!SkKz-SBa%F-ZoVN$7GX2o=(>vkE^j)BDSGXw?^%RS9F)d_4}PN+6MlI8*Uk7a28CZ)Gp*EK)`n5i z){aq=0SFSO-;sw$nAvJU-$S-cW?RSc7kjEBvWDr1zxb1J7i;!i+3PQwb=)www?7TZ zE~~u)vO>#55eLZW;)F(f0KFf8@$p)~llV{nO7K_Nq-+S^h%QV_CnXLi)p*Pq&`s!d zK2msiR;Hk_rO8`kqe_jfTmmv|$MMo0ll}mI)PO4!ikVd(ZThhi&4ZwK?tD-}noj}v zBJ?jH-%VS|=t)HuTk?J1XaDUjd_5p1kPZi6y#F6$lLeRQbj4hsr=hX z4tXkX2d5DeLMcAYTeYm|u(XvG5JpW}hcOs4#s8g#ihK%@hVz|kL=nfiBqJ{*E*WhC zht3mi$P3a(O5JiDq$Syu9p^HY&9~<#H89D8 zJm84@%TaL_BZ+qy8+T3_pG7Q%z80hnjN;j>S=&WZWF48PDD%55lVuC0%#r5(+S;WH zS7!HEzmn~)Ih`gE`faPRjPe^t%g=F ztpGVW=Cj5ZkpghCf~`ar0+j@A=?3(j@7*pq?|9)n*B4EQTA1xj<+|(Y72?m7F%&&& zdO44owDBPT(8~RO=dT-K4#Ja@^4_0v$O3kn73p6$s?mCmVDUZ+Xl@QcpR6R3B$=am z%>`r9r2Z79Q#RNK?>~lwk^nQlR=Hr-ji$Ss3ltbmB)x@0{VzHL-rxVO(++@Yr@Iu2 zTEX)_9sVM>cX$|xuqz~Y8F-(n;KLAfi*63M7mh&gsPR>N0pd9h!0bm%nA?Lr zS#iEmG|wQd^BSDMk0k?G>S-uE$vtKEF8Dq}%vLD07zK4RLoS?%F1^oZZI$0W->7Z# z?v&|a`u#UD=_>i~`kzBGaPj!mYX5g?3RC4$5EV*j0sV)>H#+$G6!ci=6`)85LWR=FCp-NUff`;2zG9nU6F~ z;3ZyE*>*LvUgae+uMf}aV}V*?DCM>{o31+Sx~6+sz;TI(VmIpDrN3z+BUj`oGGgLP z>h9~MP}Pw#YwzfGP8wSkz`V#}--6}7S9yZvb{;SX?6PM_KuYpbi~*=teZr-ga2QqIz{QrEyZ@>eN*qmy;N@FCBbRNEeeoTmQyrX;+ zCkaJ&vOIbc^2BD6_H+Mrcl?Nt7O{xz9R_L0ZPV_u!sz+TKbXmhK)0QWoe-_HwtKJ@@7=L+ z+K8hhf=4vbdg3GqGN<;v-SMIzvX=Z`WUa_91Yf89^#`G(f-Eq>odB^p-Eqx}ENk#&MxJ+%~Ad2-*`1LNT>2INPw?*V3&kE;tt?rQyBw? zI+xJD04GTz1$7~KMnfpkPRW>f%n|0YCML@ODe`10;^DXX-|Hb*IE%_Vi#Pn9@#ufA z_8NY*1U%VseqYrSm?%>F@`laz+f?+2cIE4Jg6 z_VTcx|DSEA`g!R%RS$2dSRM|9VQClsW-G<~=j5T`pTbu-x6O`R z98b;}`rPM(2={YiytrqX+uh65f?%XiPp`;4CcMT*E*dQJ+if9^D>c_Dk8A(cE<#r=&!& z_`Z01=&MEE+2@yr!|#El=yM}v>i=?w^2E_FLPy(*4A9XmCNy>cBWdx3U>1RylsItO z4V8T$z3W-qqq*H`@}lYpfh=>C!tieKhoMGUi)EpWDr;yIL&fy};Y&l|)f^QE*k~4C zH>y`Iu%#S)z)YUqWO%el*Z)ME#p{1_8-^~6UF;kBTW zMQ!eXQuzkR#}j{qb(y9^Y!X7&T}}-4$%4w@w=;w+>Z%uifR9OoQ>P?0d9xpcwa>7kTv2U zT-F?3`Q`7xOR!gS@j>7In>_h){j#@@(ynYh;nB~}+N6qO(JO1xA z@59Pxc#&I~I64slNR?#hB-4XE>EFU@lUB*D)tu%uEa))B#eJ@ZOX0hIulfnDQz-y8 z`CX@(O%_VC{Ogh&ot``jlDL%R!f>-8yq~oLGxBO?+tQb5%k@a9zTs!+=NOwSVH-cR zqFo^jHeXDA_!rx$NzdP;>{-j5w3QUrR<;}=u2|FBJ;D#v{SK@Z6mjeV7_kFmWt95$ zeGaF{IU?U>?W`jzrG_9=9}yN*LKyzz))PLE+)_jc#4Rd$yFGol;NIk(qO1$5VXR)+ zxF7%f4=Q!NzR>DVXUB&nUT&>Nyf+5QRF+Z`X-bB*7=`|Go5D1&h~ zflKLw??kpiRm0h3|1GvySC2^#kcFz^5{79KKlq@`(leBa=_4CgV9sSHr{RIJ^KwR_ zY??M}-x^=MD+9`v@I3jue=OCn0kxno#6i>b(XKk_XTp_LpI}X*UA<#* zsgvq@yKTe_dTh>q1aeae@8yur08S(Q^8kXkP_ty48V$pX#y9)FQa~E7P7}GP_CbCm zc2dQxTeW(-~Y6}im24*XOC8ySfH*HMEnW3 z4CXp8iK(Nk<^D$g0kUW`8PXn2kdcDk-H@P0?G8?|YVlIFb?a>QunCx%B9TzsqQQ~HD!UO7zq^V!v9jho_FUob&Hxi ztU1nNOK)a!gkb-K4V^QVX05*>-^i|{b`hhvQLyj`E1vAnj0fbqqO%r z6Q;X1x0dL~GqMv%8QindZ4CZ%7pYQW~ z9)I*#Gjref-q(4Z*E#1c&rE0-_(4;_M(V7rgH_7H;ps1s%GBmU z{4a|X##j#XUF2n({v?ZUUAP5k>+)^F)7n-npbV3jAlY8V3*W=fwroDS$c&r$>8aH` zH+irV{RG3^F3oW2&E%5hXgMH9>$WlqX76Cm+iFmFC-DToTa`AcuN9S!SB+BT-IA#3P)JW1m~Cuwjs`Ep(wDXE4oYmt*aU z!Naz^lM}B)JFp7ejro7MU9#cI>wUoi{lylR2~s)3M!6a=_W~ITXCPd@U9W)qA5(mdOf zd3PntGPJyRX<9cgX?(9~TZB5FdEHW~gkJXY51}?s4ZT_VEdwOwD{T2E-B>oC8|_ZwsPNj=-q(-kwy%xX2K0~H z{*+W`-)V`7@c#Iuaef=?RR2O&x>W0A^xSwh5MsjTz(DVG-EoD@asu<>72A_h<39_# zawWVU<9t{r*e^u-5Q#SUI6dV#p$NYEGyiowT>>d*or=Ps!H$-3={bB|An$GPkP5F1 zTnu=ktmF|6E*>ZQvk^~DX(k!N`tiLut*?3FZhs$NUEa4ccDw66-~P;x+0b|<!ZN7Z%A`>2tN#CdoG>((QR~IV_Gj^Yh%!HdA~4C3jOXaqb6Ou z21T~Wmi9F6(_K0@KR@JDTh3-4mv2=T7&ML<+$4;b9SAtv*Uu`0>;VVZHB{4?aIl3J zL(rMfk?1V@l)fy{J5DhVlj&cWKJCcrpOAad(7mC6#%|Sn$VwMjtx6RDx1zbQ|Ngg8N&B56DGhu;dYg$Z{=YmCNn+?ceDclp65c_RnKs4*vefnhudSlrCy6-96vSB4_sFAj# zftzECwmNEOtED^NUt{ZDjT7^g>k1w<=af>+0)%NA;IPq6qx&ya7+QAu=pk8t>KTm` zEBj9J*2t|-(h)xc>Us*jHs)w9qmA>8@u21UqzKk*Ei#0kCeW6o z-2Q+Tvt25IUkb}-_LgD1_FUJ!U8@8OC^9(~Kd*0#zr*8IQkD)6Keb(XFai5*DYf~` z@U?-{)9X&BTf!^&@^rjmvea#9OE~m(D>qfM?CFT9Q4RxqhO0sA7S)=--^*Q=kNh7Y zq%2mu_d_#23d`+v`Ol263CZ<;D%D8Njj6L4T`S*^{!lPL@pXSm>2;~Da- zBX97TS{}exvSva@J5FJVCM$j4WDQuME`vTw>PWS0!;J7R+Kq zVUy6%#n5f7EV(}J#FhDpts;>=d6ow!yhJj8j>MJ@Wr_?x30buuutIG97L1A*QFT$c ziC5rBS;#qj=~yP-yWm-p(?llTwDuhS^f&<(9vA9@UhMH2-Fe_YAG$NvK6X{!mvPK~ zuEA&PA}meylmaIbbJXDOzuIn8cJNCV{tUA<$Vb?57JyAM`*GpEfMmFq>)6$E(9e1@W`l|R%-&}38#bl~levA#fx2wiBk^)mPj?<=S&|gv zQO)4*91$n08@W%2b|QxEiO0KxABAZC{^4BX^6r>Jm?{!`ZId9jjz<%pl(G5l));*`UU3KfnuXSDj2aP>{ zRIB$9pm7lj3*Xg)c1eG!cb+XGt&#?7yJ@C)(Ik)^OZ5><4u$VLCqZ#q2NMCt5 z6$|VN(RWM;5!JV?-h<JkEZ(SZF zC(6J+>A6Am9H7OlOFq6S62-2&z^Np=#xXsOq0WUKr zY_+Ob|CQd1*!Hirj5rn*=_bM5_zKmq6lG zn*&_=x%?ATxZ8ZTzd%biKY_qyNC#ZQ1vX+vc48N>aJXEjs{Y*3Op`Q7-oz8jyAh>d zNt_qvn`>q9aO~7xm{z`ree%lJ3YHCyC`q`-jUVCn*&NIml!uuMNm|~u3#AV?6kC+B z?qrT?xu2^mobSlzb&m(8jttB^je0mx;TT8}`_w(F11IKz83NLj@OmYDpCU^u?fD{) z&=$ptwVw#uohPb2_PrFX;X^I=MVXPDpqTuYhRa>f-=wy$y3)40-;#EUDYB1~V9t%$ z^^<7Zbs0{eB93Pcy)96%XsAi2^k`Gmnypd-&x4v9rAq<>a(pG|J#+Q>E$FvMLmy7T z5_06W=*ASUyPRfgCeiPIe{b47Hjqpb`9Xyl@$6*ntH@SV^bgH&Fk3L9L=6VQb)Uqa z33u#>ecDo&bK(h1WqSH)b_Th#Tvk&%$NXC@_pg5f-Ma#7q;&0QgtsFO~`V&{1b zbSP*X)jgLtd@9XdZ#2_BX4{X~pS8okF7c1xUhEV9>PZco>W-qz7YMD`+kCGULdK|^ zE7VwQ-at{%&fv`a+b&h`TjzxsyQX05UB~a0cuU-}{*%jR48J+yGWyl3Kdz5}U>;lE zgkba*yI5>xqIPz*Y!-P$#_mhHB!0Fpnv{$k-$xxjLAc`XdmHd1k$V@2QlblfJPrly z*~-4HVCq+?9vha>&I6aRGyq2VUon^L1a)g`-Xm*@bl2|hi2b|UmVYW|b+Gy?!aS-p z86a}Jep6Mf>>}n^*Oca@Xz}kxh)Y&pX$^CFAmi#$YVf57X^}uQD!IQSN&int=D> zJ>_|au3Be?hmPKK)1^JQ(O29eTf`>-x^jF2xYK6j_9d_qFkWHIan5=7EmDvZoQWz5 zZGb<{szHc9Nf@om)K_<=FuLR<&?5RKo3LONFQZ@?dyjemAe4$yDrnD zglU#XYo6|~L+YpF#?deK6S{8A*Ou;9G`cdC4S0U74EW18bc5~4>)<*}?Z!1Y)j;Ot zosEP!pc$O^wud(={WG%hY07IE^SwS-fGbvpP?;l8>H$;}urY2JF$u#$q}E*ZG%fR# z`p{xslcvG)kBS~B*^z6zVT@e}imYcz_8PRzM4GS52#ms5Jg9z~ME+uke`(Tq1w3_6 zxUa{HerS7!Wq&y(<9yyN@P^PrQT+6ij_qW3^Q)I53iIFCJE?MVyGLID!f?QHUi1tq z0)RNIMGO$2>S%3MlBc09l!6_(ECxXTU>$KjWdZX^3R~@3!SB zah5Za2$63;#y!Y}(wg1#shMePQTzfQfXyJ-Tf`R05KYcyvo8UW9-IWGWnzxR6Vj8_la;*-z5vWuwUe7@sKr#Tr51d z2PWn5h@|?QU3>k=s{pZ9+(}oye zc*95N_iLmtmu}H-t$smi49Y&ovX}@mKYt2*?C-i3Lh4*#q5YDg1Mh`j9ovRDf9&& zp_UMQh`|pC!|=}1uWoMK5RAjdTg3pXPCsYmRkWW}^m&)u-*c_st~gcss(`haA)xVw zAf=;s>$`Gq_`A}^MjY_BnCjktBNHY1*gzh(i0BFZ{Vg^F?Pbf`8_clvdZ)5(J4EWzAP}Ba5zX=S(2{gDugTQ3`%!q`h7kYSnwC`zEWeuFlODKiityMaM9u{Z%E@@y1jmZA#ⅅ8MglG&ER{i5lN315cO?EdHNLrg? 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 z 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 Pe7HEdAEg|=c=LY&YVNkY 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 13b35eba55c6dabc3aac36f33d859266c18fa0d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5680 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 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 0a3f5fa40fb3d1e0710331a48de5d256da3f275d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 520 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 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 2f1632cfddf3d9dade342351e627a0a75609fb46..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2218 zcmV;b2vzrqP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuE6iGxuRCodHTWf3-RTMruyW6Fu zQYeUM04eX6D5c0FCjKKPrco1(K`<0SL=crI{PC3-^hZU0kQie$gh-5!7z6SH6Q0J% zqot*`H1q{R5fHFYS}dje@;kG=v$L0(yY0?wY2%*c?A&{2?!D*x?m71{of2gv!$5|C z3>qG_BW}7K_yUcT3A5C6QD<+{aq?x;MAUyAiJn#Jv8_zZtQ{P zTRzbL3U9!qVuZzS$xKU10KiW~Bgdcv1-!uAhQxf3a7q+dU6lj?yoO4Lq4TUN4}h{N z*fIM=SS8|C2$(T>w$`t@3Tka!(r!7W`x z-isCVgQD^mG-MJ;XtJuK3V{Vy72GQ83KRWsHU?e*wrhKk=ApIYeDqLi;JI1e zuvv}5^Dc=k7F7?nm3nIw$NVmU-+R>> zyqOR$-2SDpJ}Pt;^RkJytDVXNTsu|mI1`~G7yw`EJR?VkGfNdqK9^^8P`JdtTV&tX4CNcV4 z&N06nZa??Fw1AgQOUSE2AmPE@WO(Fvo`%m`cDgiv(fAeRA%3AGXUbsGw{7Q`cY;1BI#ac3iN$$Hw z0LT0;xc%=q)me?Y*$xI@GRAw?+}>=9D+KTk??-HJ4=A>`V&vKFS75@MKdSF1JTq{S zc1!^8?YA|t+uKigaq!sT;Z!&0F2=k7F0PIU;F$leJLaw2UI6FL^w}OG&!;+b%ya1c z1n+6-inU<0VM-Y_s5iTElq)ThyF?StVcebpGI znw#+zLx2@ah{$_2jn+@}(zJZ{+}_N9BM;z)0yr|gF-4=Iyu@hI*Lk=-A8f#bAzc9f z`Kd6K--x@t04swJVC3JK1cHY-Hq+=|PN-VO;?^_C#;coU6TDP7Bt`;{JTG;!+jj(` zw5cLQ-(Cz-Tlb`A^w7|R56Ce;Wmr0)$KWOUZ6ai0PhzPeHwdl0H(etP zUV`va_i0s-4#DkNM8lUlqI7>YQLf)(lz9Q3Uw`)nc(z3{m5ZE77Ul$V%m)E}3&8L0 z-XaU|eB~Is08eORPk;=<>!1w)Kf}FOVS2l&9~A+@R#koFJ$Czd%Y(ENTV&A~U(IPI z;UY+gf+&6ioZ=roly<0Yst8ck>(M=S?B-ys3mLdM&)ex!hbt+ol|T6CTS+Sc0jv(& z7ijdvFwBq;0a{%3GGwkDKTeG`b+lyj0jjS1OMkYnepCdoosNY`*zmBIo*981BU%%U z@~$z0V`OVtIbEx5pa|Tct|Lg#ZQf5OYMUMRD>Wdxm5SAqV2}3!ceE-M2 z@O~lQ0OiKQp}o9I;?uxCgYVV?FH|?Riri*U$Zi_`V2eiA>l zdSm6;SEm6#T+SpcE8Ro_f2AwxzI z44hfe^WE3!h@W3RDyA_H440cpmYkv*)6m1XazTqw%=E5Xv7^@^^T7Q2wxr+Z2kVYr - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 8aaa46ac1ae21512746f852a42ba87e4165dfdd1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM 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 b749bfef07473333cf1dd31e9eed89862a5d52aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5292 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 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 88cfd48dff1169879ba46840804b412fe02fefd6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8252 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 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 eb9b4d76e525556d5d89141648c724331630325d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! 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 d69c56691fbdb0b7efa65097c7cc1edac12a6d3e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20998 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 MPgg&ebxsLQ025IeI{*Lx 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 c04e20caf6370ebb9253ad831cc31de4a9c965f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33772 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 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 1658d85f632f5b3f3068536d5a3009f585965c4f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 300828 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 z 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

@-Em5x5RrC4CjiXGg?*HBqm^O|pLt1W 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 z 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 - }); - }); -} - -/// 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)); -} From 908169589a9e7b8bc58cc7b76fa5ed58131f92dd Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:40:18 +0300 Subject: [PATCH 141/157] feat(sql-workspace): a failed query puts the caret at the driver's error position; the Mongo query error uses the same panel (#1557) (#1726) --- lib/core/database/query_error_text.dart | 41 +++++ .../mongodb/mongo_query_workspace.dart | 12 +- .../workspace/generic_sql_workspace.dart | 15 ++ lib/features/workspace/results_tab.dart | 106 ++----------- lib/shared/widgets/query_error_view.dart | 130 ++++++++++++++++ test/core/database/query_error_text_test.dart | 49 ++++++ ...eneric_sql_workspace_error_caret_test.dart | 140 ++++++++++++++++++ .../shared/widgets/query_error_view_test.dart | 84 +++++++++++ 8 files changed, 473 insertions(+), 104 deletions(-) create mode 100644 lib/shared/widgets/query_error_view.dart create mode 100644 test/features/workspace/generic_sql_workspace_error_caret_test.dart create mode 100644 test/shared/widgets/query_error_view_test.dart diff --git a/lib/core/database/query_error_text.dart b/lib/core/database/query_error_text.dart index 91a3488a..1b8b1cac 100644 --- a/lib/core/database/query_error_text.dart +++ b/lib/core/database/query_error_text.dart @@ -1,3 +1,7 @@ +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. @@ -69,4 +73,41 @@ class QueryErrorText { 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/features/mongodb/mongo_query_workspace.dart b/lib/features/mongodb/mongo_query_workspace.dart index 5fbeaf43..d17a4692 100644 --- a/lib/features/mongodb/mongo_query_workspace.dart +++ b/lib/features/mongodb/mongo_query_workspace.dart @@ -11,6 +11,8 @@ 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'; @@ -499,15 +501,7 @@ class _MongoQueryWorkspaceState extends material.State { ], ), const Gap(8), - material.SelectableText( - tab.error!, - style: material.TextStyle( - color: cs.destructive, - fontFamily: QueryaTypography.mono, - fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, - fontSize: 12.5, - ), - ), + QueryErrorView(error: QueryErrorText.parse(tab.error!), inline: true), ], ), ); diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 6c65b58c..6c473ed9 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'; @@ -810,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; diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 1c4dc1e4..b782c987 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -5,7 +5,6 @@ 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/database/query_error_text.dart'; -import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.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'; @@ -17,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'; @@ -86,8 +86,6 @@ class ResultsTab extends material.StatefulWidget { class _ResultsTabState extends material.State { /// The driver text of a query error is folded away until asked for (#1557). - bool _showErrorDetails = false; - bool _errorCopied = false; /// Selection / focus state lives in notifiers so a navigation step rebuilds /// only the widgets that show it (toolbar, value panel, calc bar), not the @@ -254,10 +252,6 @@ class _ResultsTabState extends material.State { @override void didUpdateWidget(covariant ResultsTab oldWidget) { - if (oldWidget.errorMessage != widget.errorMessage) { - _showErrorDetails = false; - _errorCopied = false; - } super.didUpdateWidget(oldWidget); _syncGridBridge(); if (!identical(oldWidget.rows, widget.rows) || @@ -388,93 +382,6 @@ class _ResultsTabState extends material.State { super.dispose(); } - /// The driver's message as the headline, the rest (without the app's own - /// statement wrapping) behind "Show details", and a Copy button (#1557). - material.Widget _buildError( - material.BuildContext context, - QueryErrorText error, - ) { - final cs = Theme.of(context).colorScheme; - return material.Center( - 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: cs.destructive, - ), - const Gap(16), - const Text('Query Error').large().semiBold(), - const Gap(8), - material.SelectableText( - error.summary, - textAlign: 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: _showErrorDetails ? 'Hide details' : 'Show details', - icon: _showErrorDetails - ? material.Icons.expand_less_rounded - : material.Icons.expand_more_rounded, - size: QueryaControlSize.sm, - onPressed: () => setState( - () => _showErrorDetails = !_showErrorDetails, - ), - ), - const Gap(6), - QueryaButton.ghost( - label: _errorCopied ? 'Copied' : 'Copy error', - icon: _errorCopied - ? material.Icons.check_rounded - : material.Icons.copy_rounded, - size: QueryaControlSize.sm, - onPressed: () => unawaited(_copyError(error.detail)), - ), - ], - ), - ], - if (error.hasDetail && _showErrorDetails) ...[ - 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.errorAction != null) ...[ - const Gap(16), - widget.errorAction!, - ], - ], - ), - ), - ); - } - - Future _copyError(String text) async { - await Clipboard.setData(ClipboardData(text: text)); - if (!mounted) return; - setState(() => _errorCopied = true); - await Future.delayed(const Duration(seconds: 2)); - if (mounted) setState(() => _errorCopied = false); - } - @override material.Widget build(material.BuildContext context) { if (widget.isLoading) { @@ -488,7 +395,16 @@ class _ResultsTabState extends material.State { } if (widget.errorMessage != null) { - return _buildError(context, QueryErrorText.parse(widget.errorMessage!)); + return material.Center( + key: const material.ValueKey('results_mode_error'), + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: QueryErrorView( + error: QueryErrorText.parse(widget.errorMessage!), + action: widget.errorAction, + ), + ), + ); } if (widget.columns.isEmpty && widget.rows.isEmpty && widget.stagingBuffer == null) { diff --git a/lib/shared/widgets/query_error_view.dart b/lib/shared/widgets/query_error_view.dart new file mode 100644 index 00000000..deda373f --- /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 = Theme.of(context).colorScheme; + 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/test/core/database/query_error_text_test.dart b/test/core/database/query_error_text_test.dart index b289642e..9d732404 100644 --- a/test/core/database/query_error_text_test.dart +++ b/test/core/database/query_error_text_test.dart @@ -2,6 +2,7 @@ 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 = @@ -71,3 +72,51 @@ void main() { }); }); } + +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/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/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); + }); +} From 47031daff35b7d6222a1cca2f5eedf1dcbe7543d Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:50:22 +0300 Subject: [PATCH 142/157] ux(sql-workspace): one style for the toolbar controls, Explain has its own icon, labels collapse one control at a time, the tab strip uses workbench tokens (#1558) (#1727) --- lib/features/workspace/sql_query_tab_bar.dart | 12 ++--- .../workspace/sql_workspace_toolbar.dart | 44 ++++++++++------ .../e2e_transaction_guard_test.dart | 12 +++-- .../generic_sql_workspace_shortcuts_test.dart | 16 ++++-- .../sql_workspace_chrome_guards_test.dart | 51 +++++++++++++++++++ 5 files changed, 104 insertions(+), 31 deletions(-) create mode 100644 test/guards/sql_workspace_chrome_guards_test.dart 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_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/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/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/guards/sql_workspace_chrome_guards_test.dart b/test/guards/sql_workspace_chrome_guards_test.dart new file mode 100644 index 00000000..30300ba2 --- /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.colorScheme: 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')); + }); +} From ba17955bc9639dfee21d6afeab0a28bfd0439f9e Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:01:14 +0300 Subject: [PATCH 143/157] ux(sql-workspace): editor and results are one kind of card without the glow; the mode bar leaves a plain row count to the status bar (#1558) (#1728) --- .../main_screen/querya_status_bar.dart | 3 +- .../workspace/generic_sql_workspace.dart | 88 ++++++++++--------- lib/features/workspace/results_tab.dart | 30 +++++-- lib/features/workspace/sql_editor_chrome.dart | 65 ++++++++------ .../main_screen/querya_status_bar_test.dart | 14 +++ test/features/workspace/results_tab_test.dart | 57 ++++++++++++ .../workspace/sql_editor_chrome_test.dart | 82 +++++++++++++++-- 7 files changed, 257 insertions(+), 82 deletions(-) diff --git a/lib/features/main_screen/querya_status_bar.dart b/lib/features/main_screen/querya_status_bar.dart index 5523ff52..54092c0a 100644 --- a/lib/features/main_screen/querya_status_bar.dart +++ b/lib/features/main_screen/querya_status_bar.dart @@ -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/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 6c473ed9..76cecff3 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -1345,50 +1345,58 @@ 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), - ), + ], ), - ], + ), ), ); } diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index b782c987..5fc6abfc 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -382,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) { @@ -519,12 +540,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 b3a35420..9aabd99d 100644 --- a/lib/features/workspace/sql_editor_chrome.dart +++ b/lib/features/workspace/sql_editor_chrome.dart @@ -4,19 +4,28 @@ 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; -/// 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; @@ -70,38 +79,40 @@ class SqlEditorChrome extends StatelessWidget { ); } + @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/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/workspace/results_tab_test.dart b/test/features/workspace/results_tab_test.dart index b2f4ef90..d13841ca 100644 --- a/test/features/workspace/results_tab_test.dart +++ b/test/features/workspace/results_tab_test.dart @@ -779,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( diff --git a/test/features/workspace/sql_editor_chrome_test.dart b/test/features/workspace/sql_editor_chrome_test.dart index 5d9132be..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,81 @@ 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)), + ); }); }); From a278e3844e2a60636be63bccd839636609bc10cf Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:22:59 +0300 Subject: [PATCH 144/157] chore(ui): Gap from the gap package, not through shadcn; seven files drop shadcn; a ratchet on shadcn imports (#1729) (#1737) --- lib/app/app.dart | 2 +- lib/core/editor/querya_code_editor.dart | 2 +- lib/core/layout/querya_split_handle.dart | 2 +- lib/core/layout/vertical_split_pane.dart | 2 +- lib/core/storage/app_settings.dart | 2 +- lib/core/theme/app_theme.dart | 2 +- .../parser/querya_theme_color_scheme.dart | 2 +- lib/core/theme/querya_color_scheme.dart | 2 +- lib/core/theme/querya_theme.dart | 2 +- lib/core/theme/theme_controller.dart | 2 +- lib/features/erd/erd_view.dart | 2 +- .../erd/widgets/erd_edge_tip_label.dart | 2 +- lib/features/erd/widgets/erd_group_frame.dart | 2 +- lib/features/erd/widgets/erd_group_menu.dart | 2 +- lib/features/erd/widgets/erd_note_card.dart | 2 +- .../erd/widgets/erd_search_panel.dart | 2 +- lib/features/erd/widgets/erd_table_card.dart | 2 +- lib/features/erd/widgets/erd_table_menu.dart | 2 +- .../main_screen/querya_window_title_bar.dart | 3 +- .../mongodb/mongo_document_tree_view.dart | 3 +- .../mongodb/mongo_documents_json_view.dart | 3 +- .../mongodb/mongo_documents_table_view.dart | 3 +- .../settings/preferences_controls.dart | 2 +- .../settings/theme_picker_button.dart | 2 +- .../updater/update_available_badge.dart | 2 +- .../updater/update_changelog_view.dart | 2 +- .../workspace/data_grid_calc_bar.dart | 3 +- .../workspace/data_grid_filter_bar.dart | 3 +- .../workspace/data_grid_value_panel.dart | 3 +- .../workspace/grid/grid_column_sizer.dart | 2 +- .../grid/grid_selection_manager.dart | 2 +- lib/features/workspace/grid_cell_editor.dart | 2 +- lib/features/workspace/query_editor_tab.dart | 2 +- lib/features/workspace/result_grid_view.dart | 3 +- lib/features/workspace/sql_editor_chrome.dart | 2 +- lib/shared/widgets/widgets.dart | 3 +- pubspec.yaml | 2 + test/guards/shadcn_imports_ratchet_test.dart | 105 ++++++++++++++++++ 38 files changed, 152 insertions(+), 36 deletions(-) create mode 100644 test/guards/shadcn_imports_ratchet_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 6b30d595..bba6d3ed 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:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'app_lifecycle_cleanup.dart'; import '../features/main_screen/main_screen.dart'; diff --git a/lib/core/editor/querya_code_editor.dart b/lib/core/editor/querya_code_editor.dart index c1988c80..2c116935 100644 --- a/lib/core/editor/querya_code_editor.dart +++ b/lib/core/editor/querya_code_editor.dart @@ -2,7 +2,7 @@ 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.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'; diff --git a/lib/core/layout/querya_split_handle.dart b/lib/core/layout/querya_split_handle.dart index 0e7be5c0..0c888f8f 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:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; /// Accessible split-pane handle that supports mouse dragging and arrow keys. /// 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/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 83e38f2a..f035cc97 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -1,7 +1,7 @@ import 'dart:convert'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import '../motion/querya_motion_scope.dart'; import '../theme/querya_theme_preset.dart'; diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 3d8f3758..8da7ae8c 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,4 +1,4 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'querya_theme.dart'; diff --git a/lib/core/theme/parser/querya_theme_color_scheme.dart b/lib/core/theme/parser/querya_theme_color_scheme.dart index fd962e31..9d156974 100644 --- a/lib/core/theme/parser/querya_theme_color_scheme.dart +++ b/lib/core/theme/parser/querya_theme_color_scheme.dart @@ -1,7 +1,7 @@ // ignore_for_file: deprecated_member_use import 'package:flutter/foundation.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import '../querya_theme.dart'; import 'color_parser.dart'; diff --git a/lib/core/theme/querya_color_scheme.dart b/lib/core/theme/querya_color_scheme.dart index 177efc15..7b87a0b6 100644 --- a/lib/core/theme/querya_color_scheme.dart +++ b/lib/core/theme/querya_color_scheme.dart @@ -1,4 +1,4 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'querya_theme.dart'; diff --git a/lib/core/theme/querya_theme.dart b/lib/core/theme/querya_theme.dart index 3b3cc333..33c4cfca 100644 --- a/lib/core/theme/querya_theme.dart +++ b/lib/core/theme/querya_theme.dart @@ -1,4 +1,4 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'color_contrast.dart'; import 'parser/vscode_theme_manifest.dart'; diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 711e019c..3c8ce91b 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -4,7 +4,7 @@ import 'dart:io'; 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 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'parser/apply_token_colors_to_editor.dart'; import 'parser/color_parser.dart'; diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index adc5e310..aa7ad360 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -43,7 +43,7 @@ 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/shared/widgets/app_toast.dart'; import 'package:querya_desktop/shared/widgets/querya_spinner.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; // Moved out of this file (#1362); re-exported for existing importers. export 'package:querya_desktop/features/erd/widgets/erd_relation_painter.dart' diff --git a/lib/features/erd/widgets/erd_edge_tip_label.dart b/lib/features/erd/widgets/erd_edge_tip_label.dart index c0eaa35b..30afe693 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:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; /// "orders.customer_id → customers.id" next to the pointer. class ErdEdgeTipLabel extends material.StatelessWidget { diff --git a/lib/features/erd/widgets/erd_group_frame.dart b/lib/features/erd/widgets/erd_group_frame.dart index 0eccb960..dee5b89a 100644 --- a/lib/features/erd/widgets/erd_group_frame.dart +++ b/lib/features/erd/widgets/erd_group_frame.dart @@ -4,7 +4,7 @@ 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:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; /// 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. diff --git a/lib/features/erd/widgets/erd_group_menu.dart b/lib/features/erd/widgets/erd_group_menu.dart index a8c718d7..004eb802 100644 --- a/lib/features/erd/widgets/erd_group_menu.dart +++ b/lib/features/erd/widgets/erd_group_menu.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; /// Group entries of a card's menu: group it (with the marked tables), add it /// to a group, take it out of its group. diff --git a/lib/features/erd/widgets/erd_note_card.dart b/lib/features/erd/widgets/erd_note_card.dart index ebc9f87a..69b55bad 100644 --- a/lib/features/erd/widgets/erd_note_card.dart +++ b/lib/features/erd/widgets/erd_note_card.dart @@ -3,7 +3,7 @@ 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: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; /// A sticky note on the canvas: draggable by its body, resizable by its /// corner, with an edit / colour / attach / delete menu (#1283). 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..c5a81341 100644 --- a/lib/features/erd/widgets/erd_table_menu.dart +++ b/lib/features/erd/widgets/erd_table_menu.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; /// The entries of a table card's context menu. abstract final class ErdTableMenu { diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 0219599c..5bd6103a 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -20,7 +20,8 @@ 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:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; /// Custom bitsdojo title bar styled from [QueryaThemeScope] workbench tokens. class QueryaWindowTitleBar extends StatelessWidget { diff --git a/lib/features/mongodb/mongo_document_tree_view.dart b/lib/features/mongodb/mongo_document_tree_view.dart index c4d88bc9..c601fc9b 100644 --- a/lib/features/mongodb/mongo_document_tree_view.dart +++ b/lib/features/mongodb/mongo_document_tree_view.dart @@ -9,7 +9,8 @@ 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'; diff --git a/lib/features/mongodb/mongo_documents_json_view.dart b/lib/features/mongodb/mongo_documents_json_view.dart index 793958d8..b4aecadb 100644 --- a/lib/features/mongodb/mongo_documents_json_view.dart +++ b/lib/features/mongodb/mongo_documents_json_view.dart @@ -5,7 +5,8 @@ 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'; diff --git a/lib/features/mongodb/mongo_documents_table_view.dart b/lib/features/mongodb/mongo_documents_table_view.dart index e82a3982..8a0786a7 100644 --- a/lib/features/mongodb/mongo_documents_table_view.dart +++ b/lib/features/mongodb/mongo_documents_table_view.dart @@ -4,7 +4,8 @@ import 'package:mongo_dart/mongo_dart.dart' show ObjectId; 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'; diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index 264909f5..c3b03514 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -7,7 +7,7 @@ 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 { diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index 94c0315b..73729a06 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -8,7 +8,7 @@ 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; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; /// Debounce delay before requesting a hover preview load. const Duration themePreviewDebounce = Duration(milliseconds: 120); diff --git a/lib/features/updater/update_available_badge.dart b/lib/features/updater/update_available_badge.dart index 71e9e098..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). /// diff --git a/lib/features/updater/update_changelog_view.dart b/lib/features/updater/update_changelog_view.dart index a8113ba0..bfc565bb 100644 --- a/lib/features/updater/update_changelog_view.dart +++ b/lib/features/updater/update_changelog_view.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart' as material; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'package:querya_desktop/shared/widgets/querya_text.dart'; /// Lightweight Markdown-ish renderer for GitHub release notes. diff --git a/lib/features/workspace/data_grid_calc_bar.dart b/lib/features/workspace/data_grid_calc_bar.dart index c3de21d4..f4767d57 100644 --- a/lib/features/workspace/data_grid_calc_bar.dart +++ b/lib/features/workspace/data_grid_calc_bar.dart @@ -3,7 +3,8 @@ import 'package:flutter/services.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 { diff --git a/lib/features/workspace/data_grid_filter_bar.dart b/lib/features/workspace/data_grid_filter_bar.dart index 74cd2cc6..4b3d8056 100644 --- a/lib/features/workspace/data_grid_filter_bar.dart +++ b/lib/features/workspace/data_grid_filter_bar.dart @@ -4,7 +4,8 @@ import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey; 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 { diff --git a/lib/features/workspace/data_grid_value_panel.dart b/lib/features/workspace/data_grid_value_panel.dart index f642b5c9..ef187eec 100644 --- a/lib/features/workspace/data_grid_value_panel.dart +++ b/lib/features/workspace/data_grid_value_panel.dart @@ -9,7 +9,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'; 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_selection_manager.dart b/lib/features/workspace/grid/grid_selection_manager.dart index c9a03fe9..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 diff --git a/lib/features/workspace/grid_cell_editor.dart b/lib/features/workspace/grid_cell_editor.dart index 4ae8b71b..cb8737a3 100644 --- a/lib/features/workspace/grid_cell_editor.dart +++ b/lib/features/workspace/grid_cell_editor.dart @@ -4,7 +4,7 @@ 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 { 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 bf1b8cdc..fc3202c4 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -24,7 +24,8 @@ 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: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'; diff --git a/lib/features/workspace/sql_editor_chrome.dart b/lib/features/workspace/sql_editor_chrome.dart index 9aabd99d..ac030816 100644 --- a/lib/features/workspace/sql_editor_chrome.dart +++ b/lib/features/workspace/sql_editor_chrome.dart @@ -2,7 +2,7 @@ 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 and the results under them: one rounded card /// with a border. diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 44aced82..716f3d60 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -42,4 +42,5 @@ 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; diff --git a/pubspec.yaml b/pubspec.yaml index d6119139..422cf781 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,8 @@ dependencies: crypto: ^3.0.6 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 diff --git a/test/guards/shadcn_imports_ratchet_test.dart b/test/guards/shadcn_imports_ratchet_test.dart new file mode 100644 index 00000000..21f75833 --- /dev/null +++ b/test/guards/shadcn_imports_ratchet_test.dart @@ -0,0 +1,105 @@ +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/app/app.dart', + 'lib/app/querya_core_commands.dart', + 'lib/core/editor/querya_code_editor.dart', + 'lib/core/layout/querya_split_handle.dart', + 'lib/core/storage/app_settings.dart', + 'lib/core/theme/app_theme.dart', + 'lib/core/theme/parser/querya_theme_color_scheme.dart', + 'lib/core/theme/querya_color_scheme.dart', + 'lib/core/theme/querya_material_theme.dart', + 'lib/core/theme/querya_theme.dart', + 'lib/core/theme/querya_theme_scope.dart', + 'lib/core/theme/theme_controller.dart', + 'lib/core/theme/theme_editor_draft.dart', + 'lib/features/erd/erd_export_data.dart', + 'lib/features/erd/erd_view.dart', + 'lib/features/erd/widgets/erd_edge_tip_label.dart', + 'lib/features/erd/widgets/erd_group_frame.dart', + 'lib/features/erd/widgets/erd_group_menu.dart', + 'lib/features/erd/widgets/erd_note_card.dart', + 'lib/features/erd/widgets/erd_table_menu.dart', + 'lib/features/extensions/extension_stats_view.dart', + 'lib/features/main_screen/querya_window_title_bar.dart', + 'lib/features/mongodb/mongo_collections_view.dart', + 'lib/features/mongodb/mongo_databases_view.dart', + 'lib/features/mongodb/mongo_document_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/mongodb/mongo_documents_view.dart', + 'lib/features/mongodb/mongo_explorer_view.dart', + 'lib/features/mongodb/mongo_stats_view.dart', + 'lib/features/mysql/mysql_stats_view.dart', + 'lib/features/postgresql/postgres_stats_view.dart', + 'lib/features/redis/editors/redis_hash_editor.dart', + 'lib/features/redis/editors/redis_list_editor.dart', + 'lib/features/redis/editors/redis_set_editor.dart', + 'lib/features/redis/editors/redis_zset_editor.dart', + 'lib/features/redis/redis_databases_view.dart', + 'lib/features/redis/redis_explorer_view.dart', + 'lib/features/redis/redis_key_editor.dart', + 'lib/features/redis/redis_keys_view.dart', + 'lib/features/redis/redis_view.dart', + 'lib/features/settings/preferences_controls.dart', + 'lib/features/settings/theme_picker_button.dart', + 'lib/features/updater/update_changelog_view.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.'); + }); +} From f336e247597f43d2ce849daca79301fc0b788cc0 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:32:23 +0300 Subject: [PATCH 145/157] refactor(theme): QueryaUiColors and context.colors as the app's colour type, bridged to the shadcn scheme (#1730) (#1738) --- lib/core/theme/querya_color_scheme.dart | 67 +++++++++ lib/core/theme/querya_theme.dart | 9 +- lib/core/theme/querya_theme_scope.dart | 23 ++- lib/core/theme/querya_ui_colors.dart | 131 ++++++++++++++++++ .../connections/driver_manager_dialog.dart | 3 +- .../connections/new_connection_dialog.dart | 3 +- lib/shared/widgets/querya_dropdown.dart | 16 +-- test/core/theme/querya_ui_colors_test.dart | 123 ++++++++++++++++ 8 files changed, 356 insertions(+), 19 deletions(-) create mode 100644 lib/core/theme/querya_ui_colors.dart create mode 100644 test/core/theme/querya_ui_colors_test.dart diff --git a/lib/core/theme/querya_color_scheme.dart b/lib/core/theme/querya_color_scheme.dart index 7b87a0b6..4f705efe 100644 --- a/lib/core/theme/querya_color_scheme.dart +++ b/lib/core/theme/querya_color_scheme.dart @@ -1,9 +1,76 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'querya_theme.dart'; +import 'querya_ui_colors.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; } + +final Expando _uiColorsCache = Expando(); + +/// The bridge between shadcn's scheme and [QueryaUiColors] while the app still +/// runs on shadcn's shell (#1730, epic #1465); it goes with #1735. +extension QueryaUiColorsFromShadcn on ColorScheme { + /// The same colours as [QueryaUiColors] (cached per scheme instance). + QueryaUiColors get uiColors => _uiColorsCache[this] ??= QueryaUiColors( + brightness: brightness, + background: background, + foreground: foreground, + card: card, + popover: popover, + popoverForeground: popoverForeground, + primary: primary, + primaryForeground: primaryForeground, + secondary: secondary, + secondaryForeground: secondaryForeground, + muted: muted, + mutedForeground: mutedForeground, + accent: accent, + destructive: destructive, + destructiveForeground: destructiveForeground, + border: border, + ring: ring, + chart1: chart1, + chart2: chart2, + chart3: chart3, + chart4: chart4, + chart5: chart5, + ); +} + +extension QueryaUiColorsToShadcn on QueryaUiColors { + /// A shadcn scheme for the widgets still on shadcn. The fields shadcn has + /// and the app does not read follow the ones next to them, as every Querya + /// theme sets them today: card and accent text are [foreground], the input + /// outline is [border]. + ColorScheme toShadcn() => ColorScheme( + brightness: brightness, + background: background, + foreground: foreground, + card: card, + cardForeground: foreground, + popover: popover, + popoverForeground: popoverForeground, + primary: primary, + primaryForeground: primaryForeground, + secondary: secondary, + secondaryForeground: secondaryForeground, + muted: muted, + mutedForeground: mutedForeground, + accent: accent, + accentForeground: foreground, + destructive: destructive, + destructiveForeground: destructiveForeground, + border: border, + input: border, + ring: ring, + chart1: chart1, + chart2: chart2, + chart3: chart3, + chart4: chart4, + chart5: chart5, + ); +} diff --git a/lib/core/theme/querya_theme.dart b/lib/core/theme/querya_theme.dart index 33c4cfca..6f99d88b 100644 --- a/lib/core/theme/querya_theme.dart +++ b/lib/core/theme/querya_theme.dart @@ -2,14 +2,12 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'color_contrast.dart'; import 'parser/vscode_theme_manifest.dart'; +import 'querya_color_scheme.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]. class QueryaTheme { const QueryaTheme({ @@ -25,6 +23,9 @@ class QueryaTheme { final Brightness brightness; final ColorScheme colorScheme; + /// [colorScheme] as the app's own colour type (#1730). + QueryaUiColors get colors => colorScheme.uiColors; + /// VS Code `tokenColors` for syntax highlighting (imported themes). final List tokenColors; diff --git a/lib/core/theme/querya_theme_scope.dart b/lib/core/theme/querya_theme_scope.dart index 4b3ed85e..87b258cd 100644 --- a/lib/core/theme/querya_theme_scope.dart +++ b/lib/core/theme/querya_theme_scope.dart @@ -3,9 +3,11 @@ import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn hide TextExtension; +import 'querya_color_scheme.dart'; 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]. @@ -54,15 +56,26 @@ 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 ones the running shell uses, so a + /// caller that moves here from `Theme.of(context).colorScheme` sees the same + /// values, mid theme animation included. Without a shell theme (a bare app + /// in a test) the nearest [QueryaThemeScope]'s, else the default theme's. + QueryaUiColors get colors { + final shell = + dependOnInheritedWidgetOfExactType()?.data.colorScheme; + if (shell != null) return shell.uiColors; + return (QueryaThemeScope.maybeOf(this) ?? + (_ambientBrightness == Brightness.light + ? QueryaTheme.lightDefault + : QueryaTheme.darkDefault)) + .colors; + } QueryaTheme get queryaTheme => QueryaThemeScope.of(this); /// The brightness the defaults below follow when there is no - /// [QueryaThemeScope]: the shadcn theme's, else Material's. [colors] asserts - /// without a shadcn theme, and these defaults exist for exactly that case - /// (a widget under a bare app, as in tests). + /// [QueryaThemeScope]: the shadcn theme's, else Material's (a widget under a + /// bare app, as in tests). Brightness get _ambientBrightness => dependOnInheritedWidgetOfExactType() ?.data diff --git a/lib/core/theme/querya_ui_colors.dart b/lib/core/theme/querya_ui_colors.dart new file mode 100644 index 00000000..92ad1f54 --- /dev/null +++ b/lib/core/theme/querya_ui_colors.dart @@ -0,0 +1,131 @@ +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). +/// +/// Holds exactly the fields `lib/` uses of shadcn's `ColorScheme`, under the +/// same names, so a caller moves from `Theme.of(context).colorScheme` to +/// `context.colors` without other changes. While the app still runs on +/// shadcn's shell the values are read from the shadcn scheme and converted in +/// `querya_color_scheme.dart`, the one place that knows both; after #1735 this +/// is the source. +@immutable +class QueryaUiColors { + const QueryaUiColors({ + required this.brightness, + required this.background, + required this.foreground, + required this.card, + 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.destructive, + required this.destructiveForeground, + required this.border, + 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. + final Color card; + + /// 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. + final Color accent; + + /// Errors and destructive actions, and what sits on them. + final Color destructive; + final Color destructiveForeground; + + /// Hairlines and control outlines; the focus ring. + final Color border; + final Color ring; + + /// Chart series. + final Color chart1; + final Color chart2; + final Color chart3; + final Color chart4; + final Color 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), + 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), + destructive: l(a.destructive, b.destructive), + destructiveForeground: + l(a.destructiveForeground, b.destructiveForeground), + border: l(a.border, b.border), + 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, popover, popoverForeground, + primary, primaryForeground, secondary, secondaryForeground, muted, + mutedForeground, accent, destructive, destructiveForeground, border, + 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/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/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/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index b917d6d2..476ff60d 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,7 +204,7 @@ class _QueryaDropdownState extends material.State> { material.Widget _menuItem( QueryaDropdownItem item, - QueryaThemeColorScheme cs, + QueryaUiColors cs, QueryaControlSize size, ) { return _QueryaDropdownMenuItem( @@ -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, }) { @@ -512,7 +512,7 @@ class _QueryaDropdownMenuItem extends material.StatefulWidget { final QueryaDropdownItem item; final bool selected; final bool enabled; - final QueryaThemeColorScheme colorScheme; + final QueryaUiColors colorScheme; 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) { 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..e7bf84ed --- /dev/null +++ b/test/core/theme/querya_ui_colors_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_from_vscode.dart'; +import 'package:querya_desktop/core/theme/querya_color_scheme.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:shadcn_flutter/shadcn_flutter.dart' as shadcn + hide Gap, TextExtension; + +import '../../support/querya_theme_test_shell.dart'; + +/// QueryaUiColors gives the colours the shadcn scheme gives today (#1730). +void main() { + final imported = buildQueryaThemeFromVsCodeColors( + brightness: shadcn.Brightness.dark, + colors: const { + 'editor.background': '#1e1e2e', + 'focusBorder': '#f38ba8', + 'errorForeground': '#ff5555', + }, + fallback: QueryaTheme.darkDefault, + ); + final themes = { + 'dark': QueryaTheme.darkDefault, + 'light': QueryaTheme.lightDefault, + 'imported': imported, + }; + + for (final MapEntry(key: name, value: theme) in themes.entries) { + test('$name: every field equals the shadcn scheme field', () { + final s = theme.colorScheme; + final c = theme.colors; + expect(c.brightness, s.brightness); + expect(c.background, s.background); + expect(c.foreground, s.foreground); + expect(c.card, s.card); + expect(c.popover, s.popover); + expect(c.popoverForeground, s.popoverForeground); + expect(c.primary, s.primary); + expect(c.primaryForeground, s.primaryForeground); + expect(c.secondary, s.secondary); + expect(c.secondaryForeground, s.secondaryForeground); + expect(c.muted, s.muted); + expect(c.mutedForeground, s.mutedForeground); + expect(c.accent, s.accent); + expect(c.destructive, s.destructive); + expect(c.destructiveForeground, s.destructiveForeground); + expect(c.border, s.border); + expect(c.ring, s.ring); + expect([c.chart1, c.chart2, c.chart3, c.chart4, c.chart5], + [s.chart1, s.chart2, s.chart3, s.chart4, s.chart5]); + }); + + test('$name: back to a shadcn scheme it is the same scheme', () { + final s = theme.colorScheme; + final back = theme.colors.toShadcn(); + expect(back.uiColors, theme.colors); + // The fields the app does not read follow their neighbours, as every + // Querya theme sets them. + expect(back.cardForeground, s.cardForeground); + expect(back.accentForeground, s.accentForeground); + expect(back.input, s.input); + }); + } + + test('one scheme instance gives one QueryaUiColors', () { + final s = QueryaTheme.darkDefault.colorScheme; + expect(identical(s.uiColors, s.uiColors), isTrue); + }); + + 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: imported, + child: material.Builder(builder: (context) { + seen = context.colors; + return const material.SizedBox.shrink(); + }), + ), + )); + expect(seen, imported.colors); + }); + }); +} From 6ff0f52686efd690807055094d0a1074f5d02b35 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:44:54 +0300 Subject: [PATCH 146/157] refactor(connections): read colours through context.colors; a guard keeps the folder off the theme's colorScheme (#1731) (#1739) --- .../connection_databases_folder.dart | 8 +- .../connections/connections_panel.dart | 81 ++++++++++--------- .../connections_panel_extension.dart | 20 ++--- .../connections/connections_panel_mongo.dart | 44 +++++----- .../connections/connections_panel_mysql.dart | 42 +++++----- .../connections_panel_pg_tree.dart | 44 +++++----- ...connections_panel_postgres_connection.dart | 20 ++--- .../connections/connections_panel_redis.dart | 32 ++++---- .../connections_panel_sidebar.dart | 42 +++++----- .../connections/connections_panel_sqlite.dart | 34 ++++---- lib/features/connections/driver_icon.dart | 3 +- .../connections/environment_section.dart | 2 +- .../querya_connection_tree_row.dart | 22 ++--- .../connections/sdui_tree_builder.dart | 8 +- .../connections/ssh_tunnel_section.dart | 6 +- test/guards/colors_through_context_test.dart | 34 ++++++++ 16 files changed, 238 insertions(+), 204 deletions(-) create mode 100644 test/guards/colors_through_context_test.dart diff --git a/lib/features/connections/connection_databases_folder.dart b/lib/features/connections/connection_databases_folder.dart index 716a0e8f..4b8d7287 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, ), ), ), diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 095190c5..c120603e 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -83,6 +83,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 +221,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 +235,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,8 +250,8 @@ 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( @@ -259,13 +260,13 @@ class TreeObjectFilterBar extends material.StatelessWidget { onChanged: onChanged, style: material.TextStyle( fontSize: 11, - color: theme.colorScheme.foreground, + color: colors.foreground, ), decoration: material.InputDecoration( hintText: hintText, hintStyle: material.TextStyle( fontSize: 11, - color: theme.colorScheme.mutedForeground.withValues(alpha: 0.6), + color: colors.mutedForeground.withValues(alpha: 0.6), ), border: material.InputBorder.none, isDense: true, @@ -279,7 +280,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 +290,7 @@ class TreeObjectFilterBar extends material.StatelessWidget { child: material.Icon( material.Icons.close_rounded, size: 12, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ], @@ -763,21 +764,21 @@ class ConnectionsPanelState extends State { /// 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); + final colors = context.colors; final current = conn.folderId; final targets = [ for (final name in _folders) if (_folderIdByName[name] != current) MenuButton( 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( 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'), ), @@ -786,7 +787,7 @@ class ConnectionsPanelState extends State { return [ MenuButton( 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 +908,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 +925,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 +1093,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 +1140,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 +1169,7 @@ class ConnectionsPanelState extends State { fontSize: 11, letterSpacing: 0.85, fontWeight: material.FontWeight.w600, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), @@ -1179,7 +1180,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 +1190,7 @@ class ConnectionsPanelState extends State { style: material.TextStyle( fontSize: 10, fontWeight: material.FontWeight.w600, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), @@ -1202,12 +1203,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,8 +1222,8 @@ 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), @@ -1235,13 +1236,13 @@ class ConnectionsPanelState extends State { }), style: material.TextStyle( fontSize: 12, - color: theme.colorScheme.foreground, + color: colors.foreground, ), decoration: material.InputDecoration( hintText: 'Filter connections... (Ctrl+F)', hintStyle: material.TextStyle( fontSize: 12, - color: theme.colorScheme.mutedForeground + color: colors.mutedForeground .withValues(alpha: 0.6), ), border: material.InputBorder.none, @@ -1265,7 +1266,7 @@ class ConnectionsPanelState extends State { child: material.Icon( material.Icons.close_rounded, size: 14, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), ), ), @@ -1275,7 +1276,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 +1293,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 +1307,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, ), ), ), @@ -1389,13 +1390,13 @@ class ConnectionsPanelState extends State { MenuButton( leading: material.Icon(material.Icons.add_rounded, size: 18, - color: theme.colorScheme.mutedForeground), + color: colors.mutedForeground), subMenu: [ MenuButton( 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)); @@ -1408,7 +1409,7 @@ class ConnectionsPanelState extends State { 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..864bf134 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -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,14 +169,14 @@ 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, ); } @@ -184,7 +184,7 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { items: [ MenuButton( leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) { setState(() => _schema = null); _loadTree(); @@ -193,18 +193,18 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { ), MenuButton( 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( 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..8b7c11b2 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -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,24 +146,24 @@ 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( items: [ MenuButton( 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( leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) { _databases = []; _loadDatabases(); @@ -172,18 +172,18 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { ), MenuButton( 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( 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,7 +641,7 @@ 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 && @@ -654,7 +654,7 @@ class _MongoCollectionNode extends StatelessWidget { leading: material.Icon( material.Icons.description_outlined, size: 18, - color: theme.colorScheme.primary, + color: colors.primary, ), onPressed: (_) => onTap(), child: const Text('Open collection'), @@ -663,7 +663,7 @@ class _MongoCollectionNode extends StatelessWidget { leading: material.Icon( material.Icons.copy_rounded, size: 18, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), onPressed: (_) { Clipboard.setData(ClipboardData(text: name)); @@ -675,7 +675,7 @@ class _MongoCollectionNode extends StatelessWidget { leading: material.Icon( material.Icons.refresh_rounded, size: 18, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), onPressed: (_) => onRefreshCollections(), child: const Text('Refresh'), @@ -684,7 +684,7 @@ class _MongoCollectionNode extends StatelessWidget { 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..2ac4c507 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -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( items: [ MenuButton( leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) { _databases = []; _loadDatabases(); @@ -142,25 +142,25 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { if (widget.onMysqlOpenSqlWorkspace != null) MenuButton( 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( 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( 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..8eec17e6 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -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,18 +124,18 @@ 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( items: [ MenuButton( leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) { _databases = []; _loadDatabases(); @@ -144,18 +144,18 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { ), MenuButton( 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( 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..46c44bd6 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -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,18 +134,18 @@ 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( items: [ MenuButton( leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) { _databases = []; _loadDatabases(); @@ -154,18 +154,18 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { ), MenuButton( 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( 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..130134ad 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( @@ -93,7 +93,7 @@ class _ConnectionTile extends StatelessWidget { @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( items: [ MenuButton( 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( 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) { + Widget _buildMenu(BuildContext context, QueryaUiColors colors) { return ContextMenu( items: [ MenuButton( 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( 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..6dfa4201 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -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( items: [ MenuButton( leading: material.Icon(material.Icons.refresh_rounded, - size: 18, color: theme.colorScheme.mutedForeground), + size: 18, color: colors.mutedForeground), onPressed: (_) { setState(() { _tables = []; @@ -168,25 +168,25 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { if (widget.onSqliteOpenSqlWorkspace != null) MenuButton( 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( 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( 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/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/querya_connection_tree_row.dart b/lib/features/connections/querya_connection_tree_row.dart index 66195fd6..50f1e9dd 100644 --- a/lib/features/connections/querya_connection_tree_row.dart +++ b/lib/features/connections/querya_connection_tree_row.dart @@ -103,9 +103,9 @@ 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: { @@ -168,7 +168,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { textStyle: isSelected ? textStyle.copyWith( fontWeight: material.FontWeight.w600, - color: theme.colorScheme.foreground, + color: colors.foreground, ) : textStyle, ), @@ -207,7 +207,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { size: 18, color: isPinned ? wb.warning - : theme.colorScheme.mutedForeground, + : colors.mutedForeground, ), onPressed: (_) => onTogglePin!(), child: Text(isPinned ? 'Unpin from top' : 'Pin to top ⭐'), @@ -218,7 +218,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { leading: material.Icon( material.Icons.table_rows_outlined, size: 18, - color: theme.colorScheme.primary, + color: colors.primary, ), onPressed: (_) { final qualified = openSqlSchema != null && openSqlSchema!.isNotEmpty @@ -243,7 +243,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { leading: material.Icon( material.Icons.code_rounded, size: 18, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), onPressed: (_) { final qualified = openSqlSchema != null && openSqlSchema!.isNotEmpty @@ -260,7 +260,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { leading: material.Icon( material.Icons.refresh_rounded, size: 18, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), onPressed: (_) => onContextRefresh!(), child: const Text('Refresh'), @@ -269,7 +269,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { leading: material.Icon( material.Icons.copy_rounded, size: 18, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), onPressed: (_) { Clipboard.setData(ClipboardData(text: label)); @@ -283,7 +283,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { leading: material.Icon( material.Icons.terminal_rounded, size: 18, - color: theme.colorScheme.mutedForeground, + color: colors.mutedForeground, ), onPressed: (_) => onOpenSqlWorkspace!( connection!, @@ -300,7 +300,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { 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/ssh_tunnel_section.dart b/lib/features/connections/ssh_tunnel_section.dart index bbdc4755..915dae2a 100644 --- a/lib/features/connections/ssh_tunnel_section.dart +++ b/lib/features/connections/ssh_tunnel_section.dart @@ -194,8 +194,8 @@ 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( @@ -519,7 +519,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/test/guards/colors_through_context_test.dart b/test/guards/colors_through_context_test.dart new file mode 100644 index 00000000..bb85ff6d --- /dev/null +++ b/test/guards/colors_through_context_test.dart @@ -0,0 +1,34 @@ +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`. A folder joins +/// [migrated] in the PR that moves it; at the end the list is all of `lib/`. +void main() { + const migrated = [ + 'lib/features/connections', + ]; + + final banned = RegExp(r'\bcolorScheme\b|\bTheme\.of\(|\bColorScheme\b'); + + test('migrated folders read colours through context.colors', () { + final offenders = []; + for (final dir in migrated) { + for (final e in Directory(dir).listSync(recursive: true)) { + if (e is! File || !e.path.endsWith('.dart')) 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('${e.path.replaceAll(r'\', '/')}:${i + 1}: ' + '${line.trim()}'); + } + } + } + } + expect(offenders, isEmpty, + reason: 'Use context.colors (QueryaUiColors) instead.'); + }); +} From 0334740785c2c5f05e81dd072fec2505c0985747 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:51:57 +0300 Subject: [PATCH 147/157] refactor(redis, mongodb): read colours through context.colors; 15 files drop their shadcn import (#1731) (#1740) --- .../mongodb/mongo_aggregation_workspace.dart | 13 +++--- .../mongodb/mongo_collections_view.dart | 17 ++++---- .../mongodb/mongo_create_index_dialog.dart | 2 +- .../mongodb/mongo_databases_view.dart | 17 ++++---- .../mongodb/mongo_document_editor.dart | 6 +-- .../mongodb/mongo_document_tree_view.dart | 13 +++--- .../mongodb/mongo_documents_json_view.dart | 3 +- .../mongodb/mongo_documents_table_view.dart | 3 +- .../mongodb/mongo_documents_view.dart | 43 +++++++++---------- lib/features/mongodb/mongo_explorer_view.dart | 8 ++-- lib/features/mongodb/mongo_indexes_view.dart | 7 +-- .../mongodb/mongo_query_workspace.dart | 14 +++--- lib/features/mongodb/mongo_stats_view.dart | 15 +++---- .../mongodb/mongodb_connection_form.dart | 2 +- .../redis/editors/redis_hash_editor.dart | 23 +++++----- .../redis/editors/redis_list_editor.dart | 23 +++++----- .../redis/editors/redis_set_editor.dart | 23 +++++----- .../redis/editors/redis_string_editor.dart | 7 +-- .../redis/editors/redis_zset_editor.dart | 23 +++++----- lib/features/redis/redis_cli_workspace.dart | 5 ++- lib/features/redis/redis_connection_form.dart | 2 +- lib/features/redis/redis_databases_view.dart | 19 ++++---- lib/features/redis/redis_explorer_view.dart | 8 ++-- lib/features/redis/redis_key_editor.dart | 31 +++++++------ lib/features/redis/redis_keys_view.dart | 41 +++++++++--------- lib/features/redis/redis_view.dart | 15 +++---- .../mongodb/mongo_field_list_perf_test.dart | 11 +++-- .../redis/redis_type_editors_test.dart | 22 +++++----- test/guards/colors_through_context_test.dart | 2 + test/guards/shadcn_imports_ratchet_test.dart | 15 ------- 30 files changed, 205 insertions(+), 228 deletions(-) diff --git a/lib/features/mongodb/mongo_aggregation_workspace.dart b/lib/features/mongodb/mongo_aggregation_workspace.dart index 8de20940..ae9f5ed9 100644 --- a/lib/features/mongodb/mongo_aggregation_workspace.dart +++ b/lib/features/mongodb/mongo_aggregation_workspace.dart @@ -1,5 +1,6 @@ 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'; @@ -206,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, @@ -260,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( @@ -329,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( @@ -386,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})'; @@ -564,7 +565,7 @@ 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); @@ -761,7 +762,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( diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 19937831..a988c7f5 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, @@ -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,19 +387,19 @@ 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, child: material.InkWell( diff --git a/lib/features/mongodb/mongo_create_index_dialog.dart b/lib/features/mongodb/mongo_create_index_dialog.dart index d87d35f4..582f22a7 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'), diff --git a/lib/features/mongodb/mongo_databases_view.dart b/lib/features/mongodb/mongo_databases_view.dart index 53da224b..9896d5e2 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, @@ -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,19 +366,19 @@ 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, child: material.InkWell( 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 c601fc9b..25b96daf 100644 --- a/lib/features/mongodb/mongo_document_tree_view.dart +++ b/lib/features/mongodb/mongo_document_tree_view.dart @@ -3,6 +3,7 @@ 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/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'; @@ -170,7 +171,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( @@ -256,7 +257,7 @@ class _MongoDocumentTreeViewState extends State { Widget _buildDocumentNode( int docIndex, Map doc, - ColorScheme cs, + QueryaUiColors cs, ) { final docNodeId = 'doc_$docIndex'; final isExpanded = _expandedNodeIds.contains(docNodeId); @@ -404,7 +405,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'; @@ -519,7 +520,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, }) { @@ -598,7 +599,7 @@ 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); @@ -711,7 +712,7 @@ class _MongoDocumentTreeViewState extends State { Color _leafValueColor( Object? value, - ColorScheme cs, + QueryaUiColors cs, QueryaSemanticPalette palette, ) { if (value == null) return palette.muted; diff --git a/lib/features/mongodb/mongo_documents_json_view.dart b/lib/features/mongodb/mongo_documents_json_view.dart index b4aecadb..321d411e 100644 --- a/lib/features/mongodb/mongo_documents_json_view.dart +++ b/lib/features/mongodb/mongo_documents_json_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_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'; @@ -63,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 8a0786a7..54c01eb8 100644 --- a/lib/features/mongodb/mongo_documents_table_view.dart +++ b/lib/features/mongodb/mongo_documents_table_view.dart @@ -1,6 +1,7 @@ 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'; @@ -160,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 190c1143..7caf2c0a 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -1,6 +1,7 @@ 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'; @@ -15,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; @@ -369,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( @@ -425,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]), @@ -447,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), @@ -572,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), @@ -597,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; @@ -667,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, @@ -676,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; @@ -710,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() ?? '—'; @@ -788,7 +787,7 @@ class _DocumentCardState extends State<_DocumentCard> { child: _expanded ? MongoFieldList( document: widget.document, - colorScheme: cs, + colors: cs, shadcnCs: scs, onInspectField: widget.onInspectField, ) @@ -823,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) { @@ -856,7 +855,7 @@ class MongoFieldList extends StatelessWidget { fontFamily: QueryaTypography.mono, fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontWeight: material.FontWeight.w600, - color: colorScheme.foreground, + color: colors.foreground, ), ), ), @@ -888,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 @@ -897,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 396ed3fa..b55f60fe 100644 --- a/lib/features/mongodb/mongo_indexes_view.dart +++ b/lib/features/mongodb/mongo_indexes_view.dart @@ -1,6 +1,7 @@ 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'; @@ -188,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, @@ -263,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, @@ -336,7 +337,7 @@ class _IndexCard extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return Card( child: material.Padding( diff --git a/lib/features/mongodb/mongo_query_workspace.dart b/lib/features/mongodb/mongo_query_workspace.dart index d17a4692..b43431cd 100644 --- a/lib/features/mongodb/mongo_query_workspace.dart +++ b/lib/features/mongodb/mongo_query_workspace.dart @@ -2,6 +2,7 @@ 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'; @@ -220,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'), @@ -287,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( @@ -349,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, @@ -399,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), @@ -415,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, @@ -466,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( diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index eeb4dc88..2d3b9d16 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -1,14 +1,13 @@ 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/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); @@ -196,7 +195,7 @@ class _MongoStatsViewState extends material.State @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return PollStaleOverlay( status: polling.status, @@ -220,7 +219,7 @@ class _MongoStatsViewState extends material.State } material.Widget _buildBody( - material.BuildContext context, shadcn.ColorScheme cs) { + material.BuildContext context, QueryaUiColors cs) { if (_loading) { return const material.Center( child: QueryaSpinner( @@ -358,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, @@ -486,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; @@ -565,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 @@ -759,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..224c148a 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( diff --git a/lib/features/redis/editors/redis_hash_editor.dart b/lib/features/redis/editors/redis_hash_editor.dart index 098914f0..00c85a7a 100644 --- a/lib/features/redis/editors/redis_hash_editor.dart +++ b/lib/features/redis/editors/redis_hash_editor.dart @@ -1,9 +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. @@ -15,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, }); @@ -25,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 @@ -99,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, ); }, @@ -117,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: [ @@ -161,7 +160,7 @@ class RedisFieldRow extends StatelessWidget { fontSize: 12, fontFamily: QueryaTypography.mono, fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, - color: colorScheme.foreground, + color: colors.foreground, ), ), ), diff --git a/lib/features/redis/editors/redis_list_editor.dart b/lib/features/redis/editors/redis_list_editor.dart index 6cb0b9c4..02ec7673 100644 --- a/lib/features/redis/editors/redis_list_editor.dart +++ b/lib/features/redis/editors/redis_list_editor.dart @@ -1,9 +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 @@ -17,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, }); @@ -32,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 @@ -112,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, ), ), @@ -196,7 +195,7 @@ class RedisIndexedValueRow extends StatelessWidget { this.isBinary = false, this.onEdit, this.onDelete, - required this.colorScheme, + required this.colors, required this.shadcnCs, }); @@ -205,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: [ @@ -239,7 +238,7 @@ class RedisIndexedValueRow extends StatelessWidget { fontSize: 12, fontFamily: QueryaTypography.mono, fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, - color: colorScheme.foreground, + color: colors.foreground, ), ), ), diff --git a/lib/features/redis/editors/redis_set_editor.dart b/lib/features/redis/editors/redis_set_editor.dart index 76c57e06..680e409d 100644 --- a/lib/features/redis/editors/redis_set_editor.dart +++ b/lib/features/redis/editors/redis_set_editor.dart @@ -1,9 +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. @@ -15,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, }); @@ -25,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 @@ -85,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, ), ), @@ -101,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: [ @@ -129,7 +128,7 @@ class RedisMemberRow extends StatelessWidget { fontSize: 12, fontFamily: QueryaTypography.mono, fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, - color: colorScheme.foreground, + color: colors.foreground, ), ), ), diff --git a/lib/features/redis/editors/redis_string_editor.dart b/lib/features/redis/editors/redis_string_editor.dart index 7972c1ea..dcd5058b 100644 --- a/lib/features/redis/editors/redis_string_editor.dart +++ b/lib/features/redis/editors/redis_string_editor.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/database/redis_bulk.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -14,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) { @@ -82,7 +83,7 @@ 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( diff --git a/lib/features/redis/editors/redis_zset_editor.dart b/lib/features/redis/editors/redis_zset_editor.dart index 6410ead7..b1bfcbfa 100644 --- a/lib/features/redis/editors/redis_zset_editor.dart +++ b/lib/features/redis/editors/redis_zset_editor.dart @@ -1,9 +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. @@ -15,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, }); @@ -25,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 @@ -100,7 +99,7 @@ class _RedisZsetEditorState extends material.State { onDelete: widget.isReadOnly ? null : () => widget.onRemove(member), - colorScheme: widget.colorScheme, + colors: widget.colors, shadcnCs: widget.shadcnCs, ); }, @@ -118,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: [ @@ -164,7 +163,7 @@ class RedisScoredMemberRow extends StatelessWidget { fontSize: 12, fontFamily: QueryaTypography.mono, fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, - color: colorScheme.foreground, + color: colors.foreground, ), ), ), diff --git a/lib/features/redis/redis_cli_workspace.dart b/lib/features/redis/redis_cli_workspace.dart index f3f1fdd9..85e1477f 100644 --- a/lib/features/redis/redis_cli_workspace.dart +++ b/lib/features/redis/redis_cli_workspace.dart @@ -3,6 +3,7 @@ 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'; @@ -581,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, @@ -787,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), diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index 91033492..7875531d 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( 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 131a63d4..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'; @@ -259,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) { @@ -444,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), @@ -565,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 ddd9e340..a9864419 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.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/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; @@ -10,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, @@ -618,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) { @@ -705,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: @@ -834,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); @@ -871,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, @@ -902,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, ); diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 66275540..043ed6c8 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.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/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; @@ -6,8 +7,6 @@ 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'; @@ -296,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( @@ -337,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), @@ -354,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), @@ -372,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), @@ -393,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: () { @@ -417,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( @@ -447,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), @@ -593,7 +592,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), @@ -618,8 +617,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), @@ -692,7 +691,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, @@ -702,15 +701,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( @@ -800,7 +799,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, @@ -810,8 +809,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; @@ -863,7 +862,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); diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 5bb5a87f..c6716937 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; 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/motion/polling_controller.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; @@ -9,8 +10,6 @@ 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); @@ -215,7 +214,7 @@ class _RedisViewState extends material.State @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return PollStaleOverlay( status: polling.status, @@ -239,7 +238,7 @@ class _RedisViewState extends material.State } material.Widget _buildBody( - material.BuildContext context, shadcn.ColorScheme cs) { + material.BuildContext context, QueryaUiColors cs) { if (_loading) { return const material.Center( child: QueryaSpinner( @@ -405,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: [ @@ -529,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; @@ -608,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 @@ -864,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/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/redis/redis_type_editors_test.dart b/test/features/redis/redis_type_editors_test.dart index 9a115067..48fcb2df 100644 --- a/test/features/redis/redis_type_editors_test.dart +++ b/test/features/redis/redis_type_editors_test.dart @@ -2,6 +2,8 @@ import 'dart:typed_data'; 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/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'; @@ -19,7 +21,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 +31,7 @@ Future _pump( height: 600, child: material.Builder( builder: (context) => - editor(shadcn.Theme.of(context).colorScheme), + editor(context.colors), ), ), ), @@ -57,7 +59,7 @@ void main() { controller: controller, isReadOnly: false, onSave: () => saved++, - colorScheme: cs, + colors: cs, ), ); @@ -79,7 +81,7 @@ void main() { controller: controller, isReadOnly: false, onSave: () {}, - colorScheme: cs, + colors: cs, ), ); await tester.enterText(find.byType(material.TextField), 'changed'); @@ -98,7 +100,7 @@ void main() { controller: controller, isReadOnly: true, onSave: () {}, - colorScheme: cs, + colors: cs, ), ); @@ -121,7 +123,7 @@ void main() { controller: controller, isReadOnly: false, onSave: () {}, - colorScheme: cs, + colors: cs, ), ); @@ -155,7 +157,7 @@ void main() { isReadOnly: readOnly, onSet: onSet ?? (_, __) {}, onDelete: onDelete ?? (_) {}, - colorScheme: cs, + colors: cs, shadcnCs: cs, footer: footer, ), @@ -253,7 +255,7 @@ void main() { onPush: onPush ?? (_) {}, onSet: onSet ?? (_, __, {expectedCurrent}) async {}, onRemove: onRemove ?? (_, __) {}, - colorScheme: cs, + colors: cs, shadcnCs: cs, ), ); @@ -388,7 +390,7 @@ void main() { isReadOnly: readOnly, onAdd: onAdd ?? (_) {}, onRemove: onRemove ?? (_) {}, - colorScheme: cs, + colors: cs, shadcnCs: cs, ), ); @@ -456,7 +458,7 @@ void main() { isReadOnly: readOnly, onAdd: onAdd ?? (_, __) {}, onRemove: onRemove ?? (_) {}, - colorScheme: cs, + colors: cs, shadcnCs: cs, ), ); diff --git a/test/guards/colors_through_context_test.dart b/test/guards/colors_through_context_test.dart index bb85ff6d..c7371f7b 100644 --- a/test/guards/colors_through_context_test.dart +++ b/test/guards/colors_through_context_test.dart @@ -8,6 +8,8 @@ import 'package:flutter_test/flutter_test.dart'; void main() { const migrated = [ 'lib/features/connections', + 'lib/features/mongodb', + 'lib/features/redis', ]; final banned = RegExp(r'\bcolorScheme\b|\bTheme\.of\(|\bColorScheme\b'); diff --git a/test/guards/shadcn_imports_ratchet_test.dart b/test/guards/shadcn_imports_ratchet_test.dart index 21f75833..dd9d88a2 100644 --- a/test/guards/shadcn_imports_ratchet_test.dart +++ b/test/guards/shadcn_imports_ratchet_test.dart @@ -32,26 +32,11 @@ void main() { 'lib/features/erd/widgets/erd_table_menu.dart', 'lib/features/extensions/extension_stats_view.dart', 'lib/features/main_screen/querya_window_title_bar.dart', - 'lib/features/mongodb/mongo_collections_view.dart', - 'lib/features/mongodb/mongo_databases_view.dart', - 'lib/features/mongodb/mongo_document_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/mongodb/mongo_documents_view.dart', - 'lib/features/mongodb/mongo_explorer_view.dart', - 'lib/features/mongodb/mongo_stats_view.dart', 'lib/features/mysql/mysql_stats_view.dart', 'lib/features/postgresql/postgres_stats_view.dart', - 'lib/features/redis/editors/redis_hash_editor.dart', - 'lib/features/redis/editors/redis_list_editor.dart', - 'lib/features/redis/editors/redis_set_editor.dart', - 'lib/features/redis/editors/redis_zset_editor.dart', - 'lib/features/redis/redis_databases_view.dart', - 'lib/features/redis/redis_explorer_view.dart', - 'lib/features/redis/redis_key_editor.dart', - 'lib/features/redis/redis_keys_view.dart', - 'lib/features/redis/redis_view.dart', 'lib/features/settings/preferences_controls.dart', 'lib/features/settings/theme_picker_button.dart', 'lib/features/updater/update_changelog_view.dart', From 66fc974cfc36de22721ca06245e5b653efc7b685 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:03:49 +0300 Subject: [PATCH 148/157] refactor(settings, workspace): read colours through context.colors; theme_picker_button drops its shadcn import (#1731) (#1741) --- .../settings/preferences_about_section.dart | 15 +++---- .../preferences_appearance_section.dart | 10 ++--- .../settings/preferences_controls.dart | 5 ++- lib/features/settings/preferences_dialog.dart | 15 ++++--- .../preferences_shortcuts_section.dart | 26 +++++------ .../settings/theme_editor_dialog.dart | 9 ++-- .../settings/theme_editor_section.dart | 3 +- .../settings/theme_picker_button.dart | 43 ++++++++++--------- lib/features/settings/theme_preview_card.dart | 10 ++--- .../settings/theme_remote_install_dialog.dart | 2 +- .../workspace/data_grid_calc_bar.dart | 5 ++- .../workspace/data_grid_filter_bar.dart | 8 ++-- .../workspace/data_grid_groupings_view.dart | 2 +- .../workspace/data_grid_staging_toolbar.dart | 4 +- .../workspace/data_grid_value_panel.dart | 3 +- .../workspace/destructive_query_dialog.dart | 2 +- .../workspace/dml_preview_dialog.dart | 2 +- .../workspace/generic_table_view.dart | 4 +- .../workspace/grid/grid_cell_actions.dart | 2 +- .../workspace/grid/grid_cell_renderer.dart | 20 ++++----- .../workspace/grid/grid_header_renderer.dart | 20 ++++----- .../workspace/grid/grid_row_renderer.dart | 8 ++-- .../workspace/grid/grid_view_builder.dart | 6 +-- lib/features/workspace/grid_cell_editor.dart | 3 +- .../grid_cell_popover_inspector.dart | 2 +- lib/features/workspace/plan_tree_view.dart | 2 +- lib/features/workspace/result_grid_view.dart | 4 +- lib/features/workspace/results_tab.dart | 6 +-- .../workspace/sql_query_history_dialog.dart | 2 +- .../workspace/table_view_staging.dart | 2 +- test/guards/colors_through_context_test.dart | 7 ++- test/guards/shadcn_imports_ratchet_test.dart | 1 - 32 files changed, 130 insertions(+), 123 deletions(-) diff --git a/lib/features/settings/preferences_about_section.dart b/lib/features/settings/preferences_about_section.dart index db425c59..45f35fb2 100644 --- a/lib/features/settings/preferences_about_section.dart +++ b/lib/features/settings/preferences_about_section.dart @@ -33,7 +33,6 @@ class _PreferencesAboutSectionState @override Widget build(BuildContext context) { - final theme = Theme.of(context); return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, @@ -42,10 +41,10 @@ class _PreferencesAboutSectionState 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( @@ -54,10 +53,10 @@ class _PreferencesAboutSectionState 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( @@ -69,7 +68,7 @@ class _PreferencesAboutSectionState errorBuilder: (_, __, ___) => material.Icon( QueryaIcons.database, size: 24, - color: theme.colorScheme.primary, + color: context.colors.primary, ), ), ), @@ -120,10 +119,10 @@ class _PreferencesAboutSectionState 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 a5c7e115..7acc0987 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -202,7 +202,7 @@ class _PreferencesAppearanceSectionState c.selectedThemeLoadError!, style: material.TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.destructive, + color: context.colors.destructive, ), ), ), @@ -273,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( @@ -340,7 +340,7 @@ class _PreferencesAppearanceSectionState _importError!, style: material.TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.destructive, + color: context.colors.destructive, ), ), ], @@ -350,7 +350,7 @@ class _PreferencesAppearanceSectionState _folderOpenError!, style: material.TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.destructive, + color: context.colors.destructive, ), ), ], diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index c3b03514..1efefdb1 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -2,6 +2,7 @@ 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'; @@ -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; diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 78f52686..25622a80 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -2,6 +2,7 @@ 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'; @@ -237,7 +238,7 @@ class PreferencesDialogContentState @override material.Widget build(material.BuildContext context) { _visited.add(_selectedCategory); - final theme = Theme.of(context).colorScheme; + final theme = context.colors; final onPopover = theme.popoverForeground; return material.CallbackShortcuts( @@ -437,7 +438,7 @@ class PreferencesDialogContentState ); } - material.Widget _buildPaneHost(PreferencesCategory cat, ColorScheme theme) { + material.Widget _buildPaneHost(PreferencesCategory cat, QueryaUiColors theme) { final selected = cat == _selectedCategory; return material.Offstage( key: material.ValueKey('preferences_pane_${cat.name}'), @@ -460,7 +461,7 @@ class PreferencesDialogContentState material.Widget _buildCategoryRailTile( PreferencesCategory cat, - ColorScheme theme, + QueryaUiColors theme, ) { final isSelected = _selectedCategory == cat; final matches = _matchCountForCategory(cat); @@ -535,7 +536,7 @@ class PreferencesDialogContentState ); } - material.Widget _buildPane(PreferencesCategory cat, ColorScheme theme) { + material.Widget _buildPane(PreferencesCategory cat, QueryaUiColors theme) { switch (cat) { case PreferencesCategory.general: return _buildGeneralSection(theme); @@ -556,7 +557,7 @@ class PreferencesDialogContentState } } - material.Widget _buildGeneralSection(ColorScheme theme) { + material.Widget _buildGeneralSection(QueryaUiColors theme) { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ @@ -609,7 +610,7 @@ class PreferencesDialogContentState ); } - material.Widget _buildSqlSection(ColorScheme theme) { + material.Widget _buildSqlSection(QueryaUiColors theme) { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ @@ -701,7 +702,7 @@ class PreferencesDialogContentState ); } - material.Widget _buildDataGridSection(ColorScheme theme) { + material.Widget _buildDataGridSection(QueryaUiColors theme) { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ diff --git a/lib/features/settings/preferences_shortcuts_section.dart b/lib/features/settings/preferences_shortcuts_section.dart index ca53338b..aecd9b4b 100644 --- a/lib/features/settings/preferences_shortcuts_section.dart +++ b/lib/features/settings/preferences_shortcuts_section.dart @@ -250,13 +250,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 +270,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 +280,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, ), ), ], @@ -333,10 +333,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,7 +345,7 @@ 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), ], @@ -426,7 +426,7 @@ class _PreferencesShortcutsSectionState 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( @@ -443,7 +443,7 @@ class _PreferencesShortcutsSectionState fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 11, fontWeight: material.FontWeight.w600, - color: theme.colorScheme.foreground, + color: context.colors.foreground, ), ), ), diff --git a/lib/features/settings/theme_editor_dialog.dart b/lib/features/settings/theme_editor_dialog.dart index 66420771..167479d9 100644 --- a/lib/features/settings/theme_editor_dialog.dart +++ b/lib/features/settings/theme_editor_dialog.dart @@ -94,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( @@ -147,8 +147,7 @@ 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: WindowLayout.dialogConstraints( @@ -294,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 { 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 73729a06..15e0d0ec 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +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 +10,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 Gap, TextExtension; /// Debounce delay before requesting a hover preview load. const Duration themePreviewDebounce = Duration(milliseconds: 120); @@ -216,7 +217,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,7 +298,7 @@ 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); @@ -393,7 +394,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 +416,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 +493,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 +575,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 +595,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 +649,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 +673,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 +731,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 +768,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 2fd0ff8c..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: Theme.of(context).colorScheme.border.withValues(alpha: 0.6), + 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..b513b551 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( diff --git a/lib/features/workspace/data_grid_calc_bar.dart b/lib/features/workspace/data_grid_calc_bar.dart index f4767d57..329e0343 100644 --- a/lib/features/workspace/data_grid_calc_bar.dart +++ b/lib/features/workspace/data_grid_calc_bar.dart @@ -1,5 +1,6 @@ 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/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'; @@ -18,7 +19,7 @@ class DataGridCalcBar extends StatelessWidget { @override Widget build(BuildContext context) { final visible = stats.totalCount > 1 || stats.hasNumericStats; - final cs = Theme.of(context).colorScheme; + final cs = context.colors; return QueryaAnimatedExpand( expanded: visible, @@ -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', diff --git a/lib/features/workspace/data_grid_filter_bar.dart b/lib/features/workspace/data_grid_filter_bar.dart index 4b3d8056..fe299020 100644 --- a/lib/features/workspace/data_grid_filter_bar.dart +++ b/lib/features/workspace/data_grid_filter_bar.dart @@ -1,6 +1,8 @@ import 'dart:async' show Timer; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey; +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'; @@ -216,7 +218,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, @@ -280,7 +282,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( @@ -334,7 +336,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( diff --git a/lib/features/workspace/data_grid_groupings_view.dart b/lib/features/workspace/data_grid_groupings_view.dart index a83284f2..aac300d9 100644 --- a/lib/features/workspace/data_grid_groupings_view.dart +++ b/lib/features/workspace/data_grid_groupings_view.dart @@ -110,7 +110,7 @@ class _DataGridGroupingsViewState ); } - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final groups = _cachedGroups; return material.Column( 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 ef187eec..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'; @@ -275,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 5b6cc08c..e2dd1641 100644 --- a/lib/features/workspace/destructive_query_dialog.dart +++ b/lib/features/workspace/destructive_query_dialog.dart @@ -103,7 +103,7 @@ class _DestructiveQueryDialogState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); - final cs = theme.colorScheme; + final cs = context.colors; final isDark = theme.brightness == Brightness.dark; final isCritical = widget.result.maxRiskLevel == 'CRITICAL'; diff --git a/lib/features/workspace/dml_preview_dialog.dart b/lib/features/workspace/dml_preview_dialog.dart index 4c1e777f..8ab5b588 100644 --- a/lib/features/workspace/dml_preview_dialog.dart +++ b/lib/features/workspace/dml_preview_dialog.dart @@ -62,7 +62,7 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); - final cs = theme.colorScheme; + final cs = context.colors; final wb = context.workbench; final isDark = theme.brightness == Brightness.dark; final sql = widget.plan.toTransactionSql(); 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..10d53df1 100644 --- a/lib/features/workspace/grid/grid_cell_actions.dart +++ b/lib/features/workspace/grid/grid_cell_actions.dart @@ -123,7 +123,7 @@ 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( diff --git a/lib/features/workspace/grid/grid_cell_renderer.dart b/lib/features/workspace/grid/grid_cell_renderer.dart index 4d9c889c..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 && @@ -110,12 +110,12 @@ class _GridCell extends material.StatelessWidget { final isModified = cellStatus == StagedCellStatus.modified; final style = _CellLook.style( - colorScheme, + colors, isNull: isNull, isDeleted: isDeleted, ); final decoration = _CellLook.decoration( - colorScheme, + colors, background: isSelected ? _CellBackground.selected : (isDeleted @@ -160,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), ), ), ], @@ -176,12 +176,12 @@ enum _CellBackground { selected, deleted, modified, inserted, striped, none } /// 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 ColorScheme? _scheme; + 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(ColorScheme scheme) { + static void _forget(QueryaUiColors scheme) { if (identical(_scheme, scheme)) return; _scheme = scheme; _styles.fillRange(0, _styles.length, null); @@ -189,7 +189,7 @@ abstract final class _CellLook { } static material.TextStyle style( - ColorScheme scheme, { + QueryaUiColors scheme, { required bool isNull, required bool isDeleted, }) { @@ -213,7 +213,7 @@ abstract final class _CellLook { } static material.BoxDecoration decoration( - ColorScheme scheme, { + QueryaUiColors scheme, { required _CellBackground background, required bool right, required bool left, 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 f8986092..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) && @@ -122,7 +122,7 @@ class _DataRow extends material.StatefulWidget { columnName: columnName, text: c < row.length ? _maskCellText(piiKinds, c, row[c]) : '', width: columnWidths[c], - colorScheme: colorScheme, + colors: colors, striped: striped, rowStatus: rowStatus, cellStatus: stagingBuffer?.getCellStatus(modelRowIndex, c) ?? 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 cb8737a3..7b55a26f 100644 --- a/lib/features/workspace/grid_cell_editor.dart +++ b/lib/features/workspace/grid_cell_editor.dart @@ -1,5 +1,6 @@ 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/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; @@ -158,7 +159,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( diff --git a/lib/features/workspace/grid_cell_popover_inspector.dart b/lib/features/workspace/grid_cell_popover_inspector.dart index d00f7de4..bf2981ae 100644 --- a/lib/features/workspace/grid_cell_popover_inspector.dart +++ b/lib/features/workspace/grid_cell_popover_inspector.dart @@ -321,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; 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/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index fc3202c4..12cd6ae0 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -11,6 +11,7 @@ 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'; @@ -25,6 +26,7 @@ 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:gap/gap.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; export 'package:querya_desktop/features/workspace/grid/grid_column_sizer.dart'; @@ -330,7 +332,7 @@ class _VirtualResultGridState extends material.State { 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; diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 5fc6abfc..f1af2c61 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -512,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), ), ), diff --git a/lib/features/workspace/sql_query_history_dialog.dart b/lib/features/workspace/sql_query_history_dialog.dart index 4e7bb8aa..460a0a00 100644 --- a/lib/features/workspace/sql_query_history_dialog.dart +++ b/lib/features/workspace/sql_query_history_dialog.dart @@ -250,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, diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart index 37acfea7..4fcc7f9f 100644 --- a/lib/features/workspace/table_view_staging.dart +++ b/lib/features/workspace/table_view_staging.dart @@ -287,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), diff --git a/test/guards/colors_through_context_test.dart b/test/guards/colors_through_context_test.dart index c7371f7b..7f806430 100644 --- a/test/guards/colors_through_context_test.dart +++ b/test/guards/colors_through_context_test.dart @@ -3,16 +3,19 @@ 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`. A folder joins +/// (`QueryaUiColors`), not through a theme's `colorScheme`. `Theme.of` itself +/// stays allowed for what is not a colour (brightness, radius) until #1735. A folder joins /// [migrated] in the PR that moves it; at the end the list is all of `lib/`. void main() { const migrated = [ 'lib/features/connections', 'lib/features/mongodb', 'lib/features/redis', + 'lib/features/settings', + 'lib/features/workspace', ]; - final banned = RegExp(r'\bcolorScheme\b|\bTheme\.of\(|\bColorScheme\b'); + final banned = RegExp(r'\bcolorScheme\b|\bColorScheme\b'); test('migrated folders read colours through context.colors', () { final offenders = []; diff --git a/test/guards/shadcn_imports_ratchet_test.dart b/test/guards/shadcn_imports_ratchet_test.dart index dd9d88a2..a651eced 100644 --- a/test/guards/shadcn_imports_ratchet_test.dart +++ b/test/guards/shadcn_imports_ratchet_test.dart @@ -38,7 +38,6 @@ void main() { 'lib/features/mysql/mysql_stats_view.dart', 'lib/features/postgresql/postgres_stats_view.dart', 'lib/features/settings/preferences_controls.dart', - 'lib/features/settings/theme_picker_button.dart', 'lib/features/updater/update_changelog_view.dart', 'lib/features/workspace/data_grid_calc_bar.dart', 'lib/features/workspace/data_grid_filter_bar.dart', From 53d613b0dd1676115c4dc1ec57c74580dcadb392 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:01:45 +0300 Subject: [PATCH 149/157] refactor(postgresql, mysql, sqlite): read colours through context.colors; stats views drop their shadcn import (#1731) (#1742) --- lib/features/mysql/mysql_connection_form.dart | 2 +- lib/features/mysql/mysql_routine_view.dart | 9 ++++----- lib/features/mysql/mysql_sql_editor_dialog.dart | 2 +- lib/features/mysql/mysql_stats_view.dart | 14 ++++++-------- lib/features/mysql/mysql_table_view.dart | 2 +- lib/features/mysql/mysql_workspace_home.dart | 6 +++--- .../postgresql/postgres_browser_views.dart | 12 ++++++------ lib/features/postgresql/postgres_routine_view.dart | 2 +- .../postgresql/postgres_sequence_view.dart | 2 +- .../postgresql/postgres_sql_editor_dialog.dart | 2 +- lib/features/postgresql/postgres_stats_view.dart | 14 ++++++-------- .../postgres_table_privileges_dialog.dart | 5 +++-- .../postgresql/postgres_table_toolbar.dart | 2 +- .../postgresql/postgres_workspace_home.dart | 6 +++--- .../postgresql/postgresql_connection_form.dart | 2 +- lib/features/sqlite/sqlite_overview_tab.dart | 8 ++++---- lib/features/sqlite/sqlite_table_view.dart | 2 +- lib/features/sqlite/sqlite_workspace_home.dart | 6 +++--- test/guards/colors_through_context_test.dart | 3 +++ test/guards/shadcn_imports_ratchet_test.dart | 2 -- 20 files changed, 50 insertions(+), 53 deletions(-) diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 4238819f..4d16725d 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( 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_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index 68ccf297..4b74e043 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -8,8 +8,6 @@ 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); @@ -164,7 +162,7 @@ class _MysqlStatsViewState 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( @@ -260,7 +258,7 @@ class _MysqlStatsViewState extends material.State } material.Widget _header(material.BuildContext context) { - final cs = shadcn.Theme.of(context).colorScheme; + final cs = context.colors; return material.Row( children: [ material.Container( @@ -354,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; @@ -436,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 @@ -578,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', @@ -637,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/postgresql/postgres_browser_views.dart b/lib/features/postgresql/postgres_browser_views.dart index 45a0b238..a8eb0f40 100644 --- a/lib/features/postgresql/postgres_browser_views.dart +++ b/lib/features/postgresql/postgres_browser_views.dart @@ -89,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, @@ -264,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, @@ -431,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), @@ -578,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), @@ -722,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), @@ -832,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 334fffa7..bc06d4f8 100644 --- a/lib/features/postgresql/postgres_routine_view.dart +++ b/lib/features/postgresql/postgres_routine_view.dart @@ -129,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( diff --git a/lib/features/postgresql/postgres_sequence_view.dart b/lib/features/postgresql/postgres_sequence_view.dart index 5adfdef2..381f7d1a 100644 --- a/lib/features/postgresql/postgres_sequence_view.dart +++ b/lib/features/postgresql/postgres_sequence_view.dart @@ -112,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( 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_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 0381db13..a5e79406 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -9,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); @@ -202,7 +200,7 @@ class _PostgresStatsViewState 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( @@ -301,7 +299,7 @@ class _PostgresStatsViewState extends material.State } 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( @@ -369,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? ?? {}; @@ -451,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 @@ -535,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', @@ -609,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 10ddd3ce..ac55f2e7 100644 --- a/lib/features/postgresql/postgres_table_privileges_dialog.dart +++ b/lib/features/postgresql/postgres_table_privileges_dialog.dart @@ -3,6 +3,7 @@ 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'; @@ -78,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; @@ -167,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), 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_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..358c7c39 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -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( 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_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/test/guards/colors_through_context_test.dart b/test/guards/colors_through_context_test.dart index 7f806430..43f24175 100644 --- a/test/guards/colors_through_context_test.dart +++ b/test/guards/colors_through_context_test.dart @@ -10,8 +10,11 @@ void main() { const migrated = [ 'lib/features/connections', 'lib/features/mongodb', + 'lib/features/mysql', + 'lib/features/postgresql', 'lib/features/redis', 'lib/features/settings', + 'lib/features/sqlite', 'lib/features/workspace', ]; diff --git a/test/guards/shadcn_imports_ratchet_test.dart b/test/guards/shadcn_imports_ratchet_test.dart index a651eced..59c2677f 100644 --- a/test/guards/shadcn_imports_ratchet_test.dart +++ b/test/guards/shadcn_imports_ratchet_test.dart @@ -35,8 +35,6 @@ void main() { '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/mysql/mysql_stats_view.dart', - 'lib/features/postgresql/postgres_stats_view.dart', 'lib/features/settings/preferences_controls.dart', 'lib/features/updater/update_changelog_view.dart', 'lib/features/workspace/data_grid_calc_bar.dart', From ffffc5c352d638c4a871b13687ce686f2456b088 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:20:45 +0300 Subject: [PATCH 150/157] refactor(extensions): read colours through context.colors; extension_stats_view drops its shadcn import (#1731) (#1743) --- .../extension_driver_recovery_banner.dart | 9 +++++---- lib/features/extensions/extension_stats_view.dart | 15 +++++++-------- .../extensions/extension_table_toolbar.dart | 2 +- lib/features/extensions/extension_table_view.dart | 7 ++----- .../extensions/extension_workspace_home.dart | 6 +++--- .../pages/extension_manager_dialog.dart | 6 +++--- .../presentation/widgets/extension_card.dart | 9 +++++---- .../widgets/extension_sideload_dialog.dart | 2 +- .../unsandboxed_driver_consent_dialog.dart | 2 +- test/guards/colors_through_context_test.dart | 1 + test/guards/shadcn_imports_ratchet_test.dart | 1 - 11 files changed, 29 insertions(+), 31 deletions(-) diff --git a/lib/features/extensions/extension_driver_recovery_banner.dart b/lib/features/extensions/extension_driver_recovery_banner.dart index ace33501..140c0ad5 100644 --- a/lib/features/extensions/extension_driver_recovery_banner.dart +++ b/lib/features/extensions/extension_driver_recovery_banner.dart @@ -20,13 +20,14 @@ class ExtensionDriverRecoveryBanner extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); + final colors = context.colors; final isDark = theme.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 +42,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 dd9052e6..409619e8 100644 --- a/lib/features/extensions/extension_stats_view.dart +++ b/lib/features/extensions/extension_stats_view.dart @@ -6,9 +6,8 @@ import 'package:querya_desktop/core/extensions/models/extension_server_stats.dar 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); @@ -113,7 +112,7 @@ class _ExtensionStatsViewState 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( @@ -195,7 +194,7 @@ class _ExtensionStatsViewState extends material.State } 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( @@ -251,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 @@ -314,7 +313,7 @@ class _ExtensionStatsViewState extends material.State } material.Widget _buildChip( - shadcn.ColorScheme cs, ({String label, String value, material.IconData icon}) chip) { + QueryaUiColors cs, ({String label, String value, material.IconData icon}) chip) { return Card( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: material.Row( @@ -356,7 +355,7 @@ 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() @@ -457,7 +456,7 @@ 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( 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..c9ee04d4 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), ), ), ), 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 6488407d..376638bd 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -211,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( @@ -364,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( @@ -499,7 +499,7 @@ class _ExtensionManagerContentState 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..b1626a7b 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,7 +30,7 @@ class ExtensionCard extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; final radius = Theme.of(context).radiusMd; final isPreview = ExtensionSupport.isPreviewOnlyManifest(manifest); @@ -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..e2b30b7d 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( 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/test/guards/colors_through_context_test.dart b/test/guards/colors_through_context_test.dart index 43f24175..d7e933ba 100644 --- a/test/guards/colors_through_context_test.dart +++ b/test/guards/colors_through_context_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { const migrated = [ 'lib/features/connections', + 'lib/features/extensions', 'lib/features/mongodb', 'lib/features/mysql', 'lib/features/postgresql', diff --git a/test/guards/shadcn_imports_ratchet_test.dart b/test/guards/shadcn_imports_ratchet_test.dart index 59c2677f..0349ccd1 100644 --- a/test/guards/shadcn_imports_ratchet_test.dart +++ b/test/guards/shadcn_imports_ratchet_test.dart @@ -30,7 +30,6 @@ void main() { 'lib/features/erd/widgets/erd_group_menu.dart', 'lib/features/erd/widgets/erd_note_card.dart', 'lib/features/erd/widgets/erd_table_menu.dart', - 'lib/features/extensions/extension_stats_view.dart', 'lib/features/main_screen/querya_window_title_bar.dart', 'lib/features/mongodb/mongo_document_tree_view.dart', 'lib/features/mongodb/mongo_documents_json_view.dart', From 87b1b6889618ed9a6c543ffd654de345bab8e167 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:49:38 +0300 Subject: [PATCH 151/157] refactor(misc): read colours through context.colors across shared and misc features (#1731) (#1744) Migrate colour reading from Theme.of(context).colorScheme / shadcn.Theme.of(context).colorScheme to context.colors across: - lib/shared - lib/app - lib/features/command_palette - lib/features/erd - lib/features/help - lib/features/main_screen - lib/features/onboarding - lib/features/recovery - lib/features/results - lib/features/updater Drop package:shadcn_flutter from erd_export_data.dart, erd_edge_tip_label.dart, and update_changelog_view.dart, and update the shadcn ratchet accordingly. Add all 10 locations to the migrated list in colors_through_context_test.dart. Part of #1731. --- lib/app/app.dart | 3 +-- lib/core/theme/theme_controller.dart | 4 ++++ .../command_match_highlight.dart | 4 ++-- .../command_palette_dialog.dart | 4 ++-- .../quick_switcher_dialog.dart | 6 ++--- lib/features/erd/erd_export_data.dart | 4 +--- .../erd/widgets/erd_edge_tip_label.dart | 4 ++-- lib/features/erd/widgets/erd_note_card.dart | 2 +- lib/features/help/about_dialog.dart | 4 ++-- .../main_screen/querya_status_bar.dart | 4 ++-- .../main_screen/querya_window_title_bar.dart | 6 ++--- .../main_screen/workspace_empty_hero.dart | 8 +++---- lib/features/main_screen/workspace_panel.dart | 9 ++++---- .../onboarding/welcome_tour_dialog.dart | 13 +++++------ .../recovery/querya_startup_recovery_app.dart | 22 +++++++++---------- .../results/charts/quick_chart_view.dart | 2 +- .../updater/update_changelog_view.dart | 5 +++-- lib/features/updater/update_dialog.dart | 6 ++--- .../widgets/connection_tree_loading_row.dart | 5 +---- lib/shared/widgets/poll_stale_overlay.dart | 7 +++--- lib/shared/widgets/query_error_view.dart | 2 +- lib/shared/widgets/querya_badge.dart | 2 +- lib/shared/widgets/querya_dropdown.dart | 8 +++---- lib/shared/widgets/querya_spinner.dart | 6 ++--- lib/shared/widgets/tree_load_error.dart | 6 ++--- test/guards/colors_through_context_test.dart | 10 +++++++++ test/guards/shadcn_imports_ratchet_test.dart | 3 --- 27 files changed, 81 insertions(+), 78 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index bba6d3ed..861cc4f4 100644 --- a/lib/app/app.dart +++ b/lib/app/app.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, @@ -58,7 +57,7 @@ class QueryaApp extends StatelessWidget { theme: themeController.lightShadcnTheme, darkTheme: themeController.darkShadcnTheme, themeMode: themeController.themeMode, - materialTheme: themeController.materialThemeFor(colorScheme), + materialTheme: themeController.activeMaterialTheme, debugShowCheckedModeBanner: false, menuHandler: const PopoverOverlayHandler( defaultShowDuration: Duration(milliseconds: 60), diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 3c8ce91b..4a7712fb 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -172,6 +172,10 @@ class ThemeController extends ChangeNotifier with WidgetsBindingObserver { ThemeData get darkShadcnTheme => _cachedDarkShadcnTheme ??= _resolvedThemeForBrightness(Brightness.dark).toShadcnThemeData(); + /// Active Material theme aligned with [activeTheme]. + material.ThemeData get activeMaterialTheme => + materialThemeFor(activeTheme.colorScheme); + /// Cached Material theme for dialogs/dropdowns (avoids rebuild churn). material.ThemeData materialThemeFor(ColorScheme scheme) { if (_cachedMaterialTheme != null && _cachedMaterialThemeScheme == scheme) { 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..69a39412 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); @@ -238,7 +238,7 @@ class _CommandPaletteDialogState extends State { child: DecoratedBox( decoration: BoxDecoration( color: active - ? theme.colorScheme.primary + ? colors.primary .withValues(alpha: 0.12) : Colors.transparent, borderRadius: BorderRadius.circular(6), diff --git a/lib/features/command_palette/quick_switcher_dialog.dart b/lib/features/command_palette/quick_switcher_dialog.dart index 3ddacdf7..9cc45c25 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); @@ -209,7 +208,7 @@ class _QuickSwitcherDialogState extends State { ), ), 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,7 +274,7 @@ class _QuickSwitcherDialogState extends State { child: DecoratedBox( decoration: BoxDecoration( color: active - ? colors.primary.withValues(alpha: 0.12) + ? context.colors.primary.withValues(alpha: 0.12) : Colors.transparent, borderRadius: BorderRadius.circular(6), ), 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/widgets/erd_edge_tip_label.dart b/lib/features/erd/widgets/erd_edge_tip_label.dart index 30afe693..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 Gap, 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_note_card.dart b/lib/features/erd/widgets/erd_note_card.dart index 69b55bad..879b750e 100644 --- a/lib/features/erd/widgets/erd_note_card.dart +++ b/lib/features/erd/widgets/erd_note_card.dart @@ -59,7 +59,7 @@ 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, diff --git a/lib/features/help/about_dialog.dart b/lib/features/help/about_dialog.dart index 64157193..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: [ diff --git a/lib/features/main_screen/querya_status_bar.dart b/lib/features/main_screen/querya_status_bar.dart index 54092c0a..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, ), ), ), diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 5bd6103a..59016800 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -65,13 +65,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, ); } 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/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index 1d36b2d4..e727743b 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -6,6 +6,7 @@ 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'; @@ -80,7 +81,7 @@ class KbdBadge extends StatelessWidget { fontWeight: material.FontWeight.w600, fontFamily: QueryaTypography.mono, fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, - color: Theme.of(context).colorScheme.foreground, + color: context.colors.foreground, ), ), ), @@ -205,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; @@ -399,7 +400,7 @@ onPressed: _finish, Widget _buildSlideContent({ required int step, required String cmdCtrl, - required ColorScheme cs, + required QueryaUiColors cs, required QueryaWorkbenchTheme wb, }) { switch (step) { @@ -601,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, ), @@ -750,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/recovery/querya_startup_recovery_app.dart b/lib/features/recovery/querya_startup_recovery_app.dart index 63e5561d..2176403a 100644 --- a/lib/features/recovery/querya_startup_recovery_app.dart +++ b/lib/features/recovery/querya_startup_recovery_app.dart @@ -4,6 +4,7 @@ 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_typography.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/ui/querya_icons.dart'; @@ -124,8 +125,7 @@ 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( title: 'Querya - Startup Recovery', @@ -134,14 +134,14 @@ class _QueryaStartupRecoveryAppState themeMode: ThemeMode.dark, debugShowCheckedModeBanner: false, 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( @@ -153,7 +153,7 @@ class _QueryaStartupRecoveryAppState material.Icon( QueryaIcons.treeError, size: 32, - color: colorScheme.destructive, + color: colors.destructive, ), const material.SizedBox(width: 12), const material.Expanded( @@ -174,10 +174,10 @@ 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( @@ -197,8 +197,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), ), @@ -206,8 +206,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/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/updater/update_changelog_view.dart b/lib/features/updater/update_changelog_view.dart index bfc565bb..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 Gap, 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 2b8c75dd..5a8405c5 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -430,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( @@ -440,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, @@ -499,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), ), ), ), 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 index a4d24e52..625d74dd 100644 --- a/lib/shared/widgets/poll_stale_overlay.dart +++ b/lib/shared/widgets/poll_stale_overlay.dart @@ -1,6 +1,7 @@ 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 @@ -30,13 +31,13 @@ class PollStaleOverlay extends StatelessWidget { valueListenable: status, builder: (context, s, _) { if (!s.isStale) return const SizedBox.shrink(); - final scheme = Theme.of(context).colorScheme; + final colors = context.colors; return Tooltip( message: '${s.lastError}', child: DecoratedBox( key: const ValueKey('poll_stale_hint'), decoration: BoxDecoration( - color: scheme.errorContainer, + color: colors.destructive.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6), ), child: Padding( @@ -46,7 +47,7 @@ class PollStaleOverlay extends StatelessWidget { message(s), style: TextStyle( fontSize: 12, - color: scheme.onErrorContainer, + color: colors.destructive, ), ), ), diff --git a/lib/shared/widgets/query_error_view.dart b/lib/shared/widgets/query_error_view.dart index deda373f..cb073540 100644 --- a/lib/shared/widgets/query_error_view.dart +++ b/lib/shared/widgets/query_error_view.dart @@ -55,7 +55,7 @@ class _QueryErrorViewState extends material.State { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; + final cs = context.colors; final error = widget.error; final inline = widget.inline; final align = inline diff --git a/lib/shared/widgets/querya_badge.dart b/lib/shared/widgets/querya_badge.dart index 554efe3c..a40b7e90 100644 --- a/lib/shared/widgets/querya_badge.dart +++ b/lib/shared/widgets/querya_badge.dart @@ -106,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; diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index 476ff60d..557e321d 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -211,7 +211,7 @@ class _QueryaDropdownState extends material.State> { item: item, selected: item.value == widget.value, enabled: widget.enabled && item.enabled, - colorScheme: cs, + colors: cs, compact: widget.compact, size: size, onPick: () { @@ -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 QueryaUiColors colorScheme; + final QueryaUiColors colors; final bool compact; final QueryaControlSize size; final material.VoidCallback onPick; @@ -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_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/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/test/guards/colors_through_context_test.dart b/test/guards/colors_through_context_test.dart index d7e933ba..72cfcd50 100644 --- a/test/guards/colors_through_context_test.dart +++ b/test/guards/colors_through_context_test.dart @@ -8,15 +8,25 @@ import 'package:flutter_test/flutter_test.dart'; /// [migrated] in the PR that moves it; at the end the list is all of `lib/`. void main() { const migrated = [ + 'lib/app', + 'lib/features/command_palette', 'lib/features/connections', + 'lib/features/erd', 'lib/features/extensions', + 'lib/features/help', + 'lib/features/main_screen', 'lib/features/mongodb', 'lib/features/mysql', + 'lib/features/onboarding', 'lib/features/postgresql', + 'lib/features/recovery', 'lib/features/redis', + 'lib/features/results', 'lib/features/settings', 'lib/features/sqlite', + 'lib/features/updater', 'lib/features/workspace', + 'lib/shared', ]; final banned = RegExp(r'\bcolorScheme\b|\bColorScheme\b'); diff --git a/test/guards/shadcn_imports_ratchet_test.dart b/test/guards/shadcn_imports_ratchet_test.dart index 0349ccd1..c6c4171e 100644 --- a/test/guards/shadcn_imports_ratchet_test.dart +++ b/test/guards/shadcn_imports_ratchet_test.dart @@ -23,9 +23,7 @@ void main() { 'lib/core/theme/querya_theme_scope.dart', 'lib/core/theme/theme_controller.dart', 'lib/core/theme/theme_editor_draft.dart', - 'lib/features/erd/erd_export_data.dart', 'lib/features/erd/erd_view.dart', - 'lib/features/erd/widgets/erd_edge_tip_label.dart', 'lib/features/erd/widgets/erd_group_frame.dart', 'lib/features/erd/widgets/erd_group_menu.dart', 'lib/features/erd/widgets/erd_note_card.dart', @@ -35,7 +33,6 @@ void main() { 'lib/features/mongodb/mongo_documents_json_view.dart', 'lib/features/mongodb/mongo_documents_table_view.dart', 'lib/features/settings/preferences_controls.dart', - 'lib/features/updater/update_changelog_view.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', From f7ad1793cb5db4e93c2dbf6ccd41c141033fa8be Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:13:45 +0300 Subject: [PATCH 152/157] refactor(core): read colours through context.colors across core (#1731) (#1745) Read colours through context.colors / QueryaUiColors across lib/core and expand colors_through_context_test.dart to cover all of lib/ except lib/core/theme (which bridges to the shadcn shell and leaves with #1465 step 5). Drop shadcn_flutter imports in querya_split_handle.dart and theme_editor_draft.dart, updating the ratchet guard accordingly. Closes #1731. --- lib/core/layout/querya_split_handle.dart | 4 +- lib/core/motion/querya_hover_surface.dart | 6 ++- lib/core/theme/querya_semantic_palette.dart | 12 ++--- lib/core/theme/theme_editor_draft.dart | 20 ++++---- lib/core/ui/querya_tree_indent_guide.dart | 3 +- test/guards/colors_through_context_test.dart | 52 ++++++-------------- test/guards/shadcn_imports_ratchet_test.dart | 2 - 7 files changed, 39 insertions(+), 60 deletions(-) diff --git a/lib/core/layout/querya_split_handle.dart b/lib/core/layout/querya_split_handle.dart index 0c888f8f..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 Gap, 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/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/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/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_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/test/guards/colors_through_context_test.dart b/test/guards/colors_through_context_test.dart index 72cfcd50..fe74ff62 100644 --- a/test/guards/colors_through_context_test.dart +++ b/test/guards/colors_through_context_test.dart @@ -4,46 +4,26 @@ 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. A folder joins -/// [migrated] in the PR that moves it; at the end the list is all of `lib/`. +/// 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() { - const migrated = [ - 'lib/app', - 'lib/features/command_palette', - 'lib/features/connections', - 'lib/features/erd', - 'lib/features/extensions', - 'lib/features/help', - 'lib/features/main_screen', - 'lib/features/mongodb', - 'lib/features/mysql', - 'lib/features/onboarding', - 'lib/features/postgresql', - 'lib/features/recovery', - 'lib/features/redis', - 'lib/features/results', - 'lib/features/settings', - 'lib/features/sqlite', - 'lib/features/updater', - 'lib/features/workspace', - 'lib/shared', - ]; - final banned = RegExp(r'\bcolorScheme\b|\bColorScheme\b'); - test('migrated folders read colours through context.colors', () { + test('lib/ reads colours through context.colors (outside core/theme)', () { final offenders = []; - for (final dir in migrated) { - for (final e in Directory(dir).listSync(recursive: true)) { - if (e is! File || !e.path.endsWith('.dart')) 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('${e.path.replaceAll(r'\', '/')}:${i + 1}: ' - '${line.trim()}'); - } + 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()}'); } } } diff --git a/test/guards/shadcn_imports_ratchet_test.dart b/test/guards/shadcn_imports_ratchet_test.dart index c6c4171e..20de29ff 100644 --- a/test/guards/shadcn_imports_ratchet_test.dart +++ b/test/guards/shadcn_imports_ratchet_test.dart @@ -13,7 +13,6 @@ void main() { 'lib/app/app.dart', 'lib/app/querya_core_commands.dart', 'lib/core/editor/querya_code_editor.dart', - 'lib/core/layout/querya_split_handle.dart', 'lib/core/storage/app_settings.dart', 'lib/core/theme/app_theme.dart', 'lib/core/theme/parser/querya_theme_color_scheme.dart', @@ -22,7 +21,6 @@ void main() { 'lib/core/theme/querya_theme.dart', 'lib/core/theme/querya_theme_scope.dart', 'lib/core/theme/theme_controller.dart', - 'lib/core/theme/theme_editor_draft.dart', 'lib/features/erd/erd_view.dart', 'lib/features/erd/widgets/erd_group_frame.dart', 'lib/features/erd/widgets/erd_group_menu.dart', From 71431fb32883fa4fe5579be1ea12b15f17592f09 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:28:14 +0300 Subject: [PATCH 153/157] ui: context menus and title-bar menus on kit surface (#1732) (#1746) Replace shadcn ContextMenu, MenuButton, MenuItem, MenuDivider, and Menubar with UI kit primitives based on Flutter's MenuAnchor and MenuBar surface (QueryaContextMenu, QueryaMenuItem, QueryaMenuDivider, QueryaMenuBar). - Migrate title-bar dropdown menus in querya_window_title_bar.dart. - Migrate context menus across ERD (erd_view, erd_table_menu, erd_group_menu, erd_note_card, erd_group_frame). - Migrate context menus across Workspace grid cells (grid_cell_actions). - Migrate context menus across Connections panel tree rows, folders, and connection tiles (postgres, mysql, sqlite, mongo, redis, extension). - Hide ContextMenu, MenuButton, MenuDivider, MenuItem, and Menubar from the shadcn re-export in shared/widgets/widgets.dart. - Update shadcn imports ratchet guard to drop migrated files. Closes #1732. --- .../connection_databases_folder.dart | 8 +- .../connections/connections_panel.dart | 18 +- .../connections_panel_extension.dart | 10 +- .../connections/connections_panel_mongo.dart | 24 +- .../connections/connections_panel_mysql.dart | 12 +- ...connections_panel_postgres_connection.dart | 10 +- .../connections/connections_panel_redis.dart | 10 +- .../connections_panel_sidebar.dart | 14 +- .../connections/connections_panel_sqlite.dart | 12 +- .../querya_connection_tree_row.dart | 22 +- lib/features/erd/erd_view.dart | 4 +- lib/features/erd/widgets/erd_group_frame.dart | 13 +- lib/features/erd/widgets/erd_group_menu.dart | 13 +- lib/features/erd/widgets/erd_note_card.dart | 19 +- lib/features/erd/widgets/erd_table_menu.dart | 25 +- .../main_screen/querya_window_title_bar.dart | 84 ++-- .../workspace/grid/grid_cell_actions.dart | 40 +- lib/features/workspace/result_grid_view.dart | 1 + lib/shared/widgets/querya_context_menu.dart | 362 ++++++++++++++++++ lib/shared/widgets/querya_menu_bar.dart | 92 +++++ lib/shared/widgets/widgets.dart | 15 +- .../e2e_connection_form_test.dart | 3 +- test/features/erd/erd_groups_view_test.dart | 2 + .../menubar_dropdown_speed_test.dart | 36 +- .../context_menu_mode_switch_test.dart | 43 +-- .../workspace/result_grid_cell_ux_test.dart | 4 +- test/guards/shadcn_imports_ratchet_test.dart | 6 - .../widgets/querya_context_menu_test.dart | 243 ++++++++++++ 28 files changed, 924 insertions(+), 221 deletions(-) create mode 100644 lib/shared/widgets/querya_context_menu.dart create mode 100644 lib/shared/widgets/querya_menu_bar.dart create mode 100644 test/shared/widgets/querya_context_menu_test.dart diff --git a/lib/features/connections/connection_databases_folder.dart b/lib/features/connections/connection_databases_folder.dart index 4b8d7287..e69b821e 100644 --- a/lib/features/connections/connection_databases_folder.dart +++ b/lib/features/connections/connection_databases_folder.dart @@ -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 c120603e..29a8298b 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -763,20 +763,20 @@ 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) { + 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: 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: colors.mutedForeground), onPressed: (_) => _moveConnection(conn, null), @@ -785,7 +785,7 @@ class ConnectionsPanelState extends State { ]; if (targets.isEmpty) return const []; return [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.drive_file_move_outlined, size: 18, color: colors.mutedForeground), subMenu: targets, @@ -1385,14 +1385,14 @@ 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: colors.mutedForeground), subMenu: [ - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.settings_ethernet_rounded, size: 18, @@ -1405,7 +1405,7 @@ class ConnectionsPanelState extends State { }, child: const Text('New Connection'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.folder_rounded, size: 18, diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 864bf134..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. @@ -180,9 +180,9 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { ); } - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.refresh_rounded, size: 18, color: colors.mutedForeground), onPressed: (_) { @@ -191,14 +191,14 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { }, child: const Text('Refresh tree'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.edit_outlined, 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: colors.destructive), onPressed: (_) => widget.onRemove(), diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index 8b7c11b2..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; @@ -153,15 +153,15 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { size: QueryaIconSizes.sidebarConnectionIcon, color: colors.primary); - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.add_rounded, size: 18, color: colors.mutedForeground), onPressed: (_) => _createDatabase(), child: const Text('Create database'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.refresh_rounded, size: 18, color: colors.mutedForeground), onPressed: (_) { @@ -170,14 +170,14 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { }, child: const Text('Refresh databases'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.edit_outlined, 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: colors.destructive), onPressed: (_) => widget.onRemove(), @@ -648,9 +648,9 @@ class _MongoCollectionNode extends StatelessWidget { 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, @@ -659,7 +659,7 @@ class _MongoCollectionNode extends StatelessWidget { onPressed: (_) => onTap(), child: const Text('Open collection'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.copy_rounded, size: 18, @@ -670,8 +670,8 @@ class _MongoCollectionNode extends StatelessWidget { }, child: const Text('Copy name'), ), - const MenuDivider(), - MenuButton( + const QueryaMenuDivider(), + QueryaMenuItem( leading: material.Icon( material.Icons.refresh_rounded, size: 18, @@ -680,7 +680,7 @@ class _MongoCollectionNode extends StatelessWidget { onPressed: (_) => onRefreshCollections(), child: const Text('Refresh'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.delete_outline_rounded, size: 18, diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 2ac4c507..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, @@ -128,9 +128,9 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { size: QueryaIconSizes.sidebarConnectionIcon, color: colors.primary); - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.refresh_rounded, size: 18, color: colors.mutedForeground), onPressed: (_) { @@ -140,21 +140,21 @@ 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: 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: 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: colors.destructive), onPressed: (_) => widget.onRemove(), diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 8eec17e6..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, @@ -131,9 +131,9 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { size: QueryaIconSizes.sidebarConnectionIcon, color: colors.primary); - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.refresh_rounded, size: 18, color: colors.mutedForeground), onPressed: (_) { @@ -142,14 +142,14 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { }, child: const Text('Refresh databases'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.edit_outlined, 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: colors.destructive), onPressed: (_) => widget.onRemove(), diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index 46c44bd6..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; @@ -141,9 +141,9 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { size: QueryaIconSizes.sidebarConnectionIcon, color: colors.primary); - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.refresh_rounded, size: 18, color: colors.mutedForeground), onPressed: (_) { @@ -152,14 +152,14 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { }, child: const Text('Refresh databases'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.edit_outlined, 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: colors.destructive), onPressed: (_) => widget.onRemove(), diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 130134ad..9464f0e5 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -88,7 +88,7 @@ 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 @@ -113,16 +113,16 @@ class _ConnectionTile extends StatelessWidget { size: QueryaIconSizes.sidebarConnectionIcon, color: colors.primary, ); - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.edit_outlined, 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: colors.destructive), onPressed: (_) => onRemove(), @@ -274,15 +274,15 @@ class _FolderTileState extends State<_FolderTile> { } Widget _buildMenu(BuildContext context, QueryaUiColors colors) { - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.settings_ethernet_rounded, 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: colors.mutedForeground), onPressed: (_) => widget.onRemove(), diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 6dfa4201..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, @@ -151,9 +151,9 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { size: QueryaIconSizes.sidebarConnectionIcon, color: colors.primary); - return ContextMenu( + return QueryaContextMenu( items: [ - MenuButton( + QueryaMenuItem( leading: material.Icon(material.Icons.refresh_rounded, size: 18, color: colors.mutedForeground), onPressed: (_) { @@ -166,21 +166,21 @@ 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: 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: 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: colors.destructive), onPressed: (_) => widget.onRemove(), diff --git a/lib/features/connections/querya_connection_tree_row.dart b/lib/features/connections/querya_connection_tree_row.dart index 50f1e9dd..cdb743cf 100644 --- a/lib/features/connections/querya_connection_tree_row.dart +++ b/lib/features/connections/querya_connection_tree_row.dart @@ -195,11 +195,11 @@ 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 @@ -212,9 +212,9 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { 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, @@ -239,7 +239,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { }, child: const Text('Select TOP 100'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.code_rounded, size: 18, @@ -253,10 +253,10 @@ 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, @@ -265,7 +265,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { onPressed: (_) => onContextRefresh!(), child: const Text('Refresh'), ), - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.copy_rounded, size: 18, @@ -279,7 +279,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { if (onOpenSqlWorkspace != null && openSqlName == null && connection != null) - MenuButton( + QueryaMenuItem( leading: material.Icon( material.Icons.terminal_rounded, size: 18, @@ -295,8 +295,8 @@ 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, diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index aa7ad360..6c79285a 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -43,7 +43,7 @@ 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/shared/widgets/app_toast.dart'; import 'package:querya_desktop/shared/widgets/querya_spinner.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, 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' @@ -1144,7 +1144,7 @@ class _ErdViewState extends material.State builder: (context, focus, _) => material.Opacity( // Unrelated cards fade while a table is picked. opacity: focus.faded ? 0.35 : 1, - child: ContextMenu( + child: QueryaContextMenu( items: ErdTableMenu.items( table: t.name, onOpenData: widget.onOpenTable, diff --git a/lib/features/erd/widgets/erd_group_frame.dart b/lib/features/erd/widgets/erd_group_frame.dart index dee5b89a..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 Gap, 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 index 004eb802..fd441987 100644 --- a/lib/features/erd/widgets/erd_group_menu.dart +++ b/lib/features/erd/widgets/erd_group_menu.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart' as material; +import 'package:flutter/widgets.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; +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({ +List erdGroupMenu({ required String table, required Set marked, required List groups, @@ -16,7 +17,7 @@ List erdGroupMenu({ }) { final tables = {...marked, table}; return [ - MenuButton( + QueryaMenuItem( key: material.ValueKey('erd_menu_group_$table'), onPressed: (_) => onNewGroup(tables), // The way to put more tables in is not obvious: say it here. @@ -29,11 +30,11 @@ List erdGroupMenu({ : 'Group ${tables.length} tables…'), ), if (groups.any((g) => g.id != current?.id)) - MenuButton( + QueryaMenuItem( subMenu: [ for (final g in groups) if (g.id != current?.id) - MenuButton( + QueryaMenuItem( onPressed: (_) => onAddToGroup(g.id, tables), child: Text(g.name), ), @@ -41,7 +42,7 @@ List erdGroupMenu({ child: const Text('Add to group'), ), if (current != null) - MenuButton( + 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 879b750e..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 Gap, 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). @@ -65,21 +66,21 @@ class ErdNoteCard extends material.StatelessWidget { 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_table_menu.dart b/lib/features/erd/widgets/erd_table_menu.dart index c5a81341..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 Gap, 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/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 59016800..ed1bb1f2 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -21,7 +21,9 @@ 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:gap/gap.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; +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 { @@ -144,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' @@ -165,7 +165,7 @@ class QueryaWindowTitleBar extends StatelessWidget { ); }, child: const Text('New Query')), - MenuButton( + QueryaMenuItem( trailing: Text( Platform.isMacOS ? 'Cmd+O' : 'Ctrl+O', ).xSmall().muted(), @@ -178,7 +178,7 @@ class QueryaWindowTitleBar extends StatelessWidget { ); }, child: const Text('Open...')), - MenuButton( + QueryaMenuItem( trailing: Text( Platform.isMacOS ? 'Cmd+S' : 'Ctrl+S', ).xSmall().muted(), @@ -192,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, @@ -205,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, @@ -225,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, @@ -243,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, @@ -251,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, @@ -264,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, @@ -277,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, @@ -290,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, @@ -308,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), @@ -320,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), @@ -328,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, @@ -344,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, @@ -352,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, @@ -360,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, @@ -384,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, @@ -397,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/workspace/grid/grid_cell_actions.dart b/lib/features/workspace/grid/grid_cell_actions.dart index 10d53df1..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, @@ -126,7 +126,7 @@ extension _GridCellActions on _VirtualResultGridState { 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/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 12cd6ae0..7c5fcd5c 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -27,6 +27,7 @@ import 'package:querya_desktop/features/workspace/grid/grid_selection_manager.da import 'package:querya_desktop/features/workspace/grid/grid_sorting.dart'; 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'; 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_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/widgets.dart b/lib/shared/widgets/widgets.dart index 716f3d60..564f595a 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -25,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'; @@ -43,4 +45,15 @@ export 'package:querya_desktop/core/motion/querya_fade_slide.dart'; export 'package:querya_desktop/core/theme/querya_theme_scope.dart' show QueryaThemeContext; export 'package:gap/gap.dart'; -export 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, MaxGap, SliverGap, TextExtension; +export 'package:shadcn_flutter/shadcn_flutter.dart' + hide + Gap, + MaxGap, + SliverGap, + TextExtension, + ContextMenu, + MenuButton, + MenuDivider, + MenuItem, + Menubar; + 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..4d7e4920 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_form_test.dart @@ -226,7 +226,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/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/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/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/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/guards/shadcn_imports_ratchet_test.dart b/test/guards/shadcn_imports_ratchet_test.dart index 20de29ff..15c2fe35 100644 --- a/test/guards/shadcn_imports_ratchet_test.dart +++ b/test/guards/shadcn_imports_ratchet_test.dart @@ -21,12 +21,6 @@ void main() { 'lib/core/theme/querya_theme.dart', 'lib/core/theme/querya_theme_scope.dart', 'lib/core/theme/theme_controller.dart', - 'lib/features/erd/erd_view.dart', - 'lib/features/erd/widgets/erd_group_frame.dart', - 'lib/features/erd/widgets/erd_group_menu.dart', - 'lib/features/erd/widgets/erd_note_card.dart', - 'lib/features/erd/widgets/erd_table_menu.dart', - 'lib/features/main_screen/querya_window_title_bar.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', 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); + }); + }); +} From aa10d88d69f5679b6465fa54c54fcf21722a7687 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:50:46 +0300 Subject: [PATCH 154/157] ui: QueryaTextField replaces shadcn TextField in 31 files; the barrel hides it, a guard rejects it (#1733) (#1747) --- docs/ui-kit.md | 15 + lib/core/editor/querya_code_editor.dart | 37 +-- .../command_palette_dialog.dart | 4 +- .../quick_switcher_dialog.dart | 4 +- .../extension_connection_form.dart | 4 +- .../forms/connection_auth_section.dart | 10 +- .../forms/connection_database_section.dart | 4 +- .../forms/connection_host_port_section.dart | 8 +- .../new_connection_url_dialog.dart | 5 +- .../connections/new_folder_dialog.dart | 4 +- .../connections/sqlite_connection_form.dart | 8 +- .../connections/ssh_tunnel_section.dart | 42 ++- lib/features/erd/erd_group_dialog.dart | 8 +- lib/features/erd/erd_note_dialog.dart | 4 +- lib/features/erd/erd_view_name_dialog.dart | 4 +- .../pages/extension_manager_dialog.dart | 14 +- .../main_screen/safe_mode_unlock_dialog.dart | 4 +- .../mongodb/mongo_collections_view.dart | 4 +- .../mongodb/mongo_database_dialog.dart | 4 +- .../mongodb/mongo_databases_view.dart | 4 +- .../mongodb/mongo_document_tree_view.dart | 5 +- .../mongodb/mongodb_connection_form.dart | 17 +- lib/features/mysql/mysql_connection_form.dart | 10 +- .../postgresql_connection_form.dart | 14 +- .../redis/editors/redis_hash_editor.dart | 8 +- .../redis/editors/redis_list_editor.dart | 8 +- .../redis/editors/redis_set_editor.dart | 4 +- .../redis/editors/redis_zset_editor.dart | 8 +- lib/features/redis/redis_cli_workspace.dart | 4 +- lib/features/redis/redis_connection_form.dart | 27 +- lib/features/redis/redis_key_editor.dart | 8 +- lib/shared/widgets/querya_text_field.dart | 259 ++++++++++++++++++ .../widgets/ssl_certificate_fields.dart | 4 +- lib/shared/widgets/widgets.dart | 5 +- .../e2e_connection_form_test.dart | 6 +- .../e2e_redis_cli_terminal_test.dart | 8 +- .../e2e_redis_keys_tree_and_types_test.dart | 4 +- .../helpers/e2e_command_palette_helper.dart | 3 +- .../command_palette_dialog_test.dart | 3 +- .../palette_keyboard_scroll_test.dart | 3 +- .../quick_switcher_dialog_test.dart | 3 +- .../new_connection_url_dialog_test.dart | 4 +- .../extensions/extension_manager_test.dart | 8 +- .../postgresql_connection_form_test.dart | 38 +-- .../redis/redis_cli_workspace_test.dart | 6 +- .../features/redis/redis_key_editor_test.dart | 12 +- .../redis/redis_type_editors_test.dart | 8 +- test/guards/shadcn_imports_ratchet_test.dart | 12 + .../widgets/querya_text_field_test.dart | 188 +++++++++++++ 49 files changed, 656 insertions(+), 222 deletions(-) create mode 100644 lib/shared/widgets/querya_text_field.dart create mode 100644 test/shared/widgets/querya_text_field_test.dart 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/lib/core/editor/querya_code_editor.dart b/lib/core/editor/querya_code_editor.dart index 2c116935..ec6632ca 100644 --- a/lib/core/editor/querya_code_editor.dart +++ b/lib/core/editor/querya_code_editor.dart @@ -1,4 +1,5 @@ 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'; @@ -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, }; } @@ -271,16 +270,22 @@ class _QueryaCodeEditorState extends State { ); } - 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, + // 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, + readOnly: widget.readOnly, + maxLines: widget.expands ? null : widget.maxLines, + expands: widget.expands, + style: style, + textAlignVertical: _verticalAlignment, + placeholder: placeholder, + ), ); } } diff --git a/lib/features/command_palette/command_palette_dialog.dart b/lib/features/command_palette/command_palette_dialog.dart index 69a39412..952a9576 100644 --- a/lib/features/command_palette/command_palette_dialog.dart +++ b/lib/features/command_palette/command_palette_dialog.dart @@ -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(); diff --git a/lib/features/command_palette/quick_switcher_dialog.dart b/lib/features/command_palette/quick_switcher_dialog.dart index 9cc45c25..6f3660d8 100644 --- a/lib/features/command_palette/quick_switcher_dialog.dart +++ b/lib/features/command_palette/quick_switcher_dialog.dart @@ -197,11 +197,11 @@ 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]); }, diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart index 51800724..d33a2b53 100644 --- a/lib/features/connections/extension_connection_form.dart +++ b/lib/features/connections/extension_connection_form.dart @@ -235,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( 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_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/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 915dae2a..b9bdb46a 100644 --- a/lib/features/connections/ssh_tunnel_section.dart +++ b/lib/features/connections/ssh_tunnel_section.dart @@ -246,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', ), ], ), @@ -261,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', ), ], ), @@ -279,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', ), ], ), @@ -341,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, @@ -357,8 +355,6 @@ class _SshTunnelSectionState extends material.State { setState(() => _obscurePassword = !_obscurePassword); }, ), - ), - ], ), ], ), @@ -374,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', ), ], ), @@ -395,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, @@ -411,8 +405,6 @@ class _SshTunnelSectionState extends material.State { setState(() => _obscurePassphrase = !_obscurePassphrase); }, ), - ), - ], ), ], ), @@ -482,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( 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_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/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 376638bd..7680665e 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -398,18 +398,10 @@ 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( 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/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index a988c7f5..cf5df15f 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -316,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), 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 9896d5e2..82bc35d9 100644 --- a/lib/features/mongodb/mongo_databases_view.dart +++ b/lib/features/mongodb/mongo_databases_view.dart @@ -304,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), diff --git a/lib/features/mongodb/mongo_document_tree_view.dart b/lib/features/mongodb/mongo_document_tree_view.dart index 25b96daf..b82319e1 100644 --- a/lib/features/mongodb/mongo_document_tree_view.dart +++ b/lib/features/mongodb/mongo_document_tree_view.dart @@ -3,6 +3,7 @@ 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'; @@ -200,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(); diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index 224c148a..bd912a5c 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -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/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 4d16725d..cb737a69 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -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/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 358c7c39..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(), ), ), @@ -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/redis/editors/redis_hash_editor.dart b/lib/features/redis/editors/redis_hash_editor.dart index 00c85a7a..5633e552 100644 --- a/lib/features/redis/editors/redis_hash_editor.dart +++ b/lib/features/redis/editors/redis_hash_editor.dart @@ -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), diff --git a/lib/features/redis/editors/redis_list_editor.dart b/lib/features/redis/editors/redis_list_editor.dart index 02ec7673..d45aae2b 100644 --- a/lib/features/redis/editors/redis_list_editor.dart +++ b/lib/features/redis/editors/redis_list_editor.dart @@ -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), @@ -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', ), ], ), diff --git a/lib/features/redis/editors/redis_set_editor.dart b/lib/features/redis/editors/redis_set_editor.dart index 680e409d..dec0e973 100644 --- a/lib/features/redis/editors/redis_set_editor.dart +++ b/lib/features/redis/editors/redis_set_editor.dart @@ -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), diff --git a/lib/features/redis/editors/redis_zset_editor.dart b/lib/features/redis/editors/redis_zset_editor.dart index b1bfcbfa..bef7a5a3 100644 --- a/lib/features/redis/editors/redis_zset_editor.dart +++ b/lib/features/redis/editors/redis_zset_editor.dart @@ -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), diff --git a/lib/features/redis/redis_cli_workspace.dart b/lib/features/redis/redis_cli_workspace.dart index 85e1477f..9bb553a0 100644 --- a/lib/features/redis/redis_cli_workspace.dart +++ b/lib/features/redis/redis_cli_workspace.dart @@ -763,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(), ), ), diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index 7875531d..d781f2b8 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -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_key_editor.dart b/lib/features/redis/redis_key_editor.dart index a9864419..f83ac56b 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -1027,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', ), ], ), @@ -1093,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/shared/widgets/querya_text_field.dart b/lib/shared/widgets/querya_text_field.dart new file mode 100644 index 00000000..43a6d50a --- /dev/null +++ b/lib/shared/widgets/querya_text_field.dart @@ -0,0 +1,259 @@ +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, + }); + + 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; + + @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, + 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: 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.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/widgets.dart b/lib/shared/widgets/widgets.dart index 564f595a..7b9b944f 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -37,6 +37,7 @@ export 'querya_modal_dialog.dart'; export 'querya_search_field.dart'; export 'querya_spinner.dart'; export 'querya_text.dart'; +export 'querya_text_field.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'; @@ -55,5 +56,7 @@ export 'package:shadcn_flutter/shadcn_flutter.dart' MenuButton, MenuDivider, MenuItem, - Menubar; + Menubar, + InputFeature, + TextField; 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 4d7e4920..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), 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 37bd1c98..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,9 +1,9 @@ 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_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'; @@ -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 537d9386..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 @@ -142,7 +142,7 @@ void main() { await tester.enterText( find.descendant( of: find.byType(QueryaModalDialog), - matching: find.byType(TextField), + matching: find.byType(QueryaTextField), ), '120', ); @@ -175,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/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..4b1e9a47 100644 --- a/test/features/command_palette/command_palette_dialog_test.dart +++ b/test/features/command_palette/command_palette_dialog_test.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:flutter/services.dart'; 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'; @@ -46,7 +47,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..8a70ced5 100644 --- a/test/features/command_palette/palette_keyboard_scroll_test.dart +++ b/test/features/command_palette/palette_keyboard_scroll_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/services.dart'; 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'; @@ -100,7 +101,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..71a7e25f 100644 --- a/test/features/command_palette/quick_switcher_dialog_test.dart +++ b/test/features/command_palette/quick_switcher_dialog_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/services.dart'; 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'; @@ -65,7 +66,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)); 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/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart index 8225472f..0308400e 100644 --- a/test/features/extensions/extension_manager_test.dart +++ b/test/features/extensions/extension_manager_test.dart @@ -218,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); @@ -235,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); @@ -257,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']); @@ -273,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); diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart index 477a2429..8453c2db 100644 --- a/test/features/postgresql/postgresql_connection_form_test.dart +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -1,5 +1,6 @@ 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:querya_desktop/features/postgresql/postgresql_connection_form.dart'; @@ -116,10 +117,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', ); @@ -156,10 +154,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 +179,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')); @@ -223,9 +215,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 +223,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 +232,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 +241,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 +249,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', ); diff --git a/test/features/redis/redis_cli_workspace_test.dart b/test/features/redis/redis_cli_workspace_test.dart index deab2b64..ab2f3f76 100644 --- a/test/features/redis/redis_cli_workspace_test.dart +++ b/test/features/redis/redis_cli_workspace_test.dart @@ -2,10 +2,10 @@ 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/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'; @@ -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 f0dcfc14..137e6440 100644 --- a/test/features/redis/redis_key_editor_test.dart +++ b/test/features/redis/redis_key_editor_test.dart @@ -5,8 +5,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_bulk.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'; @@ -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); 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); 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), '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_type_editors_test.dart b/test/features/redis/redis_type_editors_test.dart index 48fcb2df..9e6248cb 100644 --- a/test/features/redis/redis_type_editors_test.dart +++ b/test/features/redis/redis_type_editors_test.dart @@ -2,6 +2,7 @@ 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'; @@ -10,8 +11,6 @@ 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'; @@ -41,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() { @@ -308,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(); diff --git a/test/guards/shadcn_imports_ratchet_test.dart b/test/guards/shadcn_imports_ratchet_test.dart index 15c2fe35..f9b3fdc0 100644 --- a/test/guards/shadcn_imports_ratchet_test.dart +++ b/test/guards/shadcn_imports_ratchet_test.dart @@ -72,4 +72,16 @@ void main() { 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).'); + }); } 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); + }); + }); +} From 7ea9e1754ff6654c29b58ddac3949f89d6fff1f9 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:01:17 +0300 Subject: [PATCH 155/157] ui: the hand-built Material text fields move to QueryaTextField; a guard keeps material.TextField inside the kit (#1733) (#1748) --- lib/core/editor/querya_code_editor.dart | 27 ++++++----- .../connections/connections_panel.dart | 32 +++---------- .../extensions/extension_table_view.dart | 8 +--- .../widgets/extension_sideload_dialog.dart | 9 ++-- .../mongodb/mongo_aggregation_workspace.dart | 29 ++++++------ .../mongodb/mongo_create_index_dialog.dart | 45 +++++-------------- .../redis/editors/redis_string_editor.dart | 23 +++++----- lib/features/redis/redis_keys_view.dart | 19 ++------ lib/features/settings/preferences_dialog.dart | 15 ++----- .../settings/theme_picker_button.dart | 42 ++--------------- .../settings/theme_remote_install_dialog.dart | 18 ++++---- .../workspace/data_grid_filter_bar.dart | 21 +++------ lib/features/workspace/grid_cell_editor.dart | 9 ++-- lib/shared/widgets/querya_text_field.dart | 26 ++++++++--- .../features/redis/redis_key_editor_test.dart | 6 +-- test/guards/shadcn_imports_ratchet_test.dart | 14 ++++++ 16 files changed, 123 insertions(+), 220 deletions(-) diff --git a/lib/core/editor/querya_code_editor.dart b/lib/core/editor/querya_code_editor.dart index ec6632ca..fe71993e 100644 --- a/lib/core/editor/querya_code_editor.dart +++ b/lib/core/editor/querya_code_editor.dart @@ -252,20 +252,19 @@ class _QueryaCodeEditorState extends State { final controller = _effectiveController; if (widget.variant == QueryaCodeEditorVariant.material) { - return material.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, - decoration: material.InputDecoration( - border: material.InputBorder.none, - hintText: widget.hintText, - contentPadding: - widget.contentPadding ?? const material.EdgeInsets.all(12), + 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, ), ); } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 29a8298b..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' @@ -255,23 +252,15 @@ class TreeObjectFilterBar extends material.StatelessWidget { ), const Gap(5), material.Expanded( - child: material.TextField( + child: QueryaTextField( + framed: false, controller: controller, onChanged: onChanged, style: material.TextStyle( fontSize: 11, color: colors.foreground, ), - decoration: material.InputDecoration( - hintText: hintText, - hintStyle: material.TextStyle( - fontSize: 11, - color: colors.mutedForeground.withValues(alpha: 0.6), - ), - border: material.InputBorder.none, - isDense: true, - contentPadding: material.EdgeInsets.zero, - ), + placeholder: hintText, ), ), if (isFiltered) ...[ @@ -1228,7 +1217,8 @@ class ConnectionsPanelState extends State { ), const Gap(6), material.Expanded( - child: material.TextField( + child: QueryaTextField( + framed: false, controller: _searchController, focusNode: _searchFocusNode, onChanged: (val) => _searchDebouncer.run(() { @@ -1238,17 +1228,7 @@ class ConnectionsPanelState extends State { fontSize: 12, color: colors.foreground, ), - decoration: material.InputDecoration( - hintText: 'Filter connections... (Ctrl+F)', - hintStyle: material.TextStyle( - fontSize: 12, - color: colors.mutedForeground - .withValues(alpha: 0.6), - ), - border: material.InputBorder.none, - isDense: true, - contentPadding: material.EdgeInsets.zero, - ), + placeholder: 'Filter connections… (Ctrl+F)', ), ), material.ValueListenableBuilder( diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index c9ee04d4..cc6f1ee7 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -496,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/presentation/widgets/extension_sideload_dialog.dart b/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart index e2b30b7d..b2112cb3 100644 --- a/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart +++ b/lib/features/extensions/presentation/widgets/extension_sideload_dialog.dart @@ -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/mongodb/mongo_aggregation_workspace.dart b/lib/features/mongodb/mongo_aggregation_workspace.dart index ae9f5ed9..ffbdbd4f 100644 --- a/lib/features/mongodb/mongo_aggregation_workspace.dart +++ b/lib/features/mongodb/mongo_aggregation_workspace.dart @@ -649,22 +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: QueryaTypography.mono, - fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, - 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) ...[ diff --git a/lib/features/mongodb/mongo_create_index_dialog.dart b/lib/features/mongodb/mongo_create_index_dialog.dart index 582f22a7..41f95419 100644 --- a/lib/features/mongodb/mongo_create_index_dialog.dart +++ b/lib/features/mongodb/mongo_create_index_dialog.dart @@ -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/redis/editors/redis_string_editor.dart b/lib/features/redis/editors/redis_string_editor.dart index dcd5058b..71454529 100644 --- a/lib/features/redis/editors/redis_string_editor.dart +++ b/lib/features/redis/editors/redis_string_editor.dart @@ -86,18 +86,17 @@ class RedisStringEditor extends material.StatelessWidget { 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: QueryaTypography.mono, - fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, - ), - 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/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 043ed6c8..9d528ad9 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -537,29 +537,16 @@ 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: QueryaTypography.mono, fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, ), - 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), - ), - ), onChanged: (_) => setState(() {}), ), ), diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 25622a80..2e742183 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -308,7 +308,8 @@ class PreferencesDialogContentState ), const Gap(8), material.Expanded( - child: material.TextField( + child: QueryaTextField( + framed: false, controller: _searchController, focusNode: _searchFocusNode, onChanged: (val) { @@ -333,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) diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index 15e0d0ec..1d63fe9f 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -1,6 +1,7 @@ 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'; @@ -300,7 +301,6 @@ class _ThemePickerButtonState extends material.State { material.Widget _buildMenuPanel( material.BuildContext context, QueryaUiColors cs) { final filteredThemes = _filteredThemes; - final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); return material.Column( children: [ @@ -311,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) diff --git a/lib/features/settings/theme_remote_install_dialog.dart b/lib/features/settings/theme_remote_install_dialog.dart index b513b551..3cf927d8 100644 --- a/lib/features/settings/theme_remote_install_dialog.dart +++ b/lib/features/settings/theme_remote_install_dialog.dart @@ -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/workspace/data_grid_filter_bar.dart b/lib/features/workspace/data_grid_filter_bar.dart index fe299020..efbf135a 100644 --- a/lib/features/workspace/data_grid_filter_bar.dart +++ b/lib/features/workspace/data_grid_filter_bar.dart @@ -1,6 +1,7 @@ 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'; @@ -385,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/grid_cell_editor.dart b/lib/features/workspace/grid_cell_editor.dart index 7b55a26f..70f5163f 100644 --- a/lib/features/workspace/grid_cell_editor.dart +++ b/lib/features/workspace/grid_cell_editor.dart @@ -1,5 +1,6 @@ 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'; @@ -183,7 +184,8 @@ class _GridCellEditorState extends material.State { focusNode: _focusNode, onKeyEvent: _handleKeyEvent, autofocus: true, - child: material.TextField( + child: QueryaTextField( + framed: false, controller: _controller, autofocus: true, maxLines: 1, @@ -192,11 +194,6 @@ class _GridCellEditorState extends material.State { fontFamily: 'monospace', fontFamilyFallback: QueryaTypography.cjkFontFamilyFallback, ), - decoration: const material.InputDecoration( - border: material.InputBorder.none, - isDense: true, - contentPadding: material.EdgeInsets.zero, - ), onSubmitted: (value) { _commit(value, moveNextRow: true); }, diff --git a/lib/shared/widgets/querya_text_field.dart b/lib/shared/widgets/querya_text_field.dart index 43a6d50a..bf0a3515 100644 --- a/lib/shared/widgets/querya_text_field.dart +++ b/lib/shared/widgets/querya_text_field.dart @@ -40,6 +40,9 @@ class QueryaTextField extends material.StatefulWidget { this.style, this.textAlignVertical, this.framed = true, + this.textAlign = material.TextAlign.start, + this.autocorrect = true, + this.contentPadding, }); final material.TextEditingController? controller; @@ -79,6 +82,11 @@ class QueryaTextField extends material.StatefulWidget { /// 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(); @@ -161,6 +169,8 @@ class _QueryaTextFieldState extends material.State { 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, @@ -178,19 +188,21 @@ class _QueryaTextFieldState extends material.State { cursorColor: cs.primary, decoration: material.InputDecoration( hintText: widget.placeholder, - hintStyle: material.TextStyle(fontSize: fontSize, color: muted), + 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.framed - ? material.EdgeInsets.symmetric( - vertical: _multiline ? 8 : 0, - horizontal: widget.leading == null ? 10 : 0, - ) - : material.EdgeInsets.zero, + contentPadding: widget.contentPadding ?? + (widget.framed + ? material.EdgeInsets.symmetric( + vertical: _multiline ? 8 : 0, + horizontal: widget.leading == null ? 10 : 0, + ) + : material.EdgeInsets.zero), ), ); diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart index 137e6440..dbad505c 100644 --- a/test/features/redis/redis_key_editor_test.dart +++ b/test/features/redis/redis_key_editor_test.dart @@ -297,7 +297,7 @@ void main() { expect(find.text('Rename Key'), findsOneWidget); // Enter new key name - final textField = find.byType(QueryaTextField); + final textField = find.byType(QueryaTextField).last; // the dialog, over the editor await tester.enterText(textField, 'session:renamed'); await tester.pumpAndSettle(); @@ -332,7 +332,7 @@ void main() { await tester.tap(find.byTooltip('Rename key')); await tester.pumpAndSettle(); - final textField = find.byType(QueryaTextField); + final textField = find.byType(QueryaTextField).last; // the dialog, over the editor await tester.enterText(textField, 'session:exists'); await tester.pumpAndSettle(); @@ -353,7 +353,7 @@ void main() { await tester.tap(find.byTooltip('Rename key')); await tester.pumpAndSettle(); - await tester.enterText(find.byType(QueryaTextField), 'session:exists'); + await tester.enterText(find.byType(QueryaTextField).last, 'session:exists'); await tester.pumpAndSettle(); await tester.tap(find.text('Rename')); diff --git a/test/guards/shadcn_imports_ratchet_test.dart b/test/guards/shadcn_imports_ratchet_test.dart index f9b3fdc0..2c5700d5 100644 --- a/test/guards/shadcn_imports_ratchet_test.dart +++ b/test/guards/shadcn_imports_ratchet_test.dart @@ -84,4 +84,18 @@ void main() { 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).'); + }); } From ceb610d45246b58e726063d2ff9bffad5bfa72c6 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:13:32 +0300 Subject: [PATCH 156/157] ui: QueryaColorPicker, QueryaCard and a Material slider replace the last shadcn controls of the theme editor, Preferences and three cards (#1734) (#1749) --- .../extensions/extension_stats_view.dart | 6 +- .../mongodb/mongo_aggregation_workspace.dart | 2 +- lib/features/mongodb/mongo_indexes_view.dart | 2 +- .../settings/preferences_controls.dart | 56 ++-- .../settings/theme_color_picker_dialog.dart | 8 +- lib/shared/widgets/querya_card.dart | 33 ++ lib/shared/widgets/querya_color_picker.dart | 298 ++++++++++++++++++ lib/shared/widgets/widgets.dart | 2 + .../settings/interface_scale_slider_test.dart | 3 +- .../widgets/querya_color_picker_test.dart | 31 ++ 10 files changed, 403 insertions(+), 38 deletions(-) create mode 100644 lib/shared/widgets/querya_card.dart create mode 100644 lib/shared/widgets/querya_color_picker.dart create mode 100644 test/shared/widgets/querya_color_picker_test.dart diff --git a/lib/features/extensions/extension_stats_view.dart b/lib/features/extensions/extension_stats_view.dart index 409619e8..96d525ee 100644 --- a/lib/features/extensions/extension_stats_view.dart +++ b/lib/features/extensions/extension_stats_view.dart @@ -314,7 +314,7 @@ class _ExtensionStatsViewState extends material.State material.Widget _buildChip( QueryaUiColors cs, ({String label, String value, material.IconData icon}) chip) { - return Card( + return QueryaCard( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: material.Row( children: [ @@ -361,7 +361,7 @@ class _ExtensionStatsViewState extends material.State 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, @@ -459,7 +459,7 @@ class _ExtensionStatsViewState extends material.State 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/mongodb/mongo_aggregation_workspace.dart b/lib/features/mongodb/mongo_aggregation_workspace.dart index ffbdbd4f..3d2b4271 100644 --- a/lib/features/mongodb/mongo_aggregation_workspace.dart +++ b/lib/features/mongodb/mongo_aggregation_workspace.dart @@ -569,7 +569,7 @@ class _StageCardState extends material.State<_StageCard> { final stage = widget.stage; final syntaxError = validateStage(stage); - return Card( + return QueryaCard( child: material.Padding( padding: const material.EdgeInsets.all(12), child: material.Column( diff --git a/lib/features/mongodb/mongo_indexes_view.dart b/lib/features/mongodb/mongo_indexes_view.dart index b55f60fe..37187808 100644 --- a/lib/features/mongodb/mongo_indexes_view.dart +++ b/lib/features/mongodb/mongo_indexes_view.dart @@ -339,7 +339,7 @@ class _IndexCard extends material.StatelessWidget { material.Widget build(material.BuildContext context) { final cs = context.colors; - return Card( + return QueryaCard( child: material.Padding( padding: const material.EdgeInsets.all(14), child: material.Row( diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index 1efefdb1..924e74e3 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -298,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/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/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/widgets.dart b/lib/shared/widgets/widgets.dart index 7b9b944f..6d4a609c 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -38,6 +38,8 @@ 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'; 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/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)); + }); +} From 8fb2b19d676d782b4c1514d39dde2e2e7a9fbd94 Mon Sep 17 00:00:00 2001 From: 3rdYuno <114882226+ZhuchkaTriplesix@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:55:47 +0300 Subject: [PATCH 157/157] ui: MaterialApp replaces ShadcnApp, QueryaTheme carries QueryaUiColors (#1735) (#1750) * ui: MaterialApp replaces ShadcnApp; QueryaTheme carries QueryaUiColors, AppTheme and QueryaColorScheme go (#1735) The app root is a MaterialApp over the Querya Material theme. QueryaTheme.colors is the QueryaUiColors the kit already reads, so the shadcn ColorScheme, the toShadcnThemeData bridge, AppTheme and the shadcn theme caches are gone. ThemeMode is Flutter's. The last shadcn Theme.of reads (brightness, radii), shadcn Switch and Colors move to context.colors / Material. Tests use a MaterialApp shell; four shell goldens are regenerated (a Material dialog now takes the Querya popover surface, as it does in the app). * bench: grid_perf_bench on MaterialApp; const radius in extension_card (#1735) --- benchmark/grid_perf_bench.dart | 8 +- lib/app/app.dart | 22 ++--- lib/app/querya_core_commands.dart | 2 +- .../editor/querya_highlight_controller.dart | 3 +- lib/core/storage/app_settings.dart | 3 +- lib/core/theme/app_theme.dart | 10 -- .../parser/querya_theme_color_scheme.dart | 19 ++-- .../parser/querya_theme_from_manifest.dart | 4 +- .../parser/querya_theme_from_vscode.dart | 32 +++---- lib/core/theme/querya_color_scheme.dart | 76 ---------------- lib/core/theme/querya_colors.dart | 2 +- lib/core/theme/querya_material_theme.dart | 9 +- lib/core/theme/querya_theme.dart | 54 ++++------- lib/core/theme/querya_theme_scope.dart | 38 +++----- lib/core/theme/querya_ui_colors.dart | 86 +++++++++++++++--- lib/core/theme/theme_controller.dart | 37 +++----- .../command_palette_dialog.dart | 2 +- .../quick_switcher_dialog.dart | 2 +- .../extension_driver_recovery_banner.dart | 5 +- .../presentation/widgets/extension_card.dart | 2 +- .../mongodb/mongo_aggregation_workspace.dart | 2 +- .../mongodb/mongo_collections_view.dart | 2 +- .../mongodb/mongo_databases_view.dart | 2 +- .../onboarding/welcome_tour_dialog.dart | 2 +- .../recovery/querya_startup_recovery_app.dart | 13 ++- .../preferences_appearance_section.dart | 10 +- .../preferences_shortcuts_section.dart | 10 +- .../workspace/destructive_query_dialog.dart | 5 +- .../workspace/dml_preview_dialog.dart | 5 +- lib/features/workspace/result_grid_view.dart | 3 +- lib/features/workspace/sql_editor_chrome.dart | 2 +- test/core/storage/app_settings_test.dart | 12 +-- .../theme/animated_querya_theme_test.dart | 24 ++--- .../querya_theme_color_scheme_test.dart | 18 ++-- .../querya_theme_from_manifest_test.dart | 6 +- .../theme/parser/vscode_color_map_test.dart | 2 +- test/core/theme/querya_app_theme_test.dart | 32 +++---- test/core/theme/querya_light_theme_test.dart | 19 ++-- .../theme/querya_material_theme_test.dart | 4 +- .../theme/querya_semantic_palette_test.dart | 12 +-- test/core/theme/querya_theme_scope_test.dart | 19 ++-- test/core/theme/querya_theme_test.dart | 34 ++----- test/core/theme/querya_ui_colors_test.dart | 67 +------------- test/core/theme/theme_controller_test.dart | 41 +++++---- test/core/theme/theme_editor_draft_test.dart | 4 +- test/core/theme/theme_editor_loader_test.dart | 6 +- .../theme/theme_registry_service_test.dart | 2 +- .../theme_remote_install_service_test.dart | 2 +- test/core/unsaved_work_guard_test.dart | 12 +-- .../e2e_mongo_aggregation_runner_test.dart | 2 +- .../e2e_command_palette_navigation_test.dart | 9 +- .../command_palette_dialog_test.dart | 3 +- .../palette_keyboard_scroll_test.dart | 3 +- .../quick_switcher_dialog_test.dart | 5 +- .../connection_form_sections_test.dart | 11 ++- .../connection_tree_quick_search_test.dart | 22 +++-- .../connections_panel_layout_test.dart | 53 ++++++----- .../sqlite_connection_form_test.dart | 19 ++-- .../extensions/extension_manager_test.dart | 6 +- .../goldens/app_dialog_overlay.png | Bin 17570 -> 16605 bytes .../main_screen/goldens/querya_tab_strip.png | Bin 591 -> 685 bytes .../goldens/title_bar_read_only_badge.png | Bin 818 -> 831 bytes .../goldens/workspace_empty_hero.png | Bin 6666 -> 8119 bytes .../main_screen/shell_chrome_golden_test.dart | 3 +- .../workspace_homes_and_preferences_test.dart | 19 ++-- .../mongo_aggregation_workspace_test.dart | 3 +- .../mongodb/mongo_database_dialog_test.dart | 20 ++-- .../mysql/mysql_sql_tx_guard_test.dart | 8 +- .../onboarding/welcome_tour_dialog_test.dart | 4 +- .../postgres_sql_tx_guard_test.dart | 12 +-- .../postgres_table_toolbar_layout_test.dart | 33 ++++--- .../postgresql_connection_form_test.dart | 59 ++++++------ .../results/charts/quick_chart_view_test.dart | 5 +- .../preferences_master_detail_test.dart | 15 +-- .../settings/theme_import_flow_test.dart | 4 +- .../settings/theme_preview_card_test.dart | 2 +- .../sqlite/sqlite_sql_tx_guard_test.dart | 8 +- .../data_grid_context_menu_test.dart | 9 +- .../destructive_query_dialog_test.dart | 2 +- .../workspace/dml_preview_dialog_test.dart | 4 +- .../workspace/grid_cell_editor_test.dart | 2 +- .../result_grid_lazy_context_menu_test.dart | 11 +-- .../result_grid_sorting_model_test.dart | 8 +- .../workspace/table_view_sorting_test.dart | 8 +- .../workspace/table_view_staging_test.dart | 36 ++++---- test/guards/shadcn_imports_ratchet_test.dart | 10 -- .../sql_workspace_chrome_guards_test.dart | 2 +- .../widgets/querya_modal_dialog_test.dart | 2 +- .../querya_ui_kit_theme_switch_test.dart | 2 +- test/support/querya_theme_test_shell.dart | 27 ++++-- 90 files changed, 545 insertions(+), 694 deletions(-) delete mode 100644 lib/core/theme/app_theme.dart delete mode 100644 lib/core/theme/querya_color_scheme.dart 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/lib/app/app.dart b/lib/app/app.dart index 861cc4f4..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 Gap, TextExtension; +import 'package:flutter/material.dart'; import 'app_lifecycle_cleanup.dart'; import '../features/main_screen/main_screen.dart'; @@ -52,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.activeMaterialTheme, 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/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/storage/app_settings.dart b/lib/core/storage/app_settings.dart index f035cc97..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 Gap, 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/theme/app_theme.dart b/lib/core/theme/app_theme.dart deleted file mode 100644 index 8da7ae8c..00000000 --- a/lib/core/theme/app_theme.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, 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/querya_theme_color_scheme.dart b/lib/core/theme/parser/querya_theme_color_scheme.dart index 9d156974..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 Gap, 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/querya_color_scheme.dart b/lib/core/theme/querya_color_scheme.dart deleted file mode 100644 index 4f705efe..00000000 --- a/lib/core/theme/querya_color_scheme.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; - -import 'querya_theme.dart'; -import 'querya_ui_colors.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; -} - -final Expando _uiColorsCache = Expando(); - -/// The bridge between shadcn's scheme and [QueryaUiColors] while the app still -/// runs on shadcn's shell (#1730, epic #1465); it goes with #1735. -extension QueryaUiColorsFromShadcn on ColorScheme { - /// The same colours as [QueryaUiColors] (cached per scheme instance). - QueryaUiColors get uiColors => _uiColorsCache[this] ??= QueryaUiColors( - brightness: brightness, - background: background, - foreground: foreground, - card: card, - popover: popover, - popoverForeground: popoverForeground, - primary: primary, - primaryForeground: primaryForeground, - secondary: secondary, - secondaryForeground: secondaryForeground, - muted: muted, - mutedForeground: mutedForeground, - accent: accent, - destructive: destructive, - destructiveForeground: destructiveForeground, - border: border, - ring: ring, - chart1: chart1, - chart2: chart2, - chart3: chart3, - chart4: chart4, - chart5: chart5, - ); -} - -extension QueryaUiColorsToShadcn on QueryaUiColors { - /// A shadcn scheme for the widgets still on shadcn. The fields shadcn has - /// and the app does not read follow the ones next to them, as every Querya - /// theme sets them today: card and accent text are [foreground], the input - /// outline is [border]. - ColorScheme toShadcn() => ColorScheme( - brightness: brightness, - background: background, - foreground: foreground, - card: card, - cardForeground: foreground, - popover: popover, - popoverForeground: popoverForeground, - primary: primary, - primaryForeground: primaryForeground, - secondary: secondary, - secondaryForeground: secondaryForeground, - muted: muted, - mutedForeground: mutedForeground, - accent: accent, - accentForeground: foreground, - destructive: destructive, - destructiveForeground: destructiveForeground, - border: border, - input: border, - ring: ring, - chart1: chart1, - chart2: chart2, - chart3: chart3, - chart4: chart4, - chart5: chart5, - ); -} 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_theme.dart b/lib/core/theme/querya_theme.dart index 6f99d88b..522938e8 100644 --- a/lib/core/theme/querya_theme.dart +++ b/lib/core/theme/querya_theme.dart @@ -1,30 +1,26 @@ -import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; +import 'dart:ui'; import 'color_contrast.dart'; import 'parser/vscode_theme_manifest.dart'; -import 'querya_color_scheme.dart'; import 'querya_colors.dart'; import 'querya_ui_colors.dart'; import 'querya_editor_theme.dart'; import 'querya_workbench_theme.dart'; -/// 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; - - /// [colorScheme] as the app's own colour type (#1730). - QueryaUiColors get colors => colorScheme.uiColors; + final QueryaUiColors colors; /// VS Code `tokenColors` for syntax highlighting (imported themes). final List tokenColors; @@ -44,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), @@ -84,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), @@ -112,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, @@ -156,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, ); } @@ -176,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) || @@ -204,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 87b258cd..28cdc3e1 100644 --- a/lib/core/theme/querya_theme_scope.dart +++ b/lib/core/theme/querya_theme_scope.dart @@ -1,16 +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_color_scheme.dart'; 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, @@ -56,32 +53,21 @@ extension QueryaThemeContext on BuildContext { /// Shortcut for Material [material.Theme.of]. material.ThemeData get theme => material.Theme.of(this); - /// The colours widgets read (#1730): the ones the running shell uses, so a - /// caller that moves here from `Theme.of(context).colorScheme` sees the same - /// values, mid theme animation included. Without a shell theme (a bare app - /// in a test) the nearest [QueryaThemeScope]'s, else the default theme's. - QueryaUiColors get colors { - final shell = - dependOnInheritedWidgetOfExactType()?.data.colorScheme; - if (shell != null) return shell.uiColors; - return (QueryaThemeScope.maybeOf(this) ?? - (_ambientBrightness == Brightness.light - ? QueryaTheme.lightDefault - : QueryaTheme.darkDefault)) - .colors; - } + /// 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); /// The brightness the defaults below follow when there is no - /// [QueryaThemeScope]: the shadcn theme's, else Material's (a widget under a - /// bare app, as in tests). - Brightness get _ambientBrightness => - dependOnInheritedWidgetOfExactType() - ?.data - .colorScheme - .brightness ?? - material.Theme.of(this).brightness; + /// [QueryaThemeScope]: Material's (a widget under a bare app, as in tests). + Brightness get _ambientBrightness => material.Theme.of(this).brightness; QueryaWorkbenchTheme get workbench => workbenchOrDefault; diff --git a/lib/core/theme/querya_ui_colors.dart b/lib/core/theme/querya_ui_colors.dart index 92ad1f54..b2c27de2 100644 --- a/lib/core/theme/querya_ui_colors.dart +++ b/lib/core/theme/querya_ui_colors.dart @@ -5,12 +5,9 @@ import 'package:flutter/foundation.dart'; /// The colours the app's widgets read: surfaces, text, accent, borders and /// the chart series (#1730, epic #1465). /// -/// Holds exactly the fields `lib/` uses of shadcn's `ColorScheme`, under the -/// same names, so a caller moves from `Theme.of(context).colorScheme` to -/// `context.colors` without other changes. While the app still runs on -/// shadcn's shell the values are read from the shadcn scheme and converted in -/// `querya_color_scheme.dart`, the one place that knows both; after #1735 this -/// is the source. +/// 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({ @@ -18,6 +15,7 @@ class QueryaUiColors { required this.background, required this.foreground, required this.card, + required this.cardForeground, required this.popover, required this.popoverForeground, required this.primary, @@ -27,9 +25,11 @@ class QueryaUiColors { 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, @@ -46,8 +46,9 @@ class QueryaUiColors { /// Main text and icons. final Color foreground; - /// Raised surfaces: cards, panels. + /// 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; @@ -65,15 +66,17 @@ class QueryaUiColors { final Color muted; final Color mutedForeground; - /// Hover and highlight background. + /// 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 and control outlines; the focus ring. + /// Hairlines, control outlines and the outline of an input; the focus ring. final Color border; + final Color input; final Color ring; /// Chart series. @@ -83,6 +86,62 @@ class QueryaUiColors { 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( @@ -90,6 +149,7 @@ class QueryaUiColors { 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), @@ -99,10 +159,12 @@ class QueryaUiColors { 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), @@ -113,10 +175,10 @@ class QueryaUiColors { } List get _props => [ - brightness, background, foreground, card, popover, popoverForeground, + brightness, background, foreground, card, cardForeground, popover, popoverForeground, primary, primaryForeground, secondary, secondaryForeground, muted, - mutedForeground, accent, destructive, destructiveForeground, border, - ring, chart1, chart2, chart3, chart4, chart5, + mutedForeground, accent, accentForeground, destructive, destructiveForeground, border, + input, ring, chart1, chart2, chart3, chart4, chart5, ]; @override diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 4a7712fb..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 Gap, 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,24 +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(); - - /// Active Material theme aligned with [activeTheme]. - material.ThemeData get activeMaterialTheme => - materialThemeFor(activeTheme.colorScheme); - - /// 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) { @@ -270,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() { diff --git a/lib/features/command_palette/command_palette_dialog.dart b/lib/features/command_palette/command_palette_dialog.dart index 952a9576..0e9436d1 100644 --- a/lib/features/command_palette/command_palette_dialog.dart +++ b/lib/features/command_palette/command_palette_dialog.dart @@ -240,7 +240,7 @@ class _CommandPaletteDialogState extends State { color: active ? 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 6f3660d8..81dfaa56 100644 --- a/lib/features/command_palette/quick_switcher_dialog.dart +++ b/lib/features/command_palette/quick_switcher_dialog.dart @@ -275,7 +275,7 @@ class _QuickSwitcherDialogState extends State { decoration: BoxDecoration( color: active ? context.colors.primary.withValues(alpha: 0.12) - : Colors.transparent, + : material.Colors.transparent, borderRadius: BorderRadius.circular(6), ), child: Padding( diff --git a/lib/features/extensions/extension_driver_recovery_banner.dart b/lib/features/extensions/extension_driver_recovery_banner.dart index 140c0ad5..865939e9 100644 --- a/lib/features/extensions/extension_driver_recovery_banner.dart +++ b/lib/features/extensions/extension_driver_recovery_banner.dart @@ -19,9 +19,8 @@ class ExtensionDriverRecoveryBanner extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final colors = context.colors; - final isDark = theme.brightness == material.Brightness.dark; + final colors = context.colors; + final isDark = context.colors.brightness == material.Brightness.dark; final bgColor = isDark ? colors.destructive.withValues(alpha: 0.12) diff --git a/lib/features/extensions/presentation/widgets/extension_card.dart b/lib/features/extensions/presentation/widgets/extension_card.dart index b1626a7b..bf484346 100644 --- a/lib/features/extensions/presentation/widgets/extension_card.dart +++ b/lib/features/extensions/presentation/widgets/extension_card.dart @@ -31,7 +31,7 @@ class ExtensionCard extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { final theme = context.colors; - final radius = Theme.of(context).radiusMd; + const radius = 7.0; final isPreview = ExtensionSupport.isPreviewOnlyManifest(manifest); return material.Container( diff --git a/lib/features/mongodb/mongo_aggregation_workspace.dart b/lib/features/mongodb/mongo_aggregation_workspace.dart index 3d2b4271..cd8bbc5a 100644 --- a/lib/features/mongodb/mongo_aggregation_workspace.dart +++ b/lib/features/mongodb/mongo_aggregation_workspace.dart @@ -603,7 +603,7 @@ class _StageCardState extends material.State<_StageCard> { ), ), const Gap(8), - Switch( + material.Switch( value: stage.isEnabled, onChanged: widget.onToggleEnabled, ), diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index cf5df15f..8602d1e9 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -401,7 +401,7 @@ class _CollectionRow extends StatelessWidget { Widget build(BuildContext context) { 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_databases_view.dart b/lib/features/mongodb/mongo_databases_view.dart index 82bc35d9..dbe856ca 100644 --- a/lib/features/mongodb/mongo_databases_view.dart +++ b/lib/features/mongodb/mongo_databases_view.dart @@ -380,7 +380,7 @@ class _DatabaseRow extends StatelessWidget { Widget build(BuildContext context) { 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/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index e727743b..48a36da8 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -49,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, diff --git a/lib/features/recovery/querya_startup_recovery_app.dart b/lib/features/recovery/querya_startup_recovery_app.dart index 2176403a..fa0e2687 100644 --- a/lib/features/recovery/querya_startup_recovery_app.dart +++ b/lib/features/recovery/querya_startup_recovery_app.dart @@ -5,6 +5,7 @@ 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'; @@ -127,12 +128,16 @@ class _QueryaStartupRecoveryAppState material.Widget build(material.BuildContext context) { 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: colors.background, body: material.Center( diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 7acc0987..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); } @@ -159,22 +159,22 @@ class _PreferencesAppearanceSectionState 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', ), ], diff --git a/lib/features/settings/preferences_shortcuts_section.dart b/lib/features/settings/preferences_shortcuts_section.dart index aecd9b4b..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; @@ -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) { @@ -347,7 +345,7 @@ class _PreferencesShortcutsSectionState height: 1, 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), @@ -394,7 +391,7 @@ class _PreferencesShortcutsSectionState padding: const material.EdgeInsets.symmetric(horizontal: 4), child: const Text('or').muted().xSmall(), ), - ..._keycaps(context, item.allKeys[c], theme), + ..._keycaps(context, item.allKeys[c]), ], ], ), @@ -407,7 +404,6 @@ class _PreferencesShortcutsSectionState List _keycaps( BuildContext context, List keys, - ThemeData theme, ) { final wb = context.workbenchOrDefault; return [ diff --git a/lib/features/workspace/destructive_query_dialog.dart b/lib/features/workspace/destructive_query_dialog.dart index e2dd1641..212397e4 100644 --- a/lib/features/workspace/destructive_query_dialog.dart +++ b/lib/features/workspace/destructive_query_dialog.dart @@ -102,9 +102,8 @@ class _DestructiveQueryDialogState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = context.colors; - final isDark = theme.brightness == Brightness.dark; + final cs = context.colors; + final isDark = context.colors.brightness == Brightness.dark; final isCritical = widget.result.maxRiskLevel == 'CRITICAL'; // 680 x 540 at 1x, scaled with the UI scale, and never past the window diff --git a/lib/features/workspace/dml_preview_dialog.dart b/lib/features/workspace/dml_preview_dialog.dart index 8ab5b588..c84fcc48 100644 --- a/lib/features/workspace/dml_preview_dialog.dart +++ b/lib/features/workspace/dml_preview_dialog.dart @@ -61,10 +61,9 @@ class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = context.colors; + final cs = context.colors; final wb = context.workbench; - final isDark = theme.brightness == Brightness.dark; + final isDark = context.colors.brightness == Brightness.dark; final sql = widget.plan.toTransactionSql(); return QueryaDialogCard( diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 7c5fcd5c..6cd3a283 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -332,7 +332,6 @@ class _VirtualResultGridState extends material.State { final overlay = material.Overlay.maybeOf(context); if (overlay == null || !mounted) return; - final theme = Theme.of(context); final cs = context.colors; final media = material.MediaQuery.maybeOf(context); final screenW = media?.size.width ?? 1920.0; @@ -356,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, diff --git a/lib/features/workspace/sql_editor_chrome.dart b/lib/features/workspace/sql_editor_chrome.dart index ac030816..f7d56e24 100644 --- a/lib/features/workspace/sql_editor_chrome.dart +++ b/lib/features/workspace/sql_editor_chrome.dart @@ -75,7 +75,7 @@ class SqlEditorChrome extends StatefulWidget { return inlineFieldDecoration( context.editorTheme, context.workbench, - brightness: Theme.of(context).brightness, + brightness: context.colors.brightness, ); } 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/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/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/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_ui_colors_test.dart b/test/core/theme/querya_ui_colors_test.dart index e7bf84ed..18cda112 100644 --- a/test/core/theme/querya_ui_colors_test.dart +++ b/test/core/theme/querya_ui_colors_test.dart @@ -1,74 +1,13 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/theme/parser/querya_theme_from_vscode.dart'; -import 'package:querya_desktop/core/theme/querya_color_scheme.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:shadcn_flutter/shadcn_flutter.dart' as shadcn - hide Gap, TextExtension; import '../../support/querya_theme_test_shell.dart'; -/// QueryaUiColors gives the colours the shadcn scheme gives today (#1730). +/// QueryaUiColors interpolation and `context.colors` lookup (#1730, #1735). void main() { - final imported = buildQueryaThemeFromVsCodeColors( - brightness: shadcn.Brightness.dark, - colors: const { - 'editor.background': '#1e1e2e', - 'focusBorder': '#f38ba8', - 'errorForeground': '#ff5555', - }, - fallback: QueryaTheme.darkDefault, - ); - final themes = { - 'dark': QueryaTheme.darkDefault, - 'light': QueryaTheme.lightDefault, - 'imported': imported, - }; - - for (final MapEntry(key: name, value: theme) in themes.entries) { - test('$name: every field equals the shadcn scheme field', () { - final s = theme.colorScheme; - final c = theme.colors; - expect(c.brightness, s.brightness); - expect(c.background, s.background); - expect(c.foreground, s.foreground); - expect(c.card, s.card); - expect(c.popover, s.popover); - expect(c.popoverForeground, s.popoverForeground); - expect(c.primary, s.primary); - expect(c.primaryForeground, s.primaryForeground); - expect(c.secondary, s.secondary); - expect(c.secondaryForeground, s.secondaryForeground); - expect(c.muted, s.muted); - expect(c.mutedForeground, s.mutedForeground); - expect(c.accent, s.accent); - expect(c.destructive, s.destructive); - expect(c.destructiveForeground, s.destructiveForeground); - expect(c.border, s.border); - expect(c.ring, s.ring); - expect([c.chart1, c.chart2, c.chart3, c.chart4, c.chart5], - [s.chart1, s.chart2, s.chart3, s.chart4, s.chart5]); - }); - - test('$name: back to a shadcn scheme it is the same scheme', () { - final s = theme.colorScheme; - final back = theme.colors.toShadcn(); - expect(back.uiColors, theme.colors); - // The fields the app does not read follow their neighbours, as every - // Querya theme sets them. - expect(back.cardForeground, s.cardForeground); - expect(back.accentForeground, s.accentForeground); - expect(back.input, s.input); - }); - } - - test('one scheme instance gives one QueryaUiColors', () { - final s = QueryaTheme.darkDefault.colorScheme; - expect(identical(s.uiColors, s.uiColors), isTrue); - }); - test('lerp: the ends are the themes, the middle is between', () { final a = QueryaTheme.darkDefault.colors; final b = QueryaTheme.lightDefault.colors; @@ -110,14 +49,14 @@ void main() { late QueryaUiColors seen; await tester.pumpWidget(material.MaterialApp( home: QueryaThemeScope( - data: imported, + data: QueryaTheme.lightDefault, child: material.Builder(builder: (context) { seen = context.colors; return const material.SizedBox.shrink(); }), ), )); - expect(seen, imported.colors); + expect(seen, QueryaTheme.lightDefault.colors); }); }); } diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index a67a36ed..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 { @@ -367,7 +368,7 @@ void main() { expect(c.selectedThemeId, 'fixture-custom-dark'); expect(c.activeTheme.brightness, Brightness.dark); - await c.setThemeMode(ThemeMode.light); + await c.setThemeMode(material.ThemeMode.light); expect(c.selectedThemeId, isNull); expect(c.activeTheme, QueryaTheme.lightDefault); @@ -417,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( @@ -517,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); @@ -566,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); @@ -578,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/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/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/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/features/command_palette/command_palette_dialog_test.dart b/test/features/command_palette/command_palette_dialog_test.dart index 4b1e9a47..6a31b5e0 100644 --- a/test/features/command_palette/command_palette_dialog_test.dart +++ b/test/features/command_palette/command_palette_dialog_test.dart @@ -1,5 +1,6 @@ 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'; @@ -25,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'), ), diff --git a/test/features/command_palette/palette_keyboard_scroll_test.dart b/test/features/command_palette/palette_keyboard_scroll_test.dart index 8a70ced5..6ee3d71a 100644 --- a/test/features/command_palette/palette_keyboard_scroll_test.dart +++ b/test/features/command_palette/palette_keyboard_scroll_test.dart @@ -1,4 +1,5 @@ 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'; @@ -33,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'), ), diff --git a/test/features/command_palette/quick_switcher_dialog_test.dart b/test/features/command_palette/quick_switcher_dialog_test.dart index 71a7e25f..edd2b3b3 100644 --- a/test/features/command_palette/quick_switcher_dialog_test.dart +++ b/test/features/command_palette/quick_switcher_dialog_test.dart @@ -1,4 +1,5 @@ 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'; @@ -39,7 +40,7 @@ void main() { child: Builder( builder: (context) { return Center( - child: Button.primary( + child: material.ElevatedButton( onPressed: () => showQuickSwitcher( context, initialQuery: initialQuery, @@ -100,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/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/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart index 0308400e..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'), ), @@ -152,7 +152,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'), ), @@ -199,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'), ), diff --git a/test/features/main_screen/goldens/app_dialog_overlay.png b/test/features/main_screen/goldens/app_dialog_overlay.png index d7529b52e7b67d8e1de639b11d6a2971e4eecb39..047d331de5d521a04ae4d45a56cdfb87862f8358 100644 GIT binary patch literal 16605 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 KelF{r5}E*;&HfAk delta 506 zcmZ3>dY)y1gCFBhPZ!6KiaBrZ?)Q>)6k&f5-}HWh2%}-B+~M1-KKWZ)Yya)>>H4y= zRYY)$(E|Pn>K#{E*Ir&?eDIMuq@_{?1SGjVE+6BkNM+`v-*FW;+Z zPRiUT-#V{UeLffb^#QYk`EJj1QaRVImTr^eW>7ep+I)7WcWj718$(IKm4x7TVeVHa zGZn_bgYz+#h&Aa~c|T;ts8+*Ke+qN`mkvG?c2Xwc@~v^eeF2Udy@R~=a!Z~ z_kAs9@V=_1Q1h?qmG)2TFLhPX|4RS<%bLnBZy&ndFuJ`VLH5gq#J>)v7=|}CFfj12 zurMSjC@?TEGcqzraB!g64_0y~qW)M+^nIfrF6*kdwKceYdVDNqo#H=pG#3}&4Kcqk z!H`kR+aY1y{HwesDLxHHg#>22_AX`Sl@w<1DBAVYyxD7CC@73Rs-2wAHoLg=uivLt zRYnX8W^0}-oK)I;_SeF!y_L^bR()V(Kmrrj@&IE&Vn=p8qxi!gA@eUV`~k^&y85}S Ib4q9e01&gwHvj+t 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 cc90d95f8d1fe0b9046b646541d332ab797dec24..09c3b8374ee6628dfd63f08dca82f18b936d343e 100644 GIT binary patch delta 779 zcmV+m1N8i|2EPW7L4WBogyk?p%}qVL@+{P z=LZqrN=res5G?X3R1C4u+CoJ^B_akZ!5B0NnoXk4oMWM#iO#Gt_fGctwR741!*b^C z46_Rm5fKp)5fKp)5fKp)5fKp)5m96$bim(yx^ZN4_I3HVsecc^lZ_7B5W0z~BhV#C zzNjYCYReCLFTVZ~y6Im;J8%xj=j#V|E}NgufybWcg1LVV-9^4LbtRd%7L3%`nuPF8JEODw@_TnEH~AUgRO6SLbtRd%7H_c z_Pfh`=nRUJh|Gy41ED+Gk%kUfMH6DJD$WO~`%!^!FSJ;s8}185mYSididNAM1DcA0 z>q~UhvDqQBnaIP{j^}2}DbOt18IxECuCUbXkpSENw138dxlDCv#e?RTAlO^z&D`e! zY%Q`>1*Ptzl{&SQ=%{J)Dn=H=@m&aae2YgFxbxL>Bes}8Sppka0oKu`5aM8D$> zM^qsW98rZha6}d2z!6o514mS$wQ`qoSHtYhACAve*g~`_?r=mEs{IbP>JubH5vu*b z{WFy-;D57}KO5p{o-Nu?HyuB4KOd4U@jF~8xHbXuk}ajy7wsssHm2q^D%NYb7F@eR zM^Ky+)h-m>StAqYz##qY(8~?DW+G`yD!rL>`&w z7yeCNXEMW07*qo JLSmm9f*p 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

hBML459+$E-I@`xYJVtBWo_(UaPL+v zI5G^ruA`$U?3+DlFIo7*X<6M+rvj@2J!eSAWIgG^^}%wPp1sgrK~vfxs}Hj7es{(Q zhr`JngL;)2H97}e#JqmtSkqqNu@?D5a-8DLjM0$Nj5g=OJIW^TV_?dE#u&LMafVIF(u5SV{FuKCV)uzQjHHdhewA zbsS-Rq_FLUB$8sCJ+>T|vcHO*8#8;=(2tWe&>+>sz;njZYFq?}ZkXus0Y?7iN&`(1bOrx`I|6U+k~r% zWggq7+uZ+dNushL^}4JHdTgTPfupvB-)hQfFhB1C)$nEfNM=eT)!mxhL0wsV?&D$e zd<2fV3c{c)@@}#4Par0H2pcq6o9*WC<(IPOwxztZ?dm5!nMpH73W4R_;@~ zCSpbeOA+HD!63`{(DQU|AT|U{G+_1rUyg*nS)-U@x%=FS=q^TQI?;>>9`;-e_)lo7 z{q;EMn`8gLw&Fj0fgsux%VrpR2I_vZY>A|MIO8lai-sbwQ zKtBJ;p}pe|D*adoe=T9b$1w6jG#1fkASISwP2!k^;#BR>7&JJWo5@MW7o9g=tzhiIjl`&)1%lySs0{(LrrKy0bmI*>lNfJne(% z3KGGo^oFqgYaAiq@o4GsQGcx*9KBEnv1wukFG)1eHcrv|`X+8;qi(?OV|ht1SR^=H z)bt+_gHsAN66@;fcuc|9@Ok$xy&PaMaOZj*tDTsQ@jrolLs@(e$R+5Z_=*TdrZ!@R zNzf0O?F+z=ZI3QVDS6KIzfdhK82d6_EpARB<^Re&I{dx73^gp^O#`{C_Xp%G!5sms zWZ~C8dwF@OP|MwVQQ&M8QeAS3>z8$Va+%n7KzU42AFQ}HV(Yng&gV52DEJIhkv224 zsG9%MZtwWv-3!YJtJ^IR literal 6666 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 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/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 374480ab..a3e5e3fd 100644 --- a/test/features/onboarding/welcome_tour_dialog_test.dart +++ b/test/features/onboarding/welcome_tour_dialog_test.dart @@ -270,13 +270,13 @@ void main() { final boxDecoration = keycapContainer.decoration! as material.BoxDecoration; expect(boxDecoration.color, lightTheme.workbench.surface); - // Next -> Slide 2: Fluid Sidebar uses lightTheme.colorScheme.chart4 + // 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.colorScheme.chart4); + 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/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart index 8453c2db..6b3f7cd1 100644 --- a/test/features/postgresql/postgresql_connection_form_test.dart +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -2,7 +2,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/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; @@ -12,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), @@ -44,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), @@ -67,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 { @@ -96,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 { @@ -135,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), @@ -194,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 { @@ -305,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/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/preferences_master_detail_test.dart b/test/features/settings/preferences_master_detail_test.dart index fa682f74..67825ac0 100644 --- a/test/features/settings/preferences_master_detail_test.dart +++ b/test/features/settings/preferences_master_detail_test.dart @@ -6,7 +6,8 @@ import 'package:path_provider_platform_interface/path_provider_platform_interfac import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/layout/ui_scale.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/updater/installers/update_install_context.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; @@ -49,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) { @@ -223,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) { 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 34cf73c3..9ede5510 100644 --- a/test/features/settings/theme_preview_card_test.dart +++ b/test/features/settings/theme_preview_card_test.dart @@ -57,7 +57,7 @@ void main() { (swatchBox.decoration as material.BoxDecoration).border as material.Border; expect( border.top.color, - QueryaTheme.darkDefault.colorScheme.border.withValues(alpha: 0.6), + QueryaTheme.darkDefault.colors.border.withValues(alpha: 0.6), ); }); 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/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/destructive_query_dialog_test.dart b/test/features/workspace/destructive_query_dialog_test.dart index ee9eaddc..a33b6b10 100644 --- a/test/features/workspace/destructive_query_dialog_test.dart +++ b/test/features/workspace/destructive_query_dialog_test.dart @@ -328,7 +328,7 @@ void main() { tester.widget(selectableTextFinder); expect( selectableText.style?.color, - QueryaTheme.lightDefault.colorScheme.foreground, + QueryaTheme.lightDefault.colors.foreground, ); }); diff --git a/test/features/workspace/dml_preview_dialog_test.dart b/test/features/workspace/dml_preview_dialog_test.dart index 24f465e2..2d2cc284 100644 --- a/test/features/workspace/dml_preview_dialog_test.dart +++ b/test/features/workspace/dml_preview_dialog_test.dart @@ -18,7 +18,7 @@ void main() { 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); @@ -161,7 +161,7 @@ void main() { tester.widget(selectableTextFinder); expect( selectableText.style?.color, - QueryaTheme.lightDefault.colorScheme.foreground, + QueryaTheme.lightDefault.colors.foreground, ); }); diff --git a/test/features/workspace/grid_cell_editor_test.dart b/test/features/workspace/grid_cell_editor_test.dart index 20299ce2..ad96c01e 100644 --- a/test/features/workspace/grid_cell_editor_test.dart +++ b/test/features/workspace/grid_cell_editor_test.dart @@ -150,7 +150,7 @@ void main() { ); expect( errorIcon.color, - QueryaTheme.darkDefault.colorScheme.destructive, + QueryaTheme.darkDefault.colors.destructive, ); }); }); 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_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/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/shadcn_imports_ratchet_test.dart b/test/guards/shadcn_imports_ratchet_test.dart index 2c5700d5..51ac945c 100644 --- a/test/guards/shadcn_imports_ratchet_test.dart +++ b/test/guards/shadcn_imports_ratchet_test.dart @@ -10,17 +10,7 @@ import 'package:flutter_test/flutter_test.dart'; /// and a file that imports shadcn without a prefix hides shadcn's copy. void main() { const allowed = { - 'lib/app/app.dart', - 'lib/app/querya_core_commands.dart', 'lib/core/editor/querya_code_editor.dart', - 'lib/core/storage/app_settings.dart', - 'lib/core/theme/app_theme.dart', - 'lib/core/theme/parser/querya_theme_color_scheme.dart', - 'lib/core/theme/querya_color_scheme.dart', - 'lib/core/theme/querya_material_theme.dart', - 'lib/core/theme/querya_theme.dart', - 'lib/core/theme/querya_theme_scope.dart', - 'lib/core/theme/theme_controller.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', diff --git a/test/guards/sql_workspace_chrome_guards_test.dart b/test/guards/sql_workspace_chrome_guards_test.dart index 30300ba2..585db10d 100644 --- a/test/guards/sql_workspace_chrome_guards_test.dart +++ b/test/guards/sql_workspace_chrome_guards_test.dart @@ -21,7 +21,7 @@ void main() { for (final path in chrome) { final source = code(path); expect(source.contains('colorScheme'), isFalse, - reason: '$path reads Theme.colorScheme: use context.workbench.'); + 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.'); } 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_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 dd3f26fc..bec2d174 100644 --- a/test/support/querya_theme_test_shell.dart +++ b/test/support/querya_theme_test_shell.dart @@ -1,23 +1,27 @@ +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: uiScale, child: QueryaThemeScope( @@ -25,6 +29,9 @@ Widget queryaThemeTestShell({ 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), ); }

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 FW2MZDgrB#mvS<^&w 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 diff --git a/third_party/refresh_rate/screenshots/hz.png b/third_party/refresh_rate/screenshots/hz.png deleted file mode 100644 index 261465cae1efc243627bd08176b536ada59cee69..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 299789 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�!Jb7T~2CM_)7b-%*N>y6IbX6pyIwxJ`7b;V$y? zNKFab`eN3=fGH(Cz2yPf@R|rdNzOIt^N={63YzrsV+#P6a%j&#YomPdGWjrgWN&ku z$ruaEkNAje>fQX=d}Jx+IsrHf#T2f7VawkIaOuYDftlvmy{4<9nOn)WHKKtZq z>5?njKcp(!+wq+r6QV1#^UDTEODfcC`>iL zo{tZMO)E%-q0EmgHcwtB6$YC+sy_lzL=47SfH)=PfrW##4|obP<}0WYwQCtt%TFTj z+=ZYnFDIL%&PC+tTL4-+N?T`A}EP2ESfE9s&C27H;xi~w!MAkOgL-7Lbw){kJ(qmW5e zsD4?WI8Na^x-=d6V+H6L5h52ro!-NFaHm_BW$SRC1XwnvHhunsXcaf#nwaUrCcnl*_Z%-yG^`%NJf(I5}nmbM`U4 zUQs&D3qS2sZ1+41xjCxHSm)nJ5AgQF-gVbc5KumPKt{QqjK~*72fq+tb!ZKUY~5Q zZeQOQ2}5;*un6=SsrSiN0|Ww=D<7i-&B>Y-Q0@l-w-s>Q5kvmyzzF`0_vrn$$ zVVRY+KU(JWZ+gGM6Z_49+hZ}4grju3yzjN1hX41^cb~tMW8@RjeR69WgaxuIDrRp4 zZ!$LDPA4$26-+JzC(+j@A1DD{w+JN<5;&W|oxuXH5gA+yk0mBE20A2&(dgf@QjjBX zRZx(R!LdQof&yjW=}q>Wo!P<80idDwdoEl{d+XiGjzPK@_<5uG=2OMk*gZVF4iF6D zA4aWkD5a|t1Z}z2a)5Pir2iW1E?44V_S%5iH3uYZT0IY=43L^BC*|ymx^nfY!8(fL zl-Bp{6kS=0)m{QWnH5=B-e`1`=WCBlAou4y8#Lmj1G8V)g9l$uIDYqc4l8knm|$|l zDoR>DRagwrJz>9pHt$X4;pxG2Q0;Zd2Uw1-lzeuLf~zYg+8eFd1JJQO4q%Cthc#JE(n(DQa5D@Pbk9#XA$l`?@p2 zfK-bIJ-3C*$)(9_rTIv^El*F6{at`)0~wRN4oKqw{8wa|B_GSHL>W?{-{$-vRZ|vG zy(lr}aW5XQ1w7(tVHItyHX~jmH7oWfq&(HSGk{QGK~!z1_q#-?j>oiV#>FlIFx_vp z>m_h;-7d?iqbQNO!DLj>pTm~2O<$$c_+Gj187kDfPfpVAsLtC>e|Cj=kpN(hDFT@M zvw3trj_Ou0g-`)3w$tb498aWI^KnyFApQ~{e1ezP_9ANiZv##zIxy*BGoUg{&_@(8 zq$CQP(Hh)}DhaGD;O;zB&kK4BBqDU&({$vH*whEY2F zw|>TK@bm699`Tu7;FZ@70h*O8%?%Sw_U1PXp+Ie1PbabKCo8zCs;#vI>U#CG>%7~d zyUpCbMYUX&-j$+ z@t`xt*uEU4r}!pMC=VGjsC`i|G1^YpM9eZyiKuXn7k}J0kKM z@dg1MXg-h{0=(c4lfQlh^(dtvuN4H@P+z@=f{L*XaPO!yYgHEx@&*t`~t3>Z_* zf&IaP8@@%p%0I~wFhcDV74e>*FK!J=Vhz5fJ_cPq*txPtXksJ{qZu2INH8WW`P4~s z{_@)OSD&P>E>QBAZEAq%0Xd+h1T^X!&+m6xaqoY1UAd=1O#0DGxBsdiV(7UG(>V-* z(Y;$_0RL6i@f{;bjA@&huo!`XjDh``d1xm<;P^m)PZSkRNKEXX#6JcgRB0`97WTeg zBVKY)pHA-K;DFd?-t)_o2z6brdawt&LU@dZhI0~p=FZ8fs6xiSv&5u{XISF2+Q$uk zuSTG5J#pP@J3e+&P8Gog(OS~aO_`D?sqFI$iNPxVB?*66RK)d#W5hvM+T8wJ zgR8)Frfbc=)R!~FZdEuT^yS8qFPlNmzONxdoFHQMMvha*;TDJ%3Qj=bfF~!ATVQ{@ zaLd%pJhLq0767(T4kIjwPacvIembodRPe|Lt<8CX9*A4OxJgNy%M0-aujXOA)&ta- zz-Jsl#8V2~$Lm`H!x+d={2E&a1+BPcb;QJG;b-JmAgV5VXy2J?i!I`Ah#70QEqMD@F)rj3QCK_ zMx~?$WXKUD6p)Z^1O%i*x)h{K8esq_k(LH2$&nhQJBMLFB!?RETjRd(?|I($JHGvo zhi)0KS=UAP#UURXO3h*B;j6@qCBUKAUVDdr``8`Idn~c*##2^?XD;V|N4#}8 zdY0|3naQ}jTbC2BuF+@V3)f#SMq(THW{f@|u|vahK2hEgJO{0pOn&yFo*Vv|5E4EK zt!jDLIlctMMtKI#Ug2#=t|>*4{@V3N0$&5Y-Spi&BtA-x7T5#HfoQTKnbXouLk9yL znLR#$P3{-sRTNYzy^i5`Z#-?gBV4B&)w{k<4E~oaUO1F#FZ9_!yk&Tym^^vFU$d_N zjUm!m+55c2Jzd9f;?FhDF#6kf<+bdA&}(*+zM;ogRs}A2-QRO16T(GJ-%`IrXqY&l zDFM)%fyQGxVA-jr_A=n&5EH@$JCKGYB9afy956-?bdx&URbYW1av!AhTZkW@%xBen zvG~jE1O&#s!)H2Z6Wxb5h42pyxC4F*!7Gt~pg6|S7|GB}Zq7GF=v?{PPN7Kcd2!&z6RF6% z0Cr<$1erf5_drYdyU-`Fn@ENRmYViYUm7(QI zw5&5-V~Eq?3n-K6fI8}?$WcRJ=dnm4gK&$b+UexBp)!xpB@5tF$kWw zgTwH)RtwMHfLH+=xcT#K?7@>+4du1uUSu;1TK7lZ!v;?mm==*$Y@hzWUb9 z>q4poGKn%I9EXFq|9ferd*#PGC6O!sG+_g%S{#dvJVA4C4zX4DCX;Jsa)pM?llf8N>ouX_%)~OGVLnR-LarrpP`3x_rEytc-5;V zezI%f=R^RhaquLNpy7d1#|Jo6Ns_5_WQ|2G6j9*PilqgLpm2b=P12HD&rHqFE-70ZM~EzzRC1LxV$pXg~=+lvc;oae`3d<63v(Lm_+pI~Bj3dc)kD6Z0)C6%h;nlo6;gmh7A` z`O}jmano>*`J69?csTUlv5R@508NR3 zr(~*0TF+44>BL^TLq^)9lVor`aW?GN!`h8pN7I2z_3q*HD>c|CcjeNAhSnf|XGCuk z!56AI;DU{ecDr%&=Aw+#?ep`Kd9yTEr>7)nEakXuUyRyPqZUddhKc($eMgY3ak^Bs2!MX0U$c?~)XMrb%Y-`<&eC5xZ0UiMkowx-fkxm*IYCZ3?+H_)T>ofKTN+ zR2_pz#Pi-V#RnAQOQchtD}{he6bZ(k7@oKkfXgAT#TL`s*e7{5vleUBZ<;xN&@pp_ zB+=5p#iUrvzBtI)LH_%C^4#db3(6NytU`i^BuV{~{~i7DVB}u?AE*pcMX=*VteQBJ zm34xo5u4Tl=R^AZes}9XT7}nDh|3Vg|3F9ShRrX?9l1L{&Ue!A_#--#_3iGCO zv`gr;r(93JN>xw)(14}aVg-Ke^}O?gm!+O}H-6!Ves$Bd>BojR_axRSP91EsbcXG? z;z$3bGET?2C}_j8O={DHZb&Rv`TtsiC1(F?31*FgF98Khu;ZI#Uqj#M?l=WaRr0Vw zUXyQ7dGogr0RJ@+AF}z9J?U<>EXEAS<#lL(VhOd#ARCfCPqgo(h1R>=uEY#)JNP?-79n# z+b)Z=k*`yYhyUKm32LE0IuD7qvbamqFVBT?WOW638Faw6)$CLkmXS3oE^^EkL3C_x zq`mnP&< zRO>=SR8n{=v+%{i!Kran<+4KI3~?B!kWNePo7elDcshzd>rLxTFu~F0{zLqhdB1HM zVBqB(CU-$cHN^6si^-QRIT4FxCQfBIlo zJ?eRuw({7xnthVr`7lZH{=zqwp(!z;2V@EgD?(AN{C?H>67FCoGDsSUCm)@}I`<&r z^to*Z2vC|&z4C`Rkk`8edN{@f*znA4Fy7^0yt77eEp*_ce*G2`a)YD9>0F-18>ku! zm7-H_=X5_;A?k7M%iHu7$)_{XGgid=O4%WfN|Gf@+|6K=e8&EYS_j)ZiH;()n z7UjRGaV0<7tQq#rv*5dfD;Dor>&w9AA=vxx=5gikJj7vagzGpSgwUg{C3&WPLBs8_c&qL> z{<~!{((rTmsK$$pqf!Nk`MF1+<=A`@{8?riyTtCu-SOcwKN~zkL>G)ihMZwl7%@3}w#v#hy?ejO{dpfa`o>SSsuRb~J-q!~0gK_sx2gw)NMD%ACY zGF_A!yM&(_BXvL26%P5?e&S`@U-Ulva(@XmSGq2wESb6fp0mGfq^@X+3oD*SQRGb) z{V_o!MI<_=b#Xskp3DZ8a~p;mEjw~wHS*n&6%-b<`r=c%*~g<-PtM8PLVu(Eb7L;$ zh3EQMrp(dfkdxLT1>4IA0C zt7i$z=CCoYkg9y@?$-!r=kqLgFng&sKR=e0+wuBbp!VHIv>cRndbVih&zkme-XTA= zt0p5O|8P7T$Or(x@O>+;>A^_*>LkgkI)2MD(wzi`Fhd4?*J7OXTk;IFXNE=pVGnJI z$;r+}2!8oM2h^0|{LJk624R)N{8A%znl>zQ!TPD?F+jG>CP5^GUtXq^b?Iz`=u(#~ zwq_+QVMS!M0{ zCxBVIGeSNCYs8?dAsuVzE|J8Y0mIFWowQx2o{|g8pL@|j;d#Ssvfyn<5n4qA?X`$s zKloX}W8?DVpRyOTv-dW)w;JT?z}%h^gQhOhvw2yDqU6NIx*|z>P=eY8F_X5z+bTu; zEO4|%G-|zrFEX9&gQ*g6)Xq)e*$>IJc8HY?F~@!GTI*6DGi2VTv3&i^nFE?RD{G<= z<$LNf?bg`nOVoD%e!A$K+UA@7>zihTw3R6o)-yKzZ0zzfGFKiu%)j^F@L=KvzphT` zyhwD=s9$LQrSLyR;N`zXKn>gmUYkMddb@$U16x&DC;a2`@@atctaE?I>8;57xuboj z#zB>FWGy42_KB>wqaBHgw+Ghu_dmX-4K9nf>wH0icJ6duHjR?&k=-KN(2-NMSC&VA zaeG^0wWCLX&T`w^^mhkSap?Rj%1dmd@hsIx8qUqYbWITnNy?A3(7VI#YO>{ z(u63{+N5@MgWnA7gHOL*(H1j`pzp|jH`bHiCCg9zvy(2Mh@p%KX(J>_sOS80wR0Dm zEgNBSS%s@4!9kG!yVG^??=P75RgWr%&y;c?@ewYv;U~APWt^EOy=hq-f8zW`${C zD@FuM^N)M0&x8CI(jbUIFD*^YsgY9tU`zgNvhtb2i*|tl7#Re*y+3|5o_X;=S28OK zL{Q8Cjop1=G60~{GBU~muVBztQuR6FD^y9Z5aVHoUg5Q!yexn2x3hZR>WwY^=*f=- zOvoos&lG-3(w892Z05t28>FP4>&tJmDaN3-o3}t~@VBEMSFhe~Z|_q*Iad6bIJnoM z>8NRPR)4;N+t_RMW15*Hqp$WRU4mqI|9-AP4~zczi*K1%FBAj=fQJ4Z0P$Jb+x`e^ zyZ2}Qgi3UJdSka984Lzf8tCs-%I4NfOLfeyqh*ngfxh)kF4GMZYdO3HS@=~u zZ;H*#aL0M^8dp-g17kbL!th07jGaT{`;oo3T!yZ9zVySx0Os}Qe1V|x(t!pc@W__eRb!536c0UbmJI2QhbINsgS@=ok)J|Uo;?9lDZ1a* zYF>Nnq+8P=&2vWLyOo*D?&p^wKjL9WlBsv;WN?6^zBu5}*lOIHDR~5$8c2_q*n)dv zw^s))h#;yiD>wYk=3#2wmLPuvOp> zt0}b0+(waa1T2|3gs4DSggP(;aw;e|g19)4A2H2PyLr6sRC3?fb6ok<8*34hnW<;Z zQ2tHN4G!*gV*#uu1@(EZu9|O>S8* z)lN1)=V}kdn$vdfUQ>6O2^GuZYw9k{0Z77@Sq!s{Ui2zGFs=Rx7o`F*Z};(C0`Q-> z8EtP*R;$Uo=S!!+a>;2tXI{Q5fy2$yLg`b0>)kT2lA3~ssnBf!3P}S=Fo?l-dxpnz zZephdmt}BrogM$!=#h@uH8L9inF?djulek6@(z|Ua)mIRz{Q@&IK03*W&E>@AHz4&-#hE z*-r->iq!-z*2V6GS&@m6k?#5|AIZ`4F1H=<9o^w~=zcka4P^RZCAfQm(OwD(!A)=w z242G3RbsP3`0ZAd)Y9YFUP$leK2K?R%TZcqf8XeAJ>qL&G5sB~2R#@|!KK}zvexR_ zIcs+b6a_`BnExf7mKD9z^&pyCbabLaG{s^J%O3YnD-(@3Pfc4M|JZboT%8Bugg+SO zy|3+DzwY)r9sVgS(S}!E*XcI?EC%?!Qw+S~7ekuKAR%i@7D(*a%K+!;0V|tXYsKpc zM{K{xMj3B*)i>3*;)qz5HJq9)p~^kbERc63 zhK5JKhgB$h1%O1?9HYb92z-vAUuXfM_o19 zQ4(?6{%q)L8zCk<>ndE@jI*FUJ%vDM8osL<>4m%W_g}j~yu?PfraO6!gbcCMCf#d# z)$IC5(O=^+6>;Rifbksx2nA4P$qVZct|ySlZ3?nP7(3XYEFGy=<8HkEtc$3FwwksH&hUdz`2V5#bRObKeX%zm$N zRVeS%x3=^vJVv&{pSX?=#K5ljULdB9Ki~58VmVtzM+aqC&FbnC9U0&SdkTONKHwb) z@Zqez)xp?qjzPxwM*{rbo8pS|#U-+@TA91oU+7IQWo}=*xpM~`WlQt=Bjvb^oAj#c zE3dUxsewvV+Ym!fH?mZY_l zZE^TnRa%du9`v+9!lgzF6!!KH?mc8BWPbGuPl|7AyKl00NCfc#5*M%HdF|Rn-hckN zrETENojE`w=8ktnZ@$pCGj)En!dvaJ0!DagTEg3W+5u1vj#@8HFEfwHQUf&>u*Mr1 zgjZGrs<&L#GK>i5GNWH)!t#6UA1 zo6OU`7ETPed9xUEh!w~#C?!R+us~gQAAI@fi(6GoprOh6^7{th-$?lR6&%DiHUp27 z<@l4c>WzetIFX+H?Cu?%aDq*S|Kl*9I=b@iiBVedz#F%g3(@l9!-WMoH|eOzCbod5 zVkj=LxXem8S8^M&h!Z|D2?yl>q~SCkP8C6obV8-pqkVwSx>m7aZv)KoMOv_?c4>Ra zRNhd=xs@4cc0~}Ph z5h1}ruh3}iZLvS=z%;O6_fY~B%`}>cmh!`i{}B|hsvr={hbl|6<3{bD!ik&x!7DcF zF4U}BM81H$d!1Y&XJ*IHJW;K_dro<5XyDbE2zjUC_UFf(3QTIc-X)1j3kECJX(AM! z_vVH1>(^LQ)i})dVAMUApgoG=IKSreuDdv&mYHf2P(uD}s-zJLSC~moe&2Tc^NX2& z;)Dul!Y+}5yWg!1KEMWCm_zvetd#{58GkeYb!+F)7i>*hrG^nv?laxBvg*DBk|ztW zxbYYG?k_51+fON7{0;=HS;6MHvpcEl>#rWC0gW+<)~^eK%LF?=V*Db%(kl9MJY@fI zP@?}!M;n2;oB8ow+UEDWF4SC49Ie>xzn?IkF0SQ6!cMGS%CSD4@t`n{j?0lR--sF? zRMqdzk4WRYlx*yBQ_ILNXx8KD*gP_@-JCqpxIp64bu!YT7s`t5O)!s0mBa36h6(JB zJam4tJ5$)@w}!3P>Z=D|a!s5qSi#0B)Ow?Q`cLCE2KU${|5RP)powZCEP8Z zZxt4?$(9cC%(&Lip8R7}9f=*>LLA#rw6y>W-P1LY(>S=;PD@9(Gz=oR%rL;${-Q(} zSEX&_F9Af_53nq3L<$(pm3SGsFHMi~l{@bhX zKSc|-v`!A^bCX4ly*=~wJz73HEZ6)+eRr#l0B9TI&?Z;VZik!?+s+9gLbxrEWZ^ZQ1oS^y{w-$}GF~4HoeR;zf_sc2@$q{Khmh z``2Y04VPiCNm0@9d6T!A^Kz*98E{e$*lKR?Eaibv$D}MjkC%aKS}`#v_s(nDo9Gtn zG$g?=@>@+IE+r+W(KkI$X&T(JPL^aLPrTe}UX$}IfS8;*nQy(#X6w|w4^$ofULE@y z`@9cFETG?h(TRnP_$`Vrg=uul}<2cS0Y3R7tCK%rf)~d;UD-QzmTa z0=Q-X=jbJ@eo41^TPefRRnqPdi0PnQc>LhB?AmoeTLavkw@RP*2_V0wMn23k`LZvy zovHwFMs~o{Q&)IKnvMJUL>=_p2c3s_Rl^fbj1~CQ4=`le*5)lh>E__L$D_o18owmX z?dn&shlFSTzKuse#vl2ll2BKtudv2%IR!hVPiL@6^!YxoB!-`QC5}T8^#lLnAKm9m z*qP#3&*q4vQEBF{nl)`LNnLWCdx2+X(4&4~pW{F9wS%=0R*DarSXE$e?ny$u`=DA2TA9H1t*mW>AvLW)U za~yBt4Xl||rU3y;O?A+Wi1gu?t&KIQwQ@6`W6+JeJpg|k6(ce<^y?ieC@36vlZ*^i zqyj@@^{bp&z#)%v8ZF~gzO=tC3L9dA0Mgt@7N`MD9~kL_u3jx1w*R3Emoqq+Wt(RB%-Ss>V57IB1ofV z)`3K3a%XPSK0~Fas_OkrbiaO@FlrE3YUR|<2lK*kGUXRvt4aKs6ye4^O*t5V$5dnv zr(3g41E4qeFF2!RE2MVKy%^l+Yj!sR_4aQ4d2(FX6Yq1}t*d*m*4_R7qyDsJiqW!> z!&y1aE*Pkeu9%LIZ}nKkBo)V5TmKrJJJO8gbitsS`J@hatdqy#NPc$^_1#Voek7Vo z2E7i6s;<7r^DxturLy#xD^n6AK1QeYlQYs^(e9b2;wgR#1S!WSUK>O4UZ5?4AQ*S( zXc{*%!zqNlQ$f<{a)~%^{{xLy?=Raux_+MWHHEh0MT$wXk%vT|MWv}9m@3(l-F%U@ zULSD^%Lwm>-Q1GhxtR%)k34zB-&!pT@dX_~4UL$sBf$NmVechBG-2xLbFrcM4_+_3 zp8cBNMIrJKYEs9*=J?#T?CUD6jJ$kA-M0%WCXsuy#@9Gc<7e*hg|$$#XiH8b1Ja2? zI*{!eMTdkC4egAVv!AT&Y?p`gUz<v#M|q|RyBFf=@x8g1F@?`Q%6+uZ8< z+9k_JlTHP0u}jhtbeQ}pcOevU?S@Q4dr*d!+0Q#y?VS_j_Y;>i)VY7<=4S~Q^r#Qo z{OuR&a{LE@K#bItSy#%f;CS?4_%^8Ly5(6<$TqXi4m4QG+lDjK&r~6N;;FRtpiCaU zH6w9rfXb80PxNhytVLMZ-dtv+zRlB9S((#b?M&Qrm+=lq50(sQwk*$qfe?OC=)aOk zEtX=L?dE;#*RkJJ-&%h9o!1EbJZH-{s@n`vE5U?L4cDfSvFSXEncMsK^4?mbDZLyW zNk=$&oR$T$Z|=L2RvVAwl-2Ynns40yz4d3cW5F@ErISr*Vc6IO%k5??sdj>`l^d-= z)7+-&uhgQ7d2}v%?Ofc+a8w)`d47IRC7wy*tV-QAIy$Ue%t7-ti<`YS*?MWM&*<-p zqSCh4+J-^kZ01x^MVZg3lHL5Bj$nBmW?%Dn_nEJ1J}8B@f2S#WuV=mH%l6HB;ATMo zi|!L-&E(RCmq&%fgB4~{7p4jYRPkmpEAe7fiM@ z(DA5)S`czBwn{nHpXQm$ecUK?^KrCFd3vV5IRIlcYpu9-G0yv+9&xS8&e?YOW_2}BbxeLq{s6|>k3Qif zme06hePF7LQfz8~Ww<+G`V|@J`i@)KD0CvajiB3Wip!W(s@`z&lGA|~PPgUWWu1Wi zn(C_Ev-APP*g)9HB?zE3cO1rkJ!4qsRXFRITvv%^dbt)AAWHBf_Uz=>{5Y>Jto}Id z#R0z=ZE$OrL$j2c)TJ-|8BsGhptasK_zw+t#_BNZHNc*sb$8oONVc|0A(9Vg!i<70_u1hXk8YJ0>ND&XuW3HgsMKAR`u+ z1CZ0ArJ$*mqiVUI`3+KmR&Q@_Pq5-I1;6?M3EyA?jH#bNPK@BkL2CFsyjjv>z2}R- z25Rm6)yb6ykSl~&r;TA*Nj;eh5@Wo@kS9+}K0{$wIej#<4o!u1vYYZu9e>UtSl_6y zG`w9tGr61adN*-*LA7walZ57IueG(caLrC@g8Hn<_x{NMZA{jnbmN{bXwueb}wt<_yN$7X#A{Io0KZ zC0-DrSq)c*)t$`pL=82E=y+;+5v9(sWEtXd9sU#T)j&Wg{1K_au zeDhE@X6Ac}l(W{4{btD@Ufw~Sc11OTB7|bUbL{dM0}^dDY;R;Xm)DH#nsFFp35e9X ze62C_k=nj`1DyLn^3tetp{L`u7Fr0Td}qf#xl*4mmh2|UQxEf8`O<+DFGs!`NcXqf z7)2!o=Mz8Z_)r^0m|ti#%Ng{&?n%CSVAL5@iapr4_otS$DkA4>=F>^*BrSd0kq{uI zk{Tv|{H3a<|JJQ)`p|vMn;(kQhuyU+P><`yRO>FTLn(fqv$-=|@z1C$wPvIT-SAUP z#U&GI>7}o;$xj0Vt%{2}g}o&anf_;-jYre-+n9c2x722vtbH{ooAcVm1D+YGMc(TL z44T~fvm=3CrK&~w=L%ay19C#fas7Oz)zJ_s=)ool*sRW5;6|1>~dMR)SM8ImJ=Rl)NjXzc)bi`?2^VQuvh4U!jdp2n5>$Q)ddtts~{?E1D0tBa|L zrAjKO&hx(HbAAD{ti27*d9(-m!heSLuQlG!HZ?sOr_bHRJ7auIfj$z6o#21`NeSw< z{ZFRFVozdr=S_~gcVWA)i8#negI|M-$UBwF>>a-+5c|D31dx} z5%2c*({o2Ols-usiohVr27V&`=f1{%*P~Gn1~}o3BEtg`u}E81aVWh`tfZz|wBI`C z6L3XJk@T)H^#WL=Rjq)-5FjaizsEuQT&+>rRY>Tx z9)LivhYPAn#CkCKT9#THhq87UyBmI)U5yf5?aB}S1(cEh0hA{zOW-E~7P_(2=d<6| zcpl_^>0$Jr1f=9L(TwOgKt3yN7feS{)Nx)(m-21$2Bc~>T0aQD2hX9dX(k)k@({oACl7#V)HfFw0?Yd2YZ8*v<9VsYskJ6 z+^U`F*OrL|wsKEEAeZg&)!t+?&4P+Kiwn7M?wEUK&e)>!mWl=Es7;8zIh`>2?|<|nHC2= zYqDZUTR8U&_S< zhLT8Rb$tXxw0L?pJfS_+`aP?})%3-e8NoSSK^Y{(aMz%CEQy3;9xjnCU(c=iOBlH6 zj^SA`ksHjq04sVFm)VzH>;7Td-l*x#(gh)CskWRGp1%?T5Vzwv!3;u@tXI| z4%r5Fwd}kcH4UURp!S(-P-bEPzU!E^E{?3OGO=>J#nldEJy&|Q%erP`{Fn&af3lI= z6cwt1mdWh?#_vBeKurP@$xO_Q$%vpCocRT*?7k9>0(e}wET{HTGm|*$@sg77i<{95 z;wIY22t&hdEcR*AesuuwM{R@g-2xlH>ef1H81J6B2AazNLT?3K?)b_IRnP^&cZPfJ zP|$>&%0@sSvHXMVB0$HcGjbVd_LNKHKL)}J&(9_scz|e z?A5|DN!*p$u`<}TVJ8a#YGdc?0VSCaH`dq6Jt%dCCyvXweb2KyeeMvGF-ZHqh_XHz z)6#r}ysW4%BPYu&;Y|#hkH1DIyJ3`9f~Pko)_V&Ha72~{o~;Lxnl@4n;KUKsH*g03)SHaQ zY5-ZlV;gZ11h^G=H7VFj)CTXrSMY9VxNV!Rx-EQ3%_x2{l5F0WC?2?ZhbUBW#C-~AxR)xaW|Pp)mUA?+3?CfjMKyL!9!8Pmh;{StFI-p&z@Z~I@)kh^RG#T zwT!HDy&RV>^bPU>1p?nA_XBnVp}?thtcqS7dy4&9QYvR-b|3Fe0=kFIq>$~whOMR5 z^Ru||t+pT%4|EU=HJEuuzZe_)6WQIWJ|Gzas7SzS>K}kX-kF^AfkEC882d*JNS;Et zI2odJ0it6lXsPcj0uF82d&Qj;U$z&R4ctaGA@BBuXF?Ng#_$35FZNxJFiaQhJxuUE zZH=G1#9fKNA*=E9lxb&zLItm*o)i)B7@DGfadZC~F|nMNR?=7QgRR*J@XBnHTS|O4 z#G#?*Z)kio>B+8uJoMn#nlYfy*0ln=xK4Jq+;SU0V}Nu6FzR6|(0(&?6#^-700bnD zkH zX0Bd^v6in3x2y{bKPK7lNY-tRO*`(t5C-vALIHtKZ}EC)DnnT>9T=;Xk6wVV#EItD zB#-Buh#*Dox_*+@0M}JirvQ55STJ?6P35{8B$#|ZIJ8U|{DU+`7>JtLz zSkThHJ3jBp253~$mfm;o<(PeZ^oAUG_8v7#UL2XhC3TsNn+b?K9cqe-eQ`dPY+%l9 z_mJ?0Hp?x9VW)Q~e1qipNZ+ij-)N+c)KAs(uCp>vZu|n8rHl#17u(x>3egM%z~RH}w?^{@u<-jw#Eu;N z0%$9jA)xx~j9;8ED8w+tb1}8^t%A-m{X1a2yp6sd6Z*u&k+1p0lnneW++JJysQ??N znwG%>PhazsmM8ej4mvOIS%%>CLH=?G{$8l0^g>qpCAo130LN{8ahU)hM3_J(PsOIE z!OunJj!-t2wBNta4}^de7JVVV3~+tpKKt-r}X0Gj)yVH{&dL>9g~U|jWVGVrN|5B^d_AuH=< z7n=|l?vc&yWI@ym*lU zPw$Vhiya&UoI%;PjYzFK47t4uA2jK(~#aG+@cI1g>}t1&lhJ{l&yz739T4{XxU ziVxuc(g@jV3?!TP)L#yhnp){1T;`@->jg^>@9GZlVt~T4O}!HMq{Pns@WbFBw&kp5 z7H5YG>&gm`2NyVMr$4 zqKERMM?5d}S?wg&wvW7#QzyV#9hc{7PyMIr=H4M{sWW2Yco$ZFv0ldSFnG^gIXxWU z41iaW4eAAWkQjg|fbCDU|3c;!bH?$anU3>XUWz0e4Mlt$pE;eJz9I?G2RjvihE!`> ze)HM4ZoWZ}2y-6`I~}O5SyE{^r?MDGkEoBJzW;jY!4)E+oV>g{Y>J9%-yE3e)_>zS zmrS;HzKRT102nja*&VeDsF=;gv7`t%NWNrKkpnJY$m*KUjdiqmhNhjX(AFa|h(+b= zeErgMg0r)u?OFHjS#9Hc?yZ-V(nLtWguG2f5r|Ds|5_j#2*^B?KDA^7XTITRaJ4*I zO?UsLp$*uNrP6Y4j|mA0!LAax89~!p!IjDLkBK?k$x;x{TDqZ>mykZ-5T0-E4gdTk zK~%ZB`!Oyfy6!p!z=~tKzKhcHiU$Krvd@)Gh6aA0P63Z~8)2Ctg~ZznX2y&}DuG7- zJ5b`Qss(e3>#lUlljf9{Dm$S^%;Tb4v)(e1T-fq6hyx`Wp0*{THG z&5CU4KK+U-z=l!I$MhcPDyka0P$Oa(+q1TsL7GLW78&WMQ@Zu_of&nNd;W{}fZ-X_ zo3%B*N}R=JA>1UwpGhR!`+QA_j! z#CP*@;0Z+$wV$LV0iNu!6*a!ILF4_g#qsu|FZdPo-~(V&NNdyl9?0kfFy&aVzX6%=1bm5$TIK;jkf1JCq6~; zNCj^>?+=_-i>Jz4>jX+5;F?X3W9ZXOW#vVo;X%pWQn>3Qdv|)YWUsby^(EmsS70za zfK2x|F-{ZGlmzZU(*$I@#{YP()ngp@e)zqZkso;}rZ;9Kxa8o_(ysC~^R)F4+(Z`k zoue#^s-3QB^@5lw!XuACb1{#o_G4Fmcc^fMi(SMW9QSG8@nq*RNBtW?<;$Ry9X%fj!lo0wvswQy7r@NR&j=W*{CLvWGeBhG z#9T({*QU(_EA-VSLGW9OytxY0YH4y|Bp2o}mb$pp5-p$y-QM{cb?H(C>SJw>ydQUa zzDRo{qwuFa1U?CJG-CxNOVSdJ)i2 z@=jaXi%8EK*O*4Y`oxXnnveG==*tNi#}sfrdIC41II=a2wShbgFQs4{T%x5vJ`WE+ zmw-Z*_2+NaJTC2pPw4)-YPsPqCzEkQ0p1t0U;~o2=TCP0U$s`CD-d^Y5EeHb(|yn* zxb5avh0>6%e~JS^c%y!Pes#wyX_s`GA%M5x7PF!PWL|(#j};@JL$$%WQFakh%ua^}heN*7$H5ADSN6BO`@;JTEAJK8{x@jMW`d0$P{j2ygo5ango|1S) zA*6y+8ruhApxzvLq@8M9;8Z9tpOy}fvFZB_Ba~MtlqDGAhsr%AA!HI$P8uG^u}=YH z@$@{C7fX=?aPUxG_f5bdK%4`j~e1}BeJDLJWTlIdqp*|Uj&_{^e6C-`YZSkSO zhtfCH?WjwVkWc4qK%WD3lfKSlv+`2vOv_5t( z!bet}xwH-jNBNz*QN)&1rU74_TI&{{OV~bITo5+KqJCL*j@)|Fx>Y+2-=iP71~8on zwFS2f+Js)0tOG$NjGh4h(-}0}j9*@mg2J>)M0dU%KH&KbI1E?H)z@MUH=S8UMN?75 zWcn{T|9J;JE*Y!a;C#r{8?a_os|eFdHMpKLV)b!(aHr_QL?~-E{@o#y#DP%85x8pt(m{YDMfOZL+_RF*-<0S=&bsy)#-iEsb z?11!?{XV=l27fqp&8t4}*&-DNlBmYAETaS?8z2uyI>Vi-G)i0=66vF{3QEMzERoIw zI{-l388L(X$n@K9nW#1s!&6WJ$pdT`OCGP7@r>^a?9cXzdb_*9!NGDZKE)k9Svdyw zXs5_}MLm!CSa_A$&zr;sMf@CHcWKVwl=}dk`Br@V|DTNYUu9Xm9>+=`-JzV)_2>3j z^Unr{1{^?Va%iO#!@ul?C9?5%|3rboQXd|i&KZ_o(9(mwBKTFVnd3(U5Wb7S{|YGe zVuMD#dWJzP>MSrC=nwFUgWD37IX&~X7U>jeMLt4*Up~cRJurCT>|q>!S}li%;02|L zE^rrqes=AZ)F|BIy=47@x2ft9epQaAPD$}&*MZCkugZxc+1Eh;&8MwkedWfm!S?B* zAX|p9&tKVUjr!{UmaU2*64v8lbo)q4T)=}z#8kMkTZB%Q<7&0)0XF_u!fL}p7*h7e z`L)UxxS*#Gb<_!H!CRQByX;V98;AiZupT6Ve2-!SFW=L~%l9PqPNjtK6=r|cDi0SA zbch&&sHL*mSe&Q#d07{@wn3Grw?-zMqT~JK6#$EbUuXustPsZtgb|vxoH+JV`ZTn1t_gNB+!{Q=arK}%i{(l7Cjr~9(NS68s@g4mR8XN%1 zkzB?=HM0A-uB~7!2tW9G-j-dun|oreW({aEJcFt9a?R(ktZ5TVPolB@Nk>OlEsw**ySph0&w$Od+fu~}*!B*tvXg8(8$ z`xD=CiNyZ)84iSe_I)T#5|AE|SFxjrIoR7k2BICCLnP46_IE_8qUbS_G@GX{OBL-g zIH=Lz1BSdVXXl72iWITT=WXG)$!(8mUEFZDfTJ^jJXrWeX(LV){6qPI{PrH<*u$K$ zIy;}LvpUaEAnaCo^K45-3@qZU#4!w(=1Ju)YWt^~&r@VQ!mVuGO$1uyq%#>Kw%8+2A=!X$0r(M&I+gT889EP=8T@~y+yBR@ z0r5X8p>#CmHX}M9QFk(tt4FBQm*ZchX%1Ma&hsn9(n`04!0g<)%6;VfHZ?vYkZvh8 z>J2~f43YwGd7>)TTc9`h}U7uIkz)UHVY#f9N6C(ok=z9DulS{MAXd*mRM zd9yl&A|b{Be1m`Lcx~juUEnADY*#WKftksHWXC&%Y&utfnC$EQ-&>!nRH>oILcEge z`ayi}tjpL2B$ghD}94T0j{+K?S6{LmC_*5|a_5yT*V4W7~UAzu({cdye;gcIY1-!uHvHUwvNJ zdHD={o^SXt6*tO(xymjP(1PUIkPh)aaC!-Rj8;BPII2%xICBBcjx)0MMRF8L4WD z-%3FcpOSn4p+#r!8Soi#;CmL{{py{WHj_Gqf3lfMVg0ZNqddR>z7*`kIR52PfnGfo zs)O#=1W&8zV&M7h@wwCrQ~hml(KjCOw|~5I>cnMveh#)#tq%$58ObJ(X-?7b^7^0s zT~^A#c4>YG`yz#b>Ech*nD_7BmqTIbFk6`$w}O)$R! zXk0wCy}a$G8^-BVGUchGw9^RL0}$YmBN~C{QRbitnS{m=`;-dKAdH(hqQoi5Hid>G{Rz12z{}8zaB_` zA8ycQhrF_#w=mfHG%SU(?>Qr_!T3`UBGUr#-iD{7))_+O+dsO(RfR4xpj0<}+7lT* zj^RC`KK19NYQH~|+2Pbg&;1(-a92>X1OSyQiZ|x1xqi$-QBH8WD=@wu&v+^B@6nqv zUqJicAB-LhNOkCKJK+ZRNL4fv6S?vzh0-9$j^YLye~|dkG33w9g&No^4@9fdpASy? zb!8(&du%{+dul6*>w@s8+j_IPsPqJeO}Qp?9TfgxX&^1{a8~ZwbDa)$dt+eVIWF@X zvrN1vSfOCu8I?^}fiCru9ep<}^H0eK-);}g=es{;cuaquqbke$?v3~3bK?_7(+azL3F7NK41@P6yBx_Pi~8F0`o#Cf>D;Yb?90pN9e z|A6(D^H`GB%rOmC%8E94MPARA z^#ytm*opG>Wjpkh7JA^8`Znw3o4nzJteiuBBQjIp?+D3D-F5sVCza#bAMHSYo@*4K zVp}6f;TSn9KPgykkWxXyn$L1f^7rhzbpD~kcf(I%JC)z?K{jK}*_N(LYGe6pvSwe} ztSx1h5>*TCZm+N1^uDL+=USbJSeqM<+K!SGtWlZk!nQQG>8l&?RX^{c5my)yx!wic zaHP;AR4Dul9ryj$1^8f_fp5;qrKK)Zrro8-VKX0Vh!u2xd^XpTrVThx*5!CQ`Tu#Q zhJ5*pxE4n*MA;Wf@?Z0yCNK5M2GC+srvFOa-9LQz_zu)0Q#Y4>jEdPl(=mFN`Suu? zbh^FZv1tiuduxrPqUr~XopDzQzVd|kd!`>$GY#JBW zdAOLYtqed(B6rqqy^o4e1Vb#8X{58~&SzwPwHlI_0_E}&vjca^U?nJkvPL}uGhfpW zH5n+wJfJH%yN?lUFZ5M~TRd{+j?>Z-0oY6B2-p+)DPyjSAywMc7{!*hbl z5mR~+QQveNP*H;t4h153enag60OA1b`gyzU4Cj%buzhYGmyjvw0k`Q-30?MZkGdV>UuC+x3^Pftqu?UtYhE?*DfXqdv04j?Cc0f>S}+}E9`_~PXNT~I2DwD zLu9NB-D=~z0V+!mo~DR{@*N+TD9ZR=$Q>L?|7GjfeWRt7nHWj-sIGJcE*!J){q4E# z#19`RBZfH&f$Z(zhxWV7Y3a_R@ibxE`)pBN2w8f{N|CQUPr)ZzynJ~p_4(~|>SI55iG4MYhbniimj-DO(NO6lAHVnj=So^oJJJo0ZT@7YX(hN7hbg6olQ z8C0B>lD}>=^-xyU%86dN(A~5wx;&Km=i|RImSB$!(jzI3FQ~{D{UL=J_{r>igzLO! z%!ngDdFxIjttFz*aiPXaAsVy;3Wu`eKZqX^W>->NDE%V$NGDIyP)6IqN}+pz<+1XJ zxS1nmaytcaN0iE8s}&G;7B5eSf@J*WPf3BR$Mgr?i)~s@)$fEZKGQVF)$0Su&A1QB zSkG(JUQ^+FMurNQwW2ihoQLAX!6pfZrm}U5x)1TV$nRQi7)!`Q$AJ1JP71&d@IJsX zfZc>Sbx0y$g~?3=FWXAR9b8+vyjvib?|n3V@szeSYu;?4V--8kSGTp1R|G^=wW`XD zp~GxPaqZoi>e*%*kwEpr>NGI#zcSZd?MU=<^q-|$1FOk~5Bt+)S*j+vxPswE9X*&Z2>*R5H00`)TW2VTr?1Iia)3Oy1elB+5j(fE zikCA&V-r(pw}kn9`FS~WfmFh8EV0{mu#G@$|jrWI1qmGqp zMtU)p`e0}*47&;nM*Hni&2Ojy<6_y%oIFp5dcEp^lg|EA)BxT`??!;%6ws@r<#4V& zQ&hiY3&y}6h&O?yyHnAdV5S>PFF*nEocRBlb-uj-7x!e}@Y~V6cJ2D;CqW4XZeV-d z)}5-RK|g&fcu4-}BCs#$&ra7DK(xZ=8(5Y#KlO99kTS4hNl1_ceE@paJlF;|Pw;+A-&z zu;_@^HD6q@`}+Jop{z;S-yzR%T}}+bgF1~)T0>NFan&4s+Al7Et-e3Es_35kotxij zOxiPejM(TCZ^*}HpZci_4&#P%gjRGF#4Ex<<Zk^(p< ztLOt88sg^fvPb>d0ej&Cy02%3E(1iHvexqE&C|dnQAjXAWCNP%si*(|!{g%LKfm|5 zzi)w0QUEygz3t4~joBu{}_6!VSvBCuLx7i^ORMb1m$ zKd63yDW#9_gv^Q>Hn3U&3|@mBO?^Kf_*gN8$8a(?w7Qi28zUB?nrb-^GV+`M1V{_$ zsD6PNp^!3hFrgZZ80e}p1VBnoUg0BE%P)>Z(A(0|3L)MC{Fn!`xwkbkSFElB6qbRJ zg@y`zKnq~H)&y7$7=LC$qEKT&-|~bMJBGeHe{z}LzLW?&6V+vr>ujn*HB17?%%Un5 z0JpMoay|qbp%A8Ej=j{j=N_24=RPVzT(%t%pt?t>(HYXwZJn~6D!M`iR(xKTFh2&m zxqlS>iJMQ9aA1b+&uIlNGn@77u_R zlf;qEGd|Ntz5!+ak}x9xT#cM4C$mbZxx5R#C$f3$kwtRJp-7zyB8$Au@(Vj81xN@F&eq}pQuv& zF4YBQ?mpA_{8UcP(JCG=lfO(5=RX$%A(p?qzwydfI#(Uh5?mg3~zroPzf6+wSF6<(CEIIDwE*{rDP5+s1e8GvFnGVjm}B zfOqXlm!+qAW@;GGq8MrgmSkRsPc`!p6M0$%@W$73H4qO>UFdMp_Xi?IL*8O}zyE6+ecC zy$agG_2m1d59X^+qIIO3j$2B!-qiD*(T%p&$=zuaHmu^V!Le+`=fGShBP~PEAESZ= z`~>!z_=ZMAn*SW|+n~)zZGRRZkuP6%3BfXGI=|@_9;XV}CK3J+mifRKSuo($Kn%Q} z2VoQFiJ9gZfg5|VRl1B`)h8*zjlhz|W0XlbRs`&b0c`&Ud60SSK)Kp#k;n}n?j z0(5bqp9lWowSzN>La>@RK3B8nVpr+YAXI0UKi!>;!uQ47@df1gm^v5^BsQlyGJFt zW^)z2TH{Q-%l??(Zl`hci!#b=+jo(g%K2c=dn?{$B5c;v6Ma~5cGbHhXTWZ?O1uVI ztk?7{g1N$&DR_5~Y^u&7cY+4L37RJq-VRgfaxR9yU2&dpowkKUAPbL(NFJYH3pnClscoA z{Lyw&28I!UHk?bg(oso~`1m6nUS*mcCqErBYvkQfYj$|OdIs$vPTDzMR_r*CSw5>w zfR==!-|7wznJx5Iy+j@@V#v{wpaFRo@Hr!Rq^wRm@1s#7W?N=$79vKlvGx6Vd{gL1 zLc-2GQuL6P^>s?qu9K6mOT&@qWFS#|Xn5F;_qk#SU29VykwdD&6hg}>dID~Q^{qeZ z5qsb5j_$Iwv%6K;unty@Ij@J$`y7!7n!1L01+eTwV-Z|ditApD6DlsxxthTtLAbNf zX$)PdTUD=GSbdC0bN&R?5ZDYGO7fwXC@?L;{!~wMo!n7%9j0?4W6o%0na%&cu6M_~ z(0p!StwcHsxy~C)JVn={=3S2P#$gaaFgdc^`Rhd<1z{)rc2M5tFs<95H zos-6O#h$Z|279O*gwjQhN72#wV8c2~tVjW=Pe~XV@AYtgo4z*RL1AhtTA5keNOnnX zB(vbtCX?i~MRERw+3q>0dLRs5XpFTshZGq%a>~XL^h@@M;*oOkEIg<*`a)@W`M_%p zP%Dfey$2y&E-jloC9?h1x?^+bH{xcSwLWut`(uTLE9x<4h0FDHObW%zsi(01o9&&r z#fsg+<%KbTvv3n}h>8Ps9+6}6etUO~3&UT@6Sfq64wAApQ+oEFF`XEubk_5 zz(u9`BrI?L{E5T{I}{Brq!1TUC|}BL?$;G31#RsTn*;Z8Tp7XwvoR6U@eXW3yZtK* z#UV_j>zCWP96d5p*^fepJ0Swz`or*soCO6Ze}T9AeoolYH2qq|H`q@n-c4PM>zB$N z0(r5i#H^3AZ5F$?uE`{D1c;dS=NQoeaFc`Jy@&=VEN|IAfRsAwvlRnt+Y>Y_KcF&e z?~K3><=k z7)x9So!!Zxz4ghc&dmlELrJIK3p^5j<;(aO#NEfp7k`Rv=+cutA?G(R2nkcgz(B*! z$uSoPBnNEo8g5ois?W=D^ey>tfZRG()EK^-r)!R?bV8r5CVBXME5MA}&W02Ps#z8_ zY#p0~5u7ln2oYQLU))ZAO6_<_q$7_Uh_9$q5%rYh`3wy_a1e1vx%pl#J%g~keI>}Hk=^zh1D0ewY5WbR|jFQN19JlY4juq ze+i`rjJWlM=VOTT!uwW4Kc_}9J~as-yV?Fs8^N92?F)1qv}6p7M7&f_T**TRENVz{ zlfx}E_oY0IRiaN*{qU@UYNyyXkJtFjO!~X2$v>l4)2|{3Io7|v2pkHUcK9C1r-rkMsk68?SSL4!@Mk2cVi=!_^ zFx%nwmN7i@moC{EX);B!L%7rGyiQWxmAE_A3nyh+pL&lm_1?K@0zJHCX?b;*_&uUv zm^6TW!LI!T7|Lb`;rd^D(&o_Xu|Kc?@m$~+cED+*d1csrvHW=w@u<^Eh6F5eiC6ns zwGt@254NiJG5v5&q0-YHC@lsEaYPQE+qwYdY~e4&8?sB6;XR3fLS`1ANvu3`n?w1 zOFO%gBa(P`{za-K$jc9JI<2GygO0ykWy;Sh5YI?@iQ|FY_F&n(lX+ znid;D>gFTrYyG{QYEnk>l~m=N4-by}@0oSK-6Q@f^LAJLX9ue@Z{{oyG&;*xIx9^~w}$NMUOE{zq65T67F~b* z)cLysrqZ&Qzpn-R`k>ec2@0Jx|9W&mN=>;ksw2 z+=f8}W;va>FVU#>rJ~{z47MQ+F14I1J3B38fbj+^N<*s9#BX7_{B6#6Ox?T8dz#t; zq^+PmOV24gJGourL_5*Q&_NHPr1|Ui+9K&VO>!YPg>6^YZW;}r`EFb@i}$iMk5K^O zld#a^N#AW~h}@ykUNfDGz00 z9oVK4C7deD;;9^icGd;<$_}|x2HT0kfwpAcSd~8;2yNAbmtTq}G^9^99PTs&uXL9? zqTRJ}Lo-E*r>BBQ0_H=2;8P-z9Dts>Vuxhp5J7-!lCd*p%(*|^xbDs?AH%3<;2CLg zbZiR8O7zBN=~s0M+xncw9PxvD8VdL8OBjHnNPW{4ewCl!*3HQ0UJ0Ec&*!vapZRZ@ zp|Qno>+FOBdamCK>*E!9%K-?ma9Yg)l{NgZ#K45u&4}L4t<_HH&mKz$Cjd;TyHQ~# ztC*A@el@fRMs^lNS{!5s`)tI91|n$C*hI%?$M2DwgPz6Z&znh*2kGTrvv3D8<>p?E zRbky<661S`OqR5*oE=L_5HyH%>sDS4q2-7^@K`uasA;gw_ilpbtPR~-mm2D9=YhVr zKF3nI+f`M`?jy&oF;>+;z$+ViV)}0TO-bR2 zZr3Z|cyflVbCTM5L+NK*@ssU4Y)))GU;0x&=*(XCY#4CB)p}NvF)vOTjFp&LyUB6j zac4q_v=PJ});_(Pyt#3lv9Xpmg?B=heQ0I21G)kr@66?m0a={eT;`v6MsY;AL4TC9 z6{g%)dFgxjRbjzf_hU>N?l|_aMkd{%X3T!tyx$!!;RBsrJd+lmh&;R6q2HUnmD~-AncUaD`tiokXD#%5yauJf$VIw;-C-|ayBu(;P z@g5I*m?z=l8+!hfvtN8h?$UGZU{Vl`F`<;qM+S@~vX{2gk~4iPfasI-#&>-77Nv4)up?t%vvHemyfS5~M%GFn$Z1 zL$8tZ7I`}&hkbG{qx92sfmno9>(8Bf=pKy;gI@qg2m#+Xvt7u?B2R&v>yDLfTClvaVS6l(Cgqr^@|`Fj*x0hxUAS?31=4w}Q-0A#rF%-%226Ij2LS`m6)MyNkmWO|t_kaV|? zOQ8z1f;j(9NF2fd7KJhDuM0P%R(3kp71r$P z(6ZiD!IAxTweN~c*aVE*T=aIH2uG3kt}1vB4^zEfhMPuH{tknBKQUMP&vy5Xwei|Z z1-UELm{YXQn<{my*^u6sm~TS`SslgWA%}i)P)uO;Y$#-i+mcNhDhHCE$YIq43uIOl z=uH z4A%E0h7_6LuLnMwy#6krpqWuVT>Q&mW8S zWtZc7QiM9gLa#w@A3TbGK9MJr_R%lF$S7U^ROmcOTi}sIyqwPSxJuAkau@8F7IAp$ zGa+mf<&oVlK2nwK3Wvk917Z65z8wVAQ?A=Gtd*Vlj2&KNsUz6!y7DFy@p$4oQ&|JB z$Vv&<3T`)zZd=9AHgAw~HXT*u)y*VK@#*qzt5uD`8MVqeqOqg9KLmnW?V7^JzQK?e zs3?_=Kh<7zU|mp|UxUwjFiWxRqIv-KkmDEeqZSp_7rv)2k*@a)2T%tdn9dn`g&zrO zDNo_E3o@2jea3ZHb)LT+88S?bLmF4iSsvjvNXKPyxC37!<>czG*>fGYU``@z zS`tBwnm2NJ88@%Ri!iC4_U3~d;bk2foq+_CGlnp34%Cn%eT-{CRrAt5;jPtWw| zlyb{L!EJTk1-8f>8m@k6a=^GNVI3W{TvyZ-4V;J>CcWXMV~#Rd-K^sSiQo%Cd$!!$22;Q}_K`4D)>KPuHY+;+Mc z%I``#tV&z`xpOpyYAS(n3yMmOJL;|^+S}Vd^sHjvS)1U{h0XK(fWi;BMU`t8^+a8` zb%abJV`n=p$4BGuc+EU~wK6^;GE$<&XS6fgKb_eRJ&&98rx#W@csORMT08%qi&J%> zUs20at7Gxa2|W{d72wzMhwf?7DuCQocGWQwA;*@zv+^wv)%IPB;+lZ5=1PZ7QLl~G z47&TTMsqe>!VbhH4+;FN5Uc?Baee+1XQ>pGBinv4@M1>+#fTW_oCMKIB2Q6iAl>=w z)z1@~5hgPSmm+GAQ?5Vn4)j-znnMucp11pcx(^pj}9>g!8y91X-!Q6XxVEFAU9PMurq z+dU$e%@h4>4BdfKq{U`J~A#SwN9qIfm- z*Q1tQP^>y49xS@KZcgS$y}EJnjN@oE0|%<0RkdF(%6X#Ba%$=dtAevY7Sxa3qJ%Z% zJkOgt=$^+>KhSG}*Jh>hWTSDNtLvsoJ@zLuXJdpe@DSg*HDGzi8h!T_V3n@|JYkDu z0t0D#3}Z1L7c&SEPVuf?!Mg+o2C@sQnk>>bX|x=E3QywUTczuCK#OGFv>V^@=gZHY z#v6`93JU(zn;(}bFbstweA%_TiE{4adQS(|7hxJ3i}Kw>$6cZgk}rl;@*uD;ad2=j z^fyvubuXBa-hmJ*M>dkEF{N+CuD2uaFJ}svZUrK zk1y`6qrYLxbq5XF?ukmh8XI+)y0Qh$yHWX+6UjJ{%l($P=pWy_cR1JsACrUTS1l_gMco|N>#%kRG%HRFA1f))#S|& zbIF4ugU<9fu-p{}T~*K5tJkY4atXQic$si(^sz0TtbOUEzt>9m83bDP%kHwLz010o za?H?GZb|M?=NNwB&WuqgDaje)jV(s7@v=%k@9ola6%kERjEp>pyx){PQiQU&n!3rb zhs^U-62@#D+1bQ1aHd-37HCuk-9^6*?HVM?u_lK8avWSF1O%q zR|Gj0XO6n{CK~(=G>01MytA=@l5=LRWKVbfJg_df_)d$1kX@NKoBk1 z{^QDe#i?j+(OY-zaKx-JVXxdj1&9rtpCFjqngz+A{@S0?lORn*S5!@efba#m>tO%` zx!yMiU0N=YW37*3I;PB>7Blt_KAKj(WLT)*O{jEMd_*hR)8ssVHj17@ti#J!-6ZZB zEl4>j_ci@R*@5kX4UT8Lils=75V<#DtCp0>1^<3PpyT{1SmYnWjrLSBngm(X)^Dx# z;I|Hbsc$C(30dUyYm2C8$mKLXWvP(@N0?%7@xT|_@ijtdlpRd5zxEP;MtNnHOOdM) z+g@n*_TU0bwZLOm1;27H&X3+pW;IZncSJe2pG^0p5NVY$!agU7-u3shD#?G3c1zom^ z&HFknnMI{`22*C;K*@=S<~{qRBdSvgagblMsszuqA15MBr~M)1lhFND%*(XXI=Ibo z^rYs`{d9BW?Uf^eRQa2XQB<8V`ba>{lgSyDp5)uVEUp11DrWA3V0R zV{h6jL$u?eq;r4YbM5;gI{Mi=iDxZgE>c#&h|$x0;Wm>6YG?SPppqc)fEFkJ$WGa?$jl zLvlbH<$Hl&P(;YIq-(0!fTuk(Hfxq+B)?;Tw}J}&=nKSucsKzGwz|`q9#*A9N-j%a zTo!kuaE-@LnRUP$-^ImU96YGQuLP?WRXU1KKlze>JlK1Q>q_1H(WtfFmRzFwps zjST?0$P;v#8Twv{qvu4Y&$}=EpgwL|@hZ%Vcng@cimLH&J-hGo+qIsX(JrK^(EUUF zCg?IU{_S%$_D5;(xr)t{SChWWyiP6KhdZ0uy>5tAyXHG0Rg=3=IzVAIH1lg<)YO zjC@%WM+qN4CJIT8v2jw6i}@tm3g0YKsB~k%pxTmw_P1VL9h2w*+w`za^0qEIkG~z1 z`*U2@j{UffXgUV?>irGaWvFO5#Lr^(NfQP%zXs3-KqTt2kP{`I{F4C()=o+A{`ym< zjmtM$-#dkdM^|roAX<$f1Zf$-B09q+_RHl5)hwy+en=beo?oM$Xh+H7 zdi0${biiF|&uCB7UeS4eOqy8{K_a^CMwvEC!nPFKEY{oaY3(}ijHMVr(O$Mb0WwY~ z0{MtfM@T=j7w3Z6zX2Z1M~25BIR)FHDr|?W-N>t&jzdCX)YD|Mdr>8&7ZG)b_NkJ@ zi%yduYfVZ=^+OJGB>Jn%`dTC#VUnPp1(!f@MojlSj(5cuL8AskXq*3l2AYkvGCCv$ zNVY9jdHnwR1Ex%eItk%@6-jwzga;?sX}ug{{G^w;c>r?USMs=TaZLM% zRfB+2TVe{0U)no9L{J|u*Bp`ueCm|*l3X^y z$26AIInO_#l_9{{}0-7F%|JI4y(r8=!JG`GqxBhyEOP-=Jkj@7WLN34|0H z*Uc=&@`kKN`fy8eA#BF3_o2|g-!5|dvId2mS6VDI2CjfA09(6?9XehnmqyQ1j}uN6 zu{hBugE#t^kT8P;dzA8=>lr|iwWZ{X@~?_qb|MYk15lAe`!x_cI6$T!wY;4mZ14__ z@H=Z-Jq>W-L@+LF({f_%PdytASV`lnbn=j%^ZiP4^4@&I9=k#So~w0l1Kmgmg#EP5 zWWYHpGqg3_obneBNV%JA>1A|u{$7v%OGLhDOw42;s<>%YBfX6gPdbS?=tpFhmoxEp zMz?mA%s%s9y5Qx6IZq{3p}_;PR+Bm0T#4oWDq~CvH|CHw`bBoqDCtkF8!vYgUUsEHgwtiw;03PTx6^bCW1_LoF1 zc$QZ((~~h|i`kXo1%fOM8)vD2N0hUVBRRHF&V~rvY{!z!%j%9kf(VX%dQ| z^pcz`VGob}g)vv>;f`NzOs-;%?troDYJ_Bfdg9PBTr&KN|D|e;lhqS1v#O3`y zXDQRSmR35W-|=lW@qiubBJ!KNAd?f9dXD{qpHyt^_6s;zNO?B+MUca3>3wX-rkoc5 z00U;+DA@Girq;*GPKBOm{yc`Xr=n^*gafp|nyn<8NkZp0jlQnLD7`xsnxZ{Mx07)~ z@kEA@T`McvLpj%K9{l##`+AmP(60*2=SU3|&VjdYFrEBXn|{*`T9SR+{wNJ)+Icvf z=Qc9rA>!V{N=ex7rptw;$$FU`v;>3np88_giHikiUghMdj6DDF?D_NG62?^lgA6_q zo4QP^wu9~I%_Ac^a&DN0eSrPlG5^U#%O*if`Nn&{t~gE>bMu3Co0V^>ng%*H)sOLL zTu#$Lw4}IsYp(YG?u57H(VPdRJhZbh#=<4@>PMq|U5VHHZGlvRLNuqsc)|hij!~A9 z(wnb0edcRyMaV%ltR4$Vtd#g2*js0D@f=w{CeX0cj-E%BI1T_#eQ3|t!4SFsZjho} zx+g}M1bgmsNyL!UiCdVC_guZ>*K#6~8^R5+DkZfTZYdqzP%c9W%l1yyV*BNbcg63{ zc@leM)C8JLCB8SuvOktMfQkgQ+LJlI7qr93iyC)8&J&q-hvB#ego{hotBn-sUjiMz zEnr-H4_WOyjqMz$@vyy_8~%G+bQT}M#woE1KZK5c#oAXAOY~?}7kXQ1EIVGtL>b|S z$0#i}oa0THY5=KfC8#O47#bLC9es&01Mm;47*+tJgq#iP>rq15+LY%6e#ioVBYG89 zXZAKG)1VC{tkOnzyoE*tOb5JD)1^%{LN3EO}y4U#Oz z!JiNiSq5nRKTNj*J8qX#`2D`6<#EvM8!E^lX)!<3{-?zAoWUy>E)m1Y4p0@s!J5ul z-cWCQFJejQr+KB?=(_@vf#e2LGo5hK>ela=%AJ; zH)I8InW$z!vIw?B7Z?hN2^$z1Vt)f_2N0xW*4K0PL-tr-l-ow@t@GsV^7S7>A?Tec zeJVE_ILjKz-ZS^BG`h}(I;5Q_NyL(_K3r72VX`t(kdkaa^p*X(<~A2$Z}2Sd6J`4= z`aOu-0V?TS>7EFIoiE2foK5_1FF^YT*L5FWzJ3W^rpwxQROh;HZfD|DY+n3v)Anw- z$`>Qb65}nLnx3xkT)`2KpKC`lY7Zy7pvz>L7DIJ~0l8Y5o;a+?QLl!XaXKKT_!eAk z@>KjSjl<8rBMrl3F|$ILKee>4PK|&eZfI9yDtN8ED)=P|#+T0d<}zRZ4S2xTJKc1- z$v`)_3BY+docHzw`i;C{i^=Ekp>qTCrS#4|hK3KTGl(B3Sno99YOnREtnPz9qzEe1 zhA9^sW4`!Y4X~7r;LKSc)80afN?rRd7{uGIC9l=uer)k?EM>>gooILCoV2fyZ;6i^ z7Nrl<;-_V*I3({s~Vz$P$C%LKN1p8dLD0qwQEmoY7f;^H(aS}`kW138u(FX)8I6gc;PN~&XDx{_dX?j$|>n9VLvZs zXM4}LC-QNllNrC`P}j4w>GRnQ^BjE-?qE2^T;8@uP>b&0|kvlJJy>TUMter z%n{}3;}_Zva@s+> zO9a6p&AjH=73}+DRAz26L`%u}r@qI7-oV~7+bs*FRmoSM-;ncch+>k@0=wu7vHk zmgaR%z7=;x(yCH#fp z6=5m*;;a+WH6%ak(B!M?|I{NAPX_8i+<%+jd_{-%l7D#1&x)Hn5A{cex=z8J?7gJi zBGR*rTt`Eczh%8+q<8xJHNLOLtD3l)XcJMXzpQ_Lh_{VURj9U(qTv(b-ZZIJ1rKVG zl`LFX2l40)Cdh6txsTFFTB$#J$APBro}Cb5X%zl#l+fUD{q>$PwJUNj^)GBGjIwuq zFV7Pa+x5X+5eSaP?$a|!x4Hk(g@UKd?%PJ&53n;{AqmH`k_-x~b2-9zW3IskHZT#< zl-#%eLjARqCR%UDxitQBANdqDh>+*e=`VlxDMDUay`M=M2;N5&m+60W0DPsep@X%! z!B8OnC?z2N8Wi!@`6vFH{s|NeHeTQ#2CKqJ!C-ME|HWX}?H@^D^G8xYq>Z_ZPO(*x zyVS&#TF@l#nUh+eC^lyA`qgJj9=T<@C&UL z@Y|uoswUO9hn{VC}hrRns-0W)I>xnch+?D#C zdmyIIun_zlJTefIqU4Kn?Y;ABD5kKlC{t644sia{f$A%aLF2OjRe*OH^SQ|k^lH7K zMjmed4O_1p)`B8cH^+bueZ{PI5`rs)?tvKK61=CP>?hM*FsUOsOTkPtZQN&^T z&C#qIp^R0m2S~}7=W1sla>dDv;GwlpEN@s!;XT#4hegsE$d6a}`kCR5)z)-0O-6E) z4X(`;qZ5{(Ran>L-rF84PASY#1V7|m1n)4LTbg%o@NU>$xb>!yh#f3e)*ZQLl>XNU z8Ra!!*$V*E^@=|$XwQdG9+{`GCqt@|6%e7U*e>AB4eBYRDZa60+A^FZpO95l7l9G?ARfqTm+tX217 zuOCdA=ikbRaduFbayxYOaht3CTtmY1ju{;Vh984p+mzcSq*$8>nDksbC#b7!TgY>Z zH%0(-E&;iJ_P6)o_@0+IZ>rA)@#~q-Jl>AW4PK+DnMtv@)Ui#rZ~YA#6R9cKp&bE_ z_5E`k;Pj#6rESa0yz}M?7uUn{YB4D}lpA&R1FyqhK3J&uN~(ff<7Ee+{!ZqBS&!xn z=wIY6hBf-s3mAHc*n}w;WW6(0*WmdGOucwe@)dO>_^nSGLI+a^`M2(yj+^W?w?A!5 zb)O)9;i}48o@#3~@M!tC+F+>8bK6CK@Jgm!kqNLiO1I<+@yGrOi-WinEpN>O4HZ3K z>n0NV)yR~Ym6cT_6bQQ#5DSXMzgx=bT6pUiYXZDOyCTp^Y{h7N@T|bY zPKGMJWwQ7yZ><%CWZ{w5Jyu;r>VJL5%Xhq+T>CuXgp4 zwC~3npL+l4ot>T7?Sn=bA79mM@ESjNCE8V56Y(0|wwcdXwKl{9Nc>aM! zLc^Hngh`i)=xpvvk7v@Ixw+AqIC^LO*5)vZxgGxo4|Ek_>}5eskgFGFx>$UzYSw}{ zM0t5z+GFiYY~DKv1(<%V5%6COf3We!uo87PlG5sitYGJ~=npmjdiSdla_WN~*t8H^ za)J&?LR5u6?burfm)c#Q0nFW+y4xWBVF_lf1Ty^ z1`y7C^E&$f4QG`8hBE}>w}0Wx4Dn@#$6*UrNi&G|w#n+7C%bY3Aleeqkh2%Ro+>3D zsMjzTocDn4r11YC*?;~@cI&+w60|>LZ1|Py%>ChyTW!0Og4z9fRr$-$KL1Cu!?=Of z9MAq+<24+adLLf2hH5HUd~88?9$4{?I}fjYF}i0o;2R5EMsq}`o0aruYoS{=5Mo1m zcV=_Z3ZiUYln?``n5XdL{99Ka5%>J2g)B334R9d)y*k_advIo$BmK(7N9g+PR3G3w z+7p$)ahigQh)6+B+J~zoZGELzjiNOa1}%^FbJr~7hQtNs+{=0x6zOQ(Z7IH|q2Ql< zYtNR_(W|MQZZaC@7M7c%e8*Vl?PK%dGYF*nqw%|1*A$(T?Bbl#K|h4i#YOMtAZH9= z_oQKvVyfxdEsvRWKW=Rnc@N(iH0kPpFT|DkIZ>V;T@n3QHB`k{r~RM6J8J z%i6E=jF)x)iz4YwK+;t%{YTRI{x6cgcs4SRZ7Y#W!?^zeB^pSe7@UGD z^9H9v+t@ilNYniAnq{S^epk0g_qq0nF*bwqi8l5%GeF$K|B8E);Qxqw8}L0DGhw;* zQHB)fYWnUYxZ(?9Tp&;b;eP+kfyEBlb(rkN4!LX^L;#e?cy{7?Hhn0Ia$!XBY7xe{ z#)l|;g3^n+f)_^CKdYCzc*)>zG*9`kar?RQC7*vSWxbC|#pZQ*r?bRaj@hHJ3ATJ8KV6aSEa?{E|6cLBP>dE(K$W^G`ELw4-q`V9Rl0WMn0-sL#wm%r>`t~ zbpzwOa$VTylKoAe>Ds5?9o@TW=(W_&9JcJ0(hJ$JpZckl7G&bHLK1pnv^n8^_{Ii51~s=)TEv8bs}HSz=~? zxnKU0d^G{Z<~DZ+n0FJ9FeU4&{H+@2ozAWw#!Bz0c8-4hL)iU|G>ZFiS#5vX0#KUX_n}z} ziiIGClM+kYna}I{FLc@YQe*Eq04F4@kp?>^Y$crJroC(G z@c^ZzuAVLkJXC@>cWO1oH@pS*-0j-!Y$I>-baDi*+Fb0s4KE_eJui0fbB)x+GzNGi zFM;iE?=mHyWpbwE5k{YW+}2P6Of4`BoKYm70X+D-FlZc^)9C7n2$73B)tL=I-4yr1Gi1(s!;|^`gU*4=k4`q5JqhuljWyxTjp2 zYal4gwI@b0Hq>~dVP4}lWK{(pR);52ZVkfW$xJ7g+GAHU7Yi=GU}J#h5U{=| zzt7L*HFnlb5(3sjf#R%T3Z1Y#a z^O$49H>57r_;7|M9PblXqH5j#U7cGQbEK3OF}4~E%;E$%=jivEO$|Dz)??wouJi)C z@;;JYRU<+du}2##knJyN1agcx`i;DW6TWU9AQ*nnsBu_4)U`EF@t0Bg?;=BMvy{&N zzX7{~pw_cFiyP)IYjSPca5}gvFNK_oSh$P-%2P_cN=@eeVreSFUwa}brdiq>KjzSR z-3`iO)FW_?iv>NVi3_4`pwtS^*3dZX&u09n zPCm(vltlR|r`M*3d~MhqDWeFu<=p=zcG)1k1;(7?mY~;vLzgy?{LCHZKql3EGHX*h zz%81&9QM4(@X_z%V)+sc#2N(EqBGrkOyYduzy6kEuBRklQa57ty>Gpfs$sbh9)POD*x81=08SeIFN>e|Ygc=bSk+_uMn*%sg7Ynxpl)vuz*xLv(!O zVRc{lJH?Z=Kq?J#5#|9J1CJxz5XVeFGYA?Sd5!$S8{*D2(?nT9n|hsifFs?a0rcS& z3v@bLUbq2s$nfQSI9N`!nd}eoi)75uB+jr+bu6~**e5k2BW5CTGWLZXC~`)^K56@R zMCt-yeT#BC`}`ua@i~9t7YF=>8ldwXZGKs*j>}B!l57X@NzGnFssrGw#f2c6sIhT5 z%ygC1lfh+M27#%ubRb%>0f7EH@s#R?N?#(|R>NbG^&E8m<00VUzC4>}d__kao#bkL z+3K8Wu0Hpczs~PwP9Y+@w3G|?V}#$udMZ`pzhxA z3XCzl-lA33(zJk?al?<=z@{|@dB>~+QXx*p2K@I_Svxz9WD8V1AeU54<_jQl7CSULv z>TZa;O=@SFIgg<7-Cw(?*Bth@CR0#7R~b!|{5oC6NddH~BK|m*GIS?wE+Iqg6=k!q zR$t@QKLKgqsEchX9Y}n!&p>mQ?sA|rJ~)*i5x^5Bqn)a$aAdF6{oyPuiHxBgUT9lG zHl0H{KNVrckBQ*e5I3yg00S&+HGu2i4wU-Z+KL;(akJv$^pLFNS#Q205#0ZYN|Ux` zBsh4X(PyAuIDdF7y55P7@xWWKZBrFh`doz?yDiOyfD#5|xHMk>9hcTG_~g^X#Pe$a zc{7>w^Vp(OJ7*W%BxHb7-5X{i^qg#?>gW=?f#$Y>FYIlppHTtu*=1j7JQRrq3GjT{ zIkF0sp!Y(vW;ggFt@F3RwYS}cv$Cv?Gg}ZRaJdnFVGDb>S=%A;{M6`p3K+wlunF&! zroGU>u}3%|^^lk%X2L}(vIvbf0?QzN;GfDYXy_?%k|V~^K!>Zop{z+X)bwM!M9gQo z3AW{mId8urkYOD{C1*-1ywxVHWHw_S?k};K(SzIHKQn|C2W;mDW?dB3)~zukjbapZo{-WkD@LH?W1RZ-5!Q&WD1@u&H?qhs~GW3AWgk zUeS-NC_6d#02+}X9!SW&EeiSM(w{9kJ z_7YJA<%XP^+&iDBzZnpvrKKgA?A;gcADa5GF-nrXTW8K9P<)TKAXSh(aVQpFv>7kJ z3y(%_=I7=tkD5-1Gk@@)h*tv1?+J+>ysD`r+H@wx3JT(JJmQJfQ2+6>-(W&O5^$ef z+6QRonej)bJE!g$Rak`mVx{Yaf z1jvD)7|?I6EXh<`6SIrAtR934Ik z<9v2!jW2}k($v4mwLY|Ge_KXON=J5pP8pr2cwWKps13yb>S$7D7hY*=|HRGBol=+d z?(a6@`=E=e;$pwh2IvR*OV`F;#<7>d3Jo09pQ5ln<+II7PZt~5bGvp7^3&bKr0e?n zN_aDF_urq3KRNS?3KYuQ-q9}9b8_4Dw=OO3)>Gk(va{zyKXI~9w#voxw{>G(%>B0d zxzZ$MLUA9Io0F@fFU$%+=ge)Csm*O#^=|l~s|{2PG$pBR!a`F17=S@ z03Dfw6GWly&W}=HJ-cjVA=?857fOv^3zqUDN!U-mq_6Qll9MNPcm0+F03mZCQs2&- zobmAf*S1aU5@6r&`vI!SpTWs)FpM|k3JLk|$h5cHq=VDbe+_oFegawia2%=sXOC-) zX)HL|?towkoYZ~NI50A@x>%|x(f#>Y(zaV=V|nQCPGAevCgf|6>Tr()i#Zvm0YkI8ZQODNat#>^cP1B09@DI3*F1`$sA7&od6rv4X@G zu=@Y*6uVHf+8^1`(zCy>d*Y{sg8RE#>f8W{$TliKP$&To60o18P_wVh{`Z~Q@A~IH zQD?ptgr)5oqWwOAO4PWait4*N&cF=wPhC(^%7WB9_FA!1HiX5c4A7Vow(nQ#U8)>WH;0zsQ;t^e93_JU_l>+yqR@68nZC>sR;J`CKUs~{#-KnBC_r@6TB|BA+fM;WjE(Z$Ro zDV2zl-E>nF`?9uzw7HE)7g(DJ6M+;K_J7}752|;bsl4({)`|`^Q3ZS1gWgbq#l33? z@8aFRThIEE`kslE=`9)54<_!s%FuQ<`Bn5w|DS!D0Zj-#w9F3ki_`s4hdcOz-{sZR zZav!HO@axBb((usOo6~G&%KM7^fKzpBwjpD_g@^%oQk-qfRfK0UW{5Xx4Sl8XN(nm z1=w6jOV2lK>_0DkK2Vf=<;>U97_ox{(aqX0X{jQ&1qruiT4Ayh)i6`;KeAW0F|#sd z>(9OUd8nLb+>Sz@8W^T3`ETJX5|K(E$0XLkUJY-r(fxU9T0V+dOK5;>u!fp~lVd#+ zSV-xvF}*j{ueHrZ02)m{`V5fL3fJ6)6lxM2{ye5UIMCZ0_NrJYR~I)L9gv(Fyl5Rk zDhjoh0|xZl!w0o@beyvty)sI38=qFcO-K`w{cz0_i2$jv`#T3xzrF2!(Z>TL zBeBTcBqf0pMU~y``GcR- zy|{42wDih=BQEx5DLIaohh5{Wbc1V7*1c&sJNg0Ong&uZgqm2vLYqu^c^=)of96Av z5q3#m2D4rp1S#&QUMe6oFw%8(@07YavOIs09|-0PNV%V;#7H2V(cUg!LIUzJkaj(} zpaFI!-FfW^_IOTa5_1z1&KL0&`*$LyR1n*LMhLk5Jbg_ta95w%+{hsA>{yElcoAdu z#`f=k>$x=|ijgoqTgWH%L`KvNBbz_K_g?n!4z|R-fBHOpL{VJQ{AiD#4yhI%M;{Xq zk*|D{GtQ*u}UXbM?xy|V?r&@GV;JeJ~ivNgkZQ&{DrtD%Oh!0 zv?D>UAZUavV#Hhj;%G0fe6tClI8M zE+&v^L;tzqa^S9cw7J!X|B?CQfaDTGA?kafAG58FrNZG8KDxC>%|fc>>*H+T(&NWC zX9C%^Q9_6u1b;LA$?9k^h6b1Aw`IeQLZk1uK`jU$bB@$dzVlpKR9`y!0=4s0$lgy6 ztHEt}O1djfm$iC5wqV`1Y#-FzW#`{q1gu<3+uWQ18}vFGY4R&_?=Z?$t0P@`l_#X6 zpEApJ>E8`gz7@ zyu9xT+!Jprnmlf772~k;dgPVpwYBUcaXc5D2F`bJE1k6@W&ZV@0P%^~k1r|TMdv?! z=)1OsC)L@@_rN>keKz17nvUN;@6RBIQb1Hfyz*Mje>s-;;{)~^?DX_SND!C z1Nq)Iu^i7zO}z%LOA2pz9J9I{Id@hpo2`!5lE4MFSP49LLI-CT#X-L+Ay75O>bCPh z(|On!T?Imz=yK1x{?JSeYuwG`-!CsJ_CZYv2}|w$o$PDL*~@0iDysURYKaF?vwiu} zM$HNF$?i@ck&~GJGT%SX)e$$|mf0JwzGl>^`MLM(xBFke|HhRK`OQl;Kg9oTiDxmv z+07OhTES65%7z)T`@10?A6M!QRfG|~F*mS@ymXEy6K&T^Ft-aGD-|~C+&60zPwqr{ zoCL%()(eYXx2jlkMEA>)S5%J;-}sfxGQN-Bzq2^auOGTN1Mm7b#A90rT>aeKIk*)L zXG`$tJ|NszV>(ym_7t!Dm?WcjEz%N>S0Pz;nZur^1??D{t?_4(ZT=qpKCOKI;VRZ=<&2h)Aq$QNa z!scBhBWP}fPth_kG!f=ka#^Z_>+}0vpcC|bAYlgnJ&4=XC2i*;+oQFm4A7m-?U3jJ zrvRVmJ^MVc(X;S+6jSifK$?u8eJ{1L3}Xx-OUSIiWYkTbTOhk0n|Fl9OP3?!hB!@2pjdT=}Yn>h}TJcS`ZIJ4DPF6HSeo4{HvW zOW#vUhS`jKX)x_`v>LswqViC%+G&Ux^hy&&jTUhh!{5IExbhtoSxd#0fnrEfUOQ@r zjwk~nCFcDS-NWv+(c-Ueg4qRv{>J1`9?KDYcH`K(M#@H^4;(O#3EJheq?Yam>=XL5=dZ z4$QweJykHW&}~z1wCagb)kci*L&U-4T%x8Jt~KnHU8K)RM|N8bLL z+zAx}>d`7auq4gT66eCg1k`NXu?#-3SLQwPuYRc%|P=h!Un9 zd{J7t&jWi;?Bbt(Rh(pe&A7Th_< zIS?`}?hUvEp{bQ2?=NKAtJLHq>50{^4t-CK;NbJL>CMgYjHll=XDbkd_5XHTYCJ@A zWq|sxV9z7ub+_q}l@imT2|EVRr!k<}*Z0y8V>LYk3w-f(2Ou~a9-pckO@gKtcB>U7 zrcwlq65F7Z)>U*B=+WJ5 z?BHSR+Ib$i{NKbrQ=!hfhnwqR{ zX!zDVLdSBlhBpJN0?T~Rd425C+sWfOL7T;Y-!bMZRLO!?`^KOS&5fZdUp9_=c&=Hq%(ST$a|nWF zBu6I=JI@}dYTILhGTWIY&nf^nXrt+K12u{J$KE(epyTaG3A(ZB1oYT}ly?=g4UBmypXniCu%3+B`BTbngbCdzr1q~z9+ z_8E7!wr|;|ffE#Cat~_Dk;*>iZ-B*LD-o+4Sn))~>5hCJ(!9#sv^}yq@b$TdvG2Zd z`Zh})&-UlvQ8s_uk2)#pN0@6$e9UF1jg9D=Q1fCd9s)j=S_h0OojchKu>wG8&`i@uC2p|5{+J*p(+m~Kv_CcO)R%5J(xWnwbd|ZnxoXzU zk(k$!^l+Dail6E{=$=>0>V9WYditRFzHre!0ljPloe9gX&%ylr)X!3pUv_O=56A`132clX9r)#23B@9N-jBP*iZcFzKbyN*p3#Uvw$7AK))NJ}W z0~v|F-GonB8?nZf?lE*BE)h+I_dYhimO#HY2VVb)Yb`>%)@F~z!+$!ZB^6&~4baT| z+WA+SY}>IV2j)2BjWf_~rh5Gols#6T)oHhrZ8sE=0?vWrUV$FV%%{CkHuIcI9R-56 zR~Q7!)}Py^3`vtIw5Hm|WeLPWNc^xL6!Vw~!M@wh{nF(>{WkleKur5=bsVVL64NH} zkY-RsUIspXfk?EDc0{OJeH$RuGC6QMHWu^-<;tdsF^)@V*Hv<(&)Kqtoy8C|_j~Ii zi+Y{tV|q~a?U}`hQ714Q@IKvX!ucUycg)A4Dsd&>c-tEun=OLLC*Xr9{5F@gva6Co z*xCmd+;0&aCU~G#ZFha4=9o*sz3uTx3Bc5f6~R+H(8&28#O@j`6NB*SyQ_T@i($Qe zI1nFwhhgaUw=y|_2JvF!*)h|;64R{awXdR>?kx38fwR!D-GmL3KF~Ut?RcwWb1WsK zN`25B&L4=+6~pE-z8$FZr6{RkU`wwSpK?TDECid7!Ed6an7^#CoKKU|$IY zk-ugqPqFL#d<0oye9EX3r zxV!Ywv{yMGK(9fG7xKM$EWvKq!$lN=tl7565JaxduLi&}rV?nmEF^GKUxuc?eaqpb z%RSs>;NfZU)F50s{mC}9+r;S|xX05r#=@E-0Ri})X;{m$32ze}{ej&)9le(}rc7$m zWt`*Vjf!PZ)!N4~)6PKuBTcBDZgCcLyyap0l1&fuSbPCIA=esekBIg8-bA^!J3grF z7Q~x;54Em8Q4ha`{m9F`YlP4*pPBP*2jmOq!H zPm^xV{x`ge&2GMj^-+t1d1^uEvTeT*@&b3JK9Qj*Tq?!GW;ZsJzEWbT&HC6AJkm?7 zN581}6Q(Y8o{poqzwHqy~2(DNJT--mEsAQ?((s)gG0rvoM&T)oS^0MvD5lK z?%wzbA-c)4ulS&PsUy@&kT1ABGL`>>MfAy&V!Nt*` zYDE305XxSsReBF}>2Ca5P%kDNOZ5-?Ek7aW*)+mU&{tb;>QmAKR)d=(4vTNO)<&s% zf4sF6v}jn^sB0S9M(7{Re5Vslic7EO!b)l39JjM+2YoMlYAW-Oh&(}KHXh6NVHsKZ zqUr#up>zm}<&NOYhmz6vx7$E(PFCaH7MU{G&iW*|w_nJ=syf|0b&fw8FfvF{Rkq{L z-0F3`v*5P|26RDhex5@gypn>?s>)N~+>dRq7*6UzC<1;WI=7z_HqY$*-9zI@@ZmM^)0>%_dq3Ob zO&Ej9i?x*`rns}m9b9eZItKD!B-#n=xlwrMLq#jA;Df(pfbW@46im?u=1qOlXW$7l z&fap+?7}BSuRugZ$V^+!f1m9-Wq*il^Gg+Z68r0ZEkpaAN=ZTfX9G$|_n4H}Dn9Z< zCPz@nX~*bt?^V?aPN~{d40sEdFu2;>JsZtG3!01Jr-3A=zGV|lliyj0o*)wr;qq;4 z75rIJjTB9PF$zgkh?z`v3$0Q<|3!2Zewp0RPsChT(eEL|8Vj6 zs};T!&2nS?VEcj<)K!n`;x!n4_ca=(k=xYTDcQ#mh1G@^XELKs-@hIF?GT%2S(qJ~ ze#jbEsfgdd#j0xKJmyW66=&8?Mg=}$>Z{4wdhu#HWJ0!(EYHI}*>(2zzJU}Y`-IQL zE)0BI$k;swp7v*sW=9>A9ZSE3`?5bC!gIFwAHGHOdQ)k`FUzbz%Ize*h*1`!mEF-0 zo@-T4;BV4a>Dv3^FaA(*lJ4a!^$X<;#QQ#eVh31;TAKnb1NUts^g=LFo5@HiRV9sg^@h5|Ghck^2m%pXr0bBkcS%B5>kzZP=2M) z+iaKcZ6b5%v(3%;4;9W(h%#AP(Z$AD#j}dl2nYLJeoQx!i1&|D)75ziM;x+^x8I5G z{G9zI(hoq}EE#El*Bb~^{KANIq&V-H0^<79rPV3L!GdGBM`NZBy$;j(2pSbFenp}MvCb>~>s-+XRwwy{Y?{h%y|C4Qr9(xD}l2??0yPOciQ)-e#3uZD_zJCDTFf-WN@Y z*&|(A$Tq8?X`^*YqB*xyASOZx+m5UtTlnb1{}ny%9>;Lou=j6KNN?hk4rk*~NWN_v zTYU}YI%`u`FeR~mE3|i(624`D{MPCqdoBaLmsuNOuMYXv{FACajS^$Nn$+k@LrXln z0`Zf0-tZxxDRhL4)Fy*QEPvZ9|3VUqNxM^-qcxI?hm*8XDk`h?7F`R&>;9oDe-t_h zMj|xl)xYPs?X}c!Ijac6FB8IgN8!f}O>6j7ynsPspg)E0^8nfiC@5}%`NF&KLb8(7! zUuAoZ@WgL0@YD!o46bSR!-&NYcW4m5_2)?nj3C>e-+WdYnW4b^+TEHn%ImAN59;DB zK*N{p>=~H8c|?@MYU+G%enR7Je_^bu{q^6t`{{NmhWPk`jL^`jSY>XuUZTD^*Isgl z7}p>BwQ|oWeo9Hc^$qq3O!==YX(8P4{~uXG0ao;1;TQeB0%3pOYu}{EEJ=4^Vv-v> zxG8;-B3-i(;hOx7gatYVo=Zi0{nQ9F+yc3Z+rjwlA8TSizpl6i#L9R~`&+DdK&({I z>llw-9a~3a5@6m1 z(2aRFtsM5W@l1FNtEl*F*JAjYxEhJ9wduZ*EgZd=D_sr)-P3^(u}Udq7A>-NK=bwu z@9fVIrsOk)tSm9l*n@Ctw*SZYVY^cjKVj2i$1(pg`d2DNyPZF~ihJyvIxdtTRbz4~ z_oEFb`~_SBc&opr?qlyW-ul;s+8{hN`Q*TaUgUE#XOfX7uf^b|A$-&L%cNUh!D=w* zxt^SxJKIu%fZm&Dl4W)SB34GL4(=Nqa%v5iKh$PC;bpmC4cieh^yT)K=%*Ov*z@zV zY*EIa&IO`5Wr~9x)#4A~5t2mj$FgS%zu|Si>;UxR<-OaDT@Xo`X4!2H{omlC^h#C^ z_n)(2Sfa^D!>BLcg7An$-?D!SaQpX+9`8;4s`7aA^!L1N!;H5{r!+6LHvJ<5#nS;# zvC2otteeQ%1hjC?W?=XQd!4VlI-WW9+L;b@ukMYtDro=ZIbX-Uug(l4f_W};S>LNV z;chon!8eD09M{~7pW^SjaFtcagzokaxk#T*#dQaFYi{%uKM~6*;LelkW&Fo6RATrk zxMN6OAzc_ICVYbvTth;#O7!%zRM;^;6enEjLJxq>hDv9A=IOG3x(gT$k1T)^>a{jp zH%$VK28xzfHmUBAFZGC4>Gg(+s`Tb{IPtkZF$`TJBfS#Lj-0ymEi$1`pWGj1jg{;Qu$E;f&Zk7>6m zz5wz%&O z%M~=It??i$Zyw;}Ec5(%u9UQ{Z@^2ZUz=*D4Ws}=xWu-sUpxyI3gwlJ_g4(xy+6Mc z{oA0azqQ{fZ#G?4NLqHsob_63plFni4rlwz0km*}-5wTW_`TIHfZuZDjQV-urIdvR zLeyGo^EYr-$w-TefKlH?+%)dIoM$YNUK7jk8!>!RN4>9l4J1CXOQX-6Uu~dh1UiM7jayT0RMH|Lt~L@ft~S*5vZ)YRqrV9M zq`<{SBJ3JZCtd+~;o?sd_@{Lk)r*!3SXY%}m(0b$+R7Z@&zk6eCGem>1#C6=(-s$- zo4am~EkIbio+H17WsVk>%N+!SHv(`Ru+m4fA0bRzHqyU6texS!FUjnjORp)jggcy! zRI!hY6b4V_CH>2dYnB^AgMiF#@^mlpHu&q8|K%L&vMX{pXkNVw;tvRjKhncCoMNxa zUpB7v|0`w5atsX3d_nt9=&->U2m3{H8@rtP-s^<19vJms!<%QKL8h%f3Nhj(OsjJm z10PO#wjEj%CE8ze*TqHzua5i=)rlKF!xn6NPMPCHYPlirt9Ofm(U+2uj!1`VhhX;D zd&3AQL*_aDm}+^8Ue#yZTTl*Oxon)SIXeP--DTG}=rnTb?&`tne>4{```qAZcRB2f z4gF~f$Lwjo5dJtB!K;=wINoNyU>DMcMx!6#{rPHd(Z6Xo>{Cy4DGB;e!UU#9{ zDWNn@@SIgD9fVl~p0`S87fTalhyiWq{YL*snHiOi>5Mbwl$He>?9i*L90YRN2I1M* z3K=P#tA}R$Ujuc4@O)JvH)8)IyZN~o9JJXdxc0MCWzz$R~Byj9fU?fW} zLM~d9qYur2#_W}V;14?Ep~Yiln~9+L~etXulR>w6@*FRej5H%-{*V?W%1E zcJ&_&X@7aM_ON@F-;5nZmF0ZQKzwgcU@81We_vMuFpz<2%3TgC=Pa56l#gPWw=8}8 z8$>u}mpY9oW7OV&OO99kXWAb>Jk&>-R@YIi84cE(j|OV~mO)x4z%&fu!K%Em`TV+n z{Mi!Fq4c_vPYhtLm-Ge!S&HZ1rWgSO*vCOwk^{mvNvGoHv)eC%Bj_U~IZR$#LlLem zX=Iv$m3-oDrODAwOkkVO;l@t?*?$+C=QS{5BH&(uG6rF$WBAd!8m%t8aaN3o-n`NoayBEE(R71Y^Bi_6RnFCI*z{U+LkTmCT8CwUm+ zTkSV*4%#<~KqVaVNdkg>g4;+C6LX@kXGwT_Do^-X@ z$1@hVR}d;8GTK3DN9K9*A^FkWxX^$QcY(xD`n=PqA02JaN>lH*f9R1PEffFeaOF8| zL^V#7A#E*+Vx*cSdE7;4X#m4)rA%I<#AhU%Q(glz>ySobK=8%H&cnH&6$;OK+^vAL zUwP`GqsB7Sk{=w`u+ug!iJ-8bm)H}ERKM{iwzym>DkZ_^;Zx#HlB&h`$a>=1WN!gQ zfhIPzi(bN^bb{ZWAj@)8cuDZ?%lda89G83-DaOB)nPGv)9^Ln} z7a8foef$?hZV!lqA(yc-5^j|mGF*E8{cZOrkCAX>NXYJLkgsHTk4-6cQ#?2b!vm<*!j7Ht4#HXJ}(wT^5Pk4!CAy?bqzKGn2GRwx@hjUz;aKWm(ZchSV z=vFc_C8ETW6KYCr`#{RxGw8Z+-2HqiNELT~u{QqbfJlLXUZMENwPWvd;%suO-R9Ls(O4m7NPf3lHinJ;Bb+C91p9r=PN!!Hx0hSrCS z?DW_v+SsX*L+}zOSvp{XAsYJa|02A5Pvu2|dC9QC;UXoo4ZNP$(wqeQ2`0|$ z*YM6n~ke<_=Pc#ams`?9`o}Ucqzy zdZyssm3_g3yWgEouh)IY)N<=3y?O12%}C5m9cb-ry>O_1_}v(uThrimZbOzgYfxNw zgQ|AEpcg?+%&A@fxZtBi{>Ij$C_G1@In>c5;A)>4;RI8M`Ix{3On8=Gb4Uwd_P}b{ z>-aSoc>2Q>`%f42dHv~zDQGZ%9AtAA_$fG=LU`hL z$L>B-&Gu4Dsl|`Kt5Uof9cPB97=>=)$)n~v8fER!60AaK`;C-o4GJ>2 zNT~ML?GQw~^DaJ7LvLAx25hLHCcWOpT{f?8tknLk$^aC`6F$c(_#+c&>hxS&ru)(L z{!Y{l2Pepf)@l7(5AcEE5>=gk0aO)Dt$@6uS5=D+`ad+vxsTj#3wc%y%=@)V6Fnn! z*b~I&S=ALk;yjDC{(VGd(OKRe1oC2U*a;~kW=j~XKUqPqhg@3HPdv5va zU273d@_Jp1Ji90UGMNi8(6<_o+$YIvI6`Z9EG|`)FPP{=$0yf|`E|eS2$pOL;D|Jn z1^vRW-<~V(Q;1VdT7O6LC6c@2*tmSP$?{pPp}p}b;Pt>4qTb*t*MenDHS+j#F;Dv5h0j`>kXt*un@p6l;n?gD5> zQQ7ed4w}afcOCE zye>G{^R;)@K3ToChBD9_8VuM!w0_q<2SQrQ@?^^g7>EB41 zE8rf6aeQ!Gzhc;r+swj9Cwlx?b@cG#492=x@vRj3;c7_iL%ym_*9ZOhX;CM)!E7TZ z<%k4`LpNJSTnNhhb!`0eP*<$$e7D$TMGQl@Ot?HA#0HW326!aM?*@iXd3vVL?g9EKj3MbAGCNbPQEaLafH(ZdMGHIaXsNoW)Kao?4n>L0|cz!PsfbC?pYjXVUvs&`NqCaC$omx0A+ zqXYid<~6G7ezvz~cLr^>&Fz4Vs;({6V4@R6KLHcdq&Qmh^fs%s-yma~`h(h;Wv3kRTzfBX9&QqC zQ!{PwF2SpACpK_OgOz*MJT=xVV$9~cn-qbX%SLPHEbiKH%NBcOLpMRn493QboS1^FYm=aJy#))c9z9^p{yt!__(Xl>eKzn`-kg@KDojcPcgZupQVu z-VpH(err~w>Q!RR5@21AtC-n6wvyv1)TU$o^~l1k8e4v}q^~O_q59F&T@$GdB4;FY zU(k7|-#jhW?Ea+b@z3E_1u_ez%>#MC14gNGpp9P^lU%!MU#lp%O+NI+Q*R@Fl3=ui zL`#R741A?Q+aX^&QE7!^$VENVYyn3248kYU)y4-P_H|vx;=5`J>%;C|$cdc5+nrg* zjzoM&W(p`&>JLkfIx$>Ap1_Tp%AJ$aT!Fjq>0KGWyEcdid<@gL($4Dar)4vldhJ77 zz5tkOZ?CaP^TTlNS`)N7(LxGbxr`Xjj&KOOZwFtgCR=n1B=|BHEXciE9DTA8?@{*D ztz_Qy3i)g6XZ|)1-ksK&9&nY`#8?p!>8=gWW34b+^SRK^q8Vk`hoMa!HXCpM34Gu*yXM3lAv6S1B4 zu*Fq7wc~SYj_SeXjHJsaI7XDe8mfP}9}ofJ6=zVH9Qj$8$Syj~b&fOVQGuG_%66<< zqS2C!jl@05*(a}E7*72*E(hox;JR%iUe}2O9(Q6X___|x+~FBaGWcSmuzL%!AIW@0 zh~i7?w;jmKQ(?=5Z$d#dqyvoOi%5MFH%vsz(fyU2I<#!+>58g2NRUbK3oAzUd_&*% zI`$4O*AKBXTIoElEPC-WUDF3KcRRRw{`j3!dXpgP>iz=!U5Rn|P{_5?JP%JyNZtGG zr`)NX&qsUe^|Fv~as7hZImxw$R}$S!R<>6Qt6ny8@7cS01}EJ; z+OB(U(5aAat#AXo?1cABLq1vz7H4~pb#p2m$2+MOz_T1IN%8gJVH(h+FjZ#pO~Li& z2Xp?J55~zKb8BOs=APbStGvvSKUMg0Vl(9!r=;ka=%K0HNP#$?L)nq`mJOQ7YculTUxk^NynC&`q`A6r$(H(uGs)hk6Bc)Ne8Y@37v7BDxKkW0-Zw9a zOjcmlXhSY5f#@V|)=Q&7fp$npd8bpK_N2brqQJ5BdX^)3Ko=|-cQ$VC{TjUE4P^k? z_^sgGL0ON>mrOH`+lUUTv4V1*N(TV;i7L+hT6|A@q;6mr(KcGH=28n7H0RGNAd6il z7>?G{n()3u);`lDu}c3w5lqwTepWcxq6#I1w-Q>6ttfG?>3KSv?r}Wc=)vV@5pj86 zDKUpMR{-pc7sYS*^QKfm)b85*Bdx=et7d_bk%E(LiT+1T1X2YdyGzz!aNv{~C#lxT zinM4wHn3MRrZe+KY|hSlXypJc{V>wkV+GsTzWQ~d)6amlkIDtMp-S&&woT6=&C>vE zGQ{X|Qq|7JP z&@zQ~thlfZsR9x9RSvlt#0Qr`gvB7dTZ|cvL##_2kM4r4vF^^OiW$jm++vpq{V?xZ z;<~cJG@0`1wWk>0(}!s}ISq$nB-EN#kLDMC>t;KVU{jRQAq^F?}{6ula;z&Ew!;^sQ8hzfpKZr^7v3dHT;t5PKz zI6nC&-t(vcZFhlfOt%^QPC7ddXhyr8tUwFyWtykLfQGGzv!-W%8p^j_TU&*#Q(3?E z3lr43%JXwU6qwY3Q~`FxTr-u<+Jd$w=JI`f%O{eBF@(jl-w=BpUlH)x!RXt{wroP> z6NXSe$*BRvv<8phcHmZ$lTh zPGKCUdcQ1x^zXQu3uW?E)%_|#wZySY@^+}iskl=^0vOs9w%(_KWB7#mn%Me)-nIU9 z9&iB#mh=Q#VtXK0x1j7y*+Lrf3w#_g{r)7BWNw4oXxje+wk8%$Lcn2GltvxPuLW~j zm-RMAezXeChdV6PW!llohB$ zJHtpjX6+%f+?EMC#lP{U6Q9&eJBt-i#7tB@&E2)((XHCFA3bVwBPslKz5VxcE7_cU$txzCA9!92>X zBkIfCF3Dw;u0|ywIAF0guzAj7m53Mu&=cW1L_FJ!Uanl6uEiP8rbYDOIU~-}ysZN~NkUDvT<;DptOFPM#HTiVyPOJCX zW-Von_L>MNABATUv}CtDF)VyPY+&_k!lh7&n?(* z>4veY))Qh>9NM!y2cO<7bkpf6mV5Lo(NxVDNSc!VnmRmSzuj-{keQfs{nnDARliab zIR6Hy=~uaWa0jzMvq9k|^{r|1Q=daj=2EvD>uI+LEjQ*=NOb~4xHjlBxony6aAkSj z#0f&}(J-$%cS}8?Vz37bwn6)k>6_y_+(5wzy2UpidJ@=u#sWLy-vDsgBS`M+&o0b$ zV)Df`U0eaVWz*223^B6#2%W|#t-xRNLuz&Kas%9U50sRIpq10n9Yw8#c~|Ac$rFsj ztC7~B5u|zQH~n%a^=DDTo&`aZEgym;A3T+@9KYeP2f1-A_qg(<;M$8NhbSHm88jYU zhZiFBgSRp3{GV7-leLS7Q&(&s?+xW#u*}!K6NaE-pKMDM?3TtkHwwG7-Ub@(s<^UMo>Tlw9t@`} zfo5U@m1qb7p9?m^QDBq<*L|i>q$}6S(ijmD!vlBrhv#L9aO{z1Pvy(?)6e2><~ zbWz*1wI(R)B^Uveiy*zOoR@hFpA_ox*j6-H0NViv zV!1NWo8yUj8ZW2iku2u;%15LcS%_3KQ{zppMX6P_M0M2NNV*G%`0J|2BN_E;A%VM# zmP^Pw(e3S7+EF(}&5u74a2|~$rGQ=abfN>?xHxW}a_r(R!1)emp6_roMl!e-5S_7< z6!4LrByrtsnbZSP50ulLxjB8bVt6h!59Ee29GJL{mI0bbA%L% z>@B9OtSbho>~BB1*pY4=?Z~r(0=`b*UJA{RSn&PDK{8<(=5~vguE@>0hiWfn(R4)$ zk+s_<1xt{q-(J4`)r5?7E z7=g6uq*0QGjuf83ZwM(BYWnh>y(H2w67b+0Bj@7^r(oNK!S_~MCtmv|ljrKHLrAB< zr5$GJo(*Z_wcr~V*#$XC*Sq86G}=g&i5l{2gND?8jUYymJem`aG;hR`WF)Nbe$uC6 z0~HcBAVB=+6mJiT&?(#5nx99+?3;+|c<#L^;XAUj7n5#u&UMBc?Hc|r z6gdZ-gxE?Fs^K{gs%NC-3}(&U{pV*m{9jeFX5j~KXIYPGBL{&yd{`KFD!V}Bv`SP` z*$U-x)2<>Xm6r`CUx^*QR4aTIuX)9z*AO)35Pe-kF^JT2&N~O^@dnbau|1DWUkgFUD}f&-Q>ps7|8>xu@9@)-T9Y=WneFEabe|X zWR91s)EOjkgED>T+RIpS19;npbP9si%Bm#xHK_44 zd(La#ucU@$Tfm9mfB&>VsYf!gj+nZ;PFcDJT=9LU%j>nsj-GyS8)vbqc|mTp{nzH+ z*?0FhESMj=d7*B=52b8E+jM)jDS>K4)2@t+4-hO&9Q$O5{_QIL>U*(~KWxAwHpr&? zd1+V759gnj!{{swcWHu>U>9B!ANgWOpM}<)brJLQ&)!ZA|!~-`&$w5 z0YRYaE|5_MM#!W8%$oOB($9+*fCIcxwpjHWgK~Y{S&PLG(55xvvlc-L9bz(?6veP_%FHpa9lazqD9f${3Mnr z8QG{0E}?hDLVn>_?~Jpfb>NM6KHG!o?(Jldji|>!qlYc>80U1$Acx^+PC=7taP)c> zdv#2l$tF3i@;z!aq6K9|Ze_O9Rgq`KS2-ZB5Uhv)tsu}WI>;N;*M%D0!yk~HEe>y)n?9#4dw#9a@&X9j#g)ma+^7-xtr*|$VP79 zJh0g&CQm4+xAz5^PlDTkgVMvfdFfOZ35ht+rxbyjcv&UKF_JBmnuyTy!h$USQJwNc zQNj8JCd$46Yv#u`kUQ&j-o6I}c?Imht5?uw{;N{}NtB+>6 znO{f%61{B?|Maf~m(V;S9{{|o`s%s6ZOH|1t>#{7L|>3TyzH@wyy=lRuJ{oY^B=RZ z>u&Wmu6ge!<)iI=Z6}zICwVrMq6Ij5kc2v~<0K2`#3fo(dUf=r^*rjzJp@2Gbg^(p zWY>DXw3(Pf^^Bvxv%}TQ>3rQM4uqK`j|x(iuex>2ocG5Bqi*sQTP?GpQCT&^@Cc{@ zND(x2lV|)ufeMsNPkwo#-vx~)g~Af{;v^+qHdFYr10j0`6o9E63ntHQJ)EW>wqnz^ zaXHY^X!+s3()`2=sodP|zpjA7 z+3rsJQ9OB5VwiLaSbzJD&NbV8YLhv_m^!;U*paK>1uDR$>~CyY7ag-{;d&b-qPm&) zL;Nuzv(E<|ith*WzK@t)klu!T_rjgl9laBjQ3oL+Y~#H=*P(|Lm|t(Bpp+0`j;epk zxcT_bH5Bx`NhDXp4o*`WPde;g-3Xh*@f0o;{(b{1l?Z?s;tP2Hj&2(WN$8@r?{v%`g`VP6xV_d`FVt-? zgUBY|I4zVy#qg5g39kCwmYl&=U)}nb3#FYhXC_Lw{CS^jEpC#$IS4tTNgvNq*H?P+ z0mdJBP%M5wSm=D94e@z!oaA$eiI)PI;l~)2CC`6I3oFNI9J*rUCp!nv?mp){d#ZZV z<4A@#fDF@edlvW~hxyS>!u0XTcv0Je7PEW5gf7b@(|mYv{GB1au)wO_6JyqQm!A-N z52Y4lu5G&uRVU0kcFsOrCM3?nx0lxe=$`sGxL|AbrCsT#e~2Or++I8lZanYnR``z~ z|2Z3aZ={rIV-|a*(C{>@^C56zuWPLYzl?YqKI)?fq$U2 z9tGLQ>oPd~mzB}Wg~W>+Bf@KZhQ}}^%>(XXq*|uxgyle<_EZpM-M+HwtvUX|aAk+= zXO=5Va8aAHB+%M#XloANW}pBT;?q!M-7)`R}{+ z)I4)mU4S1{g-Tokck$MG^Z@GiV^jN5`}*AsM^m{jXY~5xXs)pQS#j3CNVcbo=z9#O zNzEf9q}%ATB``Joy~|1;#_{+a@>_GqLjQxMWPo326IKJDEJOylsX!DDOUvb0B%Hqe z7tR>FSpU=R20%~^lL`+k0J!r%Z}!^&Tv0KKETB3k=dtqX@_2f6nlLB0&N;+~Q^~oH z;aXYi#j#1u$L2_y1pEOfxESwytJft@Diz#RS*ZVTt2DAUQ?5u4AOvo2PX?mzU&FTD z{qgA2J}YmluB(A3ZCL#UV?A7`zS1Bji$2q+_whFxP64S8`adpv?hJYS z4@3*8TcNffV7%)3v%C4MXRL>p_cq0s7MWl7C^vUs@KQjw z9d1{+EB^XQa=vA;pQV0N7SA9}0L8mUu6JBL-aX1}8?8paw z*|9A-$}}NTZ2#()`>h(ms4ciD!z};SaoSV>#pEAjWqyE>7BTz}R+j-F-C+s+sBZ?p zv0cC0YleLr-xe=lR#}x_y8YK8nwOr|vCoW1o+8IZ*zm z6>27OZ_D?eP97omX__@in5R;Jsu5dUV}Ab3IZ5(gRH$Ad^zn48y9L8c2He3-9|N>Y zl7!&+Z{y<3~f_XM44#^cj>L57_Fa-d;gl_$#`WYXS!WE?%^0)nN_X_6JwffM||`mBewO^;RX6b_E@9fk;^1Cb|fhQa3epu?|dUSTKlL* zR0+U%So5}Mdw?UkAqqsh59fD+%jY zdns39j^eb8$YR^?o}T+g4_D1UJSC&%~CMO?&?ghK@%cTY+6~ri~9!U#_ z0GQvO<|tAVmBO5t_qbT@t3ao%*G<8KQ>M&8!g+24*6SZ;4I$6fM-nRlA8f8YJShPG-? z8B(cW>9Uqe7}Zht3F{B-`VRt{)+F40F^J;@rJ3J4#Cnqe&wKn{kbL6YI}F=j4CqO| zbmZn|pkci#9kcGg_q(H-DU;m#%K zVXe&Dc4;98u_LrynMvtcXCwg5l2();DLtpz{WlVP#DNya{>-CXAKXKwu4XHCkpTau zN#LaYb|w3T@c+Zk_el%$zQDdAR@DFQ|D=OT*o|3IH=z3iGjau_9@xd4>n%x_+N*n|6?(EZrPlYDr~d^( zB0uhT$)iJ^GNTz1Z6a_a_xA#LvfoHM(DSLoPs-t3?Ny%ggK%MI+TnLnY;`)44AOql zGZggDexKq>wOZdm?;j}f%Z1C-kwJPhA2*JvKs$%c=0@i2r|f=Bw?TY9wMYHjYh~Ts zJyO=D9w}fc-G$i6o9Ovv6vbdKcD2epK6wj%sNF; z=f(W}ssJ%hx6C5j(?uHwQ-(?oK5YM9hAo$C*D!z{buB;n_^Pi6=@TZa^_twQ| zVhi6E0O|z-B~8m@qko$5T-Pht?z^wDXpHd#`C14I;iwx2r!Y6P-|X+Z7qWf&77_8| zsh(Z)r+KK`Ss72FXYE#>+Q{-PlKZ9ecN&%0z>yBhHhP4fR~L{&r+k|%50(H9b6$#( z=zcrM2Tgfxd;cST3S*Wu{q|vm3A2oiR2OpcSeZpH=QZ2$3YL8qi4$DO_7nr&cs>I+ zFkEENR zr}>*>ZgYtcALm@nDSsvY?W4^dqkHx&1q67QM+xV;^N}SipYPmBnyndeT=v7@xtNQ8X^NM~ZXTZ<8QYI(oo{2eWb-xI|a$ zPd<41{n4j$p%$>3*u0NBbQ}Wr=k%QfIg=cML=38}E=_n5g!6Qe%>7Wp@!>hyXTcZe zMc1Arh?ty`a9TVL+3in{PEAQ+EuHWh%~jyKW!IP^$xT<+#_8}?_qq|Cm5P-_~*;JL&P?|aYi!P*6m+6@Z2_h ze28_q&Db1fJ$e&90FNOy&sEKjSPEKnXhFX2(Hp+$=hb*VRpTt~?Q)g(>W34Dn;Y#0 zPzut+Ou&srUxl0ShCt*) zSpve)G69bh#cKOe}R{}1dJuw;38DH#He~i?~Z#7;!ytQNZpwfmx z+>~mciXpIqWh5;ti$Su&wl&UHV{&P4v-s7a$9+b|5Da(57ch%GoY^;pT_F{Yo#4KH zyhT7ac|`8VxFhK zyJK>43|_lG8SI;A=f}#K;-0BLZ_jwFwT-*#vA*8A>(xl1hBa2$%(*Nd$`t$}O+H0! zPWEi5eDMMzRO_}aD{^<9eR;TUv=7hxBcEy2YuVoVAlR7e=QgJT}06(b8Hrv<*cE)AwR4i+%LrWv-FZl73@ zbh)2ZKpih))AUB%aI#%RrWsi$oPlI3<>rtel`Z zv)WML#vor8TA8hP;V*a0_R%|IzCd=F9(9)JB)1iSmRa|#B$69?92EZ~fd=0_iZID~ z)0aJ*=eW6|!yqkY1P>(j;f!iU)g8i|`?HFT%MHB`Cg4NW#sLd(ly=PpronvKxdMGM z$Ugzp!ou}EW?vzCE{)ip41ENrAx(wVJLEru>(_aE(&4Vmk_#_AN}W!!BIj7uB02x@r>gG*bXW*#mN<{PlAKfHa9l%iq;d<1 z4&noRwZ4nZ`?d$v`}2vkmXKx>6j3@TD5$KkHJmfQu&{MmyOhMba1260&0;E6FX*;w zw=$UQx%)yH8C|h5I`w0zPyvWkZ6eo|c(}VKfF>z-n4MU{y5};(O}Cr;d*e8sPFvk_ zbKkp-)Q#C@kL#h>nqC~*dN_wJ!@%>5e+a_|PRmf4e<*(Sm3B4Yi3EWp__AtW{{SP> z3|49?=KTG_qdlB$97X+Q5={iqND6xh*5{PlIsz@uJ4VgTZCL+`3;h3Mt!t6tx_fF1^@A`0)wbcq0>2 zm^AzBuAaj<_NLu@cgEc~V}GV_cH}W*F6Pncr5QRpu`jx!kHi)@I${ZeNwD1))((wS zl|)aHYggw1$#xDGU)YSFJrYf`3%CevRE^=*8|ylHbXLqt=bq>ypyAd^Z3t4PSMsz8 zR*&5=Ui_q)l2ye3SsU3z|D-(4%k?=YE6XbE(3k0SGT_bd*lXkB5m=f)i2766-Z*EH zOw*d^aVtls3<9yj)X)J%@m-Eo)zF}ZtnCrRthQcR?H$6$pOHmyyjbiXeEzEq+Ka4k zYz@~s#2HzQRFTQaHFMhzP(iwDtaYgvMW{BR#2d!667FMS2ZsWcY1(q+J}q8nNGu)x z&X#25#eonX7r+*`<3qN)5vlUSW@cfaG+INC{1_Rt^BQWZ&kD@`av-K%@xWzC)F+wgX6d$g7?ly2G_WR0`li9~oS1 ze355}xCnPyxd)RY_mE)&`W2*{C~1kpE};k)PtSUM*p}{G6nYLh+^v*+0!Tl$%NM2L zMK+xlxBCXuQeloCKSmjCRoQ1{W!*Q|z65!GNN@POu@aL}YY2j+n9C0i>x2DGbS9y$ zz-ofoyLJjYFKI{g>jJ*YcxGc}=&+F{%W6UoU%fC=$~D5)N4tsIy}DxYUe&lY75GUA zghkS|rOKEocw*m=>nuw11u!mXHFdBoP zJyZh|0*pem+}H1A%2MAg^dfYZI9sj=7fSCiZ;26Nqf9R4Mi-I@EsH%zHJny@&fl3r z#S>xc*H(6%JHOjLDhvc#f9@Gz;{@l)(^IeP--&2!uhOMuHHKgQT|ncS2^b zZwskhO%k%=6u4kLQmFu2hDMP+-*m-jdxc{~SCf4J?+akKnaY)(NR(j#smCd=5CTR` zF{&rc)%IZVM|*djrU2xtEkEiQU?Xcu(nmaDBTL=Ij%H5S{?n1lL~#SyY-|X|HhOiO41y`pdAno!sFfyOBqb$<40n>eKh73e zzn!lO+_7ZYQa4$E(~^IAu4bku%D@wEPHc5;(=>bmQebVOLDxR6(B3YZAHt}aaIo|< zV0}-4E!71h1Gxh<6=Wj8hWZZK72|DZ(gEG-e4J(ZJHLG0ipd4w*1^V?L+38$>%91d zR<6%BOMDCdUcEgGGF<;{vr1#U%a|+Pu*kjn6rTVOUvpD(;+s-|DhTAJLkGi+1pGA_ z_nq+&>}Z+_8wl-8=!4BG9>Qi}8d{p_8b$|A@5Ia&ZOLvcJ0%L?Dyx=eDPK>!Fz7UxjN{}OCmT+Q#Jn!G2dt74Wg<2r( zHV%i25jLhk8r)=qt_=HvBf>|;tm5;WuXH0IlPKHT*9*M{%>_e?V%kxH&%d2b0IQuJ zt}J|qp>y@hfX0cd;c;L>$IqDa%EuuGWtOfj7)=ts>SI;p9lw3SxUzGN5Ba&4{k{R{ z?eLQHt=fw&G-0MEK}$&yd``I?Tg>}HNlDHmT_ePFqeV&J7oc$M<`jk80l5*_*fGLf zeECjZR$h}JSHk;kY58!LOu|0M+nSfPvF36D-ENERwPS%H zHq{0T^k`_;9v*DEt+#QN{UUoc_Ot|iqm&1Fd}PpTJ6Na}X;?B`I?>2Cs23Uz$hkm1 zRpip2!^x`#5@{KjH_?DdChMO8XM%kF3J|VN`04RO1NW^3U;Eb8lW7Aw#bcJ87lD^o+~luRc?7;M?CR%OMt33I2>y8bazl)(|Etk z-0c_fPz65%99&%9<>JNnHsfV0k8nS4uGXy#e8kFz$_rvk(Q8f(SWJvD=bfl{o95AC zPsxj2iMgRuk<@L#v&*x>zKC?qY+n#XVVo?$3Jtxz|2f2cYut4|^vE+TrNG?K|M9Y< zv~CjXGAcY_f&OsG6*#?%<`W8k!o9hvMHQXyW7X5moy4nP=;=IcZd(M zBgOv^VJ1Yuq$w^|8WbjZqsEt7MhlP2nQ3=|VsphE;~tHbJsv?&-?s6ccb!jxFyha= z+(*T(Q@p-2RYGX;Vpv!nTW3GStu^`+>EZPW;s7EY&P2)6X)Z03T-essHF$c~i~v_$ z?kNEY?e0NZxD&>+3h4Af_U?bm?$iZZ9nW>%idg5WQ?Rj@wW|v)BQ&#bOJ8r*LEQ&20;|&F= z-YTb@ONq%zX<<^5^xco|uKm077T}OW!_)cYYrQ_gBG=6#C%5=}I`q8I*r8YCWB95z zI81O%Spo*`9BVqjh4m97ut?Oc1SV3u)-+{adVly}2NW5yo}~A=7AyLF>{T4==uPxG z$Hp|XzAq%3v~Zj5=c0Ea>1z4yVAAe-6aBIo)kdD~IJ#|VsRXzOj$QKzt7HVZeX&@e) zlvc0<;*rX?4iI&NlTLu?aP80=GtjcYXcAX$(#zT54yQ1!lV+c4y5Pgf>j3%uCGWruRL6CJX*m?j7E62 zhVc|CS{cChtLJ_JW4#aRH6uH@Gy$|Zxw2UW{MMz7hrZkNCQBOWSkDiy+IKLPsMU*0 z^uN?^Gg_BCPA9(an_{Nq*b;d#*g*RR%`7_EH|_rE;|W$^F4_0 zZOgRY_Z{bc9&Lw58e*gVm5HAwC_RF<`te*tzkqMe&m3ar_I0=yW${F;g`8Y^{?3Ue z<;}oe$#;!8p-ThiayFZ@&v^D)u}E;fRWR-@YH8T4)HL+n&%VGL&EFXJ3G=+S zCDcDtboU=Jr%G0&23{mL%gG~04kJs`ta_g7*V?+mrGk?0FT0k*1%Cy$SPQYYhSlr# z8uW8I>r_~zTc>Ab7qp1BM@hJwa*geKuR#+-Zs7%b8P1mTQ6&i@z@^bzRoYNXwK83EOybD>o#j+HA&gMu+knIhd0K5jb)j= z`S3mGdOpv4HB7!?IH-=LfzvMViI+k7O&-3w%_3>>wktZ>Bf`ZTF^W+#Zzv%oN_yS) z?8{_2N9^ddTDKn^=#01D?AQDm;)rfkZDpf@yaGi#g76aOx8W>V!DdMhDzYnoH^BnQVd2W7R7ou8c>G_?FcyD*&%9WQ6Y#c(dY48lhcKAYp`rxp$)1dA+dS`Q8?O=Z) zX_d7`VWM(kzfORGAGXFm-2YbnR<>8A#iE5YF%JyU^H>Fba$dIU%#+g}M(Zxh%2DV;^P_QeYI3snFn-d3o`dhqU0CN#8_?cmzs76NmLVqyPeZhyPM z9kYOT#W)DR(mX-%R1}n0EAc_le8j1CZE(U`L1kb|#Lc{VZ-(bCihc#Ff6rW?^M^s+!cNI-FQ`m;A1;}}>WuHN z?^zb8#+O&H%7hT{D^o+e-+6~nuC^1`bjzKccl+6hFL%=1y<0x8Yt=22C%9~jL)RCQ4T}`+1qQ8qW6s7XcJhnR&v_*<6+z;=uxjK*sAn2GET&4TYmJs@OG)ik?3DLdfg6V zGgWmp1A2^GjmAKlOGuC-RdfKwsf_0y-K*tZzW^fsu(oxPXpmNGW0sr zYm*^qzti}^ZBgUCp<*%E;_U?31dQ~bzo#{L`;6Mws*cw;gvZ6j)qYY{eta!I;kJ2% z#E`9}b#v&TKGQcdeELj#|H-hibagPMZ84p9#oGGy%Bv~);tAUncX#*hfsveoUu&fs zoC;+q6=3|TDRmK-v({5wuDu-{Q{l5L@C)2O@Uar!d{IfRbYUZltzAwD={AIMS~*l%UZZ(T^ct2U3>k+L^sP#(*w*@_*6W|LoqU2@8k5h z+EiwR3y-@CIJb?;Z;MTdl%$t!5_Z;SGbSd;g=RbMs$q~1ww5w&c6NURw|Bcw2QC=< zdI?y^&4B=M&%4ti(Wv)Ol1+NsFG9L7vXRZhCls8x6b|Q5>o@@~GE2<@^-{E<=d;TM z)_pVA?``IizpMk;S1zV_?=`X=V`4xdU-;HMU7F7By=k}hJi+dV;^O^XniNlEYhQ!H`*@Nw1T+QVM2Bb(_s(EXD!7NC9e=}fnfGa4$K{5yv23CjsN9{i6D#1PMC!@XCp+ z6`2So6{-Cb6PSv}bt``BT$ffet%t<-KB^tmZr#b#t2urL`=!B;NA0Hl_Y1;s^F4>r zR*F3XY8I(L={P!RQxw8zIOP&IHJk7$-*Cx-muTtPCi}&?_Eo%HS^gQ+ezT{AwB6d- zUKZ~$M$s3k`)GuYj@@)oB}Lju{o1O&q$jE%VLMHs*rRRJLK-~vruj%Qi&sO9RJs;+ z@XU&`ESSexTy6IA&n}Sruj|{qw=R}^r