diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml new file mode 100644 index 00000000..ad8ef9c9 --- /dev/null +++ b/.github/workflows/aur-publish.yml @@ -0,0 +1,47 @@ +# Manual AUR publish (hotfix / re-publish). Normal path: Release workflow → publish-aur job. +# +# First push creates the AUR repo automatically. Requires secret AUR_SSH_PRIVATE_KEY. + +name: Publish AUR (querya-desktop) + +on: + workflow_dispatch: + inputs: + version: + description: 'pkgver (e.g. 0.4.12) — Querya-Desktop-{version}-linux.zip must exist on GitHub Releases' + required: true + type: string + release_tag: + description: 'GitHub Release tag if different from version (e.g. v0.4.12); leave empty to auto-try' + required: false + type: string + +jobs: + aur: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Require AUR SSH secret + env: + KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: | + if [ -z "${KEY:-}" ]; then + echo "Missing repository secret AUR_SSH_PRIVATE_KEY" >&2 + exit 1 + fi + + - name: SSH agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + + - name: Publish to AUR + env: + RELEASE_TAG: ${{ github.event.inputs.release_tag }} + run: | + set -euo pipefail + VER="${{ github.event.inputs.version }}" + TAG="${RELEASE_TAG:-$VER}" + chmod +x ./scripts/linux/aur_publish.sh + ./scripts/linux/aur_publish.sh "$VER" "$TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddee1899..a1f569f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -269,6 +269,9 @@ jobs: name: Publish GitHub Release needs: [build-windows, build-linux, build-macos] runs-on: ubuntu-latest + outputs: + version: ${{ needs.build-windows.outputs.version }} + release_tag: ${{ steps.rel.outputs.tag }} steps: - uses: actions/checkout@v4 with: @@ -320,7 +323,7 @@ jobs: echo "- **Linux Flatpak**: \`Querya-Desktop-${VERSION}-linux.flatpak\` — \`flatpak install --user ./Querya-Desktop-${VERSION}-linux.flatpak\`" echo "- **Windows setup**: \`Querya-Desktop-${VERSION}-windows-setup.exe\` (Inno Setup)" echo "" - echo "**Arch (AUR):** PKGBUILD in \`packaging/linux/aur/\` (community-maintained)." + echo "**Arch (AUR):** \`querya-desktop\` — \`yay -S querya-desktop\` (auto-published when \`AUR_SSH_PRIVATE_KEY\` is configured)." echo "" echo "Verify checksums: \`SHA256SUMS.txt\`" echo "" @@ -355,3 +358,41 @@ jobs: dist/SHA256SUMS.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-aur: + name: Publish AUR (querya-desktop) + needs: [publish] + runs-on: ubuntu-latest + steps: + - name: Check AUR secret + id: aur + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: | + if [ -n "${AUR_SSH_PRIVATE_KEY}" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + + - name: Wait for release assets + if: steps.aur.outputs.enabled == 'true' + run: sleep 15 + + - uses: actions/checkout@v4 + if: steps.aur.outputs.enabled == 'true' + + - name: SSH agent + if: steps.aur.outputs.enabled == 'true' + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + + - name: Publish to AUR + if: steps.aur.outputs.enabled == 'true' + run: | + set -euo pipefail + VER="${{ needs.publish.outputs.version }}" + TAG="${{ needs.publish.outputs.release_tag }}" + chmod +x ./scripts/linux/aur_publish.sh + ./scripts/linux/aur_publish.sh "$VER" "$TAG" diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index e8c4f824..ae4d82a8 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -80,11 +80,20 @@ jobs: sed -i "s/^version: .*/version: ${NEW_VERSION}+${NEW_BUILD}/" pubspec.yaml grep "^version:" pubspec.yaml + - name: Sync AUR PKGBUILD pkgver with pubspec + if: steps.check.outputs.merged == 'true' + run: | + NEW_VERSION="${{ steps.bump.outputs.new_version }}" + sed -i "s/^pkgver=.*/pkgver=${NEW_VERSION}/" packaging/linux/aur/PKGBUILD + sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/linux/aur/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('SKIP' 'SKIP' 'SKIP')/" packaging/linux/aur/PKGBUILD + grep ^pkgver packaging/linux/aur/PKGBUILD + - name: Commit version bump if: steps.check.outputs.merged == 'true' run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" - git add pubspec.yaml + git add pubspec.yaml packaging/linux/aur/PKGBUILD git commit -m "Bump version to ${{ steps.bump.outputs.new_version }}+${{ steps.bump.outputs.new_build }}" git push origin main diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 627d55d3..a64b09b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,10 +81,11 @@ version locally to avoid "works on my machine" drift. When bumping the pin, run For animated UI, **do not invent magic `Duration(...)` / raw curves** in widgets. -- Use `QueryaMotion` tokens (`fast` / `standard` / `slow`) via +- Use `QueryaMotion` tokens (`fast` / `standard` / `slow` / `treeExpand`) via `context.motionDuration` / `context.motionCurve` (or `QueryaMotion.effective*`). -- Interactive Fluid motion: `QueryaSpring` / `QueryaSpringController` when - `QueryaSpring.springsEnabled` (Full motion only). +- **Real springs only:** `QueryaSpring` / `QueryaSpringController` when + `QueryaSpring.springsEnabled` (Full motion) — tab indicator, drag settle, etc. + Do not use `springsEnabled` just to pick an emphasized cubic for fades/dialogs. - Honor Preferences Motion Full / Reduced / Off and OS `disableAnimations`. - Mid-drag layout (split panes) stays 1:1; spring settle only on drag-end. - Do not animate virtualized grid rows on scroll. diff --git a/analysis_options.yaml b/analysis_options.yaml index b526cc23..a584332e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,10 +2,16 @@ include: package:flutter_lints/flutter.yaml analyzer: exclude: - - third_party/** - - build/** + - "third_party/**" + - "build/**" + errors: + unnecessary_underscores: ignore + prefer_initializing_formals: ignore + use_null_aware_elements: ignore + deprecated_member_use: ignore linter: rules: - prefer_const_constructors - prefer_const_declarations + diff --git a/docs/motion-and-high-refresh.md b/docs/motion-and-high-refresh.md index 9fa2d07b..dfd24006 100644 --- a/docs/motion-and-high-refresh.md +++ b/docs/motion-and-high-refresh.md @@ -82,7 +82,8 @@ Introduce `lib/core/motion/` with a single source of truth for durations and cur |-------|-------|-----| | `instant` | 0 ms | reduced-motion / disabled | | `fast` | 120 ms | hover, small state changes | -| `standard` | 200 ms | dialogs, menus, expand/collapse | +| `standard` | 200 ms | dialogs, menus, general surfaces | +| `treeExpand` | 200 ms (= `standard`) | connection/SDUI tree: **chevron + height** share one clock (#480) | | `slow` | 320 ms | emphasized / large surfaces, theme cross-fade | ### 4.2 Curve tokens @@ -91,7 +92,8 @@ Introduce `lib/core/motion/` with a single source of truth for durations and cur |-------|-------|-----| | `enter` | `easeOutCubic` | elements appearing (decelerate) | | `exit` | `easeInCubic` | elements leaving (accelerate) | -| `standard` | `easeInOutCubic` | move/resize in place | +| `standardCurve` | `easeInOutCubic` | move/resize in place | +| `treeExpandCurve` | = `enter` | tree expand chevron + `QueryaAnimatedExpand` | | `emphasized` | `Curves.easeInOutCubicEmphasized` | hero / theme transitions | ### 4.3 Reduced motion / accessibility @@ -110,6 +112,16 @@ Introduce `lib/core/motion/` with a single source of truth for durations and cur - **Theme switch**: enable a tasteful `emphasized` cross-fade and consider making it on-by-default. - **List/grid item insertion** (results, history): subtle staggered fade-in for first paint only (no per-scroll cost). +### 4.5 Springs vs duration-token cubics (#481) + +`QueryaSpring.springsEnabled` (Full motion only) means **real** `SpringSimulation` / +`QueryaSpringController` — tab strip indicator, split drag settle, and similar +interruptible physics. + +Shell morphs (`QueryaFadeSlide`, `QueryaSwitchingBody`, `showAppDialog`) always use +duration tokens (`standard` + `enter`/`exit`). Do **not** treat `emphasized` cubic +as a stand-in for “Fluid spring.” + --- ## 5. Implementation plan (proposed issues) @@ -151,13 +163,21 @@ When reviewing PRs that touch animation: 2. Require Full / Reduced / Off + OS `disableAnimations` coverage for new transitions. 3. Split / resize: no spring or lag mid-drag; settle only on release / focus chrome. 4. Never stagger or fade virtualized result rows while scrolling. +5. Tree expand: chevron `AnimatedRotation` and `QueryaAnimatedExpand` **must** use + `QueryaMotion.treeExpand` + `treeExpandCurve` (not `fast`/`standardCurve` mixed + with `standard`/`enter`). +6. **Springs vs cubics (#481):** `QueryaSpring.springsEnabled` gates **real** + `SpringSimulation` / `QueryaSpringController` only (tab strip indicator, split + drag settle). Shell morphs (`QueryaFadeSlide`, `QueryaSwitchingBody`, + `showAppDialog`) use duration-token cubics (`standard`/`enter`/`exit`) — do not + brand emphasized ease as “spring”. **Allowed named non-token durations** (named + documented — not magic literals at call sites): | Constant | Value | Where | |----------|-------|--------| | `kQueryaStaggerStep` | 30 ms | `QueryaStagger` first-paint choreography | -| `kUpdateBadgePulsePeriod` | 1400 ms | Update title-bar chip pulse (chrome; see #363) | +| `kUpdateBadgePulsePeriod` | 1400 ms | Update title-bar chip pulse at Full; Reduced halves via `effectiveDuration`; Off / OS disable stop (#363, #482) | Checklist for 120 Hz verification: [perf-baseline.md](perf-baseline.md) § Fluid shell. diff --git a/docs/packaging.md b/docs/packaging.md index 0c0fb260..040322ce 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -65,7 +65,7 @@ payload as the portable zip and AppImage. | `.deb` | [`scripts/linux/build_deb.sh`](../scripts/linux/build_deb.sh) | `sudo apt install ./Querya-Desktop-{ver}-linux.deb` | | `.rpm` | [`scripts/linux/build_rpm.sh`](../scripts/linux/build_rpm.sh) | `sudo dnf install ./Querya-Desktop-{ver}-linux.rpm` | | Flatpak | [`scripts/linux/build_flatpak.sh`](../scripts/linux/build_flatpak.sh) | `flatpak install --user ./Querya-Desktop-{ver}-linux.flatpak` | -| AUR | [`packaging/linux/aur/`](../packaging/linux/aur/) | Community PKGBUILD (Release zip under `/opt`) | +| AUR | [`packaging/linux/aur/`](../packaging/linux/aur/) | `yay -S querya-desktop` (CI publishes on Release when `AUR_SSH_PRIVATE_KEY` is set) | **Runtime dependencies (deb/rpm):** GTK 3, libsecret, GLib; app indicator recommended for tray. diff --git a/docs/tags-and-releases.md b/docs/tags-and-releases.md index 2d9b0621..50a01a8b 100644 --- a/docs/tags-and-releases.md +++ b/docs/tags-and-releases.md @@ -53,7 +53,7 @@ sudo dnf install ./Querya-Desktop-X.Y.Z-linux.rpm flatpak install --user ./Querya-Desktop-X.Y.Z-linux.flatpak ``` -**Arch (AUR):** community PKGBUILD — [`packaging/linux/aur/`](../packaging/linux/aur/) (installs the Release portable zip under `/opt`). +**Arch (AUR):** `yay -S querya-desktop` — auto-published by Release CI when `AUR_SSH_PRIVATE_KEY` is configured ([`packaging/linux/aur/`](../packaging/linux/aur/)). ## Changelog в GitHub Release diff --git a/lib/app/app.dart b/lib/app/app.dart index 3fec8d08..51f10be5 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -21,17 +21,17 @@ class QueryaApp extends StatelessWidget { return ListenableBuilder( listenable: themeController, - builder: (context, _) { + builder: (context, __) { final queryaTheme = themeController.activeTheme; final colorScheme = queryaTheme.colorScheme; return ListenableBuilder( listenable: uiScaleController, - builder: (context, _) { + builder: (context, __) { final scale = uiScaleController.scale; return ListenableBuilder( listenable: motionController, - builder: (context, _) { + builder: (context, __) { final motionLevel = motionController.level; final disableAnimations = MediaQuery.maybeOf(context)?.disableAnimations ?? diff --git a/lib/core/database/result_row_string_convert.dart b/lib/core/database/result_row_string_convert.dart index 16f9f254..f77ef8b7 100644 --- a/lib/core/database/result_row_string_convert.dart +++ b/lib/core/database/result_row_string_convert.dart @@ -1,17 +1,25 @@ -/// Converts SQL result cells to display strings without a second isolate copy. -/// -/// Prefer this over [compute] for large matrices: shipping `List>` -/// across isolates often costs more than `toString()` itself and roughly -/// doubles peak memory. Yielding every [yieldEvery] rows keeps the UI isolate -/// responsive for 10k+ row caps. -library; +import 'package:flutter/foundation.dart'; const int kResultStringConvertYieldEvery = 250; +const int kResultStringConvertComputeThreshold = 1000; /// Maps null cells to `'NULL'` and others via [Object.toString]. String resultCellToDisplayString(Object? value) => value == null ? 'NULL' : value.toString(); +/// Converts [rowValues] to string rows synchronously. +List> convertResultRowsToStringsSync(List> rowValues) { + if (rowValues.isEmpty) return const []; + return [ + for (final row in rowValues) + [for (final value in row) resultCellToDisplayString(value)], + ]; +} + +/// Top-level function suitable for [compute] offloading. +List> convertResultRowsToStringsCompute(List> rowValues) => + convertResultRowsToStringsSync(rowValues); + /// Converts [rowValues] to string rows, yielding periodically. Future>> convertResultRowsToStringsYielding( List> rowValues, { @@ -31,3 +39,17 @@ Future>> convertResultRowsToStringsYielding( } return out; } + +/// Converts [rowValues] adaptively: offloads to a background isolate via [compute] +/// if row count >= [computeThreshold], otherwise yields on the main isolate. +Future>> convertResultRowsToStringsAdaptive( + List> rowValues, { + int computeThreshold = kResultStringConvertComputeThreshold, + int yieldEvery = kResultStringConvertYieldEvery, +}) async { + if (rowValues.isEmpty) return const []; + if (rowValues.length >= computeThreshold) { + return compute(convertResultRowsToStringsCompute, rowValues); + } + return convertResultRowsToStringsYielding(rowValues, yieldEvery: yieldEvery); +} diff --git a/lib/core/extensions/rpc/json_rpc_payload_limits.dart b/lib/core/extensions/rpc/json_rpc_payload_limits.dart index 72750292..274dcd7b 100644 --- a/lib/core/extensions/rpc/json_rpc_payload_limits.dart +++ b/lib/core/extensions/rpc/json_rpc_payload_limits.dart @@ -91,7 +91,7 @@ class _BoundedUtf8LineSplitter }, onError: fail, onDone: () { - if (pending.length > 0) { + if (pending.isNotEmpty) { if (pending.length > maxLineBytes) { fail( JsonRpcPayloadTooLargeException( diff --git a/lib/core/extensions/rpc/json_rpc_stdio_client.dart b/lib/core/extensions/rpc/json_rpc_stdio_client.dart index 130f56f5..4e0e5445 100644 --- a/lib/core/extensions/rpc/json_rpc_stdio_client.dart +++ b/lib/core/extensions/rpc/json_rpc_stdio_client.dart @@ -41,6 +41,12 @@ class JsonRpcStdioClient { StreamSubscription? _subscription; Object? _fatalError; + /// Number of RPC requests currently in-flight waiting for a response. + int get pendingRequestCount => _pending.length; + + /// Returns true when there is at least one active RPC request in-flight. + bool get hasPendingRequests => _pending.isNotEmpty; + /// Serializes async line handling so large-line isolate decode stays ordered. Future _lineChain = Future.value(); diff --git a/lib/core/extensions/rpc/plugin_rpc_bridge.dart b/lib/core/extensions/rpc/plugin_rpc_bridge.dart index 9e528bb3..4b0a61b9 100644 --- a/lib/core/extensions/rpc/plugin_rpc_bridge.dart +++ b/lib/core/extensions/rpc/plugin_rpc_bridge.dart @@ -117,6 +117,7 @@ class PluginRpcBridge { if (enableWatchdog) { _watchdog = SandboxWatchdog( recovery: _recovery, + isBusy: () => client.hasPendingRequests, onStopped: (reason) { if (reason == SandboxWatchdogStopReason.deadlock) { unawaited(_audit?.record( diff --git a/lib/core/extensions/sandbox/sandbox_watchdog.dart b/lib/core/extensions/sandbox/sandbox_watchdog.dart index 4a8c096a..16a25e10 100644 --- a/lib/core/extensions/sandbox/sandbox_watchdog.dart +++ b/lib/core/extensions/sandbox/sandbox_watchdog.dart @@ -25,16 +25,19 @@ enum SandboxWatchdogStopReason { class SandboxWatchdog { SandboxWatchdog({ this.pingInterval = const Duration(seconds: 30), - this.pongTimeout = const Duration(seconds: 5), + this.pongTimeout = const Duration(seconds: 15), this.recovery, + bool Function()? isBusy, Future Function()? ping, void Function(SandboxWatchdogStopReason reason)? onStopped, - }) : _pingOverride = ping, + }) : _isBusy = isBusy, + _pingOverride = ping, _onStopped = onStopped; final Duration pingInterval; final Duration pongTimeout; final SandboxAutoRecovery? recovery; + final bool Function()? _isBusy; final Future Function()? _pingOverride; final void Function(SandboxWatchdogStopReason reason)? _onStopped; @@ -107,6 +110,7 @@ class SandboxWatchdog { Future _tick() async { if (!_running || _pingInFlight) return; + if (_isBusy?.call() ?? false) return; _pingInFlight = true; try { final result = await _sendPing().timeout(pongTimeout); diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index e49694fb..0321021e 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'package:archive/archive.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; @@ -72,7 +73,8 @@ class HttpMarketplaceRepository implements MarketplaceRepository { if (response.statusCode != 200) { throw MarketplaceException('Failed to load trending extensions (HTTP ${response.statusCode})'); } - final List data = jsonDecode(response.body) as List; + final body = response.body; + final List data = await Isolate.run(() => jsonDecode(body)) as List; return data.map((json) => ExtensionManifest.fromJson(json as Map)).toList(); } @@ -89,7 +91,8 @@ class HttpMarketplaceRepository implements MarketplaceRepository { if (response.statusCode != 200) { throw MarketplaceException('Search failed (HTTP ${response.statusCode})'); } - final List data = jsonDecode(response.body) as List; + final body = response.body; + final List data = await Isolate.run(() => jsonDecode(body)) as List; return data.map((json) => ExtensionManifest.fromJson(json as Map)).toList(); } diff --git a/lib/core/motion/querya_animated_expand.dart b/lib/core/motion/querya_animated_expand.dart index 96dd97c5..62108875 100644 --- a/lib/core/motion/querya_animated_expand.dart +++ b/lib/core/motion/querya_animated_expand.dart @@ -19,8 +19,8 @@ class QueryaAnimatedExpand extends StatelessWidget { @override Widget build(BuildContext context) { return AnimatedSize( - duration: context.motionDuration(QueryaMotion.standard), - curve: context.motionCurve(QueryaMotion.enter), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), alignment: alignment, clipBehavior: Clip.hardEdge, child: diff --git a/lib/core/motion/querya_cross_fade_stack.dart b/lib/core/motion/querya_cross_fade_stack.dart index 4527cf85..9459d9cf 100644 --- a/lib/core/motion/querya_cross_fade_stack.dart +++ b/lib/core/motion/querya_cross_fade_stack.dart @@ -5,6 +5,9 @@ import 'querya_motion_context.dart'; /// Like [IndexedStack] but cross-fades the active child; off-screen children /// stay mounted (preserves SQL editor state, etc.). +/// +/// Enter/exit curves and index clamping match [QueryaSwitchingBody] (without +/// the optional slide). class QueryaCrossFadeStack extends StatelessWidget { const QueryaCrossFadeStack({ super.key, @@ -17,8 +20,11 @@ class QueryaCrossFadeStack extends StatelessWidget { @override Widget build(BuildContext context) { + assert(children.isNotEmpty, 'QueryaCrossFadeStack requires children'); + final safeIndex = index.clamp(0, children.length - 1); final duration = context.motionDuration(QueryaMotion.standard); - final curve = context.motionCurve(QueryaMotion.enter); + final inCurve = context.motionCurve(QueryaMotion.enter); + final outCurve = context.motionCurve(QueryaMotion.exit); return Stack( fit: StackFit.expand, @@ -26,17 +32,17 @@ class QueryaCrossFadeStack extends StatelessWidget { for (var i = 0; i < children.length; i++) Positioned.fill( child: IgnorePointer( - ignoring: index != i, + ignoring: i != safeIndex, child: ExcludeFocus( - excluding: index != i, + excluding: i != safeIndex, child: ExcludeSemantics( - excluding: index != i, + excluding: i != safeIndex, child: AnimatedOpacity( - opacity: index == i ? 1 : 0, + opacity: i == safeIndex ? 1 : 0, duration: duration, - curve: curve, + curve: i == safeIndex ? inCurve : outCurve, child: TickerMode( - enabled: index == i, + enabled: i == safeIndex, child: RepaintBoundary(child: children[i]), ), ), diff --git a/lib/core/motion/querya_fade_slide.dart b/lib/core/motion/querya_fade_slide.dart index c7d19086..d954b6d5 100644 --- a/lib/core/motion/querya_fade_slide.dart +++ b/lib/core/motion/querya_fade_slide.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'querya_motion.dart'; import 'querya_motion_context.dart'; -import 'querya_spring.dart'; /// Fades and optionally slides [child] when the keyed child changes. /// -/// Uses a short spring-like curve when [QueryaSpring.springsEnabled], otherwise -/// duration tokens. Prefer wrapping content with a stable [Key] on [child]. +/// Uses duration-token cubic curves ([QueryaMotion.standard] / [QueryaMotion.enter]), +/// not [QueryaSpring] — reserve springs for interruptible physics (tab indicator, +/// drag settle). Prefer wrapping content with a stable [Key] on [child]. class QueryaFadeSlide extends StatelessWidget { const QueryaFadeSlide({ super.key, @@ -24,13 +24,8 @@ class QueryaFadeSlide extends StatelessWidget { @override Widget build(BuildContext context) { - final useSpring = QueryaSpring.springsEnabled(context); - final duration = context.motionDuration( - useSpring ? QueryaMotion.standard : QueryaMotion.fast, - ); - final curve = context.motionCurve( - useSpring ? QueryaMotion.emphasized : QueryaMotion.enter, - ); + final duration = context.motionDuration(QueryaMotion.standard); + final curve = context.motionCurve(QueryaMotion.enter); return AnimatedSwitcher( duration: duration, diff --git a/lib/core/motion/querya_hover_surface.dart b/lib/core/motion/querya_hover_surface.dart index 40c3b1c1..be95a5b1 100644 --- a/lib/core/motion/querya_hover_surface.dart +++ b/lib/core/motion/querya_hover_surface.dart @@ -4,11 +4,16 @@ import 'querya_motion.dart'; import 'querya_motion_context.dart'; /// Unified hover background / border using motion tokens (Responsive chrome). +/// +/// **Scope:** selection / picker cards (e.g. connection type tiles). Dense +/// trees and explorer rows keep lighter `InkWell` / `MouseRegion` hover — +/// do not broaden adoption without an explicit follow-up. class QueryaHoverSurface extends StatefulWidget { const QueryaHoverSurface({ super.key, required this.child, this.borderRadius, + this.border, this.padding, this.hoveredColor, this.idleColor = Colors.transparent, @@ -18,6 +23,7 @@ class QueryaHoverSurface extends StatefulWidget { final Widget child; final BorderRadius? borderRadius; + final BoxBorder? border; final EdgeInsetsGeometry? padding; final Color? hoveredColor; final Color idleColor; @@ -46,6 +52,7 @@ class _QueryaHoverSurfaceState extends State { decoration: BoxDecoration( color: _hovered ? hovered : widget.idleColor, borderRadius: widget.borderRadius, + border: widget.border, ), child: widget.child, ); diff --git a/lib/core/motion/querya_motion.dart b/lib/core/motion/querya_motion.dart index d7064e3c..14efdd37 100644 --- a/lib/core/motion/querya_motion.dart +++ b/lib/core/motion/querya_motion.dart @@ -14,12 +14,16 @@ abstract final class QueryaMotion { /// Hover, small state changes. static const Duration fast = Duration(milliseconds: 120); - /// Dialogs, menus, expand/collapse. + /// Dialogs, menus, general surface transitions. static const Duration standard = Duration(milliseconds: 200); /// Emphasized transitions (theme cross-fade, large surfaces). static const Duration slow = Duration(milliseconds: 320); + /// Connection / SDUI tree expand: chevron rotation **and** height morph share + /// this duration so one gesture does not finish on two clocks (#480). + static const Duration treeExpand = standard; + /// Elements appearing (decelerate). static const Curve enter = Curves.easeOutCubic; @@ -32,6 +36,9 @@ abstract final class QueryaMotion { /// Hero / theme transitions. static const Curve emphasized = Curves.easeInOutCubicEmphasized; + /// Curve for [treeExpand] (chevron + [QueryaAnimatedExpand] height). + static const Curve treeExpandCurve = enter; + /// Returns [token] adjusted for accessibility and [QueryaMotionScope] level. static Duration effectiveDuration(BuildContext context, Duration token) { if (token == instant) return instant; diff --git a/lib/core/motion/querya_spring.dart b/lib/core/motion/querya_spring.dart index 278d35fb..f53b85ee 100644 --- a/lib/core/motion/querya_spring.dart +++ b/lib/core/motion/querya_spring.dart @@ -6,8 +6,12 @@ import 'querya_motion_scope.dart'; /// Spring presets for Fluid UI (interruptible / redirectable motion). /// /// Tuned toward critically damped motion (~Apple Response 0.3–0.5s feel). -/// Use with [SpringSimulation] / [AnimationController.animateWith], not fixed -/// [Duration] curves, when [springsEnabled] is true. +/// Use **only** with [SpringSimulation] / [AnimationController.animateWith] +/// when [springsEnabled] is true (tab indicator, drag settle, etc.). +/// +/// Do **not** branch on [springsEnabled] merely to pick an emphasized cubic +/// curve for [AnimatedOpacity] / [AnimatedSwitcher] / dialogs — those use +/// [QueryaMotion] duration tokens instead (#481). abstract final class QueryaSpring { /// Snappy panels / dialogs / tab indicator (~0.3s Response feel). static const SpringDescription snappy = SpringDescription( diff --git a/lib/core/motion/querya_spring_controller.dart b/lib/core/motion/querya_spring_controller.dart index e6342721..be401721 100644 --- a/lib/core/motion/querya_spring_controller.dart +++ b/lib/core/motion/querya_spring_controller.dart @@ -8,14 +8,19 @@ import 'querya_spring.dart'; /// Drives a scalar with interruptible / redirectable spring motion. /// /// Call [animateTo] to retarget; the current presentation value and velocity -/// are preserved (no brick-wall). When [useSprings] is false, snaps via -/// [jumpTo]. +/// are preserved (no brick-wall). +/// +/// When [useSprings] is false: +/// - if [cubicDuration] is non-null and non-zero → duration-token cubic (#493) +/// - otherwise → snap via [jumpTo] (Off / drag settle) class QueryaSpringController extends ChangeNotifier { QueryaSpringController({ required TickerProvider vsync, double value = 0, this.spring = QueryaSpring.snappy, this.useSprings = true, + this.cubicDuration, + this.cubicCurve = Curves.easeOutCubic, }) : _value = value, _target = value { _ticker = vsync.createTicker(_onTick); @@ -24,6 +29,10 @@ class QueryaSpringController extends ChangeNotifier { SpringDescription spring; bool useSprings; + /// Cubic fallback when springs are off (Reduced motion). Null / zero → snap. + Duration? cubicDuration; + Curve cubicCurve; + late final Ticker _ticker; double _value; double _velocity = 0; @@ -31,6 +40,10 @@ class QueryaSpringController extends ChangeNotifier { SpringSimulation? _simulation; Duration? _simulationStart; + double? _cubicFrom; + Duration? _cubicTotal; + Curve? _cubicActiveCurve; + double get value => _value; double get velocity => _velocity; double get target => _target; @@ -39,8 +52,7 @@ class QueryaSpringController extends ChangeNotifier { /// Instantly sets value (and clears velocity). void jumpTo(double value) { _ticker.stop(); - _simulation = null; - _simulationStart = null; + _clearMotion(); _velocity = 0; _target = value; if (_value == value) return; @@ -54,7 +66,12 @@ class QueryaSpringController extends ChangeNotifier { final startVelocity = velocity ?? _velocity; if (!useSprings) { - jumpTo(target); + final duration = cubicDuration; + if (duration == null || duration == Duration.zero) { + jumpTo(target); + return; + } + _startCubic(target, duration); return; } @@ -63,6 +80,9 @@ class QueryaSpringController extends ChangeNotifier { return; } + _cubicFrom = null; + _cubicTotal = null; + _cubicActiveCurve = null; _simulation = QueryaSpring.simulation( description: spring, start: _value, @@ -75,7 +95,37 @@ class QueryaSpringController extends ChangeNotifier { } } + void _startCubic(double target, Duration duration) { + if ((_value - target).abs() < 0.0001) { + jumpTo(target); + return; + } + _simulation = null; + _cubicFrom = _value; + _cubicTotal = duration; + _cubicActiveCurve = cubicCurve; + _velocity = 0; + _simulationStart = null; + if (!_ticker.isActive) { + _ticker.start(); + } + } + + void _clearMotion() { + _simulation = null; + _simulationStart = null; + _cubicFrom = null; + _cubicTotal = null; + _cubicActiveCurve = null; + } + void _onTick(Duration elapsed) { + final cubicTotal = _cubicTotal; + if (cubicTotal != null) { + _onCubicTick(elapsed, cubicTotal); + return; + } + final simulation = _simulation; if (simulation == null) { _ticker.stop(); @@ -93,8 +143,7 @@ class QueryaSpringController extends ChangeNotifier { if (settled) { _value = _target; _velocity = 0; - _simulation = null; - _simulationStart = null; + _clearMotion(); _ticker.stop(); notifyListeners(); return; @@ -104,6 +153,33 @@ class QueryaSpringController extends ChangeNotifier { notifyListeners(); } + void _onCubicTick(Duration elapsed, Duration cubicTotal) { + _simulationStart ??= elapsed; + final micros = cubicTotal.inMicroseconds; + if (micros <= 0) { + jumpTo(_target); + return; + } + final t = + (elapsed - _simulationStart!).inMicroseconds / micros; + final from = _cubicFrom ?? _value; + final curve = _cubicActiveCurve ?? cubicCurve; + + if (t >= 1) { + _value = _target; + _velocity = 0; + _clearMotion(); + _ticker.stop(); + notifyListeners(); + return; + } + + final curved = curve.transform(t.clamp(0.0, 1.0)); + _value = from + (_target - from) * curved; + _velocity = 0; + notifyListeners(); + } + @override void dispose() { _ticker.dispose(); diff --git a/lib/core/motion/querya_stagger.dart b/lib/core/motion/querya_stagger.dart index 654ceda7..5f10c272 100644 --- a/lib/core/motion/querya_stagger.dart +++ b/lib/core/motion/querya_stagger.dart @@ -31,6 +31,7 @@ class _QueryaStaggerState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; bool _played = false; + Duration _effectiveStep = kQueryaStaggerStep; @override void initState() { @@ -47,12 +48,13 @@ class _QueryaStaggerState extends State if (n == 0) return; final base = context.motionDuration(QueryaMotion.fast); + _effectiveStep = context.motionDuration(widget.step); if (base == QueryaMotion.instant) { _controller.value = 1; return; } - final total = base + widget.step * n; + final total = base + _effectiveStep * n; _controller.duration = total; _controller.forward(); } @@ -70,7 +72,7 @@ class _QueryaStaggerState extends State return AnimatedBuilder( animation: _controller, - builder: (context, _) { + builder: (context, __) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -96,7 +98,7 @@ class _QueryaStaggerState extends State final totalMs = _controller.duration!.inMilliseconds; if (totalMs <= 0) return 1; - final stepMs = widget.step.inMilliseconds; + final stepMs = _effectiveStep.inMilliseconds; final start = (stepMs * index) / totalMs; final end = (start + 0.35).clamp(0.0, 1.0); final t = _controller.value; diff --git a/lib/core/motion/querya_switching_body.dart b/lib/core/motion/querya_switching_body.dart index 77a54f91..a16a72bb 100644 --- a/lib/core/motion/querya_switching_body.dart +++ b/lib/core/motion/querya_switching_body.dart @@ -2,12 +2,14 @@ import 'package:flutter/material.dart'; import 'querya_motion.dart'; import 'querya_motion_context.dart'; -import 'querya_spring.dart'; /// Keep-alive indexed stack with opacity (+ optional slide) transitions. /// /// Off-screen children stay mounted (SQL editor state, etc.). Prefer this over /// hard `if` swaps for empty↔workspace and similar shell morphs. +/// +/// Uses duration-token cubics ([QueryaMotion.standard] / enter / exit), not +/// [QueryaSpring] — springs stay for interruptible physics only. class QueryaSwitchingBody extends StatelessWidget { const QueryaSwitchingBody({ super.key, @@ -26,13 +28,8 @@ class QueryaSwitchingBody extends StatelessWidget { Widget build(BuildContext context) { assert(children.isNotEmpty, 'QueryaSwitchingBody requires children'); final safeIndex = index.clamp(0, children.length - 1); - final useSpring = QueryaSpring.springsEnabled(context); - final duration = context.motionDuration( - useSpring ? QueryaMotion.standard : QueryaMotion.fast, - ); - final inCurve = context.motionCurve( - useSpring ? QueryaMotion.emphasized : QueryaMotion.enter, - ); + final duration = context.motionDuration(QueryaMotion.standard); + final inCurve = context.motionCurve(QueryaMotion.enter); final outCurve = context.motionCurve(QueryaMotion.exit); return Stack( diff --git a/lib/core/sdui/sdui_form_builder.dart b/lib/core/sdui/sdui_form_builder.dart index d34f3699..5da85b55 100644 --- a/lib/core/sdui/sdui_form_builder.dart +++ b/lib/core/sdui/sdui_form_builder.dart @@ -15,6 +15,7 @@ class SduiFormBuilder extends material.StatefulWidget { this.initialValues = const {}, this.onChanged, this.filePicker, + this.keepExistingSecrets = false, }); final SduiFormSchema schema; @@ -24,6 +25,10 @@ class SduiFormBuilder extends material.StatefulWidget { /// Injectable file picker for tests. Defaults to `openFile`. final Future Function(SduiFormField field)? filePicker; + /// When true (edit connection), blank password fields are valid and show + /// "Leave blank to keep existing" — host merges stored secrets on save. + final bool keepExistingSecrets; + @override material.State createState() => SduiFormBuilderState(); } @@ -129,13 +134,13 @@ class SduiFormBuilderState extends material.State { Future _pickFile(SduiFormField field) async { final picker = widget.filePicker; - final path = picker != null - ? await picker(field) - : (await openFile())?.path; + final path = + picker != null ? await picker(field) : (await openFile())?.path; if (path == null || !mounted) return; - _textControllers[field.id]?.text = path; + setState(() { + _textControllers[field.id]?.text = path; + }); _notifyChanged(); - setState(() {}); } @override @@ -162,39 +167,89 @@ class SduiFormBuilderState extends material.State { material.Widget _buildField(SduiFormField field) { switch (field.type) { case SduiFieldType.checkbox: - return material.CheckboxListTile( - contentPadding: material.EdgeInsets.zero, - title: Text(field.label), - value: _checkboxValues[field.id] ?? false, - controlAffinity: material.ListTileControlAffinity.leading, - onChanged: (v) { - setState(() => _checkboxValues[field.id] = v ?? false); - _notifyChanged(); - }, + // Avoid CheckboxListTile under opaque dialog DecoratedBox (Flutter 3.44+ + // ListTile ink assert — #492). + final checked = _checkboxValues[field.id] ?? false; + return material.Material( + type: material.MaterialType.transparency, + child: material.MergeSemantics( + child: material.InkWell( + onTap: () { + setState(() => _checkboxValues[field.id] = !checked); + _notifyChanged(); + }, + borderRadius: material.BorderRadius.circular(6), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + material.SizedBox( + width: 24, + height: 24, + child: material.Checkbox( + value: checked, + materialTapTargetSize: + material.MaterialTapTargetSize.shrinkWrap, + visualDensity: material.VisualDensity.compact, + onChanged: (v) { + setState(() => _checkboxValues[field.id] = v ?? false); + _notifyChanged(); + }, + ), + ), + const Gap(12), + material.Expanded(child: Text(field.label)), + ], + ), + ), + ), ); case SduiFieldType.select: + final options = field.options; + final current = _selectValues[field.id] ?? + (options.isNotEmpty ? options.first.value : ''); return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Text(field.label).small().semiBold(), const Gap(4), - material.DropdownButtonFormField( - initialValue: _selectValues[field.id], - items: [ - for (final opt in field.options) - material.DropdownMenuItem( - value: opt.value, - child: material.Text(opt.label), - ), - ], - onChanged: (v) { - setState(() => _selectValues[field.id] = v); - _notifyChanged(); - }, + material.FormField( + initialValue: current, validator: field.required - ? (v) => - (v == null || v.isEmpty) ? '${field.label} is required' : null + ? (v) => (v == null || v.isEmpty) + ? '${field.label} is required' + : null : null, + builder: (state) { + final value = state.value ?? current; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + QueryaDropdown( + value: value.isEmpty && options.isNotEmpty + ? options.first.value + : value, + expandToParent: true, + items: [ + for (final opt in options) + QueryaDropdownItem( + value: opt.value, + label: opt.label, + ), + ], + onSelected: (v) { + final next = v ?? value; + setState(() => _selectValues[field.id] = next); + state.didChange(next); + _notifyChanged(); + }, + ), + if (state.hasError) ...[ + const Gap(4), + Text(state.errorText!).xSmall().muted(), + ], + ], + ); + }, ), ], ); @@ -241,7 +296,7 @@ class SduiFormBuilderState extends material.State { ? material.TextInputType.number : material.TextInputType.text, decoration: material.InputDecoration( - hintText: field.placeholder, + hintText: _hintFor(field), ), validator: _validatorFor(field), ), @@ -250,10 +305,20 @@ class SduiFormBuilderState extends material.State { } } + String? _hintFor(SduiFormField field) { + if (widget.keepExistingSecrets && + field.type == SduiFieldType.password) { + return 'Leave blank to keep existing'; + } + return field.placeholder; + } + material.FormFieldValidator? _validatorFor(SduiFormField field) { return (value) { final text = value?.trim() ?? ''; - if (field.required && text.isEmpty) { + final allowBlankSecret = widget.keepExistingSecrets && + field.type == SduiFieldType.password; + if (field.required && text.isEmpty && !allowBlankSecret) { return '${field.label} is required'; } if (field.type == SduiFieldType.number && text.isNotEmpty) { diff --git a/lib/core/sdui/sdui_form_schema.dart b/lib/core/sdui/sdui_form_schema.dart index ed6e14ed..21a3cfe1 100644 --- a/lib/core/sdui/sdui_form_schema.dart +++ b/lib/core/sdui/sdui_form_schema.dart @@ -60,7 +60,8 @@ class SduiFormField { if (item is Map) { options.add(SduiSelectOption.fromJson(item)); } else if (item is Map) { - options.add(SduiSelectOption.fromJson(Map.from(item))); + options + .add(SduiSelectOption.fromJson(Map.from(item))); } else if (item != null) { options.add(SduiSelectOption(value: '$item', label: '$item')); } diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index 70db9a32..fafc2455 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -1,11 +1,20 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_schema.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_tokens.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Renders a sidebar-style tree from an SDUI schema with lazy expansion. /// /// Visible rows are flattened into a [ListView.builder] so only viewport /// rows are built (large schemas no longer create a full widget Column). +/// Expand chevrons and height morph share [QueryaMotion.treeExpand] / +/// [QueryaMotion.treeExpandCurve]. Height morph via [QueryaAnimatedExpand] is +/// not used on the flat virtualized row list (nested expand would fight +/// `ListView` itemExtent); chevron timing still matches native trees. class SduiTreeBuilder extends material.StatefulWidget { const SduiTreeBuilder({ super.key, @@ -48,7 +57,7 @@ class SduiTreeBuilderState extends material.State { final Set _expanded = {}; final Map _expandErrors = {}; - static const double _rowExtent = 36; + static const double _rowExtent = 28; @override void initState() { @@ -108,6 +117,14 @@ class SduiTreeBuilderState extends material.State { setState(() => _expanded.remove(node.id)); } + void _toggleExpand(SduiTreeNode node) { + if (_expanded.contains(node.id)) { + _onCollapse(node); + } else { + _onExpand(node); + } + } + List _replaceNode( List nodes, String id, @@ -152,16 +169,19 @@ class SduiTreeBuilderState extends material.State { physics: widget.maxHeight == null ? const material.NeverScrollableScrollPhysics() : const material.ClampingScrollPhysics(), - itemExtent: _rowExtent, + itemExtent: rows.any((r) => r.isError) ? null : _rowExtent, itemCount: rows.length, itemBuilder: (context, index) { final row = rows[index]; if (row.isError) { - return material.Padding( - padding: material.EdgeInsets.only(left: 36.0 + row.depth * 16.0), - child: material.Align( - alignment: material.Alignment.centerLeft, - child: Text(row.error!).muted().xSmall(), + return TreeLoadError( + title: 'Could not expand', + message: row.error!, + detailFontSize: 10, + padding: material.EdgeInsets.only( + left: 36.0 + row.depth * QueryaTreeTokens.indent, + top: 2, + bottom: 2, ), ); } @@ -178,44 +198,61 @@ class SduiTreeBuilderState extends material.State { } material.Widget _buildNodeRow(SduiTreeNode node, {required int depth}) { + final theme = Theme.of(context); + final muted = theme.colorScheme.mutedForeground; final canExpand = node.expandable || node.hasChildren; final isExpanded = _expanded.contains(node.id); final isLoading = _loading.contains(node.id); final nodeKind = _resolveNodeKind(node); final isBrowsable = nodeKind == 'table' || nodeKind == 'view'; + // Same hierarchy as native trees (#476 / #497) — no separate sduiNode size. + final iconSize = + canExpand ? QueryaIconSizes.treeGroup : QueryaIconSizes.treeLeaf; + final iconColor = isBrowsable + ? QueryaTreeTokens.leafIconColor(theme.colorScheme.primary) + : muted; + final rowLeft = + 8.0 + depth * QueryaTreeTokens.indent + (canExpand ? 0 : 4.0); - return material.InkWell( - onTap: isBrowsable ? () => widget.onNodeSelected?.call(node) : null, + final row = material.InkWell( + onTap: () { + if (isBrowsable) { + widget.onNodeSelected?.call(node); + } else if (canExpand) { + _toggleExpand(node); + } + }, + borderRadius: material.BorderRadius.circular(4), child: material.Padding( padding: material.EdgeInsets.only( - left: 8.0 + depth * 16.0, + left: rowLeft, right: 8, ), child: material.Row( children: [ if (canExpand) - material.SizedBox( - width: 28, - height: 28, - child: material.IconButton( - padding: material.EdgeInsets.zero, - iconSize: 18, - onPressed: () { - if (isExpanded) { - _onCollapse(node); - } else { - _onExpand(node); - } - }, - icon: material.Icon( - isExpanded - ? material.Icons.expand_more - : material.Icons.chevron_right, + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: () => _toggleExpand(node), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: isExpanded ? 0.25 : 0, + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, + color: muted, + ), + ), ), ), ) else - const material.SizedBox(width: 28), + const material.SizedBox(width: QueryaIconSizes.treeExpand + 4), if (isLoading) const material.SizedBox( width: 14, @@ -224,8 +261,12 @@ class SduiTreeBuilderState extends material.State { ) else material.Icon( - _iconFor(node), - size: 16, + QueryaIcons.sduiNodeIcon( + node.icon, + expandable: node.expandable, + ), + size: iconSize, + color: iconColor, ), const Gap(8), material.Expanded( @@ -234,7 +275,8 @@ class SduiTreeBuilderState extends material.State { overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( - fontSize: 12, + fontSize: 11, + color: isBrowsable ? theme.colorScheme.foreground : muted, fontWeight: isBrowsable ? material.FontWeight.w600 : null, ), ), @@ -243,6 +285,12 @@ class SduiTreeBuilderState extends material.State { ), ), ); + if (!canExpand) return row; + return material.Semantics( + button: true, + expanded: isExpanded, + child: row, + ); } String _resolveNodeKind(SduiTreeNode node) { @@ -252,32 +300,4 @@ class SduiTreeBuilderState extends material.State { final parts = node.id.split('.'); return parts.isNotEmpty ? parts.first : ''; } - - material.IconData _iconFor(SduiTreeNode node) { - switch (node.icon) { - case 'database': - return material.Icons.storage_outlined; - case 'table': - return material.Icons.table_chart_outlined; - case 'view': - case 'eye': - return material.Icons.visibility_outlined; - case 'folder': - case 'folder-table': - return material.Icons.folder_outlined; - case 'folder-eye': - return material.Icons.folder_special_outlined; - case 'folder-book': - case 'book': - return material.Icons.menu_book_outlined; - case 'columns': - return material.Icons.view_column_outlined; - case 'archive': - return material.Icons.inventory_2_outlined; - default: - return node.expandable - ? material.Icons.folder_outlined - : material.Icons.insert_drive_file_outlined; - } - } } diff --git a/lib/core/sdui/sdui_tree_schema.dart b/lib/core/sdui/sdui_tree_schema.dart index d530654a..843cdefa 100644 --- a/lib/core/sdui/sdui_tree_schema.dart +++ b/lib/core/sdui/sdui_tree_schema.dart @@ -93,4 +93,5 @@ class SduiTreeSchema { } /// Loads children for an expandable node (`fetchTreeChildren` RPC). -typedef SduiFetchTreeChildren = Future> Function(String nodeId); +typedef SduiFetchTreeChildren = Future> Function( + String nodeId); diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index bd7ce795..337a4129 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -193,7 +193,8 @@ class LocalDb { } 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'); + 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'); @@ -230,11 +231,14 @@ class LocalDb { await db.delete('app_settings', where: 'key = ?', whereArgs: [key]); } + final Map _historyInsertCounts = {}; + 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; @@ -248,12 +252,21 @@ class LocalDb { 'sql_text': sql, 'recorded_at': now, }); - await _pruneSqlQueryHistoryBucket( - db, - connectionId: connectionId, - databaseName: dbKey, - maxEntries: maxEntries, - ); + + 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. @@ -441,7 +454,8 @@ class LocalDb { /// 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'); + throw ArgumentError( + 'ConnectionRow.id cannot be null when calling updateConnection'); } final db = await _open(); final previousMaps = await db.query( @@ -640,4 +654,46 @@ class ConnectionRow { 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, + bool clearPassword = false, + bool clearConnectionString = 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: driverOptions ?? this.driverOptions, + folderId: folderId ?? this.folderId, + sortOrder: sortOrder ?? this.sortOrder, + createdAt: createdAt ?? this.createdAt, + ); + } } diff --git a/lib/core/theme/querya_theme_scope.dart b/lib/core/theme/querya_theme_scope.dart index 2da177d8..ddafa900 100644 --- a/lib/core/theme/querya_theme_scope.dart +++ b/lib/core/theme/querya_theme_scope.dart @@ -1,4 +1,6 @@ +import 'package:flutter/material.dart' as material; import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; import 'querya_editor_theme.dart'; import 'querya_semantic_palette.dart'; @@ -30,8 +32,14 @@ class QueryaThemeScope extends InheritedWidget { bool updateShouldNotify(QueryaThemeScope oldWidget) => data != oldWidget.data; } -/// Convenient access to [QueryaTheme] tokens from [BuildContext]. +/// Convenient access to [QueryaTheme] tokens and [Theme] / [ColorScheme] from [BuildContext]. 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; + QueryaTheme get queryaTheme => QueryaThemeScope.of(this); QueryaWorkbenchTheme get workbench => queryaTheme.workbench; diff --git a/lib/core/ui/querya_icon_sizes.dart b/lib/core/ui/querya_icon_sizes.dart new file mode 100644 index 00000000..90746d07 --- /dev/null +++ b/lib/core/ui/querya_icon_sizes.dart @@ -0,0 +1,26 @@ +/// Semantic icon sizes for connection trees and shared chrome. +abstract final class QueryaIconSizes { + /// Leaf row icon (table name, view name, …). + static const double treeLeaf = 12; + + /// Group / schema / default tree row icon. + static const double treeGroup = 13; + + /// Expand chevron in tree rows. + static const double treeExpand = 13; + + /// Expand chevron on connection / folder headers in the sidebar (#496). + static const double sidebarExpand = 16; + + /// Connection-type icon / logo on sidebar header rows. + static const double sidebarConnectionIcon = 16; + + /// Database / connection-level tree nodes. + static const double treeConnection = 14; + + /// Inline tree error indicator. + static const double treeError = 14; + + /// Menu / dialog leading icons. + static const double menuLeading = 18; +} diff --git a/lib/core/ui/querya_icons.dart b/lib/core/ui/querya_icons.dart new file mode 100644 index 00000000..eaba0b47 --- /dev/null +++ b/lib/core/ui/querya_icons.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart' as material; + +/// Shared Material icon registry for connection trees and chrome. +abstract final class QueryaIcons { + // -- Tree entity icons (rounded, aligned across PG / MySQL / SQLite) -- + + static const material.IconData expandClosed = + material.Icons.chevron_right_rounded; + + static const material.IconData databasesFolder = + material.Icons.dns_rounded; + static const material.IconData database = material.Icons.storage_rounded; + static const material.IconData schemasFolder = + material.Icons.account_tree_rounded; + static const material.IconData schema = material.Icons.diamond_rounded; + static const material.IconData extension = material.Icons.extension_rounded; + static const material.IconData foreignData = material.Icons.hub_rounded; + + static const material.IconData tableGroup = + material.Icons.table_chart_rounded; + static const material.IconData tableLeaf = material.Icons.grid_on_rounded; + static const material.IconData viewGroup = material.Icons.view_agenda_rounded; + static const material.IconData viewLeaf = material.Icons.view_week_rounded; + static const material.IconData materializedViewGroup = + material.Icons.dynamic_feed_rounded; + static const material.IconData materializedViewLeaf = + material.Icons.layers_rounded; + static const material.IconData functionGroup = + material.Icons.functions_rounded; + static const material.IconData functionLeaf = material.Icons.code_rounded; + static const material.IconData sequenceGroup = + material.Icons.format_list_numbered_rounded; + static const material.IconData sequenceLeaf = + material.Icons.looks_one_rounded; + + /// Alias kept for call sites that still say "sequence" as the group icon. + static const material.IconData sequence = sequenceGroup; + static const material.IconData indexes = material.Icons.table_rows_rounded; + static const material.IconData triggers = material.Icons.bolt_rounded; + static const material.IconData types = material.Icons.category_rounded; + + static const material.IconData treeError = + material.Icons.error_outline_rounded; + static const material.IconData folder = material.Icons.folder_rounded; + + // -- Built-in connection types -- + + static material.IconData connectionIcon(String type) => switch (type) { + 'mongodb' => material.Icons.eco_rounded, + 'postgresql' => material.Icons.storage_rounded, + 'mysql' => material.Icons.table_chart_rounded, + 'redis' => material.Icons.memory_rounded, + 'sqlite' => material.Icons.folder_open_rounded, + _ => material.Icons.extension_rounded, + }; + + static String? connectionAsset(String type) => switch (type) { + 'postgresql' => 'assets/images/postgresql_icon.png', + 'mysql' => 'assets/images/mysql_icon.png', + 'redis' => 'assets/images/redis_icon.png', + 'mongodb' => 'assets/images/mongodb_icon.png', + _ => null, + }; + + // -- SDUI tree nodes (rounded to match native trees) -- + + static material.IconData sduiNodeIcon( + String? icon, { + required bool expandable, + }) { + switch (icon) { + case 'database': + return database; + case 'table': + return expandable ? tableGroup : tableLeaf; + case 'view': + case 'eye': + return expandable ? viewGroup : viewLeaf; + case 'folder': + case 'folder-table': + return folder; + case 'folder-eye': + return material.Icons.folder_special_rounded; + case 'folder-book': + case 'book': + return material.Icons.menu_book_rounded; + case 'columns': + return material.Icons.view_column_rounded; + case 'archive': + return material.Icons.inventory_2_rounded; + default: + return expandable + ? folder + : material.Icons.insert_drive_file_rounded; + } + } +} diff --git a/lib/core/ui/querya_tooltip.dart b/lib/core/ui/querya_tooltip.dart new file mode 100644 index 00000000..264c8cf0 --- /dev/null +++ b/lib/core/ui/querya_tooltip.dart @@ -0,0 +1,2 @@ +/// Shared [Tooltip.waitDuration] for dense chrome (trees, grids, …). +const Duration kQueryaTooltipWait = Duration(milliseconds: 450); diff --git a/lib/core/ui/querya_tree_tokens.dart b/lib/core/ui/querya_tree_tokens.dart new file mode 100644 index 00000000..20ef57ca --- /dev/null +++ b/lib/core/ui/querya_tree_tokens.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +/// Shared connection-tree metrics and colors (PG / MySQL / SQLite / SDUI). +abstract final class QueryaTreeTokens { + /// Indent for schema rows and sibling object folders under a database. + static const double indent = 16; + + /// Leaf-row icon tint (tables, views, sequences, …). + /// + /// Takes [primary] (not [ColorScheme]) so both Material and shadcn schemes + /// can pass `.primary` without a type clash. + static Color leafIconColor(Color primary) => + primary.withValues(alpha: 0.5); +} diff --git a/lib/core/updater/github_releases_client.dart b/lib/core/updater/github_releases_client.dart index a22fed25..d262040e 100644 --- a/lib/core/updater/github_releases_client.dart +++ b/lib/core/updater/github_releases_client.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:isolate'; import 'package:http/http.dart' as http; @@ -28,7 +29,8 @@ class GitHubReleasesClient { 'GitHub Releases API returned HTTP ${response.statusCode}', ); } - final decoded = jsonDecode(response.body); + final body = response.body; + final decoded = await Isolate.run(() => jsonDecode(body)); if (decoded is! Map) { throw const GitHubReleasesException('Unexpected GitHub Releases payload'); } @@ -41,7 +43,8 @@ class GitHubReleasesClient { 'GitHub Releases API returned HTTP ${response.statusCode}', ); } - final decoded = jsonDecode(response.body); + final body = response.body; + final decoded = await Isolate.run(() => jsonDecode(body)); if (decoded is! List) { throw const GitHubReleasesException('Unexpected GitHub Releases list payload'); } diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index 31e15cab..6e2b6474 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; - +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connection_type_choice.dart'; import 'package:querya_desktop/features/connections/extension_connection_form.dart'; @@ -10,6 +11,8 @@ import 'package:querya_desktop/features/mysql/mysql_connection_form.dart'; import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; import 'package:querya_desktop/features/redis/redis_connection_form.dart'; +export 'package:querya_desktop/features/connections/connection_edit_secrets.dart'; + /// Context that stays mounted after menu overlays close (multi-step dialog flow). material.BuildContext _dialogAnchorContext(material.BuildContext context) { final navigator = material.Navigator.maybeOf(context, rootNavigator: true); @@ -61,3 +64,68 @@ Future promptCreateConnection( : null, }; } + +/// Opens the matching form prefilled for [existing] (type/driver fixed). +Future promptEditConnection( + material.BuildContext context, + ConnectionRow existing, +) async { + final dialogContext = _dialogAnchorContext(context); + if (!dialogContext.mounted) return null; + + if (ExtensionDriverCatalog.isExtensionDriverConnection(existing)) { + final manifest = ExtensionDriverCatalog.manifestForConnection(existing); + if (manifest == null) return null; + final driver = _driverForConnection(existing, manifest.contributedDrivers); + if (driver == null) return null; + return showExtensionConnectionForm( + dialogContext, + manifest: manifest, + driver: driver, + folderId: existing.folderId, + initial: existing, + ); + } + + return switch (existing.type) { + 'postgresql' => showPostgresConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + 'mysql' => showMysqlConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + 'mongodb' => showMongoConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + 'redis' => showRedisConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + 'sqlite' => showSqliteConnectionForm( + dialogContext, + folderId: existing.folderId, + initial: existing, + ), + _ => null, + }; +} + +DriverContribution? _driverForConnection( + ConnectionRow row, + Iterable drivers, +) { + final type = row.type.trim().toLowerCase(); + DriverContribution? first; + for (final driver in drivers) { + first ??= driver; + if (driver.driverId.trim().toLowerCase() == type) return driver; + } + return first; +} diff --git a/lib/features/connections/connection_edit_secrets.dart b/lib/features/connections/connection_edit_secrets.dart new file mode 100644 index 00000000..700f4732 --- /dev/null +++ b/lib/features/connections/connection_edit_secrets.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Keeps previous secure-store secrets when edit form fields are left blank. +/// +/// [ConnectionSecretsStore.writeForConnection] deletes empty values — callers +/// must merge before [LocalDb.updateConnection]. +Future mergeSecretsForConnectionUpdate( + ConnectionRow edited, +) async { + final id = edited.id; + if (id == null) { + throw ArgumentError('edited.id is required for secret merge'); + } + final prev = await ConnectionSecretsStore.readForConnection(id); + + final passwordEmpty = + edited.password == null || edited.password!.trim().isEmpty; + final password = passwordEmpty ? prev.password : edited.password; + + var connectionString = edited.connectionString; + if (connectionString == null || connectionString.trim().isEmpty) { + // Host-mode edit: do not resurrect a previous URI. + connectionString = null; + } else { + connectionString = injectUriPasswordIfMissing(connectionString, password); + } + + return edited.copyWith( + password: password, + connectionString: connectionString, + clearPassword: password == null, + clearConnectionString: connectionString == null, + ); +} + +/// Strips userinfo password so edit forms never show stored secrets. +String? redactUriPassword(String? uri) { + if (uri == null || uri.trim().isEmpty) return uri; + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null) return uri; + final info = parsed.userInfo; + if (info.isEmpty || !info.contains(':')) return uri; + final user = info.split(':').first; + return parsed.replace(userInfo: user).toString(); +} + +/// Puts [password] into URI userinfo when the URI has a user but no password. +@visibleForTesting +String injectUriPasswordIfMissing(String uri, String? password) { + if (password == null || password.isEmpty) return uri; + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null) return uri; + final info = parsed.userInfo; + if (info.isEmpty) return uri; + final parts = info.split(':'); + if (parts.length >= 2 && parts.sublist(1).join(':').isNotEmpty) { + return uri; + } + final user = parts.first; + return parsed.replace(userInfo: '$user:$password').toString(); +} diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart index 82ca3723..6ad7ed61 100644 --- a/lib/features/connections/connection_url_parser.dart +++ b/lib/features/connections/connection_url_parser.dart @@ -64,16 +64,15 @@ const _validPostgresSslModes = { if (!_validPostgresSslModes.contains(sslMode)) { return ( useSSL: null, - error: - 'Unsupported sslmode "$sslMode" for PostgreSQL. ' + error: 'Unsupported sslmode "$sslMode" for PostgreSQL. ' 'Supported: disable, require, verify-ca, verify-full.', ); } useSSL = sslMode != 'disable'; } } else if (type != 'sqlite') { - final sslQuery = uri.queryParameters['sslmode'] ?? - uri.queryParameters['ssl']; + final sslQuery = + uri.queryParameters['sslmode'] ?? uri.queryParameters['ssl']; if (sslQuery != null) { final lowerSsl = sslQuery.toLowerCase(); if (lowerSsl == 'true' || lowerSsl == 'require') { @@ -163,8 +162,8 @@ ConnectionRow? _buildConnectionRow( databaseName = null; } - authSource = uri.queryParameters['authSource'] ?? - uri.queryParameters['authsource']; + authSource = + uri.queryParameters['authSource'] ?? uri.queryParameters['authsource']; if (type == 'postgresql' || type == 'mysql' || type == 'mongodb') { connectionString = url; @@ -196,7 +195,9 @@ String _connectionName( int? defaultPort, ) { if (type == 'sqlite') { - return host == ':memory:' ? 'SQLite (Memory)' : 'SQLite (${host!.split('/').last})'; + return host == ':memory:' + ? 'SQLite (Memory)' + : 'SQLite (${host!.split('/').last})'; } final cleanHost = host ?? 'localhost'; diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index d7f6d105..e801fefb 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -38,11 +38,11 @@ import 'package:flutter/material.dart' as material Expanded, CircularProgressIndicator, Material, + Semantics, StatelessWidget, Colors, Tooltip, Color, - SelectableText, Padding, Widget, Navigator, @@ -67,6 +67,10 @@ import 'package:querya_desktop/core/sdui/sdui_tree_schema.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/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; +import 'package:querya_desktop/core/ui/querya_tooltip.dart'; +import 'package:querya_desktop/core/ui/querya_tree_tokens.dart'; import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; @@ -316,6 +320,31 @@ class ConnectionsPanelState extends State { } } + Future _editConnection(ConnectionRow conn) async { + final edited = await promptEditConnection(context, conn); + if (edited == null || !mounted) return; + final toSave = await mergeSecretsForConnectionUpdate(edited); + await LocalDb.instance.updateConnection(toSave); + await _loadData(); + if (!mounted) return; + ConnectionRow? updated; + for (final c in _connections) { + if (c.id == conn.id) { + updated = c; + break; + } + } + if (updated == null) return; + final shouldReconnect = _expandedConnections.contains(conn.id) || + widget.selectedConnectionId == conn.id; + if (shouldReconnect) { + await reconnect(updated); + } + if (widget.selectedConnectionId == conn.id) { + widget.onConnectionSelected?.call(updated); + } + } + Future _removeConnection(int id) async { await MongoService.instance.disconnectByConnectionId(id); await ExtensionDriverSession.instance.disconnect(id); @@ -346,11 +375,17 @@ class ConnectionsPanelState extends State { _expandedConnections.remove(id); }); if (conn.type == 'postgresql') { - PostgresService.instance.interrupt(conn, database: conn.databaseName ?? 'postgres', mode: PgSessionMode.readOnly); - PostgresService.instance.interrupt(conn, database: conn.databaseName ?? 'postgres', mode: PgSessionMode.readWrite); + PostgresService.instance.interrupt(conn, + database: conn.databaseName ?? 'postgres', + mode: PgSessionMode.readOnly); + PostgresService.instance.interrupt(conn, + database: conn.databaseName ?? 'postgres', + mode: PgSessionMode.readWrite); } else if (conn.type == 'mysql') { - MysqlService.instance.interrupt(conn, database: conn.databaseName ?? '', mode: MysqlSessionMode.readOnly); - MysqlService.instance.interrupt(conn, database: conn.databaseName ?? '', mode: MysqlSessionMode.readWrite); + MysqlService.instance.interrupt(conn, + database: conn.databaseName ?? '', mode: MysqlSessionMode.readOnly); + MysqlService.instance.interrupt(conn, + database: conn.databaseName ?? '', mode: MysqlSessionMode.readWrite); } else if (conn.type == 'sqlite') { SqliteService.instance.interrupt(conn, mode: SqliteSessionMode.readOnly); SqliteService.instance.interrupt(conn, mode: SqliteSessionMode.readWrite); @@ -395,29 +430,6 @@ class ConnectionsPanelState extends State { } } - /// Icon for a connection type (matches New Connection dialog). - material.IconData _iconForType(String type) { - return switch (type) { - 'mongodb' => material.Icons.eco_rounded, - 'postgresql' => material.Icons.storage_rounded, - 'mysql' => material.Icons.table_chart_rounded, - 'redis' => material.Icons.memory_rounded, - 'sqlite' => material.Icons.folder_open_rounded, - _ => material.Icons.extension_rounded, - }; - } - - /// Asset path for connection type logo (null = use icon). - static String? _iconAssetForType(String type) { - return switch (type) { - 'postgresql' => 'assets/images/postgresql_icon.png', - 'mysql' => 'assets/images/mysql_icon.png', - 'redis' => 'assets/images/redis_icon.png', - 'mongodb' => 'assets/images/mongodb_icon.png', - _ => null, - }; - } - Widget _buildConnectionTile(ConnectionRow conn) { final isSelected = widget.selectedConnectionId != null && widget.selectedConnectionId == conn.id; @@ -436,9 +448,10 @@ class ConnectionsPanelState extends State { return _PostgresConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, @@ -449,9 +462,10 @@ class ConnectionsPanelState extends State { return _MysqlConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onMysqlObjectSelected: widget.onMysqlObjectSelected, onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, @@ -462,9 +476,10 @@ class ConnectionsPanelState extends State { return _RedisConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), isExpanded: isExpanded, @@ -474,9 +489,10 @@ class ConnectionsPanelState extends State { return _MongoConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), isExpanded: isExpanded, @@ -486,9 +502,10 @@ class ConnectionsPanelState extends State { return _SqliteConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onSqliteObjectSelected: widget.onSqliteObjectSelected, onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, @@ -499,9 +516,10 @@ class ConnectionsPanelState extends State { return _ExtensionConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onObjectSelected: widget.onExtensionObjectSelected, isExpanded: isExpanded, @@ -511,9 +529,10 @@ class ConnectionsPanelState extends State { return _ConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), + onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), ); } @@ -602,7 +621,7 @@ class ConnectionsPanelState extends State { .getFolderIdByName(folderName); await _createConnection(folderId: folderId); }, - iconForType: _iconForType, + iconForType: QueryaIcons.connectionIcon, onRemoveConnection: _removeConnection, onConnectionTap: widget.onConnectionSelected, onRedisDatabaseTap: diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 6d343cf9..8f06118a 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -8,6 +8,7 @@ class _ExtensionConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onObjectSelected, this.isExpanded = false, @@ -19,6 +20,7 @@ class _ExtensionConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; /// Fires when a table/view node is clicked in the schema tree. @@ -91,8 +93,8 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { _error = null; }); try { - final schema = - await ExtensionDriverSession.instance.getSchemaTree(widget.connection); + final schema = await ExtensionDriverSession.instance + .getSchemaTree(widget.connection); if (!mounted) return; setState(() { _schema = schema; @@ -141,199 +143,194 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { if (_iconFilePath != null) { iconWidget = DriverIconImage( path: _iconFilePath!, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, fallbackIcon: widget.icon, ); } else if (widget.iconAsset != null) { iconWidget = material.Image.asset( widget.iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ); } else { iconWidget = material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ); } - return material.Padding( - padding: const material.EdgeInsets.only(bottom: 2), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Row( - children: [ - material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: widget.isExpanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - color: theme.colorScheme.mutedForeground, + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.Semantics( + button: true, + expanded: widget.isExpanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: widget.isExpanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), + ), ), ), ), ), - ), - material.Expanded( - child: _sidebarConnectionShell( - context: context, - isSelected: widget.isSelected, - onTap: widget.onTap, - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 4, - vertical: 6, - ), - child: material.Row( - children: [ - iconWidget, - const Gap(8), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Text( - widget.connection.name, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - fontWeight: widget.isSelected - ? material.FontWeight.w600 - : material.FontWeight.w500, - color: theme.colorScheme.foreground, - ), - ), - if (widget.connection.host != null) + material.Expanded( + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ material.Text( - '${widget.connection.host}:${widget.connection.port ?? ''}', + widget.connection.name, overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.mutedForeground, + fontSize: 13, + fontWeight: widget.isSelected + ? material.FontWeight.w600 + : material.FontWeight.w500, + color: theme.colorScheme.foreground, ), ), - ], - ), - ), - material.Tooltip( - message: 'Remove', - child: material.InkWell( - onTap: widget.onRemove, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon( - material.Icons.close_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ], ), ), - ), - ], + ], + ), ), ), ), - ), - ], - ), - QueryaAnimatedExpand( - expanded: widget.isExpanded, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - if (_loading) - material.Padding( - padding: const material.EdgeInsets.only( - left: 28, - top: 4, - bottom: 4, - ), - child: material.Row( - children: [ - const material.SizedBox( - width: 12, - height: 12, - child: material.CircularProgressIndicator( - strokeWidth: 1.5, - ), - ), - const Gap(8), - const Text('Loading...').muted().xSmall(), - ], - ), - ) - else if (_error != null) - material.Padding( - padding: const material.EdgeInsets.only( - left: 28, - top: 4, - bottom: 8, - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.destructive, + ], + ), + QueryaAnimatedExpand( + expanded: widget.isExpanded, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + if (_loading) + material.Padding( + padding: const material.EdgeInsets.only( + left: 28, + top: 4, + bottom: 4, + ), + child: material.Row( + children: [ + const material.SizedBox( + width: 12, + height: 12, + child: material.CircularProgressIndicator( + strokeWidth: 1.5, + ), ), - ), - const material.SizedBox(height: 6), - GhostButton( - onPressed: _loadTree, - child: const Text('Retry'), - ), - ], + const Gap(8), + const Text('Loading...').muted().xSmall(), + ], + ), + ) + else if (_error != null) + TreeLoadError( + title: 'Could not load extension tree', + message: _error!, + padding: const material.EdgeInsets.only( + left: 28, + top: 4, + bottom: 8, + ), + onRetry: _loadTree, + ) + else if (_schema != null) + material.Padding( + padding: const material.EdgeInsets.only(left: 20), + child: _schema!.roots.isEmpty + ? material.Padding( + padding: const material.EdgeInsets.fromLTRB( + 0, 8, 8, 8), + child: const Text( + 'No databases found on this server.', + ).muted().small(), + ) + : SduiTreeBuilder( + schema: _schema!, + fetchChildren: _fetchChildren, + onNodeSelected: _onNodeSelected, + maxHeight: kConnectionTreeMaxVisibleRows * + kConnectionTreeRowExtent, + ), ), - ) - else if (_schema != null) - material.Padding( - padding: const material.EdgeInsets.only(left: 20), - child: _schema!.roots.isEmpty - ? material.Padding( - padding: - const material.EdgeInsets.fromLTRB(0, 8, 8, 8), - child: const Text( - 'No databases found on this server.', - ).muted().small(), - ) - : SduiTreeBuilder( - schema: _schema!, - fetchChildren: _fetchChildren, - onNodeSelected: _onNodeSelected, - maxHeight: kConnectionTreeMaxVisibleRows * - kConnectionTreeRowExtent, - ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index cc1c5d41..4db6c556 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -9,6 +9,7 @@ class _MongoConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onDatabaseTap, this.isExpanded = false, @@ -20,6 +21,7 @@ class _MongoConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function(String database)? onDatabaseTap; final bool isExpanded; @@ -142,17 +144,20 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ) : material.Icon(widget.icon, - size: 16, color: theme.colorScheme.primary); + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary); return ContextMenu( items: [ @@ -171,6 +176,12 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { }, child: const Text('Refresh databases'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), @@ -190,19 +201,25 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { // Expand/collapse arrow material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: _expanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), @@ -281,58 +298,24 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + title: 'Could not load databases', + message: _error!, + detailFontSize: 10, padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4, right: 8), - child: material.ConstrainedBox( - constraints: const material.BoxConstraints( - maxWidth: double.infinity), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Row( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - material.Icon( - material.Icons.error_outline_rounded, - size: 14, - color: theme.colorScheme.destructive, - ), - const Gap(6), - material.Expanded( - child: material.Text( - 'Could not load databases', - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.destructive, - ), - ), - ), - ], - ), - const Gap(6), - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 10, - height: 1.35, - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ), + left: 28, + top: 4, + bottom: 4, + right: 8, ), + onRetry: _loadDatabases, ), - for (final db in _databases) - _MongoDatabaseNode( + if (_databases.isNotEmpty) + _MongoDatabasesNode( connection: widget.connection, - name: db, - onTap: () => widget.onDatabaseTap?.call(db), - onDelete: () => _deleteDatabase(db), + databases: _databases, + onDatabaseTap: widget.onDatabaseTap, + onDeleteDatabase: _deleteDatabase, onRefreshDatabases: () { setState(() => _databases = []); _loadDatabases(); @@ -348,6 +331,64 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { } } +class _MongoDatabasesNode extends StatelessWidget { + const _MongoDatabasesNode({ + required this.connection, + required this.databases, + required this.onRefreshDatabases, + required this.onDeleteDatabase, + this.onDatabaseTap, + }); + + final ConnectionRow connection; + final List databases; + final VoidCallback onRefreshDatabases; + final Future Function(String name) onDeleteDatabase; + final void Function(String database)? onDatabaseTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 20), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + _PgTreeRow( + label: 'Databases (${databases.length})', + icon: QueryaIcons.databasesFolder, + iconSize: QueryaIconSizes.treeConnection, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 4, + onTap: null, + connection: connection, + onContextRefresh: onRefreshDatabases, + ), + lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _MongoDatabaseNode( + connection: connection, + name: db, + onTap: () => onDatabaseTap?.call(db), + onDelete: () => onDeleteDatabase(db), + onRefreshDatabases: onRefreshDatabases, + ); + }, + ), + ], + ), + ); + } +} + class _MongoDatabaseNode extends StatelessWidget { const _MongoDatabaseNode({ required this.connection, @@ -370,8 +411,8 @@ class _MongoDatabaseNode extends StatelessWidget { padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), child: _PgTreeRow( label: name, - icon: material.Icons.storage_rounded, - iconSize: 13, + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 3d801c6f..d8673c16 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -9,6 +9,7 @@ class _MysqlConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onMysqlObjectSelected, this.onMysqlOpenSqlWorkspace, @@ -21,6 +22,7 @@ class _MysqlConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -107,17 +109,20 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ) : material.Icon(widget.icon, - size: 16, color: theme.colorScheme.primary); + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary); return ContextMenu( items: [ @@ -138,6 +143,12 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { widget.onMysqlOpenSqlWorkspace!(widget.connection), child: const Text('Open in SQL'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), @@ -155,19 +166,25 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { children: [ material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: _expanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), @@ -244,16 +261,15 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + title: 'Could not load databases', + message: _error!, padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), + left: 28, + top: 4, + bottom: 4, ), + onRetry: _loadDatabases, ), if (_databases.isNotEmpty) _MysqlDatabasesNode( @@ -307,8 +323,8 @@ class _MysqlDatabasesNode extends material.StatelessWidget { children: [ _PgTreeRow( label: 'Databases (${databases.length})', - icon: material.Icons.dns_rounded, - iconSize: 14, + icon: QueryaIcons.databasesFolder, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, @@ -442,22 +458,23 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { label: widget.databaseName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.storage_rounded, - iconSize: 14, + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, color: theme.colorScheme.foreground, ), verticalPadding: 4, + expanded: _expanded, onTap: _toggle, connection: widget.connection, onContextRefresh: _loadTables, @@ -490,29 +507,10 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { ), ) else if (_error != null) - material.Padding( - padding: const material.EdgeInsets.only( - left: 24, - top: 4, - bottom: 8, - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.destructive, - ), - ), - const material.SizedBox(height: 6), - GhostButton( - onPressed: _loadTables, - child: const Text('Retry'), - ), - ], - ), + TreeLoadError( + title: 'Could not load tables', + message: _error!, + onRetry: _loadTables, ), if (_tables.isNotEmpty || _views.isNotEmpty || @@ -530,8 +528,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { objectKind: MysqlObjectKind.table, onRefresh: _loadTables, label: 'Tables', - icon: material.Icons.table_chart_rounded, - itemIcon: material.Icons.grid_on_rounded, + icon: QueryaIcons.tableGroup, + itemIcon: QueryaIcons.tableLeaf, items: _tables, onItemTap: widget.onMysqlObjectSelected == null ? null @@ -549,8 +547,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { objectKind: MysqlObjectKind.view, onRefresh: _loadTables, label: 'Views', - icon: material.Icons.view_agenda_rounded, - itemIcon: material.Icons.view_week_rounded, + icon: QueryaIcons.viewGroup, + itemIcon: QueryaIcons.viewLeaf, items: _views, onItemTap: widget.onMysqlObjectSelected == null ? null @@ -568,8 +566,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { objectKind: MysqlObjectKind.procedure, onRefresh: _loadTables, label: 'Procedures', - icon: material.Icons.functions_rounded, - itemIcon: material.Icons.code_rounded, + icon: QueryaIcons.functionGroup, + itemIcon: QueryaIcons.functionLeaf, items: _procedures, onItemTap: widget.onMysqlObjectSelected == null ? null @@ -587,8 +585,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { objectKind: MysqlObjectKind.function, onRefresh: _loadTables, label: 'Functions', - icon: material.Icons.functions_rounded, - itemIcon: material.Icons.code_rounded, + icon: QueryaIcons.functionGroup, + itemIcon: QueryaIcons.functionLeaf, items: _functions, onItemTap: widget.onMysqlObjectSelected == null ? null @@ -654,21 +652,22 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { label: '${widget.label} (${widget.items.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), icon: widget.icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.mutedForeground, ), + expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), connection: widget.connection, onContextRefresh: widget.onRefresh, @@ -679,7 +678,7 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { context: context, itemCount: widget.items.length, itemExtent: kConnectionTreeRowExtent, - padding: const material.EdgeInsets.only(left: 22), + padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = widget.items[index]; return _PgTreeRow( @@ -688,8 +687,10 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { ), label: item, icon: widget.itemIcon, - iconSize: 12, - iconColor: theme.colorScheme.mutedForeground, + iconSize: QueryaIconSizes.treeLeaf, + iconColor: QueryaTreeTokens.leafIconColor( + theme.colorScheme.primary, + ), textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.foreground, diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 51ead1e5..1579556f 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -48,7 +48,7 @@ class _PgTreeRowLabel extends material.StatelessWidget { if (label.length < _tooltipMinLength) return text; return material.Tooltip( message: label, - waitDuration: const Duration(milliseconds: 450), + waitDuration: kQueryaTooltipWait, child: text, ); } @@ -61,10 +61,11 @@ class _PgTreeRow extends material.StatelessWidget { required this.label, this.leading, this.icon, - this.iconSize = 13, + this.iconSize = QueryaIconSizes.treeGroup, this.iconColor, this.trailing, this.onTap, + this.expanded, this.verticalPadding = 3, required this.textStyle, this.connection, @@ -85,6 +86,9 @@ class _PgTreeRow extends material.StatelessWidget { final material.Color? iconColor; final material.Widget? trailing; final void Function()? onTap; + + /// When non-null, row is an expand control ([Semantics.button] + expanded). + final bool? expanded; final double verticalPadding; final material.TextStyle textStyle; final ConnectionRow? connection; @@ -141,8 +145,16 @@ class _PgTreeRow extends material.StatelessWidget { ), ), ); - if (connection == null) return row; - return ContextMenu( + if (connection == null) { + return expanded == null + ? row + : material.Semantics( + button: true, + expanded: expanded, + child: row, + ); + } + final menu = ContextMenu( items: [ if (onContextRefresh != null) MenuButton( @@ -194,6 +206,12 @@ class _PgTreeRow extends material.StatelessWidget { ], child: row, ); + if (expanded == null) return menu; + return material.Semantics( + button: true, + expanded: expanded, + child: menu, + ); } } @@ -213,22 +231,23 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { label: 'Databases (${widget.databases.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.dns_rounded, - iconSize: 14, + icon: QueryaIcons.databasesFolder, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, color: theme.colorScheme.foreground, ), verticalPadding: 4, + expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), connection: widget.connection, onContextRefresh: widget.onRefreshDatabases, @@ -340,22 +359,23 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { label: widget.databaseName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.storage_rounded, - iconSize: 14, + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, color: theme.colorScheme.foreground, ), verticalPadding: 4, + expanded: _expanded, onTap: _toggle, connection: widget.connection, onContextRefresh: _loadSchemas, @@ -371,7 +391,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { connection: widget.connection, databaseName: widget.databaseName, label: 'Extensions', - icon: material.Icons.extension_rounded, + icon: QueryaIcons.extension, kind: PostgresObjectKind.databaseExtensions, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, @@ -381,7 +401,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { connection: widget.connection, databaseName: widget.databaseName, label: 'Foreign data', - icon: material.Icons.public_rounded, + icon: QueryaIcons.foreignData, kind: PostgresObjectKind.databaseForeignData, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, @@ -405,29 +425,10 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { ), ) else if (_error != null) - material.Padding( - padding: const material.EdgeInsets.only( - left: 24, - top: 4, - bottom: 8, - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.destructive, - ), - ), - const material.SizedBox(height: 6), - GhostButton( - onPressed: _loadSchemas, - child: const Text('Retry'), - ), - ], - ), + TreeLoadError( + title: 'Could not load schemas', + message: _error!, + onRetry: _loadSchemas, ), if (_schemas.isNotEmpty) _PgSchemasNode( @@ -484,11 +485,11 @@ class _PgDbToolRow extends material.StatelessWidget { child: _PgTreeRow( label: label, icon: icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: muted, trailing: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: muted, ), onTap: onPostgresObjectSelected == null @@ -555,21 +556,22 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { label: 'Schemas (${widget.schemas.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.account_tree_rounded, - iconSize: 13, + icon: QueryaIcons.schemasFolder, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.mutedForeground, ), + expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), connection: widget.connection, onContextRefresh: widget.onRefreshSchemas, @@ -702,7 +704,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { Widget build(BuildContext context) { final theme = Theme.of(context); return material.Padding( - padding: const material.EdgeInsets.only(left: 12), + padding: const material.EdgeInsets.only(left: QueryaTreeTokens.indent), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, @@ -711,21 +713,22 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { label: widget.schemaName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.diamond_outlined, - iconSize: 13, + icon: QueryaIcons.schema, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.primary.withValues(alpha: 0.6), textStyle: material.TextStyle( fontSize: 12, color: theme.colorScheme.foreground, ), + expanded: _expanded, onTap: _toggle, connection: widget.connection, onContextRefresh: _loadObjects, @@ -755,29 +758,10 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { ), ) else if (_error != null) - material.Padding( - padding: const material.EdgeInsets.only( - left: 24, - top: 4, - bottom: 8, - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.destructive, - ), - ), - const material.SizedBox(height: 6), - GhostButton( - onPressed: _loadObjects, - child: const Text('Retry'), - ), - ], - ), + TreeLoadError( + title: 'Could not load objects', + message: _error!, + onRetry: _loadObjects, ), if (_loaded && _error == null) ...[ _PgObjectGroup( @@ -789,7 +773,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Tables', - icon: material.Icons.table_chart_rounded, + icon: QueryaIcons.tableGroup, + itemIcon: QueryaIcons.tableLeaf, items: _tables, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -810,7 +795,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Views', - icon: material.Icons.view_agenda_rounded, + icon: QueryaIcons.viewGroup, + itemIcon: QueryaIcons.viewLeaf, items: _views, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -831,7 +817,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Materialized views', - icon: material.Icons.dynamic_feed_rounded, + icon: QueryaIcons.materializedViewGroup, + itemIcon: QueryaIcons.materializedViewLeaf, items: _matviews, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -852,7 +839,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Functions', - icon: material.Icons.functions_rounded, + icon: QueryaIcons.functionGroup, + itemIcon: QueryaIcons.functionLeaf, items: _functions, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -873,7 +861,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Sequences', - icon: material.Icons.format_list_numbered_rounded, + icon: QueryaIcons.sequenceGroup, + itemIcon: QueryaIcons.sequenceLeaf, items: _sequences, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -890,7 +879,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { databaseName: widget.databaseName, schemaName: widget.schemaName, label: 'Indexes', - icon: material.Icons.table_rows_rounded, + icon: QueryaIcons.indexes, kind: PostgresObjectKind.schemaIndexes, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: @@ -902,7 +891,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { databaseName: widget.databaseName, schemaName: widget.schemaName, label: 'Triggers', - icon: material.Icons.bolt_rounded, + icon: QueryaIcons.triggers, kind: PostgresObjectKind.schemaTriggers, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: @@ -914,7 +903,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { databaseName: widget.databaseName, schemaName: widget.schemaName, label: 'Types', - icon: material.Icons.category_rounded, + icon: QueryaIcons.types, kind: PostgresObjectKind.schemaTypes, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: @@ -969,11 +958,11 @@ class _PgSchemaToolRow extends material.StatelessWidget { child: _PgTreeRow( label: label, icon: icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: muted, trailing: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: muted, ), onTap: onPostgresObjectSelected == null @@ -1006,6 +995,7 @@ class _PgObjectGroup extends StatefulWidget { required this.onRefresh, required this.label, required this.icon, + required this.itemIcon, required this.items, this.onPostgresOpenSqlWorkspace, this.onItemTap, @@ -1018,6 +1008,7 @@ class _PgObjectGroup extends StatefulWidget { final VoidCallback onRefresh; final String label; final material.IconData icon; + final material.IconData itemIcon; final List items; final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; final void Function(String itemName)? onItemTap; @@ -1042,21 +1033,22 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { label: '${widget.label} (${widget.items.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), icon: widget.icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.mutedForeground, ), + expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), connection: widget.connection, onContextRefresh: widget.onRefresh, @@ -1068,7 +1060,7 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { context: context, itemCount: widget.items.length, itemExtent: kConnectionTreeRowExtent, - padding: const material.EdgeInsets.only(left: 22), + padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = widget.items[index]; return _PgTreeRow( @@ -1076,9 +1068,11 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { 'pg-${widget.objectKind.name}-${widget.databaseName}-${widget.schemaName}-$item', ), label: item, - icon: widget.icon, - iconSize: 12, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.5), + icon: widget.itemIcon, + iconSize: QueryaIconSizes.treeLeaf, + iconColor: QueryaTreeTokens.leafIconColor( + theme.colorScheme.primary, + ), textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.foreground, diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 5021b92a..87084347 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -9,6 +9,7 @@ class _PostgresConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onPostgresObjectSelected, this.onPostgresOpenSqlWorkspace, @@ -21,6 +22,7 @@ class _PostgresConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -110,17 +112,20 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ) : material.Icon(widget.icon, - size: 16, color: theme.colorScheme.primary); + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary); return ContextMenu( items: [ @@ -133,6 +138,12 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { }, child: const Text('Refresh databases'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), @@ -150,19 +161,25 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { children: [ material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: _expanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), @@ -239,19 +256,15 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + title: 'Could not load databases', + message: _error!, padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Tooltip( - message: _error!, - child: material.Text( - _error!, - overflow: material.TextOverflow.ellipsis, - maxLines: 2, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), - ), + left: 28, + top: 4, + bottom: 4, ), + onRetry: _loadDatabases, ), if (_databases.isNotEmpty) _PgDatabasesNode( diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index cc6d687c..0ea0f9c1 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -9,6 +9,7 @@ class _RedisConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onDatabaseTap, this.isExpanded = false, @@ -20,6 +21,7 @@ class _RedisConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function(int database)? onDatabaseTap; final bool isExpanded; @@ -125,17 +127,20 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ) : material.Icon(widget.icon, - size: 16, color: theme.colorScheme.primary); + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary); return ContextMenu( items: [ @@ -148,6 +153,12 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { }, child: const Text('Refresh databases'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), @@ -167,19 +178,25 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { // Expand/collapse arrow material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: _expanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), @@ -258,22 +275,25 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + title: 'Could not load Redis info', + message: _error!, padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), + left: 28, + top: 4, + bottom: 4, ), + onRetry: _loadDatabases, ), - for (final db in _databases) - _RedisDatabaseNode( - index: db.index, - keys: db.keys, - onTap: () => widget.onDatabaseTap?.call(db.index), + if (_databases.isNotEmpty) + _RedisDatabasesNode( + connection: widget.connection, + databases: _databases, + onRefreshDatabases: () { + setState(() => _databases = []); + _loadDatabases(); + }, + onDatabaseTap: widget.onDatabaseTap, ), ], ), @@ -285,6 +305,60 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { } } +class _RedisDatabasesNode extends material.StatelessWidget { + const _RedisDatabasesNode({ + required this.connection, + required this.databases, + required this.onRefreshDatabases, + this.onDatabaseTap, + }); + + final ConnectionRow connection; + final List<({int index, int keys})> databases; + final VoidCallback onRefreshDatabases; + final void Function(int database)? onDatabaseTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 20), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + _PgTreeRow( + label: 'Databases (${databases.length})', + icon: QueryaIcons.databasesFolder, + iconSize: QueryaIconSizes.treeConnection, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 4, + onTap: null, + connection: connection, + onContextRefresh: onRefreshDatabases, + ), + lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _RedisDatabaseNode( + index: db.index, + keys: db.keys, + onTap: () => onDatabaseTap?.call(db.index), + ); + }, + ), + ], + ), + ); + } +} + class _RedisDatabaseNode extends StatelessWidget { const _RedisDatabaseNode({ required this.index, @@ -300,49 +374,31 @@ class _RedisDatabaseNode extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); return material.Padding( - padding: const material.EdgeInsets.only(left: 24), - child: material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: onTap, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: - const material.EdgeInsets.symmetric(horizontal: 8, vertical: 5), - child: material.Row( - children: [ - material.Icon( - material.Icons.dns_rounded, - size: 14, - color: keys > 0 - ? theme.colorScheme.primary.withValues(alpha: 0.7) - : theme.colorScheme.mutedForeground - .withValues(alpha: 0.5), + padding: const material.EdgeInsets.only(left: 16), + child: _PgTreeRow( + label: 'db$index', + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeConnection, + iconColor: keys > 0 + ? theme.colorScheme.primary.withValues(alpha: 0.7) + : theme.colorScheme.mutedForeground.withValues(alpha: 0.5), + trailing: keys > 0 + ? material.Text( + '$keys', + style: material.TextStyle( + fontSize: 10, + color: theme.colorScheme.mutedForeground, ), - const Gap(8), - material.Expanded( - child: material.Text( - 'db$index', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 12, - color: keys > 0 - ? theme.colorScheme.foreground - : theme.colorScheme.mutedForeground, - ), - ), - ), - if (keys > 0) - material.Text( - '$keys', - style: material.TextStyle( - fontSize: 10, color: theme.colorScheme.mutedForeground), - ), - ], - ), - ), + ) + : null, + textStyle: material.TextStyle( + fontSize: 12, + color: keys > 0 + ? theme.colorScheme.foreground + : theme.colorScheme.mutedForeground, ), + verticalPadding: 3, + onTap: onTap, ), ); } diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index d054ee57..5b74129a 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -6,7 +6,7 @@ material.Widget _sidebarConnectionShell({ required material.VoidCallback? onTap, required material.Widget child, }) { - final p = Theme.of(context).colorScheme.primary; + final p = context.colors.primary; return material.Material( color: material.Colors.transparent, child: material.InkWell( @@ -75,6 +75,7 @@ class _ConnectionTile extends StatelessWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, }); @@ -83,6 +84,7 @@ class _ConnectionTile extends StatelessWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; @override @@ -91,18 +93,30 @@ class _ConnectionTile extends StatelessWidget { final iconWidget = iconAsset != null ? material.Image.asset( iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ) - : material.Icon(icon, size: 16, color: theme.colorScheme.primary); + : material.Icon( + icon, + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary, + ); return ContextMenu( items: [ + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), @@ -243,40 +257,47 @@ class _FolderTileState extends State<_FolderTile> { children: [ material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, vertical: 6), - child: material.Row( - children: [ - material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 18, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: _expanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, vertical: 6), + child: material.Row( + children: [ + material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), - ), - const Gap(2), - material.Icon(material.Icons.folder_rounded, - size: 18, color: theme.colorScheme.primary), - const Gap(8), - material.Expanded( - child: material.Text( - widget.name, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 13, - color: theme.colorScheme.foreground, + const Gap(2), + material.Icon(QueryaIcons.folder, + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary), + const Gap(8), + material.Expanded( + child: material.Text( + widget.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), ), ), - ), - ], + ], + ), ), ), ), @@ -296,10 +317,11 @@ class _FolderTileState extends State<_FolderTile> { key: material.ValueKey('folder-conn-${conn.id}'), connection: conn, icon: widget.iconForType(conn.type), - iconAsset: ConnectionsPanelState._iconAssetForType( + iconAsset: QueryaIcons.connectionAsset( conn.type, ), onRemove: () => widget.onRemoveConnection(conn.id!), + onEdit: () {}, onTap: () => widget.onConnectionTap?.call(conn), ); }, diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index b403fb6b..d783df0f 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -12,6 +12,7 @@ class _SqliteConnectionTile extends StatefulWidget { required this.icon, this.iconAsset, required this.onRemove, + required this.onEdit, this.onTap, this.onSqliteObjectSelected, this.onSqliteOpenSqlWorkspace, @@ -24,6 +25,7 @@ class _SqliteConnectionTile extends StatefulWidget { final material.IconData icon; final String? iconAsset; final VoidCallback onRemove; + final VoidCallback onEdit; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -111,17 +113,20 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { final iconWidget = widget.iconAsset != null ? material.Image.asset( widget.iconAsset!, - width: 16, - height: 16, + width: QueryaIconSizes.sidebarConnectionIcon, + height: QueryaIconSizes.sidebarConnectionIcon, + cacheWidth: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (QueryaIconSizes.sidebarConnectionIcon * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( widget.icon, - size: 16, + size: QueryaIconSizes.sidebarConnectionIcon, color: theme.colorScheme.primary, ), ) : material.Icon(widget.icon, - size: 16, color: theme.colorScheme.primary); + size: QueryaIconSizes.sidebarConnectionIcon, + color: theme.colorScheme.primary); return ContextMenu( items: [ @@ -145,6 +150,12 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { widget.onSqliteOpenSqlWorkspace!(widget.connection), child: const Text('Open in SQL'), ), + MenuButton( + leading: material.Icon(material.Icons.edit_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onEdit(), + child: const Text('Edit connection…'), + ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), @@ -162,19 +173,25 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { children: [ material.MouseRegion( cursor: material.SystemMouseCursors.click, - child: material.InkWell( - onTap: _toggle, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(2), - child: material.AnimatedRotation( - turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), - child: material.Icon( - material.Icons.chevron_right_rounded, - size: 16, - color: theme.colorScheme.mutedForeground, + child: material.Semantics( + button: true, + expanded: _expanded, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: + context.motionDuration(QueryaMotion.treeExpand), + curve: + context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.sidebarExpand, + color: theme.colorScheme.mutedForeground, + ), ), ), ), @@ -251,16 +268,15 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + title: 'Could not load objects', + message: _error!, padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error loading schema', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), + left: 28, + top: 4, + bottom: 4, ), + onRetry: _loadTables, ), if (!_loading && _error == null) material.Padding( @@ -274,8 +290,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { objectKind: SqliteObjectKind.table, onRefresh: _loadTables, label: 'Tables', - icon: material.Icons.table_chart_rounded, - itemIcon: material.Icons.grid_on_rounded, + icon: QueryaIcons.tableGroup, + itemIcon: QueryaIcons.tableLeaf, items: _tables, onItemTap: widget.onSqliteObjectSelected == null ? null @@ -291,8 +307,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { objectKind: SqliteObjectKind.view, onRefresh: _loadTables, label: 'Views', - icon: material.Icons.view_agenda_rounded, - itemIcon: material.Icons.view_week_rounded, + icon: QueryaIcons.viewGroup, + itemIcon: QueryaIcons.viewLeaf, items: _views, onItemTap: widget.onSqliteObjectSelected == null ? null @@ -364,21 +380,22 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { label: '${widget.label} (${widget.items.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), icon: widget.icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.mutedForeground, ), + expanded: _expanded, onTap: () => setState(() => _expanded = !_expanded), connection: widget.connection, onContextRefresh: widget.onRefresh, @@ -389,7 +406,7 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { context: context, itemCount: widget.items.length, itemExtent: kConnectionTreeRowExtent, - padding: const material.EdgeInsets.only(left: 22), + padding: const material.EdgeInsets.only(left: 26), itemBuilder: (context, index) { final item = widget.items[index]; return _PgTreeRow( @@ -398,8 +415,10 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { ), label: item, icon: widget.itemIcon, - iconSize: 12, - iconColor: theme.colorScheme.mutedForeground, + iconSize: QueryaIconSizes.treeLeaf, + iconColor: QueryaTreeTokens.leafIconColor( + theme.colorScheme.primary, + ), textStyle: material.TextStyle( fontSize: 11, color: theme.colorScheme.foreground, diff --git a/lib/features/connections/driver_icon.dart b/lib/features/connections/driver_icon.dart index 6aa3277b..8621793b 100644 --- a/lib/features/connections/driver_icon.dart +++ b/lib/features/connections/driver_icon.dart @@ -24,7 +24,7 @@ class DriverIcon extends StatelessWidget { final fallback = material.Icon( fallbackIcon, size: size, - color: Theme.of(context).colorScheme.primary, + color: context.colors.primary, ); if (filePath != null) { @@ -35,10 +35,13 @@ class DriverIcon extends StatelessWidget { ); } if (assetPath != null) { + final cacheSize = (size * MediaQuery.devicePixelRatioOf(context)).toInt(); return material.Image.asset( assetPath!, width: size, height: size, + cacheWidth: cacheSize, + cacheHeight: cacheSize, fit: material.BoxFit.contain, filterQuality: material.FilterQuality.medium, errorBuilder: (_, __, ___) => fallback, @@ -64,7 +67,7 @@ class DriverIconImage extends StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); + final theme = context.theme; final fallback = material.Icon( fallbackIcon, size: size, @@ -83,10 +86,13 @@ class DriverIconImage extends StatelessWidget { errorBuilder: (_, __, ___) => fallback, ); } + final cacheSize = (size * MediaQuery.devicePixelRatioOf(context)).toInt(); return material.Image.file( file, width: size, height: size, + cacheWidth: cacheSize, + cacheHeight: cacheSize, fit: material.BoxFit.contain, filterQuality: material.FilterQuality.medium, errorBuilder: (_, __, ___) => fallback, diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index 38a34464..a52e780c 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -74,86 +74,78 @@ class _DriverManagerDialogContent extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; + final theme = context.colors; final drivers = _buildDriverList(); - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 520, minWidth: 400, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Driver Manager').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Built-in Dart drivers and installed sandboxed extension drivers. ' - 'Add a server under Connection → New Database Connection.', - ).muted().small(), - ], - ), - ), - material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 12), - child: material.Container( - decoration: material.BoxDecoration( - color: theme.muted.withValues(alpha: 0.15), - borderRadius: material.BorderRadius.circular(10), - border: material.Border.all( - color: theme.border.withValues(alpha: 0.3)), - ), - child: material.ListView.separated( - shrinkWrap: true, - padding: const material.EdgeInsets.symmetric(vertical: 8), - itemCount: drivers.length, - separatorBuilder: (_, __) => material.Divider( - height: 1, - color: theme.border.withValues(alpha: 0.3), - ), - itemBuilder: (context, index) { - final info = drivers[index]; - return _DriverRow(info: info, theme: theme); - }, - ), - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Driver Manager').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Built-in Dart drivers and installed sandboxed extension drivers. ' + 'Add a server under Connection → New Database Connection.', + ).muted().small(), + ], ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + child: material.Container( decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3)), + color: theme.muted.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: theme.border.withValues(alpha: 0.3)), + ), + child: material.ListView.separated( + shrinkWrap: true, + padding: const material.EdgeInsets.symmetric(vertical: 8), + itemCount: drivers.length, + separatorBuilder: (_, __) => material.Divider( + height: 1, + color: theme.border.withValues(alpha: 0.3), ), + itemBuilder: (context, index) { + final info = drivers[index]; + return _DriverRow(info: info, theme: theme); + }, ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - PrimaryButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), ), ), - ], - ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ], ), ); } diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart index 3ed40d4a..bdeb803b 100644 --- a/lib/features/connections/extension_connection_form.dart +++ b/lib/features/connections/extension_connection_form.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/sdui/sdui_form_builder.dart'; import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_edit_secrets.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows an SDUI connection form for an installed extension driver. @@ -18,6 +19,7 @@ Future showExtensionConnectionForm( required ExtensionManifest manifest, required DriverContribution driver, int? folderId, + ConnectionRow? initial, }) { return showAppDialog( context: context, @@ -28,6 +30,7 @@ Future showExtensionConnectionForm( manifest: manifest, driver: driver, folderId: folderId, + initial: initial, ), ), ); @@ -38,11 +41,13 @@ class _ExtensionConnectionFormContent extends material.StatefulWidget { required this.manifest, required this.driver, this.folderId, + this.initial, }); final ExtensionManifest manifest; final DriverContribution driver; final int? folderId; + final ConnectionRow? initial; @override material.State<_ExtensionConnectionFormContent> createState() => @@ -59,11 +64,21 @@ class _ExtensionConnectionFormContentState var _testing = false; String? _testMessage; bool _testSucceeded = false; + late final Map _initialValues; + + bool get _isEditing => widget.initial != null; @override void initState() { super.initState(); - _nameController.text = widget.driver.displayName; + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _initialValues = _sduiInitialValuesFromConnection(initial); + } else { + _nameController.text = widget.driver.displayName; + _initialValues = const {}; + } _loadSchema(); } @@ -111,6 +126,7 @@ class _ExtensionConnectionFormContentState name: name, values: values, folderId: widget.folderId, + initial: widget.initial, ); material.Navigator.of(context).pop(row); } @@ -139,12 +155,16 @@ class _ExtensionConnectionFormContentState }); try { - final row = connectionRowFromExtensionForm( + var row = connectionRowFromExtensionForm( manifest: widget.manifest, driver: widget.driver, name: 'connection-test', values: values, + initial: widget.initial, ); + if (widget.initial?.id != null) { + row = await mergeSecretsForConnectionUpdate(row); + } final version = await ExtensionDriverSession.instance.testConnection( manifest: widget.manifest, row: row, @@ -169,31 +189,27 @@ class _ExtensionConnectionFormContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + final theme = context.colors; + final title = _isEditing + ? 'Edit ${widget.driver.displayName}' + : widget.driver.displayName; + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: WindowLayout.connectionFormMaxWidth, minWidth: 440, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - Text(widget.driver.displayName).large().semiBold(), + Text(title).large().semiBold(), const material.SizedBox(height: 6), Text( 'Extension driver · ${widget.manifest.id}', @@ -224,7 +240,12 @@ class _ExtensionConnectionFormContentState else if (_loadError != null) Text(_loadError!).muted().small() else if (_schema != null) - SduiFormBuilder(key: _formKey, schema: _schema!), + SduiFormBuilder( + key: _formKey, + schema: _schema!, + initialValues: _initialValues, + keepExistingSecrets: _isEditing, + ), if (_testMessage != null) ...[ const material.SizedBox(height: 12), material.SelectableText( @@ -287,11 +308,58 @@ class _ExtensionConnectionFormContentState ), ], ), - ), ); } } +/// Non-secret SDUI seed values from an existing [ConnectionRow] (no passwords). +Map _sduiInitialValuesFromConnection(ConnectionRow row) { + final values = {}; + + final optionsRaw = row.driverOptions; + if (optionsRaw != null && optionsRaw.trim().isNotEmpty) { + try { + final decoded = jsonDecode(optionsRaw); + if (decoded is Map) { + for (final entry in decoded.entries) { + final key = entry.key.toString(); + if (_isPasswordKey(key)) continue; + values[key] = entry.value; + } + } + } catch (_) { + // Ignore malformed driverOptions; host fields still apply. + } + } + + final host = row.host; + if (host != null && host.isNotEmpty) values['host'] = host; + if (row.port != null) values['port'] = row.port; + final username = row.username; + if (username != null && username.isNotEmpty) values['username'] = username; + final database = row.databaseName; + if (database != null && database.isNotEmpty) { + values['database'] = database; + values['databaseName'] = database; + } + values['useSSL'] = row.useSSL; + values['ssl'] = row.useSSL; + if (row.useSSL) { + values.putIfAbsent('sslMode', () => 'require'); + } + + values.removeWhere((key, _) => _isPasswordKey(key)); + return values; +} + +bool _isPasswordKey(String key) { + final lower = key.toLowerCase(); + return lower == 'password' || + lower.endsWith('password') || + lower.contains('secret') || + lower.contains('passwd'); +} + /// Loads SDUI form schema from the extension package (file path preferred). Future loadDriverConnectionFormSchema({ required ExtensionManifest manifest, @@ -321,6 +389,7 @@ ConnectionRow connectionRowFromExtensionForm({ required String name, required Map values, int? folderId, + ConnectionRow? initial, }) { final known = { 'host', @@ -358,7 +427,8 @@ ConnectionRow connectionRowFromExtensionForm({ } return ConnectionRow( - type: driver.driverId, + id: initial?.id, + type: initial?.type ?? driver.driverId, name: name, host: (host == null || host.isEmpty) ? null : host, port: port, @@ -366,9 +436,10 @@ ConnectionRow connectionRowFromExtensionForm({ password: (password == null || password.isEmpty) ? null : password, databaseName: database, useSSL: useSsl, - extensionId: manifest.id, + extensionId: initial?.extensionId ?? manifest.id, driverOptions: options.isEmpty ? null : jsonEncode(options), - folderId: folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + folderId: initial?.folderId ?? folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); } diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index c4876761..0671338d 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -1,11 +1,11 @@ import 'dart:math' as math; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/ui/querya_icons.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'; -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_hover_surface.dart'; import 'package:querya_desktop/features/connections/connection_type_choice.dart'; import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -27,22 +27,10 @@ extension ConnectionTypeX on ConnectionType { ConnectionType.mongodb => 'MongoDB', ConnectionType.sqlite => 'SQLite', }; - material.IconData get icon => switch (this) { - ConnectionType.postgresql => material.Icons.storage_rounded, - ConnectionType.mysql => material.Icons.table_chart_rounded, - ConnectionType.redis => material.Icons.memory_rounded, - ConnectionType.mongodb => material.Icons.eco_rounded, - ConnectionType.sqlite => material.Icons.folder_open_rounded, - }; + material.IconData get icon => QueryaIcons.connectionIcon(name); /// Asset path for custom icon (from Downloads). - String? get iconAsset => switch (this) { - ConnectionType.postgresql => 'assets/images/postgresql_icon.png', - ConnectionType.mysql => 'assets/images/mysql_icon.png', - ConnectionType.redis => 'assets/images/redis_icon.png', - ConnectionType.mongodb => 'assets/images/mongodb_icon.png', - ConnectionType.sqlite => null, - }; + String? get iconAsset => QueryaIcons.connectionAsset(name); bool get isSql => this == ConnectionType.postgresql || this == ConnectionType.mysql || @@ -116,27 +104,21 @@ class _NewConnectionDialogContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; + final theme = context.colors; final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(context); final dialogH = WindowLayout.newConnectionDialogHeight(context); final headerPadH = dialogMaxW < 420 ? 16.0 : 24.0; final stackFilters = dialogMaxW < 520; - return material.Container( + return material.SizedBox( width: dialogMaxW, - constraints: material.BoxConstraints( - maxWidth: dialogMaxW, - maxHeight: dialogH, - minHeight: math.min(320.0, dialogH), - ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), + child: QueryaDialogCard( + constraints: material.BoxConstraints( + maxWidth: dialogMaxW, + maxHeight: dialogH, + minHeight: math.min(320.0, dialogH), + ), + borderColor: theme.muted, child: material.SizedBox( height: dialogH, child: material.Column( @@ -396,7 +378,7 @@ class _FilterDropdowns extends StatelessWidget { } } -class _DbTypeCard extends material.StatefulWidget { +class _DbTypeCard extends material.StatelessWidget { const _DbTypeCard({ required this.choice, required this.theme, @@ -409,89 +391,69 @@ class _DbTypeCard extends material.StatefulWidget { final bool selected; final VoidCallback onTap; - @override - material.State<_DbTypeCard> createState() => _DbTypeCardState(); -} - -class _DbTypeCardState extends material.State<_DbTypeCard> { - bool _hovered = false; - @override material.Widget build(material.BuildContext context) { - final t = widget.theme; - final highlighted = widget.selected || _hovered; - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - cursor: material.SystemMouseCursors.click, - child: material.GestureDetector( - onTap: widget.onTap, - child: material.AnimatedContainer( - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.enter), - padding: - const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8), - decoration: material.BoxDecoration( - color: highlighted - ? t.muted.withValues(alpha: 0.4) - : t.muted.withValues(alpha: 0.12), - borderRadius: material.BorderRadius.circular(10), - border: material.Border.all( - color: widget.selected - ? t.primary.withValues(alpha: 0.6) - : t.border.withValues(alpha: 0.35), - width: widget.selected ? 1.5 : 1, + final t = theme; + final highlight = t.muted.withValues(alpha: 0.4); + return QueryaHoverSurface( + borderRadius: material.BorderRadius.circular(10), + padding: const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8), + idleColor: selected ? highlight : t.muted.withValues(alpha: 0.12), + hoveredColor: highlight, + border: material.Border.all( + color: selected + ? t.primary.withValues(alpha: 0.6) + : t.border.withValues(alpha: 0.35), + width: selected ? 1.5 : 1, + ), + onTap: onTap, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Expanded( + child: material.Center( + child: material.SizedBox( + width: 52, + height: 52, + child: DriverIcon( + filePath: choice.iconFile, + assetPath: choice.iconAsset, + size: 52, + fallbackIcon: choice.icon, + ), + ), ), ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Expanded( - child: material.Center( - child: material.SizedBox( - width: 52, - height: 52, - child: DriverIcon( - filePath: widget.choice.iconFile, - assetPath: widget.choice.iconAsset, - size: 52, - fallbackIcon: widget.choice.icon, + const material.SizedBox(height: 6), + material.LayoutBuilder( + builder: (context, lc) { + return material.SizedBox( + height: 38, + child: material.FittedBox( + fit: material.BoxFit.scaleDown, + alignment: material.Alignment.center, + child: material.ConstrainedBox( + constraints: material.BoxConstraints( + maxWidth: math.max(48.0, lc.maxWidth), ), - ), - ), - ), - const material.SizedBox(height: 6), - material.LayoutBuilder( - builder: (context, lc) { - return material.SizedBox( - height: 38, - child: material.FittedBox( - fit: material.BoxFit.scaleDown, - alignment: material.Alignment.center, - child: material.ConstrainedBox( - constraints: material.BoxConstraints( - maxWidth: math.max(48.0, lc.maxWidth), - ), - child: material.Text( - widget.choice.label, - textAlign: material.TextAlign.center, - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: 13, - fontWeight: material.FontWeight.w600, - height: 1.2, - color: t.foreground, - ), - ), + child: material.Text( + choice.label, + textAlign: material.TextAlign.center, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + height: 1.2, + color: t.foreground, ), ), - ); - }, - ), - ], + ), + ), + ); + }, ), - ), + ], ), ); } diff --git a/lib/features/connections/new_connection_url_dialog.dart b/lib/features/connections/new_connection_url_dialog.dart index 6c73fe42..1d359f18 100644 --- a/lib/features/connections/new_connection_url_dialog.dart +++ b/lib/features/connections/new_connection_url_dialog.dart @@ -6,7 +6,8 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows a dialog to create a new database connection from a URI. /// Returns the ConnectionRow or null if cancelled. -Future showNewConnectionUrlDialog(material.BuildContext context) { +Future showNewConnectionUrlDialog( + material.BuildContext context) { return showAppDialog( context: context, builder: (context) => material.Dialog( @@ -47,109 +48,102 @@ class _NewConnectionUrlDialogContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + final theme = context.colors; + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 580, minWidth: 420, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('New connection from URL').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Create a connection by pasting a database URI (e.g. postgresql://user:pass@host:5432/db).', - ).muted().small(), - const material.SizedBox(height: 16), - material.Container( - decoration: material.BoxDecoration( - color: theme.muted.withValues(alpha: 0.2), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('New connection from URL').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Create a connection by pasting a database URI (e.g. postgresql://user:pass@host:5432/db).', + ).muted().small(), + const material.SizedBox(height: 16), + material.Container( + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.2), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: _validationError != null + ? theme.destructive.withValues(alpha: 0.8) + : theme.border.withValues(alpha: 0.4), + ), + ), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + child: material.Row( + children: [ + material.Icon( + material.Icons.link_rounded, + size: 20, color: _validationError != null - ? theme.destructive.withValues(alpha: 0.8) - : theme.border.withValues(alpha: 0.4), + ? theme.destructive + : theme.mutedForeground, ), - ), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - child: material.Row( - children: [ - material.Icon( - material.Icons.link_rounded, - size: 20, - color: _validationError != null - ? theme.destructive - : theme.mutedForeground, + const material.SizedBox(width: 10), + material.Expanded( + child: TextField( + controller: _urlController, + placeholder: + const Text('database://user:pass@host:port/db'), + onSubmitted: (_) => _validateAndSubmit(), + onChanged: (_) { + if (_validationError != null) { + setState(() => _validationError = null); + } + }, ), - const material.SizedBox(width: 10), - material.Expanded( - child: TextField( - controller: _urlController, - placeholder: const Text('database://user:pass@host:port/db'), - onSubmitted: (_) => _validateAndSubmit(), - onChanged: (_) { - if (_validationError != null) { - setState(() => _validationError = null); - } - }, - ), - ), - ], - ), + ), + ], ), - if (_validationError != null) ...[ - const material.SizedBox(height: 8), - Text( - _validationError!, - style: material.TextStyle(color: theme.destructive), - ).small(), - ], + ), + if (_validationError != null) ...[ + const material.SizedBox(height: 8), + Text( + _validationError!, + style: material.TextStyle(color: theme.destructive), + ).small(), ], + ], + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3)), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), ), - ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const material.SizedBox(width: 12), - PrimaryButton( - onPressed: _validateAndSubmit, - child: const Text('Create'), - ), - ], - ), + const material.SizedBox(width: 12), + PrimaryButton( + onPressed: _validateAndSubmit, + child: const Text('Create'), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/connections/new_folder_dialog.dart b/lib/features/connections/new_folder_dialog.dart index f4c6a851..f7cc9ced 100644 --- a/lib/features/connections/new_folder_dialog.dart +++ b/lib/features/connections/new_folder_dialog.dart @@ -36,94 +36,88 @@ class _NewFolderDialogContentState @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + final theme = context.colors; + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 440, minWidth: 360, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('New folder').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Enter a name for the new folder in the browser tree.', - ).muted().small(), - const material.SizedBox(height: 16), - material.Container( - decoration: material.BoxDecoration( - color: theme.muted.withValues(alpha: 0.2), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: theme.border.withValues(alpha: 0.4)), - ), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - child: material.Row( - children: [ - material.Icon( - material.Icons.folder_rounded, - size: 20, - color: theme.mutedForeground, - ), - const material.SizedBox(width: 10), - material.Expanded( - child: TextField( - controller: _nameController, - placeholder: const Text('Folder name'), - onChanged: (_) => setState(() {}), - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('New folder').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Enter a name for the new folder in the browser tree.', + ).muted().small(), + const material.SizedBox(height: 16), + material.Container( + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.2), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: theme.border.withValues(alpha: 0.4)), + ), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + child: material.Row( + children: [ + material.Icon( + material.Icons.folder_rounded, + size: 20, + color: theme.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: TextField( + controller: _nameController, + placeholder: const Text('Folder name'), ), - ], - ), + ), + ], ), - ], + ), + ], + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3)), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), ), - ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const material.SizedBox(width: 12), - PrimaryButton( + const material.SizedBox(width: 12), + ListenableBuilder( + listenable: _nameController, + builder: (context, __) => PrimaryButton( onPressed: _name.isEmpty ? null : () => material.Navigator.of(context).pop(_name), child: const Text('Create'), ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index 496dbc01..c0f96817 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -13,21 +13,26 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showSqliteConnectionForm( material.BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _SqliteConnectionFormContent(folderId: folderId), + child: _SqliteConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _SqliteConnectionFormContent extends material.StatefulWidget { - const _SqliteConnectionFormContent({this.folderId}); + const _SqliteConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_SqliteConnectionFormContent> createState() => @@ -45,12 +50,22 @@ class _SqliteConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); _formValidNotifier = FormValidityNotifier(_computeFormValid); _formValidNotifier.listenTo(_nameController); _formValidNotifier.listenTo(_pathController); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _pathController.text = initial.host ?? ''; + _readOnly = initial.useSSL; + } + _formValidNotifier.seed(); } @@ -136,38 +151,37 @@ class _SqliteConnectionFormContentState void _save() { if (!_formValidNotifier.value) return; + final initial = widget.initial; final row = ConnectionRow( - id: null, - type: 'sqlite', + id: initial?.id, + type: initial?.type ?? 'sqlite', name: _nameController.text.trim(), host: _pathController.text.trim(), useSSL: _readOnly, // Store read-only toggle in useSSL field - createdAt: DateTime.now().toIso8601String(), - folderId: widget.folderId, + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, ); material.Navigator.of(context).pop(row); } @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; + final theme = context.colors; final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(context); final dialogH = WindowLayout.newConnectionDialogHeight(context); final scrollH = dialogH - 120.0; // Subtract header and footer heights - return material.Container( + return material.SizedBox( width: dialogMaxW, - constraints: material.BoxConstraints( - maxWidth: dialogMaxW, - maxHeight: dialogH, - ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(Theme.of(context).radiusXxl), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(Theme.of(context).radiusXxl), + child: QueryaDialogCard( + constraints: material.BoxConstraints( + maxWidth: dialogMaxW, + maxHeight: dialogH, + ), + borderColor: theme.muted, child: material.Column( mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, @@ -178,7 +192,11 @@ class _SqliteConnectionFormContentState child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - const Text('New SQLite Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit SQLite Connection' + : 'New SQLite Connection', + ).large().semiBold(), const Gap(6), const Text('Connect to a local SQLite database file.') .muted() @@ -193,7 +211,8 @@ class _SqliteConnectionFormContentState ), child: material.SingleChildScrollView( physics: const material.ClampingScrollPhysics(), - padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 12), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ @@ -229,7 +248,8 @@ class _SqliteConnectionFormContentState children: [ material.Checkbox( value: _readOnly, - onChanged: (v) => setState(() => _readOnly = v ?? false), + onChanged: (v) => + setState(() => _readOnly = v ?? false), ), const Gap(8), const Text('Read-only mode').small(), @@ -250,7 +270,8 @@ class _SqliteConnectionFormContentState onTap: _dismissResult, borderRadius: material.BorderRadius.circular(8), child: material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 10), decoration: material.BoxDecoration( color: _testResult == 'success' ? theme.primary.withValues(alpha: 0.12) @@ -270,7 +291,9 @@ class _SqliteConnectionFormContentState ? material.Icons.check_circle_outline : material.Icons.info_outline_rounded, size: 18, - color: _testResult == 'success' ? theme.primary : theme.destructive, + color: _testResult == 'success' + ? theme.primary + : theme.destructive, ), const Gap(10), material.Expanded( @@ -306,7 +329,8 @@ class _SqliteConnectionFormContentState ), // Footer material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), child: ValueListenableBuilder( valueListenable: _formValidNotifier.listenable, builder: (context, formValid, _) { @@ -316,7 +340,8 @@ class _SqliteConnectionFormContentState alignment: material.WrapAlignment.spaceBetween, children: [ OutlineButton( - onPressed: formValid && !_isTesting ? _testConnection : null, + onPressed: + formValid && !_isTesting ? _testConnection : null, leading: _isTesting ? material.SizedBox( width: 18, @@ -329,13 +354,17 @@ class _SqliteConnectionFormContentState : material.Icon( material.Icons.link_rounded, size: 18, - color: formValid ? theme.primary : theme.mutedForeground, + color: formValid + ? theme.primary + : theme.mutedForeground, ), child: Text( 'Test Connection', style: material.TextStyle( fontWeight: material.FontWeight.w500, - color: formValid ? theme.primary : theme.mutedForeground, + color: formValid + ? theme.primary + : theme.mutedForeground, ), ), ), @@ -343,7 +372,8 @@ class _SqliteConnectionFormContentState mainAxisSize: material.MainAxisSize.min, children: [ GhostButton( - onPressed: () => material.Navigator.of(context).pop(), + onPressed: () => + material.Navigator.of(context).pop(), child: const Text('Cancel'), ), const Gap(12), diff --git a/lib/features/connections/ssl_certificate_support.dart b/lib/features/connections/ssl_certificate_support.dart index 3d304331..bcb98239 100644 --- a/lib/features/connections/ssl_certificate_support.dart +++ b/lib/features/connections/ssl_certificate_support.dart @@ -25,7 +25,8 @@ class SslCertificatePaths { bool get hasAny => _nonEmpty(rootCert) || _nonEmpty(clientCert) || _nonEmpty(clientKey); - static bool _nonEmpty(String? value) => value != null && value.trim().isNotEmpty; + static bool _nonEmpty(String? value) => + value != null && value.trim().isNotEmpty; } SslCertificatePaths extractSslCertificatePaths(Uri uri) { diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index 312db97d..2be7c719 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -8,7 +8,6 @@ import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; diff --git a/lib/features/extensions/extension_stats_view.dart b/lib/features/extensions/extension_stats_view.dart index f7788a59..e8063b3b 100644 --- a/lib/features/extensions/extension_stats_view.dart +++ b/lib/features/extensions/extension_stats_view.dart @@ -104,7 +104,7 @@ class _ExtensionStatsViewState extends material.State { .getServerStats(widget.connectionRow); if (!mounted) return; if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() {}); + setState(() => _stats = stats); } catch (_) {} } @@ -316,7 +316,9 @@ class _ExtensionStatsViewState extends material.State { mainAxisSpacing: 16, mainAxisExtent: _summaryChipHeight, ), - children: chips.map((c) => _buildChip(cs, c)).toList(), + children: [ + for (final c in chips) _buildChip(cs, c), + ], ); }, ); diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 2fce9aca..a2f0a275 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -6,6 +6,7 @@ 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/layout/window_layout.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/features/extensions/presentation/widgets/extension_card.dart'; import 'package:querya_desktop/features/extensions/presentation/widgets/extension_sideload_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -157,108 +158,83 @@ class _ExtensionManagerContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final onPopover = theme.popoverForeground; return material.DefaultTextStyle( style: material.TextStyle(color: onPopover), child: material.IconTheme( data: material.IconThemeData(color: onPopover), - child: material.Container( + child: QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 800, minWidth: 600, maxHeight: 700, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.border), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.spaceBetween, - crossAxisAlignment: material.CrossAxisAlignment.center, - children: [ - material.Expanded( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Extensions') - .large() - .semiBold() - .foreground(), - const material.SizedBox(height: 6), - const Text( - 'Manage local and marketplace extensions') - .muted() - .small(), - ], - ), - ), - const material.SizedBox(width: 16), - PrimaryButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), + borderColor: theme.border, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Extensions') + .large() + .semiBold() + .foreground(), + const material.SizedBox(height: 6), + const Text('Manage local and marketplace extensions') + .muted() + .small(), + ], ), - ], - ), + ), + const material.SizedBox(width: 16), + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], ), - material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 24.0, vertical: 8.0), - child: material.Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildTabButton(0, 'Installed', count: _installed.length), - _buildTabButton(1, 'Marketplace'), - _buildTabButton(2, 'Updates'), - ], - ), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 24.0, vertical: 8.0), + child: QueryaTabStrip( + labels: [ + 'Installed (${_installed.length})', + 'Marketplace', + 'Updates', + ], + selectedIndex: _tabIndex, + onSelected: (index) => setState(() => _tabIndex = index), ), - material.Divider(height: 1, color: theme.border), - material.Expanded( - child: material.IndexedStack( - index: _tabIndex, - children: [ - _buildInstalledTab(), - _buildMarketplaceTab(), - _buildUpdatesTab(), - ], - ), + ), + material.Divider(height: 1, color: theme.border), + material.Expanded( + child: QueryaCrossFadeStack( + index: _tabIndex, + children: [ + _buildInstalledTab(), + _buildMarketplaceTab(), + _buildUpdatesTab(), + ], ), - ], - ), + ), + ], ), ), ), ); } - material.Widget _buildTabButton(int index, String label, {int? count}) { - final isSelected = _tabIndex == index; - final displayLabel = count != null ? '$label ($count)' : label; - return SecondaryButton( - onPressed: () => setState(() => _tabIndex = index), - child: material.Text( - displayLabel, - style: material.TextStyle( - color: isSelected ? Theme.of(context).colorScheme.primary : null, - fontWeight: - isSelected ? material.FontWeight.w600 : material.FontWeight.w400, - ), - ), - ); - } - material.Widget _buildInstalledTab() { if (_loading) { return const material.Center( @@ -418,10 +394,37 @@ class _ExtensionManagerContentState } material.Widget _buildUpdatesTab() { - return const material.Center( + if (_loading) { + return const material.Center( + child: material.CircularProgressIndicator(), + ); + } + final theme = Theme.of(context).colorScheme; + return material.Center( child: material.Padding( - padding: material.EdgeInsets.all(32.0), - child: Text('All installed extensions are up to date!'), + padding: const material.EdgeInsets.all(32.0), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(maxWidth: 420), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.system_update_alt_rounded, + size: 36, + color: theme.mutedForeground, + ), + const material.SizedBox(height: 16), + const Text('Extension update checks are not available yet') + .semiBold(), + const material.SizedBox(height: 8), + const Text( + 'Automatic update scanning ships with the Marketplace API. ' + 'Until then, reinstall from Marketplace or from a local file ' + 'to get a newer build.', + ).muted().small(), + ], + ), + ), ), ); } diff --git a/lib/features/help/about_dialog.dart b/lib/features/help/about_dialog.dart index 11eef0a7..04054392 100644 --- a/lib/features/help/about_dialog.dart +++ b/lib/features/help/about_dialog.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart' as material; import 'package:package_info_plus/package_info_plus.dart'; import 'package:querya_desktop/core/app/external_link.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows the About Querya dialog. @@ -31,84 +30,76 @@ class _AboutDialogContentState extends material.State<_AboutDialogContent> { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final wb = context.workbench; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 420, minWidth: 320, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 28, 24, 8), - child: material.Column( - children: [ - material.Icon( - material.Icons.search_rounded, - size: 48, - color: wb.accent, - ), - const material.SizedBox(height: 16), - const Text('Querya').large().semiBold(), - const material.SizedBox(height: 8), - FutureBuilder( - future: _packageInfo, - builder: (context, snapshot) { - final version = snapshot.data?.version ?? '…'; - return Text('Version $version').muted().small(); - }, - ), - const material.SizedBox(height: 16), - const Text( - 'A lightweight desktop SQL/NoSQL client.', - ).muted().small(), - const material.SizedBox(height: 12), - const Text( - 'Licensed under the MIT License.', - ).small(), - const material.SizedBox(height: 16), - GhostButton( - onPressed: () => launchRepositoryUrl(), - child: const Text('View repository'), - ), - ], - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 28, 24, 8), + child: material.Column( + children: [ + material.Icon( + material.Icons.search_rounded, + size: 48, + color: wb.accent, + ), + const material.SizedBox(height: 16), + const Text('Querya').large().semiBold(), + const material.SizedBox(height: 8), + FutureBuilder( + future: _packageInfo, + builder: (context, snapshot) { + final version = snapshot.data?.version ?? '…'; + return Text('Version $version').muted().small(); + }, + ), + const material.SizedBox(height: 16), + const Text( + 'A lightweight desktop SQL/NoSQL client.', + ).muted().small(), + const material.SizedBox(height: 12), + const Text( + 'Licensed under the MIT License.', + ).small(), + const material.SizedBox(height: 16), + GhostButton( + onPressed: () => launchRepositoryUrl(), + child: const Text('View repository'), + ), + ], ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, - vertical: 16, - ), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3), - ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3), ), ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - PrimaryButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ), ), - ], - ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ], ), ); } diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index f24f201e..f3f5a2f4 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -11,8 +11,6 @@ import 'package:querya_desktop/core/layout/querya_split_handle.dart'; import 'package:querya_desktop/core/motion/querya_spring.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; -import 'package:shadcn_flutter/shadcn_flutter.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'; diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 64f68860..7625db02 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/ui_scale.dart'; @@ -57,6 +59,19 @@ class QueryaWindowTitleBar extends StatelessWidget { ); } + @visibleForTesting + static double titleBarLeadingInset({ + required bool isMacOS, + required double Function(double designPx) scale, + }) { + // macOS traffic lights sit in the transparent titlebar (bitsdojo custom frame). + return scale(isMacOS ? 72 : 16); + } + + /// Bitsdojo chrome buttons duplicate system traffic lights on macOS. + @visibleForTesting + static bool showBitsdojoWindowButtons({required bool isMacOS}) => !isMacOS; + @visibleForTesting static WindowButtonColors closeButtonColors(BuildContext context) { final wb = context.workbench; @@ -85,7 +100,12 @@ class QueryaWindowTitleBar extends StatelessWidget { child: MoveWindow( child: Row( children: [ - const SizedBox(width: 16), + SizedBox( + width: QueryaWindowTitleBar.titleBarLeadingInset( + isMacOS: Platform.isMacOS, + scale: context.scaled, + ), + ), material.Icon( material.Icons.storage_rounded, size: 18, @@ -262,9 +282,13 @@ class QueryaWindowTitleBar extends StatelessWidget { if (activeConnection != null && isReadOnly) const QueryaReadOnlyBadge(), UpdateAvailableBadge(controller: UpdateController.instance), - MinimizeWindowButton(colors: buttonColors), - MaximizeWindowButton(colors: buttonColors), - CloseWindowButton(colors: closeButtonColors), + if (QueryaWindowTitleBar.showBitsdojoWindowButtons( + isMacOS: Platform.isMacOS, + )) ...[ + MinimizeWindowButton(colors: buttonColors), + MaximizeWindowButton(colors: buttonColors), + CloseWindowButton(colors: closeButtonColors), + ], ], ) ], diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index 2ce785d1..f56c3779 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/ui/querya_tooltip.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Layout metrics for [VirtualResultGrid]. @@ -124,16 +125,34 @@ ResultGridColumnWindow computeVisibleColumnWindow({ final start = scrollOffset.clamp(0.0, total); final end = (scrollOffset + viewportWidth).clamp(0.0, total); - // First column with any pixel past [start]. + // First column with any pixel past [start]: smallest index where columnOffsets[first + 1] > start var first = 0; - while (first < n && columnOffsets[first + 1] <= start) { - first++; + var low = 0; + var high = n - 1; + while (low <= high) { + final mid = (low + high) ~/ 2; + if (columnOffsets[mid + 1] > start) { + first = mid; + high = mid - 1; + } else { + low = mid + 1; + } } - // Last column with any pixel before [end]. + + // Last column with any pixel before [end]: largest index where columnOffsets[last] < end var last = n - 1; - while (last > 0 && columnOffsets[last] >= end) { - last--; + low = 0; + high = n - 1; + while (low <= high) { + final mid = (low + high) ~/ 2; + if (columnOffsets[mid] < end) { + last = mid; + low = mid + 1; + } else { + high = mid - 1; + } } + if (first > last) { first = last.clamp(0, n - 1); } @@ -485,7 +504,7 @@ class _GridCell extends material.StatelessWidget { return material.Tooltip( message: text, - waitDuration: const Duration(milliseconds: 400), + waitDuration: kQueryaTooltipWait, child: interactiveCell, ); } diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index d0ee2a09..06252381 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -6,7 +6,7 @@ import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_stagger.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -181,22 +181,31 @@ class _WorkspaceEmptyHeroState extends State { QueryaFadeSlide( alignment: material.Alignment.topCenter, offset: const material.Offset(0, 0.03), - child: showRecent - ? _RecentConnectionsSection( - key: const material.ValueKey('empty_recent_section'), - connections: _recent, - onOpenConnection: widget.onOpenConnection, - compact: compact, + child: !_loaded + ? const material.SizedBox( + key: material.ValueKey('empty_section_loading'), + height: 120, ) - : _QuickStartSection( - key: const material.ValueKey('empty_quick_start'), - compact: compact, - surface: wb.surface, - borderColor: - wb.borderSubtle.withValues(alpha: 0.55), - foreground: cs.foreground, - primary: cs.primary, - ), + : showRecent + ? _RecentConnectionsSection( + key: const material.ValueKey( + 'empty_recent_section', + ), + connections: _recent, + onOpenConnection: widget.onOpenConnection, + compact: compact, + ) + : _QuickStartSection( + key: const material.ValueKey( + 'empty_quick_start', + ), + compact: compact, + surface: wb.surface, + borderColor: + wb.borderSubtle.withValues(alpha: 0.55), + foreground: cs.foreground, + primary: cs.primary, + ), ), ], ), @@ -361,8 +370,8 @@ class _RecentConnectionRow extends StatelessWidget { children: [ DriverIcon( size: 20, - fallbackIcon: _iconForType(connection.type), - assetPath: _iconAssetForType(connection.type), + fallbackIcon: QueryaIcons.connectionIcon(connection.type), + assetPath: QueryaIcons.connectionAsset(connection.type), ), const material.SizedBox(width: 12), material.Expanded( @@ -459,27 +468,6 @@ class _QuickStartRow extends StatelessWidget { } } -material.IconData _iconForType(String type) { - return switch (type) { - 'mongodb' => material.Icons.eco_rounded, - 'postgresql' => material.Icons.storage_rounded, - 'mysql' => material.Icons.table_chart_rounded, - 'redis' => material.Icons.memory_rounded, - 'sqlite' => material.Icons.folder_open_rounded, - _ => material.Icons.extension_rounded, - }; -} - -String? _iconAssetForType(String type) { - return switch (type) { - 'postgresql' => 'assets/images/postgresql_icon.png', - 'mysql' => 'assets/images/mysql_icon.png', - 'redis' => 'assets/images/redis_icon.png', - 'mongodb' => 'assets/images/mongodb_icon.png', - _ => null, - }; -} - String _connectionSubtitle(ConnectionRow connection) { if (connection.type == 'sqlite') { final path = connection.databaseName ?? connection.connectionString; diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index b3c40058..c33bd719 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -13,6 +13,7 @@ import 'package:flutter/material.dart' as material Widget, Column; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/motion/querya_fade_slide.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'; @@ -132,6 +133,9 @@ class _WorkspacePanelState extends State { /// Keeps the last connected workspace mounted so empty↔active can cross-fade. material.Widget? _cachedActiveBody; + /// Connection id for the cached active body (stable FadeSlide key on empty). + int? _lastConnectedId; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -151,17 +155,27 @@ class _WorkspacePanelState extends State { if (activeConn == null) { activeBody = _cachedActiveBody ?? const material.SizedBox.expand(); } else { + _lastConnectedId = activeConn.id; activeBody = _buildActiveConnectionBody(theme, activeConn); _cachedActiveBody = activeBody; } + // Connection A→B: keyed FadeSlide inside the connected slot (#494). + // Keep last id when deselected so empty↔connected SwitchingBody is undisturbed. + final connKey = activeConn?.id ?? _lastConnectedId ?? 0; + return material.Container( color: theme.colorScheme.background, child: QueryaSwitchingBody( index: activeConn == null ? 0 : 1, children: [ empty, - material.SizedBox.expand(child: activeBody), + QueryaFadeSlide( + child: material.SizedBox.expand( + key: ValueKey('ws_conn_$connKey'), + child: activeBody, + ), + ), ], ), ); @@ -175,115 +189,141 @@ class _WorkspacePanelState extends State { switch (activeConn.type) { case 'postgresql': final pg = widget.selectedPostgresObject; - driverWorkspace = pg == null - ? PostgresWorkspaceHome( - key: ValueKey('pg_home_${activeConn.id}'), - connectionRow: activeConn, - postgresSqlEditorContext: widget.postgresSqlEditorContext, - postgresSqlEditorContextToken: - widget.postgresSqlEditorContextToken, - sqlTabRequestToken: widget.postgresSqlTabRequestToken, - isReadOnly: widget.isReadOnly, - ) - : buildPostgresObjectWorkspace( - connection: activeConn, - pg: pg, - ); + driverWorkspace = _homeObjectMorph( + showingObject: pg != null, + home: PostgresWorkspaceHome( + key: ValueKey('pg_home_${activeConn.id}'), + connectionRow: activeConn, + postgresSqlEditorContext: widget.postgresSqlEditorContext, + postgresSqlEditorContextToken: + widget.postgresSqlEditorContextToken, + sqlTabRequestToken: widget.postgresSqlTabRequestToken, + isReadOnly: widget.isReadOnly, + ), + object: pg == null + ? null + : buildPostgresObjectWorkspace( + connection: activeConn, + pg: pg, + ), + ); break; case 'mysql': final my = widget.selectedMysqlObject; - if (my == null) { - driverWorkspace = MysqlWorkspaceHome( + material.Widget? mysqlObject; + if (my != null) { + if (my.kind == MysqlObjectKind.procedure || + my.kind == MysqlObjectKind.function) { + mysqlObject = MysqlRoutineView( + key: ValueKey( + 'mysql_${activeConn.id}_${my.database}_${my.name}_${my.kind}', + ), + connectionRow: activeConn, + database: my.database, + routineName: my.name, + isFunction: my.kind == MysqlObjectKind.function, + ); + } else { + mysqlObject = MysqlTableView( + key: ValueKey( + 'mysql_${activeConn.id}_${my.database}_${my.name}_${my.kind}', + ), + connectionRow: activeConn, + database: my.database, + tableName: my.name, + isView: my.kind == MysqlObjectKind.view, + ); + } + } + driverWorkspace = _homeObjectMorph( + showingObject: my != null, + home: MysqlWorkspaceHome( key: ValueKey('mysql_home_${activeConn.id}'), connectionRow: activeConn, sqlTabRequestToken: widget.mysqlSqlTabRequestToken, isReadOnly: widget.isReadOnly, - ); - } else if (my.kind == MysqlObjectKind.procedure || - my.kind == MysqlObjectKind.function) { - driverWorkspace = MysqlRoutineView( - key: ValueKey( - 'mysql_${activeConn.id}_${my.database}_${my.name}_${my.kind}', - ), - connectionRow: activeConn, - database: my.database, - routineName: my.name, - isFunction: my.kind == MysqlObjectKind.function, - ); - } else { - driverWorkspace = MysqlTableView( - key: ValueKey( - 'mysql_${activeConn.id}_${my.database}_${my.name}_${my.kind}', - ), - connectionRow: activeConn, - database: my.database, - tableName: my.name, - isView: my.kind == MysqlObjectKind.view, - ); - } + ), + object: mysqlObject, + ); break; case 'mongodb': final mongoDb = widget.selectedMongoDb; - driverWorkspace = mongoDb != null - ? MongoExplorerView( - key: ValueKey('mongo_${activeConn.id}_db_$mongoDb'), - connectionRow: activeConn, - database: mongoDb, - ) - : MongoStatsView( - key: ValueKey(activeConn.id), - connectionRow: activeConn, - ); + driverWorkspace = _homeObjectMorph( + showingObject: mongoDb != null, + home: MongoStatsView( + key: ValueKey('mongo_stats_${activeConn.id}'), + connectionRow: activeConn, + ), + object: mongoDb == null + ? null + : MongoExplorerView( + key: ValueKey('mongo_${activeConn.id}_db_$mongoDb'), + connectionRow: activeConn, + database: mongoDb, + ), + ); break; case 'redis': final redisDb = widget.selectedRedisDb; - driverWorkspace = redisDb != null - ? RedisExplorerView( - key: ValueKey('redis_${activeConn.id}_db_$redisDb'), - connectionRow: activeConn, - database: redisDb, - ) - : RedisView( - key: ValueKey(activeConn.id), - connectionRow: activeConn, - ); + driverWorkspace = _homeObjectMorph( + showingObject: redisDb != null, + home: RedisView( + key: ValueKey('redis_stats_${activeConn.id}'), + connectionRow: activeConn, + ), + object: redisDb == null + ? null + : RedisExplorerView( + key: ValueKey('redis_${activeConn.id}_db_$redisDb'), + connectionRow: activeConn, + database: redisDb, + ), + ); break; case 'sqlite': final sq = widget.selectedSqliteObject; - driverWorkspace = sq == null - ? SqliteWorkspaceHome( - key: ValueKey('sqlite_home_${activeConn.id}'), - connectionRow: activeConn, - sqlTabRequestToken: widget.sqliteSqlTabRequestToken, - isReadOnly: widget.isReadOnly, - ) - : SqliteTableView( - key: ValueKey( - 'sqlite_${activeConn.id}_${sq.name}_${sq.kind}', + driverWorkspace = _homeObjectMorph( + showingObject: sq != null, + home: SqliteWorkspaceHome( + key: ValueKey('sqlite_home_${activeConn.id}'), + connectionRow: activeConn, + sqlTabRequestToken: widget.sqliteSqlTabRequestToken, + isReadOnly: widget.isReadOnly, + ), + object: sq == null + ? null + : SqliteTableView( + key: ValueKey( + 'sqlite_${activeConn.id}_${sq.name}_${sq.kind}', + ), + connectionRow: activeConn, + tableName: sq.name, + isView: sq.kind == SqliteObjectKind.view, ), - connectionRow: activeConn, - tableName: sq.name, - isView: sq.kind == SqliteObjectKind.view, - ); + ); break; default: if (ExtensionDriverCatalog.isExtensionDriverConnection(activeConn)) { final obj = widget.selectedExtensionObject; - driverWorkspace = obj == null - ? ExtensionWorkspaceHome( - key: ValueKey('ext_home_${activeConn.id}'), - connectionRow: activeConn, - sqlTabRequestToken: widget.extensionSqlTabRequestToken, - isReadOnly: widget.isReadOnly, - ) - : ExtensionTableView( - key: ValueKey( - 'ext_table_${activeConn.id}_${obj.database}_${obj.name}', + driverWorkspace = _homeObjectMorph( + showingObject: obj != null, + home: ExtensionWorkspaceHome( + key: ValueKey('ext_home_${activeConn.id}'), + connectionRow: activeConn, + sqlTabRequestToken: widget.extensionSqlTabRequestToken, + isReadOnly: widget.isReadOnly, + ), + object: obj == null + ? null + : ExtensionTableView( + key: ValueKey( + 'ext_table_${activeConn.id}_${obj.database}_${obj.name}', + ), + connectionRow: activeConn, + database: obj.database, + tableName: obj.name, ), - connectionRow: activeConn, - database: obj.database, - tableName: obj.name, - ); + ); } break; } @@ -314,4 +354,27 @@ class _WorkspacePanelState extends State { ), ); } + + /// Home (stats / SQL) stays keep-alive; object/explorer morphs in on top. + /// + /// Object→object switches use [QueryaFadeSlide] so table/DB changes do not + /// hard-cut. Does not animate virtualized result rows inside those views. + material.Widget _homeObjectMorph({ + required bool showingObject, + required material.Widget home, + required material.Widget? object, + }) { + return QueryaSwitchingBody( + index: showingObject ? 1 : 0, + children: [ + home, + QueryaFadeSlide( + child: object ?? + const material.SizedBox.expand( + key: ValueKey('workspace_object_placeholder'), + ), + ), + ], + ); + } } diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 7ed1903f..8e6a9168 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -3,7 +3,6 @@ import 'dart:math' show min; 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_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; diff --git a/lib/features/mongodb/mongo_database_dialog.dart b/lib/features/mongodb/mongo_database_dialog.dart index af10e3d9..7c9221c4 100644 --- a/lib/features/mongodb/mongo_database_dialog.dart +++ b/lib/features/mongodb/mongo_database_dialog.dart @@ -26,14 +26,6 @@ class _CreateMongoDBDialogContentState extends material.State<_CreateMongoDBDialogContent> { final _nameController = material.TextEditingController(); - @override - void initState() { - super.initState(); - _nameController.addListener(_onFieldChanged); - } - - void _onFieldChanged() => setState(() {}); - bool get _formValid => _nameController.text.trim().isNotEmpty; void _save() { @@ -43,85 +35,79 @@ class _CreateMongoDBDialogContentState @override void dispose() { - _nameController.removeListener(_onFieldChanged); _nameController.dispose(); super.dispose(); } @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; + final theme = context.colors; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints(context, maxWidth: 500), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Row( - children: [ - material.Icon(material.Icons.storage_rounded, - size: 24, color: theme.primary), - const Gap(12), - const Text('Create Database').large().semiBold(), - ], - ), - const Gap(8), - const Text('Enter the name for the new MongoDB database.') - .muted() - .small(), - ], - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + children: [ + material.Icon(material.Icons.storage_rounded, + size: 24, color: theme.primary), + const Gap(12), + const Text('Create Database').large().semiBold(), + ], + ), + const Gap(8), + const Text('Enter the name for the new MongoDB database.') + .muted() + .small(), + ], ), - const material.Divider(height: 1), - material.Padding( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Database Name').small().semiBold(), - const Gap(8), - TextField( - controller: _nameController, - placeholder: const Text('mydb'), - ), - ], - ), + ), + const material.Divider(height: 1), + material.Padding( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Database Name').small().semiBold(), + const Gap(8), + TextField( + controller: _nameController, + placeholder: const Text('mydb'), + ), + ], ), - const material.Divider(height: 1), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( + ), + const material.Divider(height: 1), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + ListenableBuilder( + listenable: _nameController, + builder: (context, __) => PrimaryButton( onPressed: _formValid ? _save : null, child: const Text('Create'), ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/mongodb/mongo_databases_view.dart b/lib/features/mongodb/mongo_databases_view.dart index 4c88374b..20e7686d 100644 --- a/lib/features/mongodb/mongo_databases_view.dart +++ b/lib/features/mongodb/mongo_databases_view.dart @@ -2,7 +2,6 @@ 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/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index bba3ba4d..6cc9dafa 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -5,7 +5,6 @@ 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/database/mongodb_service.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index a7319e89..f75cc028 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -3,7 +3,6 @@ import 'dart:convert'; 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_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index cce26a9c..a19f14d2 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -690,7 +690,7 @@ class _MongoStatsViewState extends material.State { title, _twoColumnMetrics( context, - data.entries.map((e) => MapEntry(e.key, e.value)).toList(), + [ for (final e in data.entries) MapEntry(e.key, e.value) ], ), ); } diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index b1586c18..88bf852d 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; @@ -46,21 +47,26 @@ class MongoConnectionData { Future showMongoConnectionForm( BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _MongoConnectionFormContent(folderId: folderId), + child: _MongoConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _MongoConnectionFormContent extends material.StatefulWidget { - const _MongoConnectionFormContent({this.folderId}); + const _MongoConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_MongoConnectionFormContent> createState() => @@ -89,6 +95,8 @@ class _MongoConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); @@ -105,6 +113,23 @@ class _MongoConnectionFormContentState _sslRootCertController.addListener(_syncUriSslParams); _sslCertController.addListener(_syncUriSslParams); _sslKeyController.addListener(_syncUriSslParams); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _hostController.text = initial.host ?? 'localhost'; + _portController.text = (initial.port ?? 27017).toString(); + _usernameController.text = initial.username ?? ''; + _databaseController.text = initial.databaseName ?? ''; + _authSourceController.text = initial.authSource ?? ''; + _useSSL = initial.useSSL; + final redacted = redactUriPassword(initial.connectionString) ?? ''; + _connectionStringController.text = redacted; + if (redacted.isNotEmpty) { + _useConnectionString = true; + } + } + _formValidNotifier.seed(); } @@ -279,8 +304,10 @@ class _MongoConnectionFormContentState final displayName = data.name.isNotEmpty ? data.name : 'MongoDB ${data.host}:${data.port}'; + final initial = widget.initial; final row = ConnectionRow( - type: 'mongodb', + id: initial?.id, + type: initial?.type ?? 'mongodb', name: displayName, host: data.host, port: data.port, @@ -290,8 +317,11 @@ class _MongoConnectionFormContentState authSource: data.authSource, useSSL: data.useSSL, connectionString: data.connectionString, - folderId: widget.folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); @@ -300,24 +330,17 @@ class _MongoConnectionFormContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: WindowLayout.connectionFormMaxWidth, maxHeight: WindowLayout.connectionFormMongoMaxHeight, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + borderColor: theme.muted, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), child: Column( @@ -331,7 +354,11 @@ class _MongoConnectionFormContentState color: theme.primary, ), const Gap(12), - const Text('MongoDB Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit MongoDB Connection' + : 'MongoDB Connection', + ).large().semiBold(), ], ), const Gap(8), @@ -455,7 +482,11 @@ class _MongoConnectionFormContentState children: [ TextField( controller: _passwordController, - placeholder: const Text('Password'), + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), obscureText: !_showPassword, ), material.Positioned( @@ -668,7 +699,6 @@ class _MongoConnectionFormContentState ), ], ), - ), ); } } diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 2bd146c1..47f573f9 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; @@ -14,21 +15,26 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showMysqlConnectionForm( BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _MysqlConnectionFormContent(folderId: folderId), + child: _MysqlConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _MysqlConnectionFormContent extends material.StatefulWidget { - const _MysqlConnectionFormContent({this.folderId}); + const _MysqlConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_MysqlConnectionFormContent> createState() => @@ -55,6 +61,8 @@ class _MysqlConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); @@ -73,6 +81,19 @@ class _MysqlConnectionFormContentState _sslRootCertController.addListener(_syncUriSslParams); _sslCertController.addListener(_syncUriSslParams); _sslKeyController.addListener(_syncUriSslParams); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _hostController.text = initial.host ?? ''; + _portController.text = (initial.port ?? 3306).toString(); + _usernameController.text = initial.username ?? ''; + _databaseController.text = initial.databaseName ?? ''; + _useSSL = initial.useSSL; + _connectionStringController.text = + redactUriPassword(initial.connectionString) ?? ''; + } + _formValidNotifier.seed(); } @@ -215,8 +236,10 @@ class _MysqlConnectionFormContentState : (uri.isNotEmpty ? 'MySQL (URI)' : 'MySQL $host:$port${database.isNotEmpty ? '/$database' : ''}'); + final initial = widget.initial; final row = ConnectionRow( - type: 'mysql', + id: initial?.id, + type: initial?.type ?? 'mysql', name: displayName, host: uri.isNotEmpty ? null : host, port: uri.isNotEmpty ? null : port, @@ -229,8 +252,11 @@ class _MysqlConnectionFormContentState uri.isNotEmpty ? null : (database.isEmpty ? null : database), useSSL: _useSSL || _hasSslCertificateFields(), connectionString: uri.isEmpty ? null : uri, - folderId: widget.folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); } @@ -269,24 +295,17 @@ class _MysqlConnectionFormContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: WindowLayout.connectionFormMaxWidth, maxHeight: WindowLayout.connectionFormMaxHeight, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + borderColor: theme.muted, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), child: material.Column( @@ -299,6 +318,8 @@ class _MysqlConnectionFormContentState height: 24, child: material.Image.asset( 'assets/images/mysql_icon.png', + cacheWidth: (40 * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (40 * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( material.Icons.table_chart_rounded, @@ -308,7 +329,11 @@ class _MysqlConnectionFormContentState ), ), const Gap(12), - const Text('MySQL Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit MySQL Connection' + : 'MySQL Connection', + ).large().semiBold(), ], ), const Gap(8), @@ -408,7 +433,11 @@ class _MysqlConnectionFormContentState children: [ TextField( controller: _passwordController, - placeholder: const Text('Password'), + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), obscureText: !_showPassword, ), material.Positioned( @@ -585,7 +614,6 @@ class _MysqlConnectionFormContentState ), ], ), - ), ); } } diff --git a/lib/features/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart index e1c09ef8..d18b62bb 100644 --- a/lib/features/mysql/mysql_sql_editor_dialog.dart +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -73,102 +73,93 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; return material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: material.Container( + child: QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 720, minWidth: 480, maxHeight: 520, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('SQL query').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Table browse uses SELECT with LIMIT/OFFSET. ' - 'Edit or write your own SELECT. Reset restores the browse query. ' - 'Run reloads the grid; unchanged data looks the same.', - ).muted().xSmall(), - ], - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('SQL query').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Table browse uses SELECT with LIMIT/OFFSET. ' + 'Edit or write your own SELECT. Reset restores the browse query. ' + 'Run reloads the grid; unchanged data looks the same.', + ).muted().xSmall(), + ], ), - material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 24), - child: material.SizedBox( - height: 280, - child: material.Container( - decoration: - SqlEditorChrome.inlineFieldDecorationFromContext( - context, - ), - child: QueryaCodeEditor( - controller: _controller, - language: QueryaCodeLanguage.sql, - fontSize: 12, - variant: QueryaCodeEditorVariant.material, - textAlignVertical: material.TextAlignVertical.top, - hintText: 'SELECT …', - contentPadding: const material.EdgeInsets.all(12), - ), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 24), + child: material.SizedBox( + height: 280, + child: material.Container( + decoration: SqlEditorChrome.inlineFieldDecorationFromContext( + context, ), - ), - ), - if (_error != null) - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 0), - child: material.Text( - _error!, - style: material.TextStyle( - color: theme.destructive, fontSize: 12), + child: QueryaCodeEditor( + controller: _controller, + language: QueryaCodeLanguage.sql, + fontSize: 12, + variant: QueryaCodeEditorVariant.material, + textAlignVertical: material.TextAlignVertical.top, + hintText: 'SELECT …', + contentPadding: const material.EdgeInsets.all(12), ), ), + ), + ), + if (_error != null) material.Padding( - padding: const material.EdgeInsets.all(20), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - OutlineButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(8), - OutlineButton( - onPressed: () { - setState(() { - _error = null; - _controller.text = widget.browseSql; - }); - }, - child: const Text('Reset'), - ), - const Gap(8), - PrimaryButton( - onPressed: _submit, - child: const Text('Run'), - ), - ], + padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 0), + child: material.Text( + _error!, + style: material.TextStyle( + color: theme.destructive, fontSize: 12), ), ), - ], - ), + material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(8), + OutlineButton( + onPressed: () { + setState(() { + _error = null; + _controller.text = widget.browseSql; + }); + }, + child: const Text('Reset'), + ), + const Gap(8), + PrimaryButton( + onPressed: _submit, + child: const Text('Run'), + ), + ], + ), + ), + ], ), ), ); diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 5ece7c49..5bf34a27 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -11,7 +11,6 @@ import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index d281bb0c..01d4d0a2 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -126,7 +126,7 @@ class _MysqlStatsViewState extends material.State { final stats = await conn.serverStats(); if (!mounted) return; if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() {}); + setState(() => _stats = stats); } catch (_) {} } @@ -147,7 +147,7 @@ class _MysqlStatsViewState extends material.State { ); final cs = Theme.of(context).colorScheme; - final width = material.MediaQuery.sizeOf(context).width; + final width = MediaQuery.sizeOf(context).width; if (_loading) { return material.Center( @@ -254,6 +254,8 @@ class _MysqlStatsViewState extends material.State { height: 28, child: material.Image.asset( 'assets/images/mysql_icon.png', + cacheWidth: (28 * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (28 * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( material.Icons.storage_rounded, diff --git a/lib/features/postgresql/postgres_sql_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index 1f28fefb..49177e71 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -101,102 +101,93 @@ class _PostgresSqlEditorDialogState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; return material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: material.Container( + child: QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 720, minWidth: 480, maxHeight: 520, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('SQL query').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Table browse uses SELECT with LIMIT/OFFSET. ' - 'Edit it or write your own SELECT. Reset restores the table browse query. ' - 'Run reloads the grid from the database; if rows are unchanged, the view will look the same.', - ).muted().xSmall(), - ], - ), + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('SQL query').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Table browse uses SELECT with LIMIT/OFFSET. ' + 'Edit it or write your own SELECT. Reset restores the table browse query. ' + 'Run reloads the grid from the database; if rows are unchanged, the view will look the same.', + ).muted().xSmall(), + ], ), - material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 24), - child: material.SizedBox( - height: 280, - child: material.Container( - decoration: - SqlEditorChrome.inlineFieldDecorationFromContext( - context, - ), - child: QueryaCodeEditor( - controller: _controller, - language: QueryaCodeLanguage.sql, - fontSize: 12, - variant: QueryaCodeEditorVariant.material, - textAlignVertical: material.TextAlignVertical.top, - hintText: 'SELECT …', - contentPadding: const material.EdgeInsets.all(12), - ), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 24), + child: material.SizedBox( + height: 280, + child: material.Container( + decoration: SqlEditorChrome.inlineFieldDecorationFromContext( + context, ), - ), - ), - if (_error != null) - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 0), - child: material.Text( - _error!, - style: material.TextStyle( - color: theme.destructive, fontSize: 12), + child: QueryaCodeEditor( + controller: _controller, + language: QueryaCodeLanguage.sql, + fontSize: 12, + variant: QueryaCodeEditorVariant.material, + textAlignVertical: material.TextAlignVertical.top, + hintText: 'SELECT …', + contentPadding: const material.EdgeInsets.all(12), ), ), + ), + ), + if (_error != null) material.Padding( - padding: const material.EdgeInsets.all(20), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - OutlineButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(8), - OutlineButton( - onPressed: () { - setState(() { - _error = null; - _controller.text = widget.browseSql; - }); - }, - child: const Text('Reset'), - ), - const Gap(8), - PrimaryButton( - onPressed: _submit, - child: const Text('Run'), - ), - ], + padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 0), + child: material.Text( + _error!, + style: material.TextStyle( + color: theme.destructive, fontSize: 12), ), ), - ], - ), + material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(8), + OutlineButton( + onPressed: () { + setState(() { + _error = null; + _controller.text = widget.browseSql; + }); + }, + child: const Text('Reset'), + ), + const Gap(8), + PrimaryButton( + onPressed: _submit, + child: const Text('Run'), + ), + ], + ), + ), + ], ), ), ); diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 0cd85bed..2efe818b 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -13,7 +13,6 @@ import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; @@ -358,8 +357,8 @@ class _PostgresSqlWorkspaceState extends material.State { n++; } - // Yielding convert avoids isolate double-copy of the matrix (#421). - final outRows = await convertResultRowsToStringsYielding(rawRows); + // Adaptive convert offloads to background compute for large row sets (#522). + final outRows = await convertResultRowsToStringsAdaptive(rawRows); setState(() { _columns = cols; diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 48cd1988..ab73ce50 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -143,7 +143,7 @@ class _PostgresStatsViewState extends material.State { final stats = await c.serverStats(); if (!mounted) return; if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() {}); + setState(() => _stats = stats); } catch (_) {} } @@ -276,6 +276,8 @@ class _PostgresStatsViewState extends material.State { height: 28, child: material.Image.asset( 'assets/images/postgresql_icon.png', + cacheWidth: (32 * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (32 * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( material.Icons.storage_rounded, diff --git a/lib/features/postgresql/postgres_table_privileges_dialog.dart b/lib/features/postgresql/postgres_table_privileges_dialog.dart index c5234604..fc3a2f10 100644 --- a/lib/features/postgresql/postgres_table_privileges_dialog.dart +++ b/lib/features/postgresql/postgres_table_privileges_dialog.dart @@ -77,7 +77,6 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final mq = material.MediaQuery.sizeOf(context); final hInset = WindowLayout.dialogVerticalInset(mq.height) * 2; final wInset = WindowLayout.dialogHorizontalInset(mq.width) * 2; @@ -103,66 +102,59 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { child: material.SizedBox( width: dialogWidth, height: dialogHeight, - child: material.DecoratedBox( - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(20, 16, 20, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('Table privileges').large().semiBold(), - const material.SizedBox(height: 4), - material.Text( - '${widget.schema}.${widget.tableName}', - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: theme.mutedForeground, - ), - maxLines: 2, - overflow: material.TextOverflow.ellipsis, + child: QueryaDialogCard( + borderColor: theme.muted, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(20, 16, 20, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Table privileges').large().semiBold(), + const material.SizedBox(height: 4), + material.Text( + '${widget.schema}.${widget.tableName}', + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: theme.mutedForeground, ), - const material.SizedBox(height: 4), - const Text( - 'From information_schema.role_table_grants (read-only).', - ).muted().xSmall(), - ], - ), + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ), + const material.SizedBox(height: 4), + const Text( + 'From information_schema.role_table_grants (read-only).', + ).muted().xSmall(), + ], ), - const material.Divider(height: 1), - material.Expanded(child: _buildListArea(theme)), - const material.Divider(height: 1), - material.Padding( - padding: const material.EdgeInsets.all(12), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ + ), + const material.Divider(height: 1), + material.Expanded(child: _buildListArea(theme)), + const material.Divider(height: 1), + material.Padding( + padding: const material.EdgeInsets.all(12), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + if (!_loading && _error == null) ...[ + const Gap(8), OutlineButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), + onPressed: _load, + child: const Text('Reload'), ), - if (!_loading && _error == null) ...[ - const Gap(8), - OutlineButton( - onPressed: _load, - child: const Text('Reload'), - ), - ], ], - ), + ], ), - ], - ), + ), + ], ), ), ), diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 636aa0ce..5a85e20b 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -200,7 +200,7 @@ class _PostgresTableViewState extends material.State { List.generate(row.length, (i) => row[i]), ]; - final stringRows = await convertResultRowsToStringsYielding(rawRows); + final stringRows = await convertResultRowsToStringsAdaptive(rawRows); if (!mounted) return; setState(() { @@ -257,7 +257,7 @@ class _PostgresTableViewState extends material.State { List.generate(row.length, (i) => row[i]), ]; - final stringRows = await convertResultRowsToStringsYielding(rawRows); + final stringRows = await convertResultRowsToStringsAdaptive(rawRows); if (!mounted) return; setState(() { diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index a373fb1a..4442e66d 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -13,21 +14,26 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showPostgresConnectionForm( BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _PostgresConnectionFormContent(folderId: folderId), + child: _PostgresConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _PostgresConnectionFormContent extends material.StatefulWidget { - const _PostgresConnectionFormContent({this.folderId}); + const _PostgresConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_PostgresConnectionFormContent> createState() => @@ -54,6 +60,8 @@ class _PostgresConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); @@ -72,6 +80,20 @@ class _PostgresConnectionFormContentState _sslRootCertController.addListener(_syncUriSslParams); _sslCertController.addListener(_syncUriSslParams); _sslKeyController.addListener(_syncUriSslParams); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _hostController.text = initial.host ?? ''; + _portController.text = (initial.port ?? 5432).toString(); + _usernameController.text = initial.username ?? ''; + _databaseController.text = initial.databaseName ?? ''; + _useSSL = initial.useSSL; + _connectionStringController.text = + redactUriPassword(initial.connectionString) ?? ''; + // Password left empty — mergeSecretsForConnectionUpdate keeps existing. + } + _formValidNotifier.seed(); } @@ -159,8 +181,10 @@ class _PostgresConnectionFormContentState String? sslKey, }) { final userInfoParts = [ - if (username != null && username.isNotEmpty) Uri.encodeComponent(username), - if (password != null && password.isNotEmpty) Uri.encodeComponent(password), + if (username != null && username.isNotEmpty) + Uri.encodeComponent(username), + if (password != null && password.isNotEmpty) + Uri.encodeComponent(password), ]; final queryParams = { if (sslRootCert != null && sslRootCert.isNotEmpty) @@ -298,8 +322,10 @@ class _PostgresConnectionFormContentState : (effectiveUri.isNotEmpty ? 'PostgreSQL: $effectiveHost:$effectivePort' : 'PostgreSQL $host:$port/$database'); + final initial = widget.initial; final row = ConnectionRow( - type: 'postgresql', + id: initial?.id, + type: initial?.type ?? 'postgresql', name: displayName, host: uriHost ?? (effectiveUri.isEmpty ? host : null), port: uriPort ?? (effectiveUri.isEmpty ? port : null), @@ -312,8 +338,11 @@ class _PostgresConnectionFormContentState effectiveUri.isNotEmpty ? null : (database.isEmpty ? null : database), useSSL: effectiveUseSSL, connectionString: effectiveUri.isEmpty ? null : effectiveUri, - folderId: widget.folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); } @@ -329,15 +358,15 @@ class _PostgresConnectionFormContentState Text(label).xSmall().muted(), const Gap(4), material.Row( - children: [ - material.Expanded( - child: TextField( - key: Key(label), - controller: controller, - placeholder: const Text('/path/to/file.pem'), - onChanged: (_) => _syncUriSslParams(), - ), - ), + children: [ + material.Expanded( + child: TextField( + key: Key(label), + controller: controller, + placeholder: const Text('/path/to/file.pem'), + onChanged: (_) => _syncUriSslParams(), + ), + ), const Gap(8), GhostButton( onPressed: () => _pickCertificateFile(controller), @@ -383,24 +412,17 @@ class _PostgresConnectionFormContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: WindowLayout.connectionFormMaxWidth, maxHeight: WindowLayout.connectionFormMaxHeight, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + borderColor: theme.muted, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ // Header material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), @@ -414,6 +436,8 @@ class _PostgresConnectionFormContentState height: 24, child: material.Image.asset( 'assets/images/postgresql_icon.png', + cacheWidth: (40 * MediaQuery.devicePixelRatioOf(context)).toInt(), + cacheHeight: (40 * MediaQuery.devicePixelRatioOf(context)).toInt(), fit: material.BoxFit.contain, errorBuilder: (_, __, ___) => material.Icon( material.Icons.storage_rounded, @@ -423,7 +447,11 @@ class _PostgresConnectionFormContentState ), ), const Gap(12), - const Text('PostgreSQL Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit PostgreSQL Connection' + : 'PostgreSQL Connection', + ).large().semiBold(), ], ), const Gap(8), @@ -530,7 +558,11 @@ class _PostgresConnectionFormContentState children: [ TextField( controller: _passwordController, - placeholder: const Text('Password'), + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), obscureText: !_showPassword, ), material.Positioned( @@ -728,7 +760,6 @@ class _PostgresConnectionFormContentState ), ], ), - ), ); } } diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index d864cd8a..f006c45f 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; @@ -13,21 +14,26 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showRedisConnectionForm( BuildContext context, { int? folderId, + ConnectionRow? initial, }) async { return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), - child: _RedisConnectionFormContent(folderId: folderId), + child: _RedisConnectionFormContent( + folderId: folderId, + initial: initial, + ), ), ); } class _RedisConnectionFormContent extends material.StatefulWidget { - const _RedisConnectionFormContent({this.folderId}); + const _RedisConnectionFormContent({this.folderId, this.initial}); final int? folderId; + final ConnectionRow? initial; @override material.State<_RedisConnectionFormContent> createState() => @@ -53,6 +59,8 @@ class _RedisConnectionFormContentState Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool get _isEditing => widget.initial != null; + @override void initState() { super.initState(); @@ -69,6 +77,18 @@ class _RedisConnectionFormContentState _sslRootCertController.addListener(_syncUriSslParams); _sslCertController.addListener(_syncUriSslParams); _sslKeyController.addListener(_syncUriSslParams); + + final initial = widget.initial; + if (initial != null) { + _nameController.text = initial.name; + _hostController.text = initial.host ?? ''; + _portController.text = (initial.port ?? 6379).toString(); + _usernameController.text = initial.username ?? ''; + _useSSL = initial.useSSL; + _connectionStringController.text = + redactUriPassword(initial.connectionString) ?? ''; + } + _formValidNotifier.seed(); } @@ -195,8 +215,10 @@ class _RedisConnectionFormContentState final port = int.tryParse(_portController.text.trim()) ?? 6379; final uri = _effectiveConnectionUri(); final displayName = name.isNotEmpty ? name : 'Redis $host:$port'; + final initial = widget.initial; final row = ConnectionRow( - type: 'redis', + id: initial?.id, + type: initial?.type ?? 'redis', name: displayName, host: uri.isNotEmpty ? null : host, port: uri.isNotEmpty ? null : port, @@ -207,8 +229,11 @@ class _RedisConnectionFormContentState _passwordController.text.isEmpty ? null : _passwordController.text, useSSL: _useSSL || _hasSslCertificateFields(), connectionString: uri.isEmpty ? null : uri, - folderId: widget.folderId, - createdAt: DateTime.now().toUtc().toIso8601String(), + extensionId: initial?.extensionId, + driverOptions: initial?.driverOptions, + folderId: initial?.folderId ?? widget.folderId, + sortOrder: initial?.sortOrder ?? 0, + createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); material.Navigator.of(context).pop(row); } @@ -244,24 +269,17 @@ class _RedisConnectionFormContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: WindowLayout.connectionFormMaxWidth, maxHeight: WindowLayout.connectionFormMaxHeight, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + borderColor: theme.muted, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), child: material.Column( @@ -272,7 +290,11 @@ class _RedisConnectionFormContentState material.Icon(material.Icons.memory_rounded, size: 24, color: theme.primary), const Gap(12), - const Text('Redis Connection').large().semiBold(), + Text( + _isEditing + ? 'Edit Redis Connection' + : 'Redis Connection', + ).large().semiBold(), ], ), const Gap(8), @@ -360,7 +382,11 @@ class _RedisConnectionFormContentState children: [ TextField( controller: _passwordController, - placeholder: const Text('Password'), + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), obscureText: !_showPassword, ), material.Positioned( @@ -530,7 +556,6 @@ class _RedisConnectionFormContentState ), ], ), - ), ); } } diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index 48665b49..af037daf 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -45,6 +45,7 @@ class _RedisExplorerViewState extends material.State { // View mode bool _showStats = false; + int _refreshEpoch = 0; // Navigation state String? _selectedKey; @@ -224,7 +225,7 @@ class _RedisExplorerViewState extends material.State { _BreadcrumbBar( crumbs: _crumbs, onCrumbTap: _onCrumbTap, - onRefresh: () => setState(() {}), + onRefresh: () => setState(() => _refreshEpoch++), onStats: () => setState(() => _showStats = true), ), const Divider(height: 1), @@ -238,7 +239,7 @@ class _RedisExplorerViewState extends material.State { // Key editor if (_selectedKey != null) { return RedisKeyEditor( - key: ValueKey('key_${widget.database}_$_selectedKey'), + key: ValueKey('key_${widget.database}_${_selectedKey}_$_refreshEpoch'), connection: conn, database: widget.database, keyName: _selectedKey!, @@ -250,7 +251,7 @@ class _RedisExplorerViewState extends material.State { // Keys list return RedisKeysView( - key: ValueKey('keys_${widget.database}'), + key: ValueKey('keys_${widget.database}_$_refreshEpoch'), connection: conn, database: widget.database, onKeyTap: _navigateToKey, diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index 5a46f646..5a0d1415 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 071827d3..364402ce 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart' as material; 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_theme_scope.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 4ffa5d0a..e73ffa95 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -156,7 +156,7 @@ class _RedisViewState extends material.State { final info = parseRedisInfo(raw); if (!mounted) return; if (!replaceIfChanged(_info, info, (v) => _info = v)) return; - setState(() {}); + setState(() => _info = info); } catch (_) {} } @@ -680,11 +680,10 @@ class _RedisViewState extends material.State { {List? keys}) { if (data == null || data.isEmpty) return const material.SizedBox.shrink(); final entries = keys != null - ? keys - .map((k) => MapEntry(k, data[k])) - .where((e) => e.value != null) - .map((e) => MapEntry(e.key, e.value as String)) - .toList() + ? >[ + for (final k in keys) + if (data[k] != null) MapEntry(k, data[k]!), + ] : data.entries.toList(); if (entries.isEmpty) return const material.SizedBox.shrink(); return _card( @@ -692,7 +691,7 @@ class _RedisViewState extends material.State { title, _twoColumnMetrics( context, - entries.map((e) => MapEntry(_labelFor(e.key), e.value)).toList(), + [ for (final e in entries) MapEntry(_labelFor(e.key), e.value) ], ), ); } diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 1c0d93b4..cd47133a 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -35,22 +35,6 @@ class _PreferencesAppearanceSectionState bool _installingFromUrl = false; bool _openingThemesFolder = false; - @override - void initState() { - super.initState(); - _controller.addListener(_onThemeChanged); - } - - @override - void dispose() { - _controller.removeListener(_onThemeChanged); - super.dispose(); - } - - void _onThemeChanged() { - if (mounted) setState(() {}); - } - Future _setThemeMode(ThemeMode mode) async { await _controller.setThemeMode(mode); } @@ -160,9 +144,12 @@ class _PreferencesAppearanceSectionState @override material.Widget build(material.BuildContext context) { - final c = _controller; - final themes = c.availableThemes; - final refreshingThemes = c.isLoadingAvailableThemes; + return ListenableBuilder( + listenable: _controller, + builder: (context, __) { + final c = _controller; + final themes = c.availableThemes; + final refreshingThemes = c.isLoadingAvailableThemes; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, @@ -265,7 +252,7 @@ class _PreferencesAppearanceSectionState label: 'Motion', control: material.ListenableBuilder( listenable: QueryaMotionController.instance, - builder: (context, _) { + builder: (context, __) { final controller = QueryaMotionController.instance; return PreferencesDropdownMenu( value: controller.level, @@ -364,5 +351,7 @@ class _PreferencesAppearanceSectionState ), ], ); + }, +); } } diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index 2418fe6e..61817ed1 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -28,6 +28,79 @@ class PreferencesHint extends StatelessWidget { } } +/// Leading checkbox + title/subtitle for Preferences (no [ListTile]). +/// +/// Avoids Flutter 3.44+ asserts when Preferences chrome uses an opaque +/// [DecoratedBox] above Material ink (#491). +class PreferencesCheckboxRow extends StatelessWidget { + const PreferencesCheckboxRow({ + super.key, + required this.value, + required this.onChanged, + required this.title, + this.subtitle, + }); + + final bool value; + final material.ValueChanged? onChanged; + final material.Widget title; + final material.Widget? subtitle; + + @override + material.Widget build(material.BuildContext context) { + final enabled = onChanged != null; + void toggle() { + if (enabled) onChanged!(!value); + } + + return material.Material( + type: material.MaterialType.transparency, + child: material.MergeSemantics( + child: material.InkWell( + onTap: enabled ? toggle : null, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 4), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SizedBox( + width: 24, + height: 24, + child: material.Checkbox( + value: value, + onChanged: enabled + ? (v) { + if (v != null) onChanged!(v); + } + : null, + materialTapTargetSize: + material.MaterialTapTargetSize.shrinkWrap, + visualDensity: material.VisualDensity.compact, + ), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + title, + if (subtitle != null) ...[ + const material.SizedBox(height: 2), + subtitle!, + ], + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + /// Label + full-width control row for Preferences (uniform dropdown width). class PreferencesFieldRow extends StatelessWidget { const PreferencesFieldRow({ @@ -132,7 +205,9 @@ class _InterfaceScaleSliderState extends material.State { void _onCommittedScaleChanged() { if (_dragScale != null || !mounted) return; - setState(() {}); + setState(() { + _dragScale = null; + }); } bool _onKeyEvent(KeyEvent event) { diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 3a3924ab..6d2554c6 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -96,220 +96,205 @@ class _PreferencesDialogContentState @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final onPopover = theme.popoverForeground; return material.DefaultTextStyle( style: material.TextStyle(color: onPopover), child: material.IconTheme( data: material.IconThemeData(color: onPopover), - child: material.Container( + child: QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: WindowLayout.preferencesDialogMaxWidth, minWidth: WindowLayout.preferencesDialogMinWidth, maxHeight: WindowLayout.preferencesDialogMaxHeight, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.border), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Preferences').large().semiBold().foreground(), - const material.SizedBox(height: 6), - const PreferencesHint( - 'Changes apply immediately. SQL timeouts are global for all connections of that type.', - ), - ], - ), + borderColor: theme.border, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Preferences').large().semiBold().foreground(), + const material.SizedBox(height: 6), + const PreferencesHint( + 'Changes apply immediately. SQL timeouts are global for all connections of that type.', + ), + ], ), - material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 8), - child: _loading - ? const material.Center( - child: material.Padding( - padding: material.EdgeInsets.all(24), - child: material.CircularProgressIndicator(), + ), + material.Expanded( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 8), + child: _loading + ? const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(24), + child: material.CircularProgressIndicator(), + ), + ) + : material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('General') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + PreferencesCheckboxRow( + value: _checkUpdatesOnStartup, + title: const Text( + 'Automatically check for updates on startup', + ).small(), + subtitle: const Text( + 'Queries GitHub Releases silently when Querya starts.', + ).muted().xSmall(), + onChanged: (v) { + unawaited(_setCheckUpdatesOnStartup(v)); + }, ), - ) - : material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - const Text('General') - .semiBold() - .small() - .foreground(), - const material.SizedBox(height: 8), - material.CheckboxListTile( - contentPadding: material.EdgeInsets.zero, - controlAffinity: - material.ListTileControlAffinity.leading, - title: const Text( - 'Automatically check for updates on startup', - ).small(), - subtitle: const Text( - 'Queries GitHub Releases silently when Querya starts.', - ).muted().xSmall(), - value: _checkUpdatesOnStartup, - onChanged: (v) { - if (v != null) { - unawaited(_setCheckUpdatesOnStartup(v)); - } - }, - ), - const material.SizedBox(height: 24), - const PreferencesAppearanceSection(), - const material.SizedBox(height: 24), - const PreferencesExtensionsSection(), - const material.SizedBox(height: 24), - const Text('SQL — PostgreSQL') - .semiBold() - .small() - .foreground(), - const material.SizedBox(height: 8), - PreferencesFieldRow( - label: 'Statement timeout', - control: SqlStatementTimeoutDropdown( - value: _pgTimeout, - expandToParent: true, - onChanged: (v) => unawaited(_setPg(v)), - ), + const material.SizedBox(height: 24), + const PreferencesAppearanceSection(), + const material.SizedBox(height: 24), + const PreferencesExtensionsSection(), + const material.SizedBox(height: 24), + const Text('SQL — PostgreSQL') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + PreferencesFieldRow( + label: 'Statement timeout', + control: SqlStatementTimeoutDropdown( + value: _pgTimeout, + expandToParent: true, + onChanged: (v) => unawaited(_setPg(v)), ), - const material.SizedBox(height: 24), - const Text('SQL — MySQL / MariaDB') - .semiBold() - .small() - .foreground(), - const material.SizedBox(height: 8), - PreferencesFieldRow( - label: 'Statement timeout', - control: SqlStatementTimeoutDropdown( - value: _mysqlTimeout, - expandToParent: true, - onChanged: (v) => unawaited(_setMysql(v)), - ), - ), - const material.SizedBox(height: 24), - const Text('SQL editor') - .semiBold() - .small() - .foreground(), - const material.SizedBox(height: 8), - PreferencesFieldRow( - label: 'Max rows in results', - control: PreferencesDropdownMenu( - value: _maxRows, - onSelected: (v) { - if (v != null) unawaited(_setMaxRows(v)); - }, - entries: [ - for (final n in kSqlResultMaxRowsPresets) - material.DropdownMenuEntry( - value: n, - label: '$n', - ), - ], - ), - ), - const material.SizedBox(height: 12), - PreferencesFieldRow( - label: 'Query history limit', - hint: - 'Per connection and database; oldest queries are dropped.', - control: PreferencesDropdownMenu( - value: _historyMax, - onSelected: (v) { - if (v != null) { - unawaited(_setHistoryMax(v)); - } - }, - entries: [ - for (final n - in kSqlHistoryMaxEntriesPresets) - material.DropdownMenuEntry( - value: n, - label: '$n entries', - ), - ], - ), + ), + const material.SizedBox(height: 24), + const Text('SQL — MySQL / MariaDB') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + PreferencesFieldRow( + label: 'Statement timeout', + control: SqlStatementTimeoutDropdown( + value: _mysqlTimeout, + expandToParent: true, + onChanged: (v) => unawaited(_setMysql(v)), ), - const material.SizedBox(height: 12), - PreferencesFieldRow( - label: 'Font size', - control: PreferencesDropdownMenu( - value: _fontSize, - onSelected: (v) { - if (v != null) unawaited(_setFont(v)); - }, - entries: const [ - material.DropdownMenuEntry( - value: 11.0, - label: '11 pt', - ), - material.DropdownMenuEntry( - value: 12.0, - label: '12 pt', - ), - material.DropdownMenuEntry( - value: 13.0, - label: '13 pt', - ), - material.DropdownMenuEntry( - value: 14.0, - label: '14 pt', - ), + ), + const material.SizedBox(height: 24), + const Text('SQL editor') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + PreferencesFieldRow( + label: 'Max rows in results', + control: PreferencesDropdownMenu( + value: _maxRows, + onSelected: (v) { + if (v != null) unawaited(_setMaxRows(v)); + }, + entries: [ + for (final n in kSqlResultMaxRowsPresets) material.DropdownMenuEntry( - value: 16.0, - label: '16 pt', + value: n, + label: '$n', ), + ], + ), + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Query history limit', + hint: + 'Per connection and database; oldest queries are dropped.', + control: PreferencesDropdownMenu( + value: _historyMax, + onSelected: (v) { + if (v != null) { + unawaited(_setHistoryMax(v)); + } + }, + entries: [ + for (final n in kSqlHistoryMaxEntriesPresets) material.DropdownMenuEntry( - value: 18.0, - label: '18 pt', + value: n, + label: '$n entries', ), - ], - ), + ], ), - const material.SizedBox(height: 16), - const PreferencesHint( - 'Preferences are stored locally in SQLite (non-secret keys only).', + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Font size', + control: PreferencesDropdownMenu( + value: _fontSize, + onSelected: (v) { + if (v != null) unawaited(_setFont(v)); + }, + entries: const [ + material.DropdownMenuEntry( + value: 11.0, + label: '11 pt', + ), + material.DropdownMenuEntry( + value: 12.0, + label: '12 pt', + ), + material.DropdownMenuEntry( + value: 13.0, + label: '13 pt', + ), + material.DropdownMenuEntry( + value: 14.0, + label: '14 pt', + ), + material.DropdownMenuEntry( + value: 16.0, + label: '16 pt', + ), + material.DropdownMenuEntry( + value: 18.0, + label: '18 pt', + ), + ], ), - ], - ), + ), + const material.SizedBox(height: 16), + const PreferencesHint( + 'Preferences are stored locally in SQLite (non-secret keys only).', + ), + ], + ), + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3)), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), ), - ), - child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, - children: [ - PrimaryButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ), + ], ), - ], - ), + ), + ], ), ), ), diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index a8b93197..30cdd59f 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -76,7 +76,10 @@ class _ThemePickerButtonState extends material.State { material.ScrollController(); final material.TextEditingController _searchController = material.TextEditingController(); + final material.ValueNotifier _menuOpen = + material.ValueNotifier(false); bool _triggerHovered = false; + bool _closingWithExit = false; String? _previewThemeId; String? _previewThemeLabel; QueryaTheme? _previewTheme; @@ -97,9 +100,26 @@ class _ThemePickerButtonState extends material.State { _searchController.removeListener(_onSearchChanged); _searchController.dispose(); _scrollController.dispose(); + _menuOpen.dispose(); super.dispose(); } + /// Plays exit fade-slide, then removes the [MenuAnchor] overlay (#499). + Future _closeWithExit() async { + if (!_controller.isOpen || _closingWithExit) return; + _closingWithExit = true; + _menuOpen.value = false; + final duration = context.motionDuration(QueryaMotion.standard); + if (duration > QueryaMotion.instant) { + await Future.delayed(duration); + } + if (!mounted) return; + if (_controller.isOpen) { + _controller.close(); + } + _closingWithExit = false; + } + void _resetPreviewState() { _previewDebounce?.cancel(); _previewRequestSerial++; @@ -154,8 +174,12 @@ class _ThemePickerButtonState extends material.State { }); } + String _searchQuery = ''; + void _onSearchChanged() { - setState(() {}); + setState(() { + _searchQuery = _searchController.text; + }); if (_scrollController.hasClients) { _scrollController.jumpTo(0); } @@ -167,7 +191,7 @@ class _ThemePickerButtonState extends material.State { } List get _filteredThemes => - filterThemeDefinitions(widget.themes, _searchController.text); + filterThemeDefinitions(widget.themes, _searchQuery); bool get _enabled => !widget.isLoading; @@ -192,6 +216,14 @@ class _ThemePickerButtonState extends material.State { final anchor = material.MenuAnchor( controller: _controller, + onOpen: () { + _closingWithExit = false; + _menuOpen.value = true; + }, + onClose: () { + _closingWithExit = false; + _menuOpen.value = false; + }, crossAxisUnconstrained: false, alignmentOffset: material.Offset( 0, @@ -223,10 +255,13 @@ class _ThemePickerButtonState extends material.State { ), ), menuChildren: [ - material.SizedBox( - width: menuWidth, - height: menuHeight, - child: _buildMenuPanel(context, cs), + _ThemePickerMenuEnter( + openNotifier: _menuOpen, + child: material.SizedBox( + width: menuWidth, + height: menuHeight, + child: _buildMenuPanel(context, cs), + ), ), ], builder: (context, controller, child) { @@ -354,7 +389,7 @@ class _ThemePickerButtonState extends material.State { widget.onSelected(theme.id); _clearSearch(); _resetPreviewState(); - _controller.close(); + unawaited(_closeWithExit()); }, ); }, @@ -403,16 +438,10 @@ class _ThemePickerButtonState extends material.State { ? material.MainAxisSize.max : material.MainAxisSize.min, children: [ - material.Expanded( - child: material.Text( - _triggerLabel, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - style: QueryaDropdownTokens.triggerTextStyle( - context, - _enabled ? cs.popoverForeground : cs.mutedForeground, - ), - ), + _triggerLabelText( + context: context, + cs: cs, + expand: widget.expandToParent || fieldWidth != null, ), material.SizedBox(width: chevronGap), material.Icon( @@ -433,7 +462,7 @@ class _ThemePickerButtonState extends material.State { onTap: _enabled ? () { if (controller.isOpen) { - controller.close(); + unawaited(_closeWithExit()); } else { _clearSearch(); _resetPreviewState(); @@ -446,6 +475,61 @@ class _ThemePickerButtonState extends material.State { ), ); } + + material.Widget _triggerLabelText({ + required material.BuildContext context, + required ColorScheme cs, + required bool expand, + }) { + final text = material.Text( + _triggerLabel, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: QueryaDropdownTokens.triggerTextStyle( + context, + _enabled ? cs.popoverForeground : cs.mutedForeground, + ), + ); + if (expand) { + return material.Expanded(child: text); + } + return text; + } +} + +/// Enter/exit fade-slide for theme menu body while the overlay stays mounted. +class _ThemePickerMenuEnter extends material.StatelessWidget { + const _ThemePickerMenuEnter({ + required this.openNotifier, + required this.child, + }); + + final material.ValueNotifier openNotifier; + final material.Widget child; + + @override + material.Widget build(material.BuildContext context) { + final duration = context.motionDuration(QueryaMotion.standard); + final enter = context.motionCurve(QueryaMotion.enter); + final exit = context.motionCurve(QueryaMotion.exit); + return material.ValueListenableBuilder( + valueListenable: openNotifier, + builder: (context, open, _) { + final curve = open ? enter : exit; + return material.AnimatedSlide( + offset: open ? material.Offset.zero : const material.Offset(0, -0.04), + duration: duration, + curve: curve, + child: material.AnimatedOpacity( + opacity: open ? 1 : 0, + duration: duration, + curve: curve, + child: child, + ), + ); + }, + ); + } } class _ThemePickerRow extends material.StatefulWidget { diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 5a478613..f8dd7eb9 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -11,7 +11,6 @@ import 'package:querya_desktop/core/database/sql_limit.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; @@ -177,8 +176,8 @@ class _SqliteSqlWorkspaceState extends material.State { return cols.map((col) => row[col]).toList(); }).toList(); - // Yielding convert avoids isolate double-copy of the matrix (#421). - final outRows = await convertResultRowsToStringsYielding(rawRows); + // Adaptive convert offloads to background compute for large row sets (#522). + final outRows = await convertResultRowsToStringsAdaptive(rawRows); setState(() { _columns = cols; diff --git a/lib/features/updater/update_available_badge.dart b/lib/features/updater/update_available_badge.dart index 72384c19..a4c9b696 100644 --- a/lib/features/updater/update_available_badge.dart +++ b/lib/features/updater/update_available_badge.dart @@ -9,7 +9,10 @@ import 'package:querya_desktop/features/updater/update_controller.dart'; import 'package:querya_desktop/features/updater/update_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -/// Soft pulse period for the update chip (documented chrome constant; see F9). +/// Soft pulse period for the update chip at Full motion (see F9 / #482). +/// +/// Under [QueryaMotionLevel.reduced] the effective period is halved via +/// [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. @@ -29,6 +32,9 @@ class UpdateAvailableBadgeState extends material.State @visibleForTesting bool get isPulseAnimating => _pulse.isAnimating; + @visibleForTesting + Duration? get pulseDuration => _pulse.duration; + @override void initState() { super.initState(); @@ -57,7 +63,6 @@ class UpdateAvailableBadgeState extends material.State void _onControllerChanged() { if (!mounted) return; - setState(() {}); _syncPulse(); } @@ -73,7 +78,14 @@ class UpdateAvailableBadgeState extends material.State _pulse.value = 0; return; } - if (!_pulse.isAnimating) { + + final period = + QueryaMotion.effectiveDuration(context, kUpdateBadgePulsePeriod); + final periodChanged = _pulse.duration != period; + if (periodChanged) { + _pulse.duration = period; + } + if (!_pulse.isAnimating || periodChanged) { _pulse.repeat(reverse: true); } } @@ -87,14 +99,22 @@ class UpdateAvailableBadgeState extends material.State @override material.Widget build(material.BuildContext context) { - if (!widget.controller.showBadge) { - return const material.SizedBox.shrink(); - } + return ListenableBuilder( + listenable: widget.controller, + builder: (context, __) { + if (!widget.controller.showBadge) { + return const material.SizedBox.shrink(); + } final version = widget.controller.pendingUpdate?.version ?? ''; final wb = context.workbench; // Depend on motion so Off/Reduced rebuilds re-sync the pulse. context.motionDuration(QueryaMotion.fast); + final reduced = + QueryaMotionScope.maybeOf(context) == QueryaMotionLevel.reduced; + // Quieter chrome under Reduced (#482). + final fillAmp = reduced ? 0.04 : 0.08; + final borderAmp = reduced ? 0.12 : 0.25; return material.Padding( padding: const material.EdgeInsets.only(right: 8), @@ -115,12 +135,12 @@ class UpdateAvailableBadgeState extends material.State padding: const material.EdgeInsets.symmetric( horizontal: 10, vertical: 4), decoration: material.BoxDecoration( - color: - wb.accent.withValues(alpha: 0.12 + 0.08 * _pulse.value), + color: wb.accent + .withValues(alpha: 0.12 + fillAmp * _pulse.value), borderRadius: material.BorderRadius.circular(999), border: material.Border.all( - color: - wb.accent.withValues(alpha: 0.35 + 0.25 * _pulse.value), + color: wb.accent + .withValues(alpha: 0.35 + borderAmp * _pulse.value), ), ), child: child, @@ -142,5 +162,7 @@ class UpdateAvailableBadgeState extends material.State ), ), ); + }, +); } } diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index eebfb7f6..4103b4d2 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -3,7 +3,8 @@ import 'dart:io'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.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/updater/app_updater_service.dart'; import 'package:querya_desktop/core/updater/update_manifest.dart'; import 'package:querya_desktop/features/updater/update_changelog_view.dart'; @@ -226,72 +227,81 @@ class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusXxl; final wb = context.workbench; - return material.Container( + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, maxWidth: 520, minWidth: 360, maxHeight: 640, ), - decoration: material.BoxDecoration( - color: theme.popover, - borderRadius: material.BorderRadius.circular(radius), - border: material.Border.all(color: theme.muted), - ), - child: material.ClipRRect( - borderRadius: material.BorderRadius.circular(radius), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Row( - children: [ - material.Icon( - material.Icons.system_update_alt_rounded, - color: wb.accent, - ), - const material.SizedBox(width: 10), - const Text('Software Update').large().semiBold(), - ], - ), - const material.SizedBox(height: 8), - Text(_subtitle()).muted().small(), - ], - ), - ), - material.Flexible( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, - vertical: 8, + borderColor: theme.muted, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + material.Icon( + material.Icons.system_update_alt_rounded, + color: wb.accent, + ), + const material.SizedBox(width: 10), + const Text('Software Update').large().semiBold(), + ], ), - child: _body(context), - ), + const material.SizedBox(height: 8), + Text(_subtitle()).muted().small(), + ], ), - material.Container( + ), + material.Flexible( + child: material.SingleChildScrollView( padding: const material.EdgeInsets.symmetric( horizontal: 24, - vertical: 16, + vertical: 8, ), - decoration: material.BoxDecoration( - border: material.Border( - top: material.BorderSide( - color: theme.border.withValues(alpha: 0.3), - ), + child: material.AnimatedSwitcher( + duration: context.motionDuration(QueryaMotion.standard), + switchInCurve: context.motionCurve(QueryaMotion.enter), + switchOutCurve: context.motionCurve(QueryaMotion.exit), + layoutBuilder: (currentChild, previousChildren) { + return material.Stack( + alignment: material.Alignment.topCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: material.KeyedSubtree( + key: material.ValueKey(_phase), + child: _body(context), ), ), - child: _actions(context), ), - ], - ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3), + ), + ), + ), + child: _actions(context), + ), + ], ), ); } diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart index 1a92ceaa..ec8a2a37 100644 --- a/lib/shared/widgets/app_dialog.dart +++ b/lib/shared/widgets/app_dialog.dart @@ -4,7 +4,6 @@ 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/motion/querya_spring.dart'; /// Shows a modal dialog with a frosted, dimmed backdrop over the app. /// @@ -76,12 +75,9 @@ class _BlurredDialogScaffoldState extends State<_BlurredDialogScaffold> { void _rebuildCurved() { _curved?.dispose(); - final useSpring = QueryaSpring.springsEnabled(context); _curved = CurvedAnimation( parent: widget.animation, - curve: context.motionCurve( - useSpring ? QueryaMotion.emphasized : QueryaMotion.enter, - ), + curve: context.motionCurve(QueryaMotion.enter), reverseCurve: context.motionCurve(QueryaMotion.exit), ); } diff --git a/lib/shared/widgets/querya_dialog_card.dart b/lib/shared/widgets/querya_dialog_card.dart new file mode 100644 index 00000000..29f71fd8 --- /dev/null +++ b/lib/shared/widgets/querya_dialog_card.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Dialog shell: [Material] popover fill (ListTile ink host) under the same +/// [Container] constraints as the pre-migration shell. +/// +/// Re-applies the ambient [DefaultTextStyle] / [IconTheme] after [Material], +/// which would otherwise inject [ThemeData.textTheme] and bloat dense dialog +/// chrome (Extension Manager overflow). +class QueryaDialogCard extends material.StatelessWidget { + const QueryaDialogCard({ + super.key, + required this.child, + this.constraints, + this.borderColor, + }); + + final material.Widget child; + final material.BoxConstraints? constraints; + final material.Color? borderColor; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + final borderRadius = material.BorderRadius.circular(radius); + final textStyle = material.DefaultTextStyle.of(context).style; + final iconTheme = material.IconTheme.of(context); + + final card = material.Material( + color: theme.popover, + elevation: 0, + shape: material.RoundedRectangleBorder( + borderRadius: borderRadius, + side: material.BorderSide(color: borderColor ?? theme.border), + ), + clipBehavior: material.Clip.antiAlias, + child: material.DefaultTextStyle( + style: textStyle, + child: material.IconTheme( + data: iconTheme, + child: child, + ), + ), + ); + + if (constraints == null) return card; + + return material.Container( + constraints: constraints, + child: card, + ); + } +} diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index e47b127f..4aef0e94 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -119,8 +119,9 @@ class _QueryaDropdownState extends material.State> { } _cachedMenuItems = List>.from(widget.items); _cachedMenuValue = widget.value; - _cachedMenuChildren = - widget.items.map((item) => _menuItem(item, cs)).toList(); + _cachedMenuChildren = [ + for (final item in widget.items) _menuItem(item, cs), + ]; return _cachedMenuChildren!; } diff --git a/lib/shared/widgets/querya_tab_strip.dart b/lib/shared/widgets/querya_tab_strip.dart index 947072bd..7af07be4 100644 --- a/lib/shared/widgets/querya_tab_strip.dart +++ b/lib/shared/widgets/querya_tab_strip.dart @@ -9,10 +9,11 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; /// A compact, keyboard-operable tab strip using Querya's motion and theme. /// -/// Selection uses a sliding pill indicator (spring when [QueryaSpring.springsEnabled]) -/// so tab changes feel continuous / redirectable. +/// Selection uses a sliding pill indicator: spring when Full +/// ([QueryaSpring.springsEnabled]), duration-token cubic under Reduced (#493), +/// snap when Off / OS `disableAnimations`. /// -/// Spring ticks rebuild only the pill ([_TabStripIndicator]), not the tab row. +/// Spring/cubic ticks rebuild only the pill ([_TabStripIndicator]), not the tab row. class QueryaTabStrip extends material.StatefulWidget { const QueryaTabStrip({ super.key, @@ -55,6 +56,18 @@ class _QueryaTabStripState extends material.State final springs = QueryaSpring.springsEnabled(context); _indicatorLeft.useSprings = springs; _indicatorWidth.useSprings = springs; + + // Reduced: animate with halved fast token; Off / disableAnimations → snap. + Duration? cubic; + if (!springs) { + final d = context.motionDuration(QueryaMotion.fast); + if (d > Duration.zero) cubic = d; + } + final curve = context.motionCurve(QueryaMotion.enter); + _indicatorLeft.cubicDuration = cubic; + _indicatorWidth.cubicDuration = cubic; + _indicatorLeft.cubicCurve = curve; + _indicatorWidth.cubicCurve = curve; } @override @@ -238,7 +251,7 @@ class _QueryaTabStripState extends material.State } /// Sliding pill; listens to springs so the tab [Row] is not rebuilt per tick. -class _TabStripIndicator extends material.StatefulWidget { +class _TabStripIndicator extends material.StatelessWidget { const _TabStripIndicator({ required this.left, required this.width, @@ -249,63 +262,31 @@ class _TabStripIndicator extends material.StatefulWidget { final QueryaSpringController width; final material.Color color; - @override - material.State<_TabStripIndicator> createState() => - _TabStripIndicatorState(); -} - -class _TabStripIndicatorState extends material.State<_TabStripIndicator> { - @override - void initState() { - super.initState(); - widget.left.addListener(_onTick); - widget.width.addListener(_onTick); - } - - @override - void didUpdateWidget(covariant _TabStripIndicator oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.left != widget.left) { - oldWidget.left.removeListener(_onTick); - widget.left.addListener(_onTick); - } - if (oldWidget.width != widget.width) { - oldWidget.width.removeListener(_onTick); - widget.width.addListener(_onTick); - } - } - - @override - void dispose() { - widget.left.removeListener(_onTick); - widget.width.removeListener(_onTick); - super.dispose(); - } - - void _onTick() { - if (mounted) setState(() {}); - } - @override material.Widget build(material.BuildContext context) { - final w = widget.width.value; - if (w <= 0) return const material.SizedBox.shrink(); - return material.Positioned( - key: const material.ValueKey('querya_tab_indicator'), - left: widget.left.value, - width: w, - top: 0, - bottom: 0, - child: material.RepaintBoundary( - child: material.IgnorePointer( - child: material.DecoratedBox( - decoration: material.BoxDecoration( - color: widget.color, - borderRadius: material.BorderRadius.circular(6), + return ListenableBuilder( + listenable: Listenable.merge([left, width]), + builder: (context, __) { + final w = width.value; + if (w <= 0) return const material.SizedBox.shrink(); + return material.Positioned( + key: const material.ValueKey('querya_tab_indicator'), + left: left.value, + width: w, + top: 0, + bottom: 0, + child: material.RepaintBoundary( + child: material.IgnorePointer( + child: material.DecoratedBox( + decoration: material.BoxDecoration( + color: color, + borderRadius: material.BorderRadius.circular(6), + ), + ), ), ), - ), - ), + ); + }, ); } } diff --git a/lib/shared/widgets/tree_load_error.dart b/lib/shared/widgets/tree_load_error.dart new file mode 100644 index 00000000..aabaee49 --- /dev/null +++ b/lib/shared/widgets/tree_load_error.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Inline error block for connection tree lazy-load failures. +/// +/// Always shows the error icon + title row by default (Mongo dialect); +/// pass [showTitleRow]: false only for ultra-compact one-liners. +class TreeLoadError extends material.StatelessWidget { + const TreeLoadError({ + super.key, + this.title = 'Could not load', + required this.message, + this.onRetry, + this.retryLabel = 'Retry', + this.padding = const material.EdgeInsets.only( + left: 24, + top: 4, + bottom: 8, + ), + this.detailFontSize = 11, + this.showTitleRow = true, + }); + + final String title; + final String message; + final VoidCallback? onRetry; + final String retryLabel; + final material.EdgeInsetsGeometry padding; + final double detailFontSize; + final bool showTitleRow; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final destructive = theme.colorScheme.destructive; + final muted = theme.colorScheme.mutedForeground; + + return material.Padding( + padding: padding, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + if (showTitleRow) + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Icon( + QueryaIcons.treeError, + size: QueryaIconSizes.treeError, + color: destructive, + ), + const Gap(6), + material.Expanded( + child: material.Text( + title, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 12, + color: destructive, + ), + ), + ), + ], + ), + if (showTitleRow) const Gap(6), + material.SelectableText( + message, + style: material.TextStyle( + fontSize: detailFontSize, + height: showTitleRow ? 1.35 : null, + color: showTitleRow ? muted : destructive, + ), + ), + if (onRetry != null) ...[ + const material.SizedBox(height: 6), + GhostButton( + onPressed: onRetry, + child: Text(retryLabel), + ), + ], + ], + ), + ); + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 3fcb27e4..10890c12 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -11,6 +11,7 @@ library; export 'app_dialog.dart'; export 'app_toast.dart'; export 'export_menu_button.dart'; +export 'querya_dialog_card.dart'; export 'querya_tab_strip.dart'; export 'querya_dropdown.dart' show @@ -18,4 +19,7 @@ export 'querya_dropdown.dart' QueryaDropdownItem, QueryaDropdownTokens, kPreferencesLabelWidth; +export 'tree_load_error.dart'; +export 'package:querya_desktop/core/theme/querya_theme_scope.dart' + show QueryaThemeContext; export 'package:shadcn_flutter/shadcn_flutter.dart'; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ff1df342..4d44b1bc 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,7 +8,7 @@ import Foundation import bitsdojo_window_macos import device_info_plus import file_selector_macos -import flutter_secure_storage_macos +import flutter_secure_storage_darwin import irondash_engine_context import package_info_plus import refresh_rate @@ -20,7 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) RefreshRatePlugin.register(with: registry.registrar(forPlugin: "RefreshRatePlugin")) diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 3cc05eb2..b8e380c0 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -1,7 +1,12 @@ import Cocoa import FlutterMacOS +import bitsdojo_window_macos + +class MainFlutterWindow: BitsdojoWindow { + override func bitsdojo_window_configure() -> UInt { + return BDW_CUSTOM_FRAME | BDW_HIDE_ON_STARTUP + } -class MainFlutterWindow: NSWindow { override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame diff --git a/packaging/linux/aur/PKGBUILD b/packaging/linux/aur/PKGBUILD index 01f085e7..f0070a5d 100644 --- a/packaging/linux/aur/PKGBUILD +++ b/packaging/linux/aur/PKGBUILD @@ -1,8 +1,8 @@ # Maintainer: QueryaHub # AUR package — installs the official Release portable Linux zip under /opt. -# Bump pkgver/pkgrel when a new GitHub Release is published. +# pkgver is synced by version-bump.yml; CI publishes real sha256sums on Release. pkgname=querya-desktop -pkgver=0.4.11-b +pkgver=0.4.12 pkgrel=1 pkgdesc="Multi-database desktop client (PostgreSQL, MySQL, Redis, MongoDB, SQLite)" arch=('x86_64') @@ -15,8 +15,9 @@ optdepends=( source=( "Querya-Desktop-${pkgver}-linux.zip::https://github.com/QueryaHub/Querya-Desktop/releases/download/${pkgver}/Querya-Desktop-${pkgver}-linux.zip" "querya_desktop.desktop" + "querya_desktop.png" ) -sha256sums=('SKIP' 'SKIP') +sha256sums=('SKIP' 'SKIP' 'SKIP') prepare() { bsdtar -xf "$srcdir/Querya-Desktop-${pkgver}-linux.zip" -C "$srcdir" diff --git a/packaging/linux/aur/README.md b/packaging/linux/aur/README.md index fdcfdd8b..ae555733 100644 --- a/packaging/linux/aur/README.md +++ b/packaging/linux/aur/README.md @@ -1,19 +1,38 @@ # Arch Linux (AUR) -Community packaging for Arch-based distros. The PKGBUILD installs the official -**portable Linux zip** from GitHub Releases under `/opt/querya-desktop`. +Official **`querya-desktop`** package on AUR. Installs the Release portable Linux zip +under `/opt/querya-desktop`. -## Before publishing to AUR +## Install -1. Copy `PKGBUILD`, `querya_desktop.desktop`, and `querya_desktop.png` into a clean build directory. -2. Bump `pkgver` / `pkgrel` to match the GitHub Release tag and AUR revision. -3. Run `makepkg -si` locally and smoke-launch `querya_desktop`. -4. Generate `.SRCINFO`: `makepkg --printsrcinfo > .SRCINFO` -5. Push to your AUR repo (e.g. `querya-desktop`). +```bash +yay -S querya-desktop +# or: paru -S querya-desktop +``` -`querya_desktop.desktop` and the 512×512 icon are the same assets used by -`.deb` / `.rpm` packaging (`packaging/linux/querya_desktop.desktop` and -`macos/Runner/Assets.xcassets/.../app_icon_512.png`). +## CI publish + +| Trigger | Workflow | +|---------|----------| +| After GitHub Release | [Release](../../.github/workflows/release.yml) → job `publish-aur` | +| Manual hotfix | [Publish AUR](../../.github/workflows/aur-publish.yml) | + +Requires repository secret **`AUR_SSH_PRIVATE_KEY`**. Without it, Release skips AUR +(no failed job). First `git push` creates the AUR repo automatically. + +`pkgver` in the template PKGBUILD is synced on merge to `main` by +[version-bump.yml](../../.github/workflows/version-bump.yml); CI fills real +`sha256sums` and `.SRCINFO` at publish time via +[scripts/linux/aur_publish.sh](../../scripts/linux/aur_publish.sh). + +## Local smoke test + +```bash +cp packaging/linux/aur/{PKGBUILD,querya_desktop.desktop,querya_desktop.png} /tmp/querya-aur/ +cd /tmp/querya-aur +makepkg -si +querya_desktop +``` ## Updates diff --git a/pubspec.yaml b/pubspec.yaml index 2bfd7caa..86e7e1f9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ version: 0.4.12+1 environment: - sdk: ^3.5.0 + sdk: '>=3.5.0 <4.0.0' dependencies: flutter: @@ -18,25 +18,26 @@ dependencies: bitsdojo_window: ^0.1.6 path: ^1.9.0 path_provider: ^2.1.5 + sqflite: ^2.3.2 sqflite_common_ffi: ^2.3.2 mongo_dart: ^0.10.8 redis: ^4.0.0 postgres: ^3.5.6 - fl_chart: ^0.69.0 + fl_chart: ^1.2.0 mysql_client: ^0.0.27 - flutter_secure_storage: ^9.2.4 + flutter_secure_storage: ^10.3.1 file_selector: ^1.1.0 syntax_highlight: ^0.5.0 archive: ^4.0.9 url_launcher: ^6.3.1 - package_info_plus: ^8.3.0 + package_info_plus: ^9.0.1 flutter_svg: ^2.3.0 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 # Used in tests to mock paths (path_provider has no plugin in flutter test). path_provider_platform_interface: ^2.1.2 diff --git a/scripts/linux/aur_publish.sh b/scripts/linux/aur_publish.sh new file mode 100755 index 00000000..83b18ecc --- /dev/null +++ b/scripts/linux/aur_publish.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Push querya-desktop PKGBUILD to AUR (used by Release CI and aur-publish workflow). +# +# Prerequisites: ssh-agent loaded with AUR key; docker available for makepkg --printsrcinfo. +# +# Usage: +# aur_publish.sh [release_tag] +# +# release_tag — GitHub Release tag (tries release_tag, version, vversion for asset URL). +set -euo pipefail + +VERSION="${1:?version required}" +RELEASE_TAG="${2:-$VERSION}" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TEMPLATE="$ROOT/packaging/linux/aur" +AUR_REPO="${AUR_REPO:-querya-desktop}" +WORK="$ROOT/aur-repo" +ZIP="Querya-Desktop-${VERSION}-linux.zip" +REPO="${GITHUB_REPOSITORY:-QueryaHub/Querya-Desktop}" + +download_release_zip() { + local tag url + for tag in "$RELEASE_TAG" "$VERSION" "v${VERSION}"; do + url="https://github.com/${REPO}/releases/download/${tag}/${ZIP}" + echo "Fetching ${url}" + if curl -fsSL -o "$ZIP" "$url"; then + echo "Downloaded from tag ${tag}" + return 0 + fi + echo "retry with next tag candidate..." + sleep 5 + done + return 1 +} + +echo "AUR publish: pkgver=${VERSION} release_tag=${RELEASE_TAG}" + +cd "$ROOT" +for attempt in 1 2 3 4 5 6; do + if download_release_zip; then + break + fi + if [ "$attempt" -eq 6 ]; then + echo "error: could not download ${ZIP}" >&2 + exit 1 + fi + echo "retry ${attempt}..." + sleep 10 +done + +ZIP_SHA="$(sha256sum "$ZIP" | awk '{print $1}')" +DESKTOP_SHA="$(sha256sum "$TEMPLATE/querya_desktop.desktop" | awk '{print $1}')" +PNG_SHA="$(sha256sum "$TEMPLATE/querya_desktop.png" | awk '{print $1}')" + +mkdir -p ~/.ssh +ssh-keyscan -t rsa,ecdsa,ed25519 aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null + +rm -rf "$WORK" +if ! git clone "ssh://aur@aur.archlinux.org/${AUR_REPO}.git" "$WORK" 2>/dev/null; then + mkdir -p "$WORK" + git -C "$WORK" init + git -C "$WORK" remote add origin "ssh://aur@aur.archlinux.org/${AUR_REPO}.git" +fi + +cp "$TEMPLATE/PKGBUILD" "$WORK/PKGBUILD" +cp "$TEMPLATE/querya_desktop.desktop" "$WORK/querya_desktop.desktop" +cp "$TEMPLATE/querya_desktop.png" "$WORK/querya_desktop.png" + +cd "$WORK" +sed -i "s/^pkgver=.*/pkgver=${VERSION}/" PKGBUILD +sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD +sed -i "s/^sha256sums=.*/sha256sums=('${ZIP_SHA}' '${DESKTOP_SHA}' '${PNG_SHA}')/" PKGBUILD + +out="$WORK/.SRCINFO" +docker run --rm \ + -v "$WORK:/pkg" \ + archlinux:latest \ + bash -lc ' + set -euo pipefail + pacman -Syu --noconfirm --needed archlinux-keyring pacman base-devel >/dev/null 2>&1 + useradd -m -s /bin/bash builduser + chown -R builduser:builduser /pkg + runuser -u builduser -- env HOME=/home/builduser bash -lc "cd /pkg && makepkg --printsrcinfo" + ' > "$out" +sudo chown -R "$(id -u):$(id -g)" "$WORK" 2>/dev/null || true +test -s "$out" +grep -qE "^pkgbase[[:space:]]*=" "$out" || { echo "::error::Invalid .SRCINFO"; head -50 "$out"; exit 1; } + +git config user.email "github-actions[bot]@users.noreply.github.com" +git config user.name "github-actions[bot]" +git add PKGBUILD .SRCINFO querya_desktop.desktop querya_desktop.png +if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 +fi +git commit -m "chore: ${VERSION}" +git push origin HEAD:master + +echo "AUR ${AUR_REPO} updated to ${VERSION}" diff --git a/test/core/database/result_row_string_convert_test.dart b/test/core/database/result_row_string_convert_test.dart index de451b1b..44d42535 100644 --- a/test/core/database/result_row_string_convert_test.dart +++ b/test/core/database/result_row_string_convert_test.dart @@ -2,24 +2,58 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; void main() { - group('convertResultRowsToStringsYielding', () { - test('maps null to NULL and yields without isolate', () async { - final rows = >[ - [1, null, 'a'], - [2, 'x', null], - ]; + group('result_row_string_convert', () { + final sampleRows = >[ + [1, null, 'a'], + [2, 'x', null], + ]; + + final expectedOutput = [ + ['1', 'NULL', 'a'], + ['2', 'x', 'NULL'], + ]; + + test('convertResultRowsToStringsSync maps rows correctly', () { + expect(convertResultRowsToStringsSync(sampleRows), expectedOutput); + expect(convertResultRowsToStringsSync(const []), isEmpty); + }); + + test('convertResultRowsToStringsCompute maps rows correctly', () { + expect(convertResultRowsToStringsCompute(sampleRows), expectedOutput); + expect(convertResultRowsToStringsCompute(const []), isEmpty); + }); + + test('convertResultRowsToStringsYielding maps null to NULL and yields', () async { final out = await convertResultRowsToStringsYielding( - rows, + sampleRows, yieldEvery: 1, ); - expect(out, [ - ['1', 'NULL', 'a'], - ['2', 'x', 'NULL'], - ]); + expect(out, expectedOutput); + expect(await convertResultRowsToStringsYielding(const []), isEmpty); + }); + + test('convertResultRowsToStringsAdaptive handles small payload via yielding', () async { + final out = await convertResultRowsToStringsAdaptive( + sampleRows, + computeThreshold: 100, + ); + expect(out, expectedOutput); + expect(await convertResultRowsToStringsAdaptive(const []), isEmpty); }); - test('empty input returns empty', () async { - expect(await convertResultRowsToStringsYielding(const []), isEmpty); + test('convertResultRowsToStringsAdaptive handles large payload via compute', () async { + final largeRows = List>.generate( + 10, + (i) => [i, null, 'val_$i'], + ); + final out = await convertResultRowsToStringsAdaptive( + largeRows, + computeThreshold: 5, + ); + expect(out.length, 10); + expect(out[0], ['0', 'NULL', 'val_0']); + expect(out[9], ['9', 'NULL', 'val_9']); }); }); } + diff --git a/test/core/extensions/sandbox/sandbox_watchdog_test.dart b/test/core/extensions/sandbox/sandbox_watchdog_test.dart index d6038503..cbf08f2c 100644 --- a/test/core/extensions/sandbox/sandbox_watchdog_test.dart +++ b/test/core/extensions/sandbox/sandbox_watchdog_test.dart @@ -254,6 +254,34 @@ void main() { await client.close(); await handle.dispose(); }); + + test('skips ping tick when isBusy returns true', () async { + final process = _FakeProcess(); + final handle = await _handle(process, tempBase); + var pings = 0; + var busy = true; + + final watchdog = SandboxWatchdog( + pingInterval: const Duration(milliseconds: 15), + pongTimeout: const Duration(seconds: 1), + isBusy: () => busy, + ping: () async { + pings++; + return 'pong'; + }, + ); + + watchdog.start(handle); + await Future.delayed(const Duration(milliseconds: 50)); + expect(pings, 0, reason: 'Pings should be skipped while busy'); + + busy = false; + await Future.delayed(const Duration(milliseconds: 50)); + expect(pings, greaterThan(0), reason: 'Pings should resume when idle'); + + watchdog.stop(); + await handle.dispose(); + }); }); group('SandboxWatchdog.isPong', () { diff --git a/test/core/motion/querya_animated_expand_test.dart b/test/core/motion/querya_animated_expand_test.dart index 362a3941..c39090f3 100644 --- a/test/core/motion/querya_animated_expand_test.dart +++ b/test/core/motion/querya_animated_expand_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; void main() { testWidgets('QueryaAnimatedExpand hides child when collapsed', @@ -23,6 +24,18 @@ void main() { expect(find.text('child'), findsOneWidget); }); + + testWidgets('uses treeExpand duration/curve tokens', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: _ExpandHost(expanded: true), + ), + ); + + final size = tester.widget(find.byType(AnimatedSize)); + expect(size.duration, QueryaMotion.treeExpand); + expect(size.curve, QueryaMotion.treeExpandCurve); + }); } class _ExpandHost extends StatelessWidget { diff --git a/test/core/motion/querya_cross_fade_stack_test.dart b/test/core/motion/querya_cross_fade_stack_test.dart index 61b0ff6e..36e0b2ef 100644 --- a/test/core/motion/querya_cross_fade_stack_test.dart +++ b/test/core/motion/querya_cross_fade_stack_test.dart @@ -54,4 +54,49 @@ void main() { focusNode1.dispose(); focusNode2.dispose(); }); + + testWidgets('QueryaCrossFadeStack clamps out-of-range index', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: QueryaCrossFadeStack( + index: 99, + children: [ + Text('only', key: Key('only_child')), + ], + ), + ), + ), + ); + + final opacity = + tester.widget(find.byType(AnimatedOpacity)); + expect(opacity.opacity, 1.0); + expect(find.byKey(const Key('only_child')), findsOneWidget); + }); + + testWidgets('QueryaCrossFadeStack uses exit curve when fading out', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: QueryaCrossFadeStack( + index: 0, + children: [ + Text('a', key: Key('a')), + Text('b', key: Key('b')), + ], + ), + ), + ), + ); + + final opacities = + tester.widgetList(find.byType(AnimatedOpacity)); + expect(opacities.elementAt(0).opacity, 1.0); + expect(opacities.elementAt(1).opacity, 0.0); + // Inactive layer uses exit curve; active uses enter. + expect(opacities.elementAt(0).curve, isNot(opacities.elementAt(1).curve)); + }); } diff --git a/test/core/motion/querya_fade_slide_test.dart b/test/core/motion/querya_fade_slide_test.dart index 112d03f1..0c67cc93 100644 --- a/test/core/motion/querya_fade_slide_test.dart +++ b/test/core/motion/querya_fade_slide_test.dart @@ -92,7 +92,7 @@ void main() { expect(find.text('a'), findsNothing); }); - testWidgets('uses standard duration when springs enabled (full)', + testWidgets('uses standard/enter duration tokens (full motion)', (tester) async { await tester.pumpWidget( wrap( @@ -104,10 +104,10 @@ void main() { final switcher = tester.widget(find.byType(AnimatedSwitcher)); expect(switcher.duration, QueryaMotion.standard); - expect(switcher.switchInCurve, QueryaMotion.emphasized); + expect(switcher.switchInCurve, QueryaMotion.enter); }); - testWidgets('uses fast duration when reduced (no springs)', (tester) async { + testWidgets('halves standard duration when reduced', (tester) async { await tester.pumpWidget( wrap( const QueryaFadeSlide( @@ -122,7 +122,7 @@ void main() { switcher.duration, QueryaMotion.effectiveDuration( tester.element(find.byType(QueryaFadeSlide)), - QueryaMotion.fast, + QueryaMotion.standard, ), ); expect(switcher.switchInCurve, QueryaMotion.enter); diff --git a/test/core/motion/querya_hover_surface_test.dart b/test/core/motion/querya_hover_surface_test.dart index e0afb04b..d2a86c5d 100644 --- a/test/core/motion/querya_hover_surface_test.dart +++ b/test/core/motion/querya_hover_surface_test.dart @@ -155,4 +155,20 @@ void main() { ); expect(region.cursor, SystemMouseCursors.click); }); + + testWidgets('applies optional border on decoration', (tester) async { + await tester.pumpWidget( + wrap( + QueryaHoverSurface( + border: Border.all(color: const Color(0xFF445566), width: 2), + child: const SizedBox(width: 40, height: 20), + ), + ), + ); + final animated = + tester.widget(find.byType(AnimatedContainer)); + final decoration = animated.decoration! as BoxDecoration; + expect(decoration.border, isA()); + expect((decoration.border! as Border).top.width, 2); + }); } diff --git a/test/core/motion/querya_motion_test.dart b/test/core/motion/querya_motion_test.dart index a7d91d0c..aa26700f 100644 --- a/test/core/motion/querya_motion_test.dart +++ b/test/core/motion/querya_motion_test.dart @@ -11,6 +11,7 @@ void main() { expect(QueryaMotion.fast, const Duration(milliseconds: 120)); expect(QueryaMotion.standard, const Duration(milliseconds: 200)); expect(QueryaMotion.slow, const Duration(milliseconds: 320)); + expect(QueryaMotion.treeExpand, QueryaMotion.standard); }); test('curve constants are set', () { @@ -18,6 +19,7 @@ void main() { expect(QueryaMotion.exit, Curves.easeInCubic); expect(QueryaMotion.standardCurve, Curves.easeInOutCubic); expect(QueryaMotion.emphasized, Curves.easeInOutCubicEmphasized); + expect(QueryaMotion.treeExpandCurve, QueryaMotion.enter); }); }); diff --git a/test/core/motion/querya_spring_test.dart b/test/core/motion/querya_spring_test.dart index 0cbb61fc..db2d299a 100644 --- a/test/core/motion/querya_spring_test.dart +++ b/test/core/motion/querya_spring_test.dart @@ -251,6 +251,47 @@ void main() { expect(controller.isAnimating, isFalse); }); + testWidgets('cubics when springs off and cubicDuration set', (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost( + useSprings: false, + cubicDuration: const Duration(milliseconds: 100), + onCreated: (c) => controller = c, + ), + ), + ); + + controller.animateTo(1); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + expect(controller.value, greaterThan(0)); + expect(controller.value, lessThan(1)); + expect(controller.isAnimating, isTrue); + + await tester.pumpAndSettle(); + expect(controller.value, closeTo(1, 0.001)); + expect(controller.isAnimating, isFalse); + }); + + testWidgets('cubic Duration.zero snaps', (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost( + useSprings: false, + cubicDuration: Duration.zero, + onCreated: (c) => controller = c, + ), + ), + ); + + controller.animateTo(1); + expect(controller.value, 1); + expect(controller.isAnimating, isFalse); + }); + testWidgets('notifies listeners on animate', (tester) async { late QueryaSpringController controller; var notifications = 0; @@ -295,11 +336,13 @@ class _SpringHost extends StatefulWidget { required this.onCreated, this.useSprings = true, this.spring = QueryaSpring.snappy, + this.cubicDuration, }); final ValueChanged onCreated; final bool useSprings; final SpringDescription spring; + final Duration? cubicDuration; @override State<_SpringHost> createState() => _SpringHostState(); @@ -316,6 +359,7 @@ class _SpringHostState extends State<_SpringHost> vsync: this, useSprings: widget.useSprings, spring: widget.spring, + cubicDuration: widget.cubicDuration, ); widget.onCreated(_controller); } diff --git a/test/core/motion/querya_stagger_test.dart b/test/core/motion/querya_stagger_test.dart index e21be0ed..820b992c 100644 --- a/test/core/motion/querya_stagger_test.dart +++ b/test/core/motion/querya_stagger_test.dart @@ -102,6 +102,24 @@ void main() { expect(opacityOf(tester, 'item-2'), 1.0); }); + testWidgets('Reduced motion halves stagger step timing', (tester) async { + await tester.pumpWidget( + wrap( + QueryaStagger( + step: const Duration(milliseconds: 80), + children: texts(3), + ), + level: QueryaMotionLevel.reduced, + ), + ); + // Full would still have item-2 at 0 after 30ms with 80ms step; Reduced + // halves step to 40ms so later items start earlier. + await tester.pump(const Duration(milliseconds: 30)); + expect(opacityOf(tester, 'item-0'), greaterThan(0)); + await tester.pumpAndSettle(); + expect(opacityOf(tester, 'item-2'), 1.0); + }); + testWidgets('OS disableAnimations skips stagger', (tester) async { await tester.pumpWidget( MaterialApp( diff --git a/test/core/motion/querya_switching_body_test.dart b/test/core/motion/querya_switching_body_test.dart index ecab84c6..d39b686d 100644 --- a/test/core/motion/querya_switching_body_test.dart +++ b/test/core/motion/querya_switching_body_test.dart @@ -228,8 +228,7 @@ void main() { expect(opacity.duration, QueryaMotion.instant); }); - testWidgets('reduced motion disables springs path (fast halved)', - (tester) async { + testWidgets('halves standard duration when reduced', (tester) async { await tester.pumpWidget( wrap( const QueryaSwitchingBody( @@ -246,7 +245,7 @@ void main() { opacity.duration, QueryaMotion.effectiveDuration( tester.element(find.byType(QueryaSwitchingBody)), - QueryaMotion.fast, + QueryaMotion.standard, ), ); }); diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart index 3181f68e..1b85f76a 100644 --- a/test/core/sdui/sdui_builders_test.dart +++ b/test/core/sdui/sdui_builders_test.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/core/sdui/sdui_form_builder.dart'; import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_builder.dart'; import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; import '../../support/querya_theme_test_shell.dart'; @@ -116,6 +118,12 @@ void main() { expect(values['port'], 5432); expect(values['ssl'], isFalse); expect(key.currentState!.passwordFieldIds, ['password']); + expect(find.byType(material.CheckboxListTile), findsNothing); + expect(find.byType(material.Checkbox), findsOneWidget); + + await tester.tap(find.byType(material.Checkbox)); + await tester.pump(); + expect(key.currentState!.snapshotValues()['ssl'], isTrue); }); testWidgets('file_picker uses injectable picker', (tester) async { @@ -143,6 +151,76 @@ void main() { expect(key.currentState!.snapshotValues()['db'], '/tmp/test.db'); }); + + testWidgets( + 'keepExistingSecrets allows blank required password with hint', + (tester) async { + final schema = SduiFormSchema.fromJson(const { + 'fields': [ + {'id': 'host', 'type': 'text', 'label': 'Host', 'required': true}, + { + 'id': 'password', + 'type': 'password', + 'label': 'Password', + 'required': true, + 'placeholder': 'Secret', + }, + ], + }); + final key = material.GlobalKey(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiFormBuilder( + key: key, + schema: schema, + initialValues: const {'host': 'db.local'}, + keepExistingSecrets: true, + ), + ), + ), + ); + + expect(find.text('Leave blank to keep existing'), findsOneWidget); + expect(find.text('Secret'), findsNothing); + + final values = key.currentState!.collectValues(); + expect(values, isNotNull); + expect(values!['host'], 'db.local'); + expect(values['password'], ''); + expect(find.text('Password is required'), findsNothing); + }, + ); + + testWidgets( + 'required password still blocks create when keepExistingSecrets is false', + (tester) async { + final schema = SduiFormSchema.fromJson(const { + 'fields': [ + { + 'id': 'password', + 'type': 'password', + 'label': 'Password', + 'required': true, + }, + ], + }); + final key = material.GlobalKey(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiFormBuilder(key: key, schema: schema), + ), + ), + ); + + expect(key.currentState!.collectValues(), isNull); + await tester.pump(); + expect(find.text('Password is required'), findsOneWidget); + }, + ); }); group('SduiTreeSchema', () { @@ -206,13 +284,52 @@ void main() { expect(find.text('Databases'), findsOneWidget); expect(find.text('analytics'), findsNothing); - await tester.tap(find.byIcon(material.Icons.chevron_right)); + await tester.tap(find.byIcon(QueryaIcons.expandClosed)); await tester.pumpAndSettle(); expect(fetches, 1); expect(find.text('analytics'), findsOneWidget); }); + testWidgets('expand chevron uses QueryaMotion tokens (Off = instant)', + (tester) async { + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'databases', + 'label': 'Databases', + 'expandable': true, + }, + ], + }); + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.off, + child: material.Scaffold( + body: SduiTreeBuilder( + schema: schema, + fetchChildren: (_) async => const [ + SduiTreeNode(id: 'db1', label: 'analytics'), + ], + ), + ), + ), + ), + ); + + final rotation = tester.widget( + find.byType(material.AnimatedRotation), + ); + expect(rotation.duration, Duration.zero); + + await tester.tap(find.byIcon(QueryaIcons.expandClosed)); + await tester.pumpAndSettle(); + + expect(find.text('analytics'), findsOneWidget); + }); + testWidgets('selects table nodes by id prefix when meta is empty', (tester) async { SduiTreeNode? selected; diff --git a/test/core/storage/local_db_secrets_test.dart b/test/core/storage/local_db_secrets_test.dart index 51032fd4..dacae451 100644 --- a/test/core/storage/local_db_secrets_test.dart +++ b/test/core/storage/local_db_secrets_test.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import '../../memory_secrets_backend.dart'; @@ -117,7 +118,9 @@ void main() { expect(s.connectionString, isNull); }); - test('updateConnection atomically updates SQLite row and secure-store secrets', () async { + test( + 'updateConnection atomically updates SQLite row and secure-store secrets', + () async { const initialRow = ConnectionRow( type: 'postgres', name: 'PG_Init', @@ -137,7 +140,8 @@ void main() { port: 5433, username: 'root', password: 'new-secret-password', - connectionString: 'postgres://root:new-secret-password@db.example.com:5433/mydb', + connectionString: + 'postgres://root:new-secret-password@db.example.com:5433/mydb', createdAt: '2026-01-01T00:00:00Z', ); await LocalDb.instance.updateConnection(updatedRow); @@ -149,14 +153,18 @@ void main() { expect(loaded.port, 5433); expect(loaded.username, 'root'); expect(loaded.password, 'new-secret-password'); - expect(loaded.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); + expect(loaded.connectionString, + 'postgres://root:new-secret-password@db.example.com:5433/mydb'); final secrets = await ConnectionSecretsStore.readForConnection(id); expect(secrets.password, 'new-secret-password'); - expect(secrets.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); + expect(secrets.connectionString, + 'postgres://root:new-secret-password@db.example.com:5433/mydb'); }); - test('removeConnection still deletes SQLite row when secure-store delete fails', () async { + test( + 'removeConnection still deletes SQLite row when secure-store delete fails', + () async { const row = ConnectionRow( type: 'redis', name: 'R3', @@ -174,7 +182,8 @@ void main() { expect(list.where((c) => c.id == id), isEmpty); }); - test('addConnection rolls back SQLite row when secure-store write fails', () async { + test('addConnection rolls back SQLite row when secure-store write fails', + () async { testMemorySecrets.failNextWrite = StateError('keychain write failed'); const row = ConnectionRow( type: 'redis', @@ -194,7 +203,9 @@ void main() { expect(list.where((c) => c.name == 'R4'), isEmpty); }); - test('updateConnection rolls back SQLite and secrets when secure-store write fails', () async { + test( + 'updateConnection rolls back SQLite and secrets when secure-store write fails', + () async { const initialRow = ConnectionRow( type: 'postgres', name: 'PG_Before', @@ -231,5 +242,41 @@ void main() { expect(loaded.username, 'admin'); expect(loaded.password, 'old-password'); }); + + test( + 'mergeSecretsForConnectionUpdate keeps password when form leaves it blank', + () async { + const initialRow = ConnectionRow( + type: 'postgresql', + name: 'PG', + host: 'localhost', + port: 5432, + username: 'admin', + password: 'keep-me', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(initialRow); + + final edited = ConnectionRow( + id: id, + type: 'postgresql', + name: 'PG Renamed', + host: 'db.example.com', + port: 5432, + username: 'admin', + password: null, + createdAt: '2026-01-01T00:00:00Z', + ); + final merged = await mergeSecretsForConnectionUpdate(edited); + expect(merged.password, 'keep-me'); + expect(merged.name, 'PG Renamed'); + expect(merged.host, 'db.example.com'); + + await LocalDb.instance.updateConnection(merged); + final loaded = (await LocalDb.instance.getConnections()) + .singleWhere((c) => c.id == id); + expect(loaded.password, 'keep-me'); + expect(loaded.name, 'PG Renamed'); + }); }); } diff --git a/test/core/storage/sql_query_history_test.dart b/test/core/storage/sql_query_history_test.dart index e0342270..f7a2d6f5 100644 --- a/test/core/storage/sql_query_history_test.dart +++ b/test/core/storage/sql_query_history_test.dart @@ -124,6 +124,36 @@ void main() { expect(list.map((e) => e.sqlText), ['q4', 'q3', 'q2']); }); + test('prunes in batches when maxEntries > 10', () async { + const row = ConnectionRow( + type: 'mysql', + name: 'M2', + host: '127.0.0.1', + port: 3306, + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + + // Insert 25 items with maxEntries = 15 (batch threshold = 10) + for (var i = 0; i < 25; i++) { + await LocalDb.instance.recordSqlQueryHistory( + connectionId: id, + databaseName: 'db_batch', + sqlText: 'query_$i', + maxEntries: 15, + ); + } + + final list = await LocalDb.instance.listSqlQueryHistory( + connectionId: id, + databaseName: 'db_batch', + limit: 100, + ); + // On 20th insert (batch threshold 10 hit twice), pruned to 15. Then 5 more inserted (21..24) -> total 20 items. + expect(list.length, lessThanOrEqualTo(20)); + expect(list.first.sqlText, 'query_24'); + }); + test('history lookup index includes database_name', () async { await LocalDb.instance.getAppSetting('__touch__'); // ensure DB open final dbFile = p.join(tempDir.path, 'querya_desktop', 'querya.db'); diff --git a/test/core/ui/querya_icons_test.dart b/test/core/ui/querya_icons_test.dart new file mode 100644 index 00000000..c9847d2c --- /dev/null +++ b/test/core/ui/querya_icons_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; + +void main() { + group('QueryaIcons.connectionIcon', () { + test('maps built-in connection types', () { + expect( + QueryaIcons.connectionIcon('postgresql'), + material.Icons.storage_rounded, + ); + expect( + QueryaIcons.connectionIcon('mysql'), + material.Icons.table_chart_rounded, + ); + expect( + QueryaIcons.connectionIcon('redis'), + material.Icons.memory_rounded, + ); + expect( + QueryaIcons.connectionIcon('mongodb'), + material.Icons.eco_rounded, + ); + expect( + QueryaIcons.connectionIcon('sqlite'), + material.Icons.folder_open_rounded, + ); + }); + + test('falls back to extension icon for unknown types', () { + expect( + QueryaIcons.connectionIcon('clickhouse'), + material.Icons.extension_rounded, + ); + }); + }); + + group('QueryaIcons.connectionAsset', () { + test('returns bundled logos for known SQL/NoSQL drivers', () { + expect( + QueryaIcons.connectionAsset('postgresql'), + 'assets/images/postgresql_icon.png', + ); + expect(QueryaIcons.connectionAsset('sqlite'), isNull); + }); + }); + + group('QueryaIcons.sduiNodeIcon', () { + test('uses rounded tree icons for SDUI nodes', () { + expect( + QueryaIcons.sduiNodeIcon('database', expandable: false), + QueryaIcons.database, + ); + expect( + QueryaIcons.sduiNodeIcon('table', expandable: true), + QueryaIcons.tableGroup, + ); + expect( + QueryaIcons.sduiNodeIcon('table', expandable: false), + QueryaIcons.tableLeaf, + ); + expect( + QueryaIcons.sduiNodeIcon('view', expandable: true), + QueryaIcons.viewGroup, + ); + expect( + QueryaIcons.sduiNodeIcon('view', expandable: false), + QueryaIcons.viewLeaf, + ); + expect( + QueryaIcons.sduiNodeIcon(null, expandable: true), + QueryaIcons.folder, + ); + expect( + QueryaIcons.sduiNodeIcon(null, expandable: false), + material.Icons.insert_drive_file_rounded, + ); + }); + }); + + test('tree size tokens are ordered leaf < group < connection < sidebar', () { + expect(QueryaIconSizes.treeLeaf, lessThan(QueryaIconSizes.treeGroup)); + expect(QueryaIconSizes.treeGroup, lessThan(QueryaIconSizes.treeConnection)); + expect( + QueryaIconSizes.treeExpand, + lessThan(QueryaIconSizes.sidebarExpand), + ); + expect( + QueryaIconSizes.sidebarExpand, + QueryaIconSizes.sidebarConnectionIcon, + ); + }); + + test('SDUI trees share native treeGroup/treeLeaf sizes (no sduiNode)', () { + // Guards against reintroducing a dead parallel size token (#497). + expect(QueryaIconSizes.treeGroup, 13); + expect(QueryaIconSizes.treeLeaf, 12); + }); +} diff --git a/test/features/connections/connection_edit_helpers_test.dart b/test/features/connections/connection_edit_helpers_test.dart new file mode 100644 index 00000000..2560c2b6 --- /dev/null +++ b/test/features/connections/connection_edit_helpers_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; + +void main() { + group('redactUriPassword', () { + test('strips password from userinfo', () { + expect( + redactUriPassword('postgresql://alice:s3cret@db.example:5432/app'), + 'postgresql://alice@db.example:5432/app', + ); + }); + + test('leaves uri without password unchanged', () { + const uri = 'postgresql://alice@db.example:5432/app'; + expect(redactUriPassword(uri), uri); + }); + }); + + group('injectUriPasswordIfMissing', () { + test('injects password when user has no password', () { + expect( + injectUriPasswordIfMissing( + 'postgresql://alice@db.example:5432/app', + 's3cret', + ), + 'postgresql://alice:s3cret@db.example:5432/app', + ); + }); + + test('keeps existing password', () { + const uri = 'postgresql://alice:keep@db.example:5432/app'; + expect(injectUriPasswordIfMissing(uri, 'other'), uri); + }); + }); + + group('ConnectionRow.copyWith', () { + test('can clear password with flag', () { + const row = ConnectionRow( + id: 1, + type: 'postgresql', + name: 'n', + password: 'x', + createdAt: 't', + ); + expect(row.copyWith(clearPassword: true).password, isNull); + expect(row.copyWith(password: 'y').password, 'y'); + }); + }); +} diff --git a/test/features/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart index bc68c223..4fc3efa8 100644 --- a/test/features/extensions/extension_manager_test.dart +++ b/test/features/extensions/extension_manager_test.dart @@ -6,6 +6,7 @@ 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/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; import 'package:querya_desktop/features/extensions/presentation/widgets/extension_card.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -114,6 +115,8 @@ void main() { expect(find.text('Installed (0)'), findsOneWidget); expect(find.text('Marketplace'), findsOneWidget); expect(find.text('Install from file…'), findsOneWidget); + expect(find.byType(QueryaTabStrip), findsOneWidget); + expect(find.byType(QueryaCrossFadeStack), findsOneWidget); // Switch to Marketplace tab await tester.tap(find.text('Marketplace')); @@ -122,6 +125,19 @@ void main() { expect(find.text('ClickHouse Driver'), findsOneWidget); expect(find.textContaining('preview listings only'), findsOneWidget); expect(find.text('Preview'), findsWidgets); + + await tester.tap(find.text('Updates')); + await tester.pumpAndSettle(); + + expect( + find.text('All installed extensions are up to date!'), + findsNothing, + ); + expect( + find.text('Extension update checks are not available yet'), + findsOneWidget, + ); + expect(find.textContaining('Marketplace API'), findsOneWidget); }); }); } diff --git a/test/features/main_screen/querya_window_title_bar_test.dart b/test/features/main_screen/querya_window_title_bar_test.dart index 8f1f2674..afe0a7e6 100644 --- a/test/features/main_screen/querya_window_title_bar_test.dart +++ b/test/features/main_screen/querya_window_title_bar_test.dart @@ -97,6 +97,35 @@ void main() { expect(background, _customSurface); }); + testWidgets('title bar leading inset reserves macOS traffic-light space', + (tester) async { + expect( + QueryaWindowTitleBar.titleBarLeadingInset( + isMacOS: true, + scale: (v) => v, + ), + 72, + ); + expect( + QueryaWindowTitleBar.titleBarLeadingInset( + isMacOS: false, + scale: (v) => v, + ), + 16, + ); + }); + + test('bitsdojo window buttons hidden on macOS, shown elsewhere', () { + expect( + QueryaWindowTitleBar.showBitsdojoWindowButtons(isMacOS: true), + isFalse, + ); + expect( + QueryaWindowTitleBar.showBitsdojoWindowButtons(isMacOS: false), + isTrue, + ); + }); + testWidgets('read-only state is persistently visible in title bar', (tester) async { await tester.pumpWidget( diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index e3bf96c1..2ed4a56a 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -106,6 +106,24 @@ void main() { expect(window.last, lessThan(30)); expect(window.leadingWidth, greaterThan(0)); }); + + test('handles large scale column sets (10000 columns) efficiently', () { + final widths = List.filled(10000, 100); + final offsets = computeResultGridColumnOffsets(widths); + final window = computeVisibleColumnWindow( + columnWidths: widths, + columnOffsets: offsets, + scrollOffset: 500000, + viewportWidth: 1000, + overscanColumns: 2, + ); + // scrollOffset 500000 = index 5000 (since width is 100) + // viewport 1000 = 10 columns (indices 5000..5009) + // with overscan 2 -> first: 4998, last: 5011 + expect(window.first, 4998); + expect(window.last, 5011); + expect(window.leadingWidth, 4998 * 100.0); + }); }); group('ResultsTab', () { diff --git a/test/features/main_screen/workspace_empty_hero_test.dart b/test/features/main_screen/workspace_empty_hero_test.dart index 99a90e56..af169d75 100644 --- a/test/features/main_screen/workspace_empty_hero_test.dart +++ b/test/features/main_screen/workspace_empty_hero_test.dart @@ -73,6 +73,30 @@ void main() { expect(sqliteTapped, isTrue); }); + testWidgets('does not flash Quick start before recent load completes', + (tester) async { + await tester.pumpWidget( + heroShell( + child: material.SizedBox( + width: 900, + height: 700, + child: WorkspaceEmptyHero( + onNewConnection: () {}, + ), + ), + ), + ); + // First frame before async recent load settles. + await tester.pump(); + + expect( + find.byKey(const material.ValueKey('empty_section_loading')), + findsOneWidget, + ); + expect(find.text('Quick start'), findsNothing); + expect(find.text('Recent connections'), findsNothing); + }); + testWidgets('WorkspaceEmptyHero opens a recent connection', (tester) async { ConnectionRow? opened; 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 27f4d17f..dce57565 100644 --- a/test/features/main_screen/workspace_homes_and_preferences_test.dart +++ b/test/features/main_screen/workspace_homes_and_preferences_test.dart @@ -181,6 +181,8 @@ void main() { await tester.pump(const Duration(milliseconds: 400)); expect(find.text('Preferences'), findsOneWidget); + // Dialog chrome uses opaque DecoratedBox — must not host ListTile (#491). + expect(find.byType(material.CheckboxListTile), findsNothing); }); }); } diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index 38e3a405..79f795ce 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/main_screen/workspace_panel.dart'; +import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; +import 'package:querya_desktop/features/redis/redis_view.dart'; import '../../support/layout_overflow.dart'; import '../../support/querya_theme_test_shell.dart'; @@ -18,6 +21,15 @@ void main() { createdAt: '0', ); + const redisConnection = ConnectionRow( + id: 42, + type: 'redis', + name: 'redis-test', + host: '127.0.0.1', + port: 6379, + createdAt: '0', + ); + group('WorkspacePanel layout (no connection)', () { final sizes = { 'narrow_tall': const material.Size(320, 720), @@ -75,7 +87,8 @@ void main() { ), ); - expect(find.byKey(const material.Key('workspace_run_button')), findsNothing); + expect( + find.byKey(const material.Key('workspace_run_button')), findsNothing); expect(find.text('Execute/Refresh (F5)'), findsNothing); }); @@ -93,7 +106,8 @@ void main() { ), ); - expect(find.byKey(const material.Key('workspace_run_button')), findsNothing); + expect( + find.byKey(const material.Key('workspace_run_button')), findsNothing); expect(find.text('Query History'), findsNothing); expect(find.textContaining('Coming in a future release'), findsNothing); }); @@ -109,7 +123,7 @@ void main() { ), ), ); - expect(find.byType(QueryaSwitchingBody), findsOneWidget); + expect(find.byType(QueryaSwitchingBody), findsWidgets); await pumpWidgetWithSurfaceSize( tester, @@ -123,7 +137,7 @@ void main() { ), ); await tester.pump(); - expect(find.byType(QueryaSwitchingBody), findsOneWidget); + expect(find.byType(QueryaSwitchingBody), findsWidgets); expect(find.text('Unsupported connection type'), findsOneWidget); // Back to empty — keep-alive stack stays mounted. @@ -137,7 +151,109 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.byType(QueryaSwitchingBody), findsOneWidget); + expect(find.byType(QueryaSwitchingBody), findsWidgets); + }); + }); + + group('WorkspacePanel home↔object morph', () { + testWidgets('Redis stats↔explorer uses SwitchingBody + FadeSlide', + (tester) async { + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(activeConnection: redisConnection), + ), + ), + ); + await tester.pump(); + + // Outer empty↔connected + inner home↔object (+ hero FadeSlide keep-alive). + expect(find.byType(QueryaSwitchingBody), findsNWidgets(2)); + expect(find.byType(QueryaFadeSlide), findsWidgets); + expect(find.byType(RedisView), findsOneWidget); + + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel( + activeConnection: redisConnection, + selectedRedisDb: 0, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byType(QueryaSwitchingBody), findsNWidgets(2)); + expect(find.byType(QueryaFadeSlide), findsWidgets); + expect(find.byType(RedisExplorerView), findsOneWidget); + // Home stays keep-alive under SwitchingBody. + expect(find.byType(RedisView), findsOneWidget); + + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(activeConnection: redisConnection), + ), + ), + ); + // Avoid pumpAndSettle — Redis stats polling keeps a ticker alive. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + + expect(find.byType(RedisView), findsOneWidget); + expect(find.byType(QueryaSwitchingBody), findsNWidgets(2)); + }); + + testWidgets('connection A→B morph uses outer QueryaFadeSlide', + (tester) async { + const redisB = ConnectionRow( + id: 43, + type: 'redis', + name: 'redis-b', + host: '127.0.0.1', + port: 6380, + createdAt: '0', + ); + + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(activeConnection: redisConnection), + ), + ), + ); + await tester.pump(); + + expect(find.byKey(const material.ValueKey('ws_conn_42')), findsOneWidget); + expect(find.byType(RedisView), findsOneWidget); + + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(activeConnection: redisB), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byKey(const material.ValueKey('ws_conn_43')), findsOneWidget); + // Outer empty↔connected FadeSlide + home↔object FadeSlide. + expect(find.byType(QueryaFadeSlide), findsWidgets); + expect(find.byType(RedisView), findsOneWidget); + expect(find.byType(QueryaSwitchingBody), findsNWidgets(2)); }); }); } diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart index adda7db4..a3461d88 100644 --- a/test/features/postgresql/postgresql_connection_form_test.dart +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -90,7 +90,8 @@ void main() { expect(result, isNull); }); - testWidgets('Save from URI extracts host and port for display', (tester) async { + testWidgets('Save from URI extracts host and port for display', + (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); ConnectionRow? result; await tester.pumpWidget( @@ -114,7 +115,11 @@ 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) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == + 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), 'postgresql://u:p@remote.example.com:5433/db', ); @@ -125,7 +130,8 @@ void main() { expect(result!.host, 'remote.example.com'); expect(result!.port, 5433); expect(result!.name, 'PostgreSQL: remote.example.com:5433'); - expect(result!.connectionString, 'postgresql://u:p@remote.example.com:5433/db'); + expect(result!.connectionString, + 'postgresql://u:p@remote.example.com:5433/db'); }); testWidgets('SSL certificate path is appended to the URI', (tester) async { @@ -149,7 +155,11 @@ 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) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == + 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), 'postgresql://u:p@remote.example.com:5433/db', ); @@ -176,14 +186,19 @@ void main() { 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) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == + 'postgresql://user:pass@host:5432/dbname?sslmode=require', ), ); expect(uriField.controller?.text, contains('sslrootcert')); expect(uriField.controller?.text, contains('root.pem')); }); - testWidgets('Save with SSL certs and no URI builds a connection URI', (tester) async { + testWidgets('Save with SSL certs and no URI builds a connection URI', + (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); ConnectionRow? result; await tester.pumpWidget( @@ -207,31 +222,52 @@ void main() { await tester.enterText( find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'My PostgreSQL Server', + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'My PostgreSQL Server', ), 'Cert PG', ); await tester.enterText( - find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'localhost', - ).first, + find + .byWidgetPredicate( + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'localhost', + ) + .first, 'pg.example.com', ); await tester.enterText( - find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgres', - ).first, + find + .byWidgetPredicate( + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'postgres', + ) + .first, 'appdb', ); await tester.enterText( - find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'postgres', - ).last, + find + .byWidgetPredicate( + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'postgres', + ) + .last, 'admin', ); await tester.enterText( find.byWidgetPredicate( - (w) => w is TextField && w.placeholder is Text && (w.placeholder as Text).data == 'Password', + (w) => + w is TextField && + w.placeholder is Text && + (w.placeholder as Text).data == 'Password', ), 'secret', ); @@ -268,5 +304,63 @@ void main() { expect(result!.connectionString, contains('secret')); expect(result!.useSSL, true); }); + + testWidgets('edit mode prefills fields and keeps password empty', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + const initial = ConnectionRow( + id: 42, + type: 'postgresql', + name: 'Prod', + host: 'db.example.com', + port: 5433, + username: 'app', + password: 'must-not-appear', + databaseName: 'appdb', + createdAt: '2026-01-01T00:00:00Z', + ); + ConnectionRow? result; + + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showPostgresConnectionForm( + context, + initial: initial, + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Edit PostgreSQL Connection'), findsOneWidget); + expect(find.text('Leave blank to keep existing'), findsOneWidget); + expect(find.text('must-not-appear'), findsNothing); + expect(find.text('Prod'), findsOneWidget); + expect(find.text('db.example.com'), findsOneWidget); + expect(find.text('5433'), findsOneWidget); + expect(find.text('appdb'), findsOneWidget); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.id, 42); + expect(result!.type, 'postgresql'); + expect(result!.name, 'Prod'); + expect(result!.host, 'db.example.com'); + expect(result!.password, isNull); + expect(result!.createdAt, '2026-01-01T00:00:00Z'); + }); }); } diff --git a/test/features/settings/preferences_checkbox_row_test.dart b/test/features/settings/preferences_checkbox_row_test.dart new file mode 100644 index 00000000..412f90c3 --- /dev/null +++ b/test/features/settings/preferences_checkbox_row_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + testWidgets('PreferencesCheckboxRow toggles without CheckboxListTile', + (tester) async { + var value = false; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.StatefulBuilder( + builder: (context, setState) { + return PreferencesCheckboxRow( + value: value, + title: const Text('Toggle me').small(), + subtitle: const Text('Hint').muted().xSmall(), + onChanged: (v) => setState(() => value = v), + ); + }, + ), + ), + ), + ); + + expect(find.byType(material.CheckboxListTile), findsNothing); + expect(find.byType(material.Checkbox), findsOneWidget); + expect(tester.widget(find.byType(material.Checkbox)).value, + isFalse); + + await tester.tap(find.text('Toggle me')); + await tester.pump(); + expect(value, isTrue); + + await tester.tap(find.byType(material.Checkbox)); + await tester.pump(); + expect(value, isFalse); + }); +} diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index 6defda18..fe40911b 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -581,6 +581,60 @@ void main() { expect(picked, ThemeController.builtinQueryaLightId); }); + + testWidgets('compact trigger uses min mainAxisSize (no forced Expanded)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(3), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + final rows = tester.widgetList(find.byType(material.Row)); + final triggerRow = rows.firstWhere( + (row) => row.mainAxisSize == material.MainAxisSize.min, + ); + expect(triggerRow.mainAxisSize, material.MainAxisSize.min); + expect( + triggerRow.children.whereType(), + isEmpty, + ); + }); + + testWidgets('selecting theme keeps overlay briefly for exit motion', + (tester) async { + final themes = _fakeThemes(5); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Theme 00')); + await tester.pumpAndSettle(); + expect(find.byType(material.ListView), findsOneWidget); + + await tester.tap(find.text('Theme 01')); + await tester.pump(); // start exit; overlay still mounted + expect(find.byType(material.ListView), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.byType(material.ListView), findsNothing); + }); }); group('filterThemeDefinitions metadata', () { diff --git a/test/features/updater/update_available_badge_test.dart b/test/features/updater/update_available_badge_test.dart index 5e859b0a..6f61d05a 100644 --- a/test/features/updater/update_available_badge_test.dart +++ b/test/features/updater/update_available_badge_test.dart @@ -89,4 +89,36 @@ void main() { expect(find.textContaining('v1.0.0 available'), findsOneWidget); expect(state.isPulseAnimating, isFalse); }); + + testWidgets('reduced motion pulses at half period', (tester) async { + controller.setPendingUpdate( + const UpdateManifest( + version: '2.0.0', + changelog: '', + assets: [], + ), + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.reduced, + child: material.Scaffold( + body: UpdateAvailableBadge(controller: controller), + ), + ), + ), + ); + await tester.pump(); + + final state = tester.state( + find.byType(UpdateAvailableBadge), + ); + expect(find.textContaining('v2.0.0 available'), findsOneWidget); + expect(state.isPulseAnimating, isTrue); + expect( + state.pulseDuration, + Duration(microseconds: kUpdateBadgePulsePeriod.inMicroseconds ~/ 2), + ); + }); } diff --git a/test/shared/querya_tab_strip_test.dart b/test/shared/querya_tab_strip_test.dart index 6253709e..3ef0399a 100644 --- a/test/shared/querya_tab_strip_test.dart +++ b/test/shared/querya_tab_strip_test.dart @@ -201,6 +201,45 @@ void main() { ); }); + testWidgets('reduced motion slides indicator with cubic (not snap)', + (tester) async { + var selected = 0; + await tester.pumpWidget( + stripShell( + level: QueryaMotionLevel.reduced, + child: material.StatefulBuilder( + builder: (context, setState) => material.Center( + child: QueryaTabStrip( + labels: const ['Server', 'SQL', 'History'], + selectedIndex: selected, + onSelected: (index) => setState(() => selected = index), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + final startLeft = indicatorOf(tester).left!; + + await tester.tap(find.bySemanticsLabel('History')); + await tester.pump(); + await tester.pump(); // post-frame sync starts cubic + await tester.pump(const Duration(milliseconds: 20)); + + final midLeft = indicatorOf(tester).left!; + expect(midLeft, greaterThan(startLeft)); + final history = + tester.getRect(find.byKey(const material.ValueKey('querya_tab_History'))); + final strip = tester.getRect(find.byType(QueryaTabStrip)); + final endLeft = history.left - strip.left; + expect(midLeft, lessThan(endLeft - 0.5)); + + await tester.pumpAndSettle(); + expect(indicatorOf(tester).left, closeTo(endLeft, 1.0)); + }); + testWidgets('redirect mid-slide settles on final selection', (tester) async { var selected = 0; await tester.pumpWidget( diff --git a/test/shared/widgets/tree_load_error_test.dart b/test/shared/widgets/tree_load_error_test.dart new file mode 100644 index 00000000..4e79bf53 --- /dev/null +++ b/test/shared/widgets/tree_load_error_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/tree_load_error.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + testWidgets('TreeLoadError shows message and retry', (tester) async { + var retried = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: TreeLoadError( + message: 'connection refused', + onRetry: () => retried = true, + ), + ), + ); + + expect(find.text('Could not load'), findsOneWidget); + expect(find.text('connection refused'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(find.byIcon(material.Icons.error_outline_rounded), findsOneWidget); + + await tester.tap(find.text('Retry')); + await tester.pump(); + + expect(retried, isTrue); + }); + + testWidgets('TreeLoadError title row uses custom title', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const TreeLoadError( + title: 'Could not load databases', + message: 'timeout', + ), + ), + ); + + expect(find.text('Could not load databases'), findsOneWidget); + expect(find.byIcon(material.Icons.error_outline_rounded), findsOneWidget); + }); + + testWidgets('TreeLoadError can hide title row', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const TreeLoadError( + message: 'timeout', + showTitleRow: false, + ), + ), + ); + + expect(find.text('Could not load'), findsNothing); + expect(find.byIcon(material.Icons.error_outline_rounded), findsNothing); + expect(find.text('timeout'), findsOneWidget); + }); +}