diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index d3e25e73..7bb22c56 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-04-24 10:36:12.197519","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-04-24 12:51:31.086903","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d790a24b..eaf951e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -# Сборка Windows/Linux zip + GitHub Release. +# Сборка Windows / Linux / macOS zip + GitHub Release. # # Автоматически: push тега X.Y.Z или vX.Y.Z. # Имена zip берутся из semver в pubspec на закоммиченном ref (после merge в main он может @@ -130,9 +130,47 @@ jobs: path: Querya-Desktop-${{ steps.version.outputs.version }}-linux.zip if-no-files-found: error + build-macos: + name: Build macOS + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Get version from pubspec.yaml + id: version + run: | + FULL_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //') + VERSION=$(echo "$FULL_VERSION" | sed 's/+.*//') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.38.5' + channel: 'stable' + cache: true + + - name: Get dependencies + run: flutter pub get + + - name: Build macOS (release) + run: flutter build macos --release + + - name: Zip macOS .app + run: | + APP="build/macos/Build/Products/Release/querya_desktop.app" + test -d "$APP" + ditto -c -k --keepParent "$APP" "Querya-Desktop-${{ steps.version.outputs.version }}-macos.zip" + + - uses: actions/upload-artifact@v4 + with: + name: bundle-macos + path: Querya-Desktop-${{ steps.version.outputs.version }}-macos.zip + if-no-files-found: error + publish: name: Publish GitHub Release - needs: [build-windows, build-linux] + needs: [build-windows, build-linux, build-macos] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -150,7 +188,7 @@ jobs: set -euo pipefail mkdir -p dist find artifacts -name '*.zip' -exec mv -t dist/ {} + - test "$(find dist -name '*.zip' | wc -l)" -eq 2 + test "$(find dist -name '*.zip' | wc -l)" -eq 3 cd dist sha256sum *.zip | tee SHA256SUMS.txt @@ -174,6 +212,7 @@ jobs: ### Downloads - **Linux**: `Querya-Desktop-${{ needs.build-windows.outputs.version }}-linux.zip` - **Windows**: `Querya-Desktop-${{ needs.build-windows.outputs.version }}-windows.zip` + - **macOS**: `Querya-Desktop-${{ needs.build-windows.outputs.version }}-macos.zip` (unsigned `.app` in zip; right-click → Open on first launch) Verify checksums: `SHA256SUMS.txt` diff --git a/CHANGELOG.md b/CHANGELOG.md index f98a2f0a..8b44bbc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.3] - 2026-04-25 + +### Added + +- **Performance** — narrower workspace rebuilds via `ValueNotifier` / `ValueListenableBuilder` on the main screen; `RepaintBoundary` around connections and workspace; folder expansion uses local state so the whole sidebar does not rebuild on every toggle. +- **Lists** — virtualized long lists for MongoDB documents/collections, Redis keys, and PostgreSQL browser views (indexes, triggers, types, extensions, foreign data). +- **Tests** — `MainScreenWorkspaceState`, `showAppDialog`, `RedisKeysView` (with `RedisConnectionTestFake`), and expanded connections panel folder collapse/expand coverage. +- **Docs** — `docs/perf-baseline.md` (Flutter DevTools checklist for regression comparison). + +### Changed + +- **Dialogs** — slightly shorter transition and lower blur sigma in `showAppDialog` to reduce GPU load on modest hardware. +- **Connections tree** — slightly shorter chevron rotation animation. + +### Fixed + +- **PostgreSQL server dashboard** — removed fixed-height cards and chip layout that caused vertical overflow (yellow/black debug stripes) on the stats view. + ## [0.1.2] - 2026-04-24 ### Changed @@ -39,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Linux desktop build/install layout no longer targets `/usr/local` when building the app bundle. +[0.1.3]: https://github.com/QueryaHub/Querya-Desktop/compare/0.1.2...0.1.3 [0.1.2]: https://github.com/QueryaHub/Querya-Desktop/compare/0.1.1...0.1.2 [0.1.1]: https://github.com/QueryaHub/Querya-Desktop/compare/0.1.0...0.1.1 [0.1.0]: https://github.com/QueryaHub/Querya-Desktop/compare/0.0.1...0.1.0 diff --git a/docs/perf-baseline.md b/docs/perf-baseline.md new file mode 100644 index 00000000..bcfb5d69 --- /dev/null +++ b/docs/perf-baseline.md @@ -0,0 +1,10 @@ +# Flutter performance baseline (DevTools) + +Use this checklist once per milestone so timeline comparisons stay meaningful. Run a **profile** or **release** build, not debug. + +1. **Open DevTools → Performance** and start recording. +2. **Modal**: open any screen that uses `showAppDialog`; stop recording; note frame build/raster time around the transition. +3. **Connections tree**: expand/collapse a folder and a DB branch; note jank spikes. +4. **Heavy scroll**: PostgreSQL/MySQL table view or Mongo documents list with many rows; scroll quickly for 2–3 seconds. + +Save a screenshot or export the timeline when filing regressions. After UI changes, repeat the same steps and compare peak frame times and rebuild counts (Widget rebuild stats in DevTools). diff --git a/docs/tags-and-releases.md b/docs/tags-and-releases.md index 5e4439f4..105ce0b7 100644 --- a/docs/tags-and-releases.md +++ b/docs/tags-and-releases.md @@ -11,7 +11,7 @@ - либо `v0.1.1` — тоже допустимо, CI сравнивает с pubspec без префикса `v`. 4. Запушьте тег: `git push origin 0.1.1` -После этого workflow **[Release](../.github/workflows/release.yml)** запустится **автоматически**: соберёт **Windows** и **Linux** (zip), **`SHA256SUMS.txt`**, создаст или обновит **GitHub Release** с этими файлами. +После этого workflow **[Release](../.github/workflows/release.yml)** запустится **автоматически**: соберёт **Windows**, **Linux** и **macOS** (zip), **`SHA256SUMS.txt`**, создаст или обновит **GitHub Release** с этими файлами. Если после merge в `main` сработал **автобамп** версии в `pubspec`, а вы поставили тег со **старым** номером (например тег `0.1.1`, а в коммите уже `0.1.2+…`), сборка **всё равно пройдёт**: имена zip возьмутся из **pubspec** (`0.1.2`), а GitHub Release останется на **вашем теге** (`0.1.1`). В логах будет предупреждение; чтобы номер тега и архивов совпадали, ставьте тег на актуальный semver из `pubspec` (например `0.1.2`). @@ -22,7 +22,7 @@ ## Что внутри релиза -- Имена архивов: `Querya-Desktop-X.Y.Z-linux.zip`, `Querya-Desktop-X.Y.Z-windows.zip`. +- Имена архивов: `Querya-Desktop-X.Y.Z-linux.zip`, `Querya-Desktop-X.Y.Z-windows.zip`, `Querya-Desktop-X.Y.Z-macos.zip` (внутри неподписанный `.app`). - Версия для имён и тега — **semver из pubspec**; build `+N` попадает в текст релиза как **полный pubspec version**. ## Changelog (git-cliff) @@ -50,5 +50,4 @@ ## Платформы -- **Поставка через CI:** Windows и Linux (см. `release.yml`). -- **macOS:** локальная сборка возможна (`flutter build macos`); отдельного job подписи/notarize в этом workflow нет. +- **Поставка через CI:** Windows, Linux и macOS (см. `release.yml`); macOS-сборка **без** подписи и notarize — для установки может понадобиться «Открыть» через контекстное меню при первом запуске. diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index dd52a23e..94760aa9 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -302,6 +302,74 @@ class RedisConnection { } } +/// In-memory stub for widget tests (no socket). Handles SELECT, DBSIZE, SCAN, +/// TYPE, TTL used by [RedisKeysView]. +class RedisConnectionTestFake extends RedisConnection { + RedisConnectionTestFake({ + this.firstScanKeys = const ['alpha', 'beta'], + this.secondScanKeys = const [], + this.dbSizeResult = 2, + }) : super(id: -1, name: 'test-fake', host: 'localhost', port: 6379); + + final List firstScanKeys; + final List secondScanKeys; + final int dbSizeResult; + + bool _firstScanDone = false; + + @override + bool get isConnected => _isConnected; + + @override + Future connect() async { + _isConnected = true; + _conn = null; + _command = null; + } + + @override + Future disconnect() async { + _isConnected = false; + final c = _conn; + _conn = null; + _command = null; + try { + await c?.close(); + } catch (_) {} + } + + @override + Future sendCommand(List args) async { + if (!_isConnected) { + throw StateError('Not connected to Redis'); + } + final op = args.first.toString().toUpperCase(); + switch (op) { + case 'SELECT': + return 'OK'; + case 'DBSIZE': + return dbSizeResult; + case 'SCAN': + final cursor = int.tryParse(args[1].toString()) ?? 0; + if (cursor == 0 && !_firstScanDone) { + _firstScanDone = true; + final next = secondScanKeys.isNotEmpty ? 1 : 0; + return [next, firstScanKeys]; + } + if (cursor == 1 && secondScanKeys.isNotEmpty) { + return [0, secondScanKeys]; + } + return [0, []]; + case 'TYPE': + return 'string'; + case 'TTL': + return -1; + default: + return null; + } + } +} + class RedisConnectionException implements Exception { RedisConnectionException(this.message); final String message; diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index c2c34439..2669049c 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' as material show AlertDialog, BoxConstraints, BuildContext, Column, ConstrainedBox, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color, LayoutBuilder, TextPainter, TextSpan, TextDirection, SelectableText, Padding, Widget, Navigator, ValueKey, FontWeight, VoidCallback; +import 'package:flutter/material.dart' as material show AlertDialog, BoxConstraints, BuildContext, Column, ConstrainedBox, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color, LayoutBuilder, TextPainter, TextSpan, TextDirection, SelectableText, Padding, Widget, Navigator, ValueKey, FontWeight, VoidCallback, RepaintBoundary; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; @@ -309,8 +309,9 @@ class ConnectionsPanelState extends State { ), Divider(height: 1, color: theme.colorScheme.border.withValues(alpha: 0.22)), Expanded( - child: material.CustomScrollView( - slivers: [ + child: material.RepaintBoundary( + child: material.CustomScrollView( + slivers: [ material.SliverPadding( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 12), sliver: material.SliverToBoxAdapter( @@ -322,15 +323,13 @@ class ConnectionsPanelState extends State { for (final name in _folders) _FolderTile( name: name, - isExpanded: _expandedFolders.contains(name), - onToggle: () { - setState(() { - if (_expandedFolders.contains(name)) { - _expandedFolders.remove(name); - } else { - _expandedFolders.add(name); - } - }); + initiallyExpanded: _expandedFolders.contains(name), + onExpansionCommitted: (folderName, expanded) { + if (expanded) { + _expandedFolders.add(folderName); + } else { + _expandedFolders.remove(folderName); + } }, connections: _connections .where((c) => c.folderId == _folderIdByName[name]) @@ -396,6 +395,7 @@ class ConnectionsPanelState extends State { ), ), ], + ), ), ), ], @@ -531,11 +531,11 @@ class _ConnectionTile extends StatelessWidget { } } -class _FolderTile extends StatelessWidget { +class _FolderTile extends StatefulWidget { const _FolderTile({ required this.name, - required this.isExpanded, - required this.onToggle, + required this.initiallyExpanded, + required this.onExpansionCommitted, required this.connections, required this.onRemove, required this.onNewConnection, @@ -548,8 +548,8 @@ class _FolderTile extends StatelessWidget { }); final String name; - final bool isExpanded; - final VoidCallback onToggle; + final bool initiallyExpanded; + final void Function(String folderName, bool expanded) onExpansionCommitted; final List connections; final VoidCallback onRemove; final void Function(String folderName) onNewConnection; @@ -560,6 +560,33 @@ class _FolderTile extends StatelessWidget { final void Function(ConnectionRow connection, String database)? onMongoDBDatabaseTap; final Widget Function(ConnectionRow conn)? buildConnectionTile; + @override + State<_FolderTile> createState() => _FolderTileState(); +} + +class _FolderTileState extends State<_FolderTile> { + late bool _expanded; + + @override + void initState() { + super.initState(); + _expanded = widget.initiallyExpanded; + } + + @override + void didUpdateWidget(_FolderTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.name != oldWidget.name || + widget.initiallyExpanded != oldWidget.initiallyExpanded) { + _expanded = widget.initiallyExpanded; + } + } + + void _toggle() { + setState(() => _expanded = !_expanded); + widget.onExpansionCommitted(widget.name, _expanded); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -567,12 +594,12 @@ class _FolderTile extends StatelessWidget { items: [ MenuButton( leading: material.Icon(material.Icons.settings_ethernet_rounded, size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (menuContext) => onNewConnection(name), + onPressed: (menuContext) => widget.onNewConnection(widget.name), child: const Text('New connection'), ), MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), - onPressed: (_) => onRemove(), + onPressed: (_) => widget.onRemove(), child: const Text('Remove folder'), ), ], @@ -585,15 +612,15 @@ class _FolderTile extends StatelessWidget { material.MouseRegion( cursor: material.SystemMouseCursors.click, child: material.InkWell( - onTap: onToggle, + 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: isExpanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 100), child: material.Icon( material.Icons.chevron_right_rounded, size: 18, @@ -605,7 +632,7 @@ class _FolderTile extends StatelessWidget { const Gap(8), material.Expanded( child: material.Text( - name, + widget.name, overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( @@ -619,19 +646,18 @@ class _FolderTile extends StatelessWidget { ), ), ), - // Show connections inside this folder when expanded - if (isExpanded) - for (final conn in connections) + if (_expanded) + for (final conn in widget.connections) material.Padding( padding: const material.EdgeInsets.only(left: 24), - child: buildConnectionTile != null - ? buildConnectionTile!(conn) + child: widget.buildConnectionTile != null + ? widget.buildConnectionTile!(conn) : _ConnectionTile( connection: conn, - icon: iconForType(conn.type), + icon: widget.iconForType(conn.type), iconAsset: ConnectionsPanelState._iconAssetForType(conn.type), - onRemove: () => onRemoveConnection(conn.id!), - onTap: () => onConnectionTap?.call(conn), + onRemove: () => widget.onRemoveConnection(conn.id!), + onTap: () => widget.onConnectionTap?.call(conn), ), ), ], @@ -789,7 +815,7 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), + duration: const Duration(milliseconds: 100), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, @@ -1121,7 +1147,7 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), + duration: const Duration(milliseconds: 100), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, @@ -1431,7 +1457,7 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), + duration: const Duration(milliseconds: 100), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, @@ -1672,7 +1698,7 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), + duration: const Duration(milliseconds: 100), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, @@ -1915,7 +1941,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { label: widget.databaseName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), + duration: const Duration(milliseconds: 100), child: material.Icon( material.Icons.chevron_right_rounded, size: 14, @@ -2262,7 +2288,7 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { label: 'Databases (${widget.databases.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), + duration: const Duration(milliseconds: 100), child: material.Icon( material.Icons.chevron_right_rounded, size: 14, @@ -2371,7 +2397,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { label: widget.databaseName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), + duration: const Duration(milliseconds: 100), child: material.Icon( material.Icons.chevron_right_rounded, size: 14, @@ -2552,7 +2578,7 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { label: 'Schemas (${widget.schemas.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), + duration: const Duration(milliseconds: 100), child: material.Icon( material.Icons.chevron_right_rounded, size: 14, @@ -2686,7 +2712,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { label: widget.schemaName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), + duration: const Duration(milliseconds: 100), child: material.Icon( material.Icons.chevron_right_rounded, size: 14, @@ -2955,7 +2981,7 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { label: '${widget.label} (${widget.items.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: const Duration(milliseconds: 150), + duration: const Duration(milliseconds: 100), child: material.Icon( material.Icons.chevron_right_rounded, size: 13, diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index ac394acd..17a8a22c 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -2,7 +2,19 @@ import 'dart:math' as math; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material - show Scaffold, Container, MainAxisSize, GestureDetector, MouseRegion, SystemMouseCursors, HitTestBehavior, Icons, Icon, BuildContext, Widget; + show + Scaffold, + Container, + MainAxisSize, + GestureDetector, + MouseRegion, + SystemMouseCursors, + HitTestBehavior, + Icons, + Icon, + BuildContext, + Widget, + RepaintBoundary; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/core/theme/querya_colors.dart'; @@ -13,6 +25,7 @@ import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'main_screen_workspace_state.dart'; import 'workspace_panel.dart'; class MainScreen extends StatefulWidget { @@ -26,43 +39,17 @@ class _MainScreenState extends State { final GlobalKey _connectionsPanelKey = GlobalKey(); - static const double _minLeftWidth = 180; - static const double _maxLeftWidth = 500; - /// Minimum width reserved for workspace (avoid Row overflow when window is narrow). - static const double _minWorkspaceWidth = 64; - static const double _resizeHandleWidth = 6; - double _leftPanelWidth = 260; - - /// Currently selected connection (null = no connection selected). - ConnectionRow? _activeConnection; - - /// Currently selected Redis database (null = show stats). - int? _activeRedisDb; - - /// Currently selected MongoDB database (null = show stats). - String? _activeMongoDB; - - /// When set, user selected a PostgreSQL object in the tree. - ({String database, String schema, String name, PostgresObjectKind kind})? - _selectedPostgresObject; - - /// Bumped to tell [PostgresWorkspaceHome] to switch to the SQL tab. - int _postgresSqlTabRequestToken = 0; + final ValueNotifier _workspace = + ValueNotifier(MainScreenWorkspaceState.empty); - /// When set, user selected a MySQL table or view in the tree. - ({String database, String name, MysqlObjectKind kind})? _selectedMysqlObject; - - /// Bumped to tell [MysqlWorkspaceHome] to switch to the SQL tab. - int _mysqlSqlTabRequestToken = 0; + @override + void dispose() { + _workspace.dispose(); + super.dispose(); + } void _onConnectionSelected(ConnectionRow connection) { - setState(() { - _activeConnection = connection; - _activeRedisDb = null; - _activeMongoDB = null; - _selectedPostgresObject = null; - _selectedMysqlObject = null; - }); + _workspace.value = _workspace.value.selectConnection(connection); } void _onPostgresObjectSelected( @@ -72,18 +59,13 @@ class _MainScreenState extends State { String name, PostgresObjectKind kind, ) { - setState(() { - _activeConnection = connection; - _activeRedisDb = null; - _activeMongoDB = null; - _selectedMysqlObject = null; - _selectedPostgresObject = ( - database: database, - schema: schema, - name: name, - kind: kind, - ); - }); + _workspace.value = _workspace.value.selectPostgresObject( + connection, + database, + schema, + name, + kind, + ); } void _onMysqlObjectSelected( @@ -92,59 +74,28 @@ class _MainScreenState extends State { String name, MysqlObjectKind kind, ) { - setState(() { - _activeConnection = connection; - _activeRedisDb = null; - _activeMongoDB = null; - _selectedPostgresObject = null; - _selectedMysqlObject = ( - database: database, - name: name, - kind: kind, - ); - }); + _workspace.value = _workspace.value.selectMysqlObject( + connection, + database, + name, + kind, + ); } void _onRedisDatabaseSelected(ConnectionRow connection, int database) { - setState(() { - _activeConnection = connection; - _activeRedisDb = database; - _activeMongoDB = null; - _selectedPostgresObject = null; - _selectedMysqlObject = null; - }); + _workspace.value = _workspace.value.selectRedisDb(connection, database); } void _onMongoDBDatabaseSelected(ConnectionRow connection, String database) { - setState(() { - _activeConnection = connection; - _activeRedisDb = null; - _activeMongoDB = database; - _selectedPostgresObject = null; - _selectedMysqlObject = null; - }); + _workspace.value = _workspace.value.selectMongoDb(connection, database); } void _onPostgresOpenSqlWorkspace(ConnectionRow connection) { - setState(() { - _activeConnection = connection; - _activeRedisDb = null; - _activeMongoDB = null; - _selectedPostgresObject = null; - _selectedMysqlObject = null; - _postgresSqlTabRequestToken++; - }); + _workspace.value = _workspace.value.openPostgresSqlWorkspace(connection); } void _onMysqlOpenSqlWorkspace(ConnectionRow connection) { - setState(() { - _activeConnection = connection; - _activeRedisDb = null; - _activeMongoDB = null; - _selectedPostgresObject = null; - _selectedMysqlObject = null; - _mysqlSqlTabRequestToken++; - }); + _workspace.value = _workspace.value.openMysqlSqlWorkspace(connection); } Future _openNewConnectionFromHero() async { @@ -154,7 +105,8 @@ class _MainScreenState extends State { await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); } - Future _onNewDatabaseConnectionFromMenu(material.BuildContext menuContext) async { + Future _onNewDatabaseConnectionFromMenu( + material.BuildContext menuContext) async { final row = await promptCreateConnection(menuContext, folderId: null); if (!mounted || row == null) return; await LocalDb.instance.addConnection(row); @@ -179,75 +131,17 @@ class _MainScreenState extends State { ), Divider(height: 1, color: theme.border.withValues(alpha: 0.22)), Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final maxLeft = constraints.maxWidth - - _resizeHandleWidth - - _minWorkspaceWidth; - double leftW; - if (maxLeft <= 0) { - leftW = 0; - } else if (maxLeft < _minLeftWidth) { - leftW = maxLeft; - } else { - leftW = _leftPanelWidth.clamp( - _minLeftWidth, - math.min(_maxLeftWidth, maxLeft), - ); - } - return Row( - children: [ - SizedBox( - width: leftW, - child: ConnectionsPanel( - key: _connectionsPanelKey, - selectedConnectionId: _activeConnection?.id, - onConnectionSelected: _onConnectionSelected, - onRedisDatabaseSelected: _onRedisDatabaseSelected, - onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, - onPostgresObjectSelected: _onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, - onMysqlObjectSelected: _onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: _onMysqlOpenSqlWorkspace, - ), - ), - _VerticalResizeHandle( - onDrag: (dx) { - setState(() { - final w = MediaQuery.sizeOf(context).width; - final ml = w - - _resizeHandleWidth - - _minWorkspaceWidth; - if (ml <= 0) return; - final next = _leftPanelWidth + dx; - if (ml < _minLeftWidth) { - _leftPanelWidth = next.clamp(0, ml); - } else { - _leftPanelWidth = next.clamp( - _minLeftWidth, - math.min(_maxLeftWidth, ml), - ); - } - }); - }, - ), - Expanded( - child: WorkspacePanel( - activeConnection: _activeConnection, - selectedRedisDb: _activeRedisDb, - selectedMongoDb: _activeMongoDB, - selectedPostgresObject: _selectedPostgresObject, - postgresSqlTabRequestToken: _postgresSqlTabRequestToken, - selectedMysqlObject: _selectedMysqlObject, - mysqlSqlTabRequestToken: _mysqlSqlTabRequestToken, - onRequestNewConnection: () { - _openNewConnectionFromHero(); - }, - ), - ), - ], - ); - }, + child: _MainContentSplit( + connectionsPanelKey: _connectionsPanelKey, + workspace: _workspace, + onConnectionSelected: _onConnectionSelected, + onPostgresObjectSelected: _onPostgresObjectSelected, + onMysqlObjectSelected: _onMysqlObjectSelected, + onRedisDatabaseSelected: _onRedisDatabaseSelected, + onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, + onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, + onMysqlOpenSqlWorkspace: _onMysqlOpenSqlWorkspace, + onRequestNewConnection: _openNewConnectionFromHero, ), ), ], @@ -258,6 +152,142 @@ class _MainScreenState extends State { } } +/// Owns splitter width so resizing does not rebuild [MainScreen] or title bar. +class _MainContentSplit extends StatefulWidget { + const _MainContentSplit({ + required this.connectionsPanelKey, + required this.workspace, + required this.onConnectionSelected, + required this.onPostgresObjectSelected, + required this.onMysqlObjectSelected, + required this.onRedisDatabaseSelected, + required this.onMongoDBDatabaseSelected, + required this.onPostgresOpenSqlWorkspace, + required this.onMysqlOpenSqlWorkspace, + required this.onRequestNewConnection, + }); + + final GlobalKey connectionsPanelKey; + final ValueNotifier workspace; + final void Function(ConnectionRow) onConnectionSelected; + final void Function( + ConnectionRow, + String database, + String schema, + String name, + PostgresObjectKind kind, + ) onPostgresObjectSelected; + final void Function( + ConnectionRow, + String database, + String name, + MysqlObjectKind kind, + ) onMysqlObjectSelected; + final void Function(ConnectionRow, int) onRedisDatabaseSelected; + final void Function(ConnectionRow, String) onMongoDBDatabaseSelected; + final void Function(ConnectionRow) onPostgresOpenSqlWorkspace; + final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; + final VoidCallback onRequestNewConnection; + + @override + State<_MainContentSplit> createState() => _MainContentSplitState(); +} + +class _MainContentSplitState extends State<_MainContentSplit> { + static const double _minLeftWidth = 180; + static const double _maxLeftWidth = 500; + static const double _minWorkspaceWidth = 64; + static const double _resizeHandleWidth = 6; + double _leftPanelWidth = 260; + + @override + material.Widget build(material.BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final maxLeft = + constraints.maxWidth - _resizeHandleWidth - _minWorkspaceWidth; + double leftW; + if (maxLeft <= 0) { + leftW = 0; + } else if (maxLeft < _minLeftWidth) { + leftW = maxLeft; + } else { + leftW = _leftPanelWidth.clamp( + _minLeftWidth, + math.min(_maxLeftWidth, maxLeft), + ); + } + return Row( + children: [ + SizedBox( + width: leftW, + child: material.RepaintBoundary( + child: ValueListenableBuilder( + valueListenable: widget.workspace, + builder: (context, ws, _) { + return ConnectionsPanel( + key: widget.connectionsPanelKey, + selectedConnectionId: ws.activeConnection?.id, + onConnectionSelected: widget.onConnectionSelected, + onRedisDatabaseSelected: widget.onRedisDatabaseSelected, + onMongoDBDatabaseSelected: + widget.onMongoDBDatabaseSelected, + onPostgresObjectSelected: + widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: + widget.onPostgresOpenSqlWorkspace, + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + ); + }, + ), + ), + ), + _VerticalResizeHandle( + onDrag: (dx) { + setState(() { + final w = MediaQuery.sizeOf(context).width; + final ml = w - _resizeHandleWidth - _minWorkspaceWidth; + if (ml <= 0) return; + final next = _leftPanelWidth + dx; + if (ml < _minLeftWidth) { + _leftPanelWidth = next.clamp(0, ml); + } else { + _leftPanelWidth = next.clamp( + _minLeftWidth, + math.min(_maxLeftWidth, ml), + ); + } + }); + }, + ), + Expanded( + child: material.RepaintBoundary( + child: ValueListenableBuilder( + valueListenable: widget.workspace, + builder: (context, ws, _) { + return WorkspacePanel( + activeConnection: ws.activeConnection, + selectedRedisDb: ws.activeRedisDb, + selectedMongoDb: ws.activeMongoDB, + selectedPostgresObject: ws.selectedPostgresObject, + postgresSqlTabRequestToken: + ws.postgresSqlTabRequestToken, + selectedMysqlObject: ws.selectedMysqlObject, + mysqlSqlTabRequestToken: ws.mysqlSqlTabRequestToken, + onRequestNewConnection: widget.onRequestNewConnection, + ); + }, + ), + ), + ), + ], + ); + }, + ); + } +} + class _VerticalResizeHandle extends StatelessWidget { const _VerticalResizeHandle({required this.onDrag}); @@ -287,7 +317,8 @@ class _CustomTitleBar extends StatefulWidget { }); final ColorScheme theme; - final Future Function(material.BuildContext context) onNewDatabaseConnection; + final Future Function(material.BuildContext context) + onNewDatabaseConnection; @override State<_CustomTitleBar> createState() => _CustomTitleBarState(); @@ -337,11 +368,16 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { children: [ MenuButton( subMenu: [ - MenuButton(onPressed: (_) {}, child: const Text('New')), - MenuButton(onPressed: (_) {}, child: const Text('Open...')), - MenuButton(onPressed: (_) {}, child: const Text('Save')), + MenuButton( + onPressed: (_) {}, child: const Text('New')), + MenuButton( + onPressed: (_) {}, + child: const Text('Open...')), + MenuButton( + onPressed: (_) {}, child: const Text('Save')), const MenuDivider(), - MenuButton(onPressed: (_) {}, child: const Text('Exit')), + MenuButton( + onPressed: (_) {}, child: const Text('Exit')), ], child: const Text('File'), ), @@ -361,43 +397,57 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { MenuButton( subMenu: [ MenuButton( - leading: const material.Icon(material.Icons.add_link_rounded, size: 18), - trailing: const Text('Shift+Ctrl+N').xSmall().muted(), - onPressed: (ctx) => widget.onNewDatabaseConnection(ctx), + leading: const material.Icon( + material.Icons.add_link_rounded, size: 18), + trailing: + const Text('Shift+Ctrl+N').xSmall().muted(), + onPressed: (ctx) => + widget.onNewDatabaseConnection(ctx), child: const Text('New Database Connection'), ), MenuButton( - leading: const material.Icon(material.Icons.link_rounded, size: 18), + leading: const material.Icon( + material.Icons.link_rounded, size: 18), onPressed: (_) {}, child: const Text('New Connection from URL'), ), MenuButton( - leading: const material.Icon(material.Icons.settings_rounded, size: 18), + leading: const material.Icon( + material.Icons.settings_rounded, size: 18), onPressed: (ctx) => showDriverManagerDialog(ctx), child: const Text('Driver Manager'), ), const MenuDivider(), MenuButton( enabled: false, - leading: const material.Icon(material.Icons.power_rounded, size: 18), + leading: const material.Icon( + material.Icons.power_rounded, size: 18), onPressed: (_) {}, child: const Text('Connect'), ), MenuButton( - leading: const material.Icon(material.Icons.refresh_rounded, size: 18), + leading: const material.Icon( + material.Icons.refresh_rounded, size: 18), onPressed: (_) {}, child: const Text('Invalidate/Reconnect'), ), MenuButton( - leading: const material.Icon(material.Icons.power_off_rounded, size: 18), + leading: const material.Icon( + material.Icons.power_off_rounded, size: 18), onPressed: (_) {}, child: const Text('Disconnect'), ), - MenuButton(onPressed: (_) {}, child: const Text('Disconnect All')), - MenuButton(onPressed: (_) {}, child: const Text('Disconnect Others')), + MenuButton( + onPressed: (_) {}, + child: const Text('Disconnect All')), + MenuButton( + onPressed: (_) {}, + child: const Text('Disconnect Others')), const MenuDivider(), MenuButton( - leading: const material.Icon(material.Icons.lock_outline_rounded, size: 18), + leading: const material.Icon( + material.Icons.lock_outline_rounded, + size: 18), onPressed: (_) {}, child: const Text('Read-only'), ), @@ -406,8 +456,11 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { ), MenuButton( subMenu: [ - MenuButton(onPressed: (_) {}, child: const Text('About')), - MenuButton(onPressed: (_) {}, child: const Text('Documentation')), + MenuButton( + onPressed: (_) {}, child: const Text('About')), + MenuButton( + onPressed: (_) {}, + child: const Text('Documentation')), ], child: const Text('Help'), ), diff --git a/lib/features/main_screen/main_screen_workspace_state.dart b/lib/features/main_screen/main_screen_workspace_state.dart new file mode 100644 index 00000000..d3aecf01 --- /dev/null +++ b/lib/features/main_screen/main_screen_workspace_state.dart @@ -0,0 +1,193 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; + +/// Workspace / tree selection shown in [WorkspacePanel] and [ConnectionsPanel]. +@immutable +class MainScreenWorkspaceState { + const MainScreenWorkspaceState({ + this.activeConnection, + this.activeRedisDb, + this.activeMongoDB, + this.selectedPostgresObject, + this.postgresSqlTabRequestToken = 0, + this.selectedMysqlObject, + this.mysqlSqlTabRequestToken = 0, + }); + + final ConnectionRow? activeConnection; + final int? activeRedisDb; + final String? activeMongoDB; + final ({String database, String schema, String name, PostgresObjectKind kind})? + selectedPostgresObject; + final int postgresSqlTabRequestToken; + final ({String database, String name, MysqlObjectKind kind})? + selectedMysqlObject; + final int mysqlSqlTabRequestToken; + + static const empty = MainScreenWorkspaceState(); + + MainScreenWorkspaceState selectConnection(ConnectionRow connection) { + return MainScreenWorkspaceState( + activeConnection: connection, + activeRedisDb: null, + activeMongoDB: null, + selectedPostgresObject: null, + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + selectedMysqlObject: null, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + ); + } + + MainScreenWorkspaceState selectPostgresObject( + ConnectionRow connection, + String database, + String schema, + String name, + PostgresObjectKind kind, + ) { + return MainScreenWorkspaceState( + activeConnection: connection, + activeRedisDb: null, + activeMongoDB: null, + selectedPostgresObject: ( + database: database, + schema: schema, + name: name, + kind: kind, + ), + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + selectedMysqlObject: null, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + ); + } + + MainScreenWorkspaceState selectMysqlObject( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + ) { + return MainScreenWorkspaceState( + activeConnection: connection, + activeRedisDb: null, + activeMongoDB: null, + selectedPostgresObject: null, + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + selectedMysqlObject: ( + database: database, + name: name, + kind: kind, + ), + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + ); + } + + MainScreenWorkspaceState selectRedisDb(ConnectionRow connection, int db) { + return MainScreenWorkspaceState( + activeConnection: connection, + activeRedisDb: db, + activeMongoDB: null, + selectedPostgresObject: null, + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + selectedMysqlObject: null, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + ); + } + + MainScreenWorkspaceState selectMongoDb( + ConnectionRow connection, String database) { + return MainScreenWorkspaceState( + activeConnection: connection, + activeRedisDb: null, + activeMongoDB: database, + selectedPostgresObject: null, + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + selectedMysqlObject: null, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + ); + } + + MainScreenWorkspaceState openPostgresSqlWorkspace(ConnectionRow connection) { + return MainScreenWorkspaceState( + activeConnection: connection, + activeRedisDb: null, + activeMongoDB: null, + selectedPostgresObject: null, + postgresSqlTabRequestToken: postgresSqlTabRequestToken + 1, + selectedMysqlObject: null, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + ); + } + + MainScreenWorkspaceState openMysqlSqlWorkspace(ConnectionRow connection) { + return MainScreenWorkspaceState( + activeConnection: connection, + activeRedisDb: null, + activeMongoDB: null, + selectedPostgresObject: null, + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + selectedMysqlObject: null, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken + 1, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is MainScreenWorkspaceState && + activeConnection?.id == other.activeConnection?.id && + activeRedisDb == other.activeRedisDb && + activeMongoDB == other.activeMongoDB && + _pgEquals(selectedPostgresObject, other.selectedPostgresObject) && + postgresSqlTabRequestToken == other.postgresSqlTabRequestToken && + _mysqlEquals(selectedMysqlObject, other.selectedMysqlObject) && + mysqlSqlTabRequestToken == other.mysqlSqlTabRequestToken; + } + + @override + int get hashCode => Object.hash( + activeConnection?.id, + activeRedisDb, + activeMongoDB, + selectedPostgresObject == null + ? 0 + : Object.hash( + selectedPostgresObject!.database, + selectedPostgresObject!.schema, + selectedPostgresObject!.name, + selectedPostgresObject!.kind, + ), + postgresSqlTabRequestToken, + selectedMysqlObject == null + ? 0 + : Object.hash( + selectedMysqlObject!.database, + selectedMysqlObject!.name, + selectedMysqlObject!.kind, + ), + mysqlSqlTabRequestToken, + ); +} + +bool _pgEquals( + ({String database, String schema, String name, PostgresObjectKind kind})? a, + ({String database, String schema, String name, PostgresObjectKind kind})? b, +) { + if (identical(a, b)) return true; + if (a == null || b == null) return false; + return a.database == b.database && + a.schema == b.schema && + a.name == b.name && + a.kind == b.kind; +} + +bool _mysqlEquals( + ({String database, String name, MysqlObjectKind kind})? a, + ({String database, String name, MysqlObjectKind kind})? b, +) { + if (identical(a, b)) return true; + if (a == null || b == null) return false; + return a.database == b.database && a.name == b.name && a.kind == b.kind; +} diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index beee2950..5e0bd9f7 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -470,7 +470,7 @@ class _TabButtonState extends State<_TabButton> { child: material.GestureDetector( onTap: widget.onTap, child: material.AnimatedContainer( - duration: const Duration(milliseconds: 150), + duration: const Duration(milliseconds: 120), curve: material.Curves.easeOut, padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: material.BoxDecoration( @@ -505,7 +505,7 @@ class _RunButtonState extends State<_RunButton> { cursor: material.SystemMouseCursors.click, child: material.AnimatedScale( scale: _hovered ? 1.03 : 1.0, - duration: const Duration(milliseconds: 120), + duration: const Duration(milliseconds: 100), curve: material.Curves.easeOut, child: OutlineButton( onPressed: () {}, diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 4594365b..76f907ed 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -233,7 +233,7 @@ class _MongoCollectionsViewState extends material.State { ); } - return material.SingleChildScrollView( + return material.Padding( padding: const material.EdgeInsets.all(24), child: _buildCard(cs), ); @@ -248,9 +248,9 @@ class _MongoCollectionsViewState extends material.State { border: material.Border.all( color: cs.border.withValues(alpha: 0.4), width: 1), ), + clipBehavior: material.Clip.antiAlias, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, children: [ // Card header material.Container( @@ -329,27 +329,31 @@ class _MongoCollectionsViewState extends material.State { ], ), ), - // Collection rows - for (var i = 0; i < _collections.length; i++) ...[ - if (i > 0) - Divider( - height: 1, - color: cs.border.withValues(alpha: 0.15)), - _CollectionRow( - collection: _collections[i], - colorScheme: cs, - onView: () => - widget.onCollectionTap?.call(_collections[i].name), - onDrop: () => _dropCollection(_collections[i].name), - ), - ], - if (_collections.isEmpty) - material.Padding( - padding: const material.EdgeInsets.all(24), - child: material.Center( - child: const Text('No collections found').muted(), - ), - ), + // Collection rows (virtualized) + material.Expanded( + child: _collections.isEmpty + ? material.Center( + child: const Text('No collections found').muted(), + ) + : material.ListView.separated( + cacheExtent: 400, + itemCount: _collections.length, + separatorBuilder: (_, __) => Divider( + height: 1, + color: cs.border.withValues(alpha: 0.15), + ), + itemBuilder: (context, i) { + return _CollectionRow( + collection: _collections[i], + colorScheme: cs, + onView: () => widget.onCollectionTap + ?.call(_collections[i].name), + onDrop: () => + _dropCollection(_collections[i].name), + ); + }, + ), + ), ], ), ); diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index c6f06344..e1d1a3f2 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -208,12 +208,33 @@ class _MongoDocumentsViewState extends material.State { const Divider(height: 1), // Error banner if (_error != null) _buildErrorBanner(cs), - // Document list + // Document list (virtualized) material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.all(16), - child: _buildDocumentCards(cs), - ), + child: _documents.isEmpty + ? material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(48), + child: const Text('No documents found').muted(), + ), + ) + : material.ListView.separated( + padding: const material.EdgeInsets.all(16), + cacheExtent: 400, + itemCount: _documents.length, + separatorBuilder: (_, __) => const Gap(8), + itemBuilder: (context, i) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; + return _DocumentCard( + document: _documents[i], + index: _skip + i, + colorScheme: cs, + shadcnCs: shadcnCs, + onView: () => + widget.onDocumentTap?.call(_documents[i]), + onDelete: () => _deleteDocument(_documents[i]), + ); + }, + ), ), // Pagination bar _buildPaginationBar(cs), @@ -290,36 +311,6 @@ class _MongoDocumentsViewState extends material.State { ); } - Widget _buildDocumentCards(ColorScheme cs) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; - if (_documents.isEmpty) { - return material.Center( - child: material.Padding( - padding: const material.EdgeInsets.all(48), - child: const Text('No documents found').muted(), - ), - ); - } - - return material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - for (var i = 0; i < _documents.length; i++) ...[ - if (i > 0) const Gap(8), - _DocumentCard( - document: _documents[i], - index: _skip + i, - colorScheme: cs, - shadcnCs: shadcnCs, - onView: () => - widget.onDocumentTap?.call(_documents[i]), - onDelete: () => _deleteDocument(_documents[i]), - ), - ], - ], - ); - } - Widget _buildPaginationBar(ColorScheme cs) { final shadcnCs = shadcn.Theme.of(context).colorScheme; final currentPage = (_skip / _limit).floor() + 1; diff --git a/lib/features/postgresql/postgres_browser_views.dart b/lib/features/postgresql/postgres_browser_views.dart index 680ca5d8..1e63af48 100644 --- a/lib/features/postgresql/postgres_browser_views.dart +++ b/lib/features/postgresql/postgres_browser_views.dart @@ -125,58 +125,62 @@ class _PostgresIndexListViewState extends material.State child: material.Scrollbar( controller: _scroll, thumbVisibility: true, - child: material.SingleChildScrollView( + child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - for (final r in _rows) ...[ - material.Container( - margin: const material.EdgeInsets.only(bottom: 12), - padding: const material.EdgeInsets.all(12), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.12), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.35)), - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Text( - '${r.tableName} · ${r.indexName}', - style: material.TextStyle( - fontWeight: material.FontWeight.w600, - fontSize: 12, - color: cs.foreground, - ), + cacheExtent: 400, + itemCount: _rows.isEmpty ? 1 : _rows.length, + itemBuilder: (context, i) { + if (_rows.isEmpty) { + return const Text('No indexes in this schema.') + .muted() + .small(); + } + final r = _rows[i]; + return material.Padding( + padding: material.EdgeInsets.only( + bottom: i < _rows.length - 1 ? 12 : 0), + child: material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.35)), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Text( + '${r.tableName} · ${r.indexName}', + style: material.TextStyle( + fontWeight: material.FontWeight.w600, + fontSize: 12, + color: cs.foreground, ), - material.Text( - 'Size: ${_formatBytes(r.sizeBytes)}', - style: material.TextStyle( - fontSize: 11, - color: cs.mutedForeground, - ), + ), + material.Text( + 'Size: ${_formatBytes(r.sizeBytes)}', + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, ), - const Gap(8), - material.SelectableText( - r.indexDef, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 11, - height: 1.4, - color: cs.foreground, - ), + ), + const Gap(8), + material.SelectableText( + r.indexDef, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 11, + height: 1.4, + color: cs.foreground, ), - ], - ), + ), + ], ), - ], - if (_rows.isEmpty) - const Text('No indexes in this schema.').muted().small(), - ], - ), + ), + ); + }, ), ), ), @@ -300,51 +304,55 @@ class _PostgresTriggerListViewState extends material.State { child: material.Scrollbar( controller: _scroll, thumbVisibility: true, - child: material.SingleChildScrollView( + child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - for (final r in _rows) - material.Padding( - padding: const material.EdgeInsets.symmetric(vertical: 4), - child: material.Row( - children: [ - material.Expanded( - child: material.Text( - r.name, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: cs.foreground, - ), - ), + cacheExtent: 400, + itemCount: _rows.isEmpty ? 1 : _rows.length, + itemBuilder: (context, i) { + if (_rows.isEmpty) { + return const Text('No user types in this schema.') + .muted() + .small(); + } + final r = _rows[i]; + return material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 4), + child: material.Row( + children: [ + material.Expanded( + child: material.Text( + r.name, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: cs.foreground, ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, vertical: 2), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.35), - borderRadius: material.BorderRadius.circular(4), - ), - child: Text(r.kind).xSmall().muted(), - ), - ], + ), ), - ), - if (_rows.isEmpty) - const Text('No user types in this schema.').muted().small(), - ], - ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + borderRadius: material.BorderRadius.circular(4), + ), + child: Text(r.kind).xSmall().muted(), + ), + ], + ), + ); + }, ), ), ), @@ -610,39 +620,39 @@ class _PostgresExtensionListViewState child: material.Scrollbar( controller: _scroll, thumbVisibility: true, - child: material.SingleChildScrollView( + child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - for (final r in _rows) - material.Padding( - padding: const material.EdgeInsets.symmetric(vertical: 6), - child: material.Row( - children: [ - material.Icon(material.Icons.extension_rounded, - size: 16, color: cs.primary), - const Gap(8), - material.Expanded( - child: material.Text( - r.name, - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - fontWeight: material.FontWeight.w600, - color: cs.foreground, - ), - ), + cacheExtent: 400, + itemCount: _rows.isEmpty ? 1 : _rows.length, + itemBuilder: (context, i) { + if (_rows.isEmpty) { + return const Text('No extensions installed.').muted().small(); + } + final r = _rows[i]; + return material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 6), + child: material.Row( + children: [ + material.Icon(material.Icons.extension_rounded, + size: 16, color: cs.primary), + const Gap(8), + material.Expanded( + child: material.Text( + r.name, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: cs.foreground, ), - Text('v${r.version}').muted().xSmall(), - ], + ), ), - ), - if (_rows.isEmpty) - const Text('No extensions installed.').muted().small(), - ], - ), + Text('v${r.version}').muted().xSmall(), + ], + ), + ); + }, ), ), ), @@ -747,6 +757,13 @@ class _PostgresFdwListViewState extends material.State { style: material.TextStyle(color: cs.destructive)), ); } + final fdwBody = _fdws.isEmpty ? 1 : _fdws.length; + const fdwHeaderCount = 1; + final fdwEnd = fdwHeaderCount + fdwBody; + const srvHeaderCount = 1; + final srvBody = _servers.isEmpty ? 1 : _servers.length; + final totalItems = fdwEnd + srvHeaderCount + srvBody; + return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ @@ -759,47 +776,68 @@ class _PostgresFdwListViewState extends material.State { child: material.Scrollbar( controller: _scroll, thumbVisibility: true, - child: material.SingleChildScrollView( + child: material.ListView.builder( controller: _scroll, padding: const material.EdgeInsets.all(16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - const Text('Foreign-data wrappers').small().semiBold(), - const Gap(8), - for (final r in _fdws) - material.Padding( - padding: const material.EdgeInsets.only(bottom: 8), - child: material.Text( - '${r.name}${r.handler != null ? ' · ${r.handler}' : ''}', - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: cs.foreground, - ), + cacheExtent: 400, + itemCount: totalItems, + itemBuilder: (context, i) { + if (i == 0) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Foreign-data wrappers').small().semiBold(), + const Gap(8), + ], + ); + } + if (i < fdwEnd) { + if (_fdws.isEmpty) { + return const Text('No foreign-data wrappers.') + .muted() + .xSmall(); + } + final r = _fdws[i - fdwHeaderCount]; + return material.Padding( + padding: const material.EdgeInsets.only(bottom: 8), + child: material.Text( + '${r.name}${r.handler != null ? ' · ${r.handler}' : ''}', + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: cs.foreground, ), ), - if (_fdws.isEmpty) - const Text('No foreign-data wrappers.').muted().xSmall(), - const Gap(24), - const Text('Foreign servers').small().semiBold(), - const Gap(8), - for (final r in _servers) - material.Padding( - padding: const material.EdgeInsets.only(bottom: 8), - child: material.Text( - '${r.serverName} → ${r.fdwName}', - style: material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: cs.foreground, - ), - ), + ); + } + if (i == fdwEnd) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Gap(24), + const Text('Foreign servers').small().semiBold(), + const Gap(8), + ], + ); + } + if (_servers.isEmpty) { + return const Text('No foreign servers.').muted().xSmall(); + } + final r = _servers[i - fdwEnd - srvHeaderCount]; + return material.Padding( + padding: const material.EdgeInsets.only(bottom: 8), + child: material.Text( + '${r.serverName} → ${r.fdwName}', + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: cs.foreground, ), - if (_servers.isEmpty) - const Text('No foreign servers.').muted().xSmall(), - ], - ), + ), + ); + }, ), ), ), diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 5115aefd..4c8483ae 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -8,8 +8,8 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _pollInterval = Duration(seconds: 5); -const _summaryChipHeight = 72.0; -const _gridCardHeight = 220.0; +const _summaryChipHeight = 88.0; +const _gridCardMinHeight = 220.0; class PostgresStatsView extends material.StatefulWidget { const PostgresStatsView({ @@ -307,8 +307,10 @@ class _PostgresStatsViewState extends material.State { material.Widget chip( String label, String value, material.IconData icon) { return material.Expanded( - child: material.SizedBox( - height: _summaryChipHeight, + child: material.ConstrainedBox( + constraints: const material.BoxConstraints( + minHeight: _summaryChipHeight, + ), child: material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 16, vertical: 12), @@ -319,8 +321,12 @@ class _PostgresStatsViewState extends material.State { color: cs.border.withValues(alpha: 0.5)), ), child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - material.Icon(icon, size: 20, color: cs.primary), + material.Padding( + padding: const material.EdgeInsets.only(top: 2), + child: material.Icon(icon, size: 20, color: cs.primary), + ), const Gap(12), material.Expanded( child: material.Column( @@ -330,7 +336,17 @@ class _PostgresStatsViewState extends material.State { children: [ Text(label).muted().xSmall(), const Gap(2), - Text(value).semiBold().small(), + material.Text( + value, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), ], ), ), @@ -357,11 +373,13 @@ class _PostgresStatsViewState extends material.State { material.Widget _card(material.BuildContext context, String title, material.Widget body, - {double? height}) { + {double? minHeight}) { final cs = shadcn.Theme.of(context).colorScheme; return material.Container( width: double.infinity, - height: height, + constraints: minHeight != null + ? material.BoxConstraints(minHeight: minHeight) + : const material.BoxConstraints(), padding: const material.EdgeInsets.all(20), decoration: material.BoxDecoration( color: cs.card, @@ -401,7 +419,7 @@ class _PostgresStatsViewState extends material.State { _row(context, 'Max connections', maxConn), ], ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, ); } @@ -423,7 +441,7 @@ class _PostgresStatsViewState extends material.State { _row(context, 'Timezone', settings['timezone'] ?? '—'), ], ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, ); } diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 27980faf..8cbcf4ba 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -165,10 +165,47 @@ class _RedisKeysViewState extends material.State { const Divider(height: 1), if (_error != null) _buildErrorBanner(cs), material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.all(16), - child: _buildKeysList(cs), - ), + child: _keys.isEmpty + ? material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(48), + child: const Text('No keys found').muted(), + ), + ) + : material.ListView.builder( + padding: const material.EdgeInsets.all(16), + cacheExtent: 400, + itemCount: _keys.length + (_hasMore ? 1 : 0), + itemBuilder: (context, i) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; + if (i >= _keys.length) { + return material.Padding( + padding: const material.EdgeInsets.only(top: 12), + child: material.Center( + child: OutlineButton( + onPressed: _loadingMore ? null : _loadMore, + size: ButtonSize.small, + child: _loadingMore + ? const Text('Loading...') + : Text( + 'Load more (${_keys.length} / $_dbSize)'), + ), + ), + ); + } + return material.Padding( + padding: EdgeInsets.only(top: i > 0 ? 4 : 0), + child: _KeyTile( + keyInfo: _keys[i], + colorScheme: cs, + shadcnCs: shadcnCs, + onTap: () => widget.onKeyTap?.call( + _keys[i].name, _keys[i].type), + onDelete: () => _deleteKey(_keys[i]), + ), + ); + }, + ), ), _buildStatusBar(cs), ], @@ -239,47 +276,6 @@ class _RedisKeysViewState extends material.State { ); } - Widget _buildKeysList(ColorScheme cs) { - final shadcnCs = shadcn.Theme.of(context).colorScheme; - if (_keys.isEmpty) { - return material.Center( - child: material.Padding( - padding: const material.EdgeInsets.all(48), - child: const Text('No keys found').muted(), - ), - ); - } - - return material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - for (var i = 0; i < _keys.length; i++) ...[ - if (i > 0) const Gap(4), - _KeyTile( - keyInfo: _keys[i], - colorScheme: cs, - shadcnCs: shadcnCs, - onTap: () => - widget.onKeyTap?.call(_keys[i].name, _keys[i].type), - onDelete: () => _deleteKey(_keys[i]), - ), - ], - if (_hasMore) ...[ - const Gap(12), - material.Center( - child: OutlineButton( - onPressed: _loadingMore ? null : _loadMore, - size: ButtonSize.small, - child: _loadingMore - ? const Text('Loading...') - : Text('Load more (${_keys.length} / $_dbSize)'), - ), - ), - ], - ], - ); - } - Widget _buildStatusBar(ColorScheme cs) { final shadcnCs = shadcn.Theme.of(context).colorScheme; return material.Container( diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart index cdfbbfa5..a5e42ac5 100644 --- a/lib/shared/widgets/app_dialog.dart +++ b/lib/shared/widgets/app_dialog.dart @@ -15,7 +15,7 @@ Future showAppDialog({ barrierDismissible: false, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, barrierColor: Colors.transparent, - transitionDuration: const Duration(milliseconds: 240), + transitionDuration: const Duration(milliseconds: 200), pageBuilder: (ctx, animation, secondaryAnimation) { return _BlurredDialogScaffold( barrierDismissible: barrierDismissible, @@ -67,7 +67,7 @@ class _BlurredDialogScaffold extends StatelessWidget { final t = curved.value; return ClipRect( child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10.0 * t, sigmaY: 10.0 * t), + filter: ImageFilter.blur(sigmaX: 8.0 * t, sigmaY: 8.0 * t), child: Container( color: Colors.black.withValues(alpha: 0.32 * t), ), diff --git a/pubspec.yaml b/pubspec.yaml index 72dce075..8b8306c8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.1.2+2 +version: 0.1.3+1 environment: diff --git a/test/features/connections/connections_panel_layout_test.dart b/test/features/connections/connections_panel_layout_test.dart index fadb91a7..13832ac5 100644 --- a/test/features/connections/connections_panel_layout_test.dart +++ b/test/features/connections/connections_panel_layout_test.dart @@ -225,6 +225,22 @@ void main() { _expectTextCount('PG local', 1); _expectTextCount('Redis local', 1); _expectTextCount('Mongo local', 1); + + // First-load folders start expanded; collapsing hides nested connections + // without a full-panel setState (local _FolderTile state). + await tester.tap(find.text('LayoutTestFolder')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 120)); + _expectTextCount('PG local', 0); + _expectTextCount('Redis local', 0); + _expectTextCount('Mongo local', 0); + + await tester.tap(find.text('LayoutTestFolder')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 120)); + _expectTextCount('PG local', 1); + _expectTextCount('Redis local', 1); + _expectTextCount('Mongo local', 1); }); }); } diff --git a/test/features/main_screen/main_screen_workspace_state_test.dart b/test/features/main_screen/main_screen_workspace_state_test.dart new file mode 100644 index 00000000..1c712e93 --- /dev/null +++ b/test/features/main_screen/main_screen_workspace_state_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/main_screen/main_screen_workspace_state.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; + +void main() { + final createdAt = DateTime.utc(2025).toIso8601String(); + final pgConn = ConnectionRow( + type: 'postgresql', + name: 'pg', + host: '127.0.0.1', + port: 5432, + createdAt: createdAt, + id: 10, + ); + final mysqlConn = ConnectionRow( + type: 'mysql', + name: 'my', + host: '127.0.0.1', + port: 3306, + createdAt: createdAt, + id: 11, + ); + + group('MainScreenWorkspaceState', () { + test('empty has no selection', () { + expect(MainScreenWorkspaceState.empty.activeConnection, isNull); + expect(MainScreenWorkspaceState.empty.selectedPostgresObject, isNull); + expect(MainScreenWorkspaceState.empty.selectedMysqlObject, isNull); + }); + + test('selectConnection clears db-specific selection', () { + final withPg = MainScreenWorkspaceState.empty.selectPostgresObject( + pgConn, + 'db', + 'public', + 't1', + PostgresObjectKind.table, + ); + final next = withPg.selectConnection(mysqlConn); + expect(next.activeConnection?.id, 11); + expect(next.selectedPostgresObject, isNull); + expect(next.activeRedisDb, isNull); + expect(next.activeMongoDB, isNull); + }); + + test('selectPostgresObject keeps connection and pg tuple', () { + final s = MainScreenWorkspaceState.empty.selectPostgresObject( + pgConn, + 'app', + 'public', + 'users', + PostgresObjectKind.table, + ); + expect(s.activeConnection?.id, 10); + expect(s.selectedPostgresObject?.name, 'users'); + expect(s.selectedPostgresObject?.kind, PostgresObjectKind.table); + }); + + test('openPostgresSqlWorkspace bumps token and clears selection', () { + final withObj = MainScreenWorkspaceState.empty.selectPostgresObject( + pgConn, + 'db', + 's', + 'fn', + PostgresObjectKind.function, + ); + final sql = withObj.openPostgresSqlWorkspace(pgConn); + expect(sql.postgresSqlTabRequestToken, 1); + expect(sql.selectedPostgresObject, isNull); + }); + + test('selectRedisDb and selectMongoDb are mutually exclusive fields', () { + final redis = MainScreenWorkspaceState.empty.selectRedisDb(pgConn, 3); + expect(redis.activeRedisDb, 3); + expect(redis.activeMongoDB, isNull); + final mongo = redis.selectMongoDb(pgConn, 'inventory'); + expect(mongo.activeMongoDB, 'inventory'); + expect(mongo.activeRedisDb, isNull); + }); + + test('equality uses connection id and selections', () { + final a = MainScreenWorkspaceState.empty.selectMysqlObject( + mysqlConn, + 'db1', + 'orders', + MysqlObjectKind.table, + ); + final b = MainScreenWorkspaceState.empty.selectMysqlObject( + mysqlConn, + 'db1', + 'orders', + MysqlObjectKind.table, + ); + expect(a, b); + final c = MainScreenWorkspaceState.empty.selectMysqlObject( + mysqlConn, + 'db1', + 'other', + MysqlObjectKind.table, + ); + expect(a, isNot(c)); + }); + }); +} diff --git a/test/features/redis/redis_keys_view_test.dart b/test/features/redis/redis_keys_view_test.dart new file mode 100644 index 00000000..dc8cd064 --- /dev/null +++ b/test/features/redis/redis_keys_view_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/redis/redis_keys_view.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('RedisKeysView lists keys from ListView after load', (tester) async { + final fake = RedisConnectionTestFake( + firstScanKeys: const ['key_a', 'key_b'], + dbSizeResult: 2, + ); + await fake.connect(); + + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: RedisKeysView( + connection: fake, + database: 0, + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsWidgets); + expect(find.text('key_a'), findsOneWidget); + expect(find.text('key_b'), findsOneWidget); + await fake.disconnect(); + }); + + testWidgets('RedisKeysView shows empty state when scan returns no keys', + (tester) async { + final fake = RedisConnectionTestFake( + firstScanKeys: const [], + dbSizeResult: 0, + ); + await fake.connect(); + + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.SizedBox( + width: 400, + height: 400, + child: RedisKeysView( + connection: fake, + database: 0, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('No keys found'), findsOneWidget); + await fake.disconnect(); + }); +} diff --git a/test/shared/app_dialog_test.dart b/test/shared/app_dialog_test.dart new file mode 100644 index 00000000..e4dc3e35 --- /dev/null +++ b/test/shared/app_dialog_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/app_dialog.dart'; + +void main() { + testWidgets('showAppDialog presents builder child and can be dismissed', + (tester) async { + late BuildContext ctx; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + ctx = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + final future = showAppDialog( + context: ctx, + barrierDismissible: true, + builder: (c) => const AlertDialog(title: Text('Dialog title')), + ); + + await tester.pump(); + expect(find.text('Dialog title'), findsOneWidget); + + Navigator.of(ctx, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + await future; + }); + + testWidgets('showAppDialog uses BackdropFilter on scaffold', (tester) async { + late BuildContext ctx; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + ctx = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + showAppDialog( + context: ctx, + builder: (c) => const SimpleDialog(title: Text('X')), + ); + await tester.pump(); + expect(find.byType(BackdropFilter), findsWidgets); + }); +}