diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 00000000..d3e34c34 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,19 @@ +# Copy to .env and adjust if host ports conflict with local services. +# cp .env.example .env + +POSTGRES_PORT=5432 +MYSQL_PORT=3306 +REDIS_PORT=6379 +MONGO_PORT=27017 + +POSTGRES_USER=querya +POSTGRES_PASSWORD=querya +POSTGRES_DB=querya + +MYSQL_ROOT_PASSWORD=querya +MYSQL_DATABASE=querya +MYSQL_USER=querya +MYSQL_PASSWORD=querya + +MONGO_INITDB_ROOT_USERNAME=querya +MONGO_INITDB_ROOT_PASSWORD=querya diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..fd0ad3d1 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,142 @@ +# Querya Desktop — local database stack for manual testing. +# +# Start: +# cd docker && docker compose up -d +# +# Stop and remove volumes (re-run init scripts on next up): +# docker compose down -v +# +# ── Connection cheat sheet (host: localhost) ───────────────────────────── +# PostgreSQL port 5432 db querya user querya password querya +# schemas: shop.* + database analytics +# MySQL port 3306 db querya user querya password querya +# extra database: analytics +# Redis port 6379 no auth keys prefix querya:* +# MongoDB port 27017 db querya user querya password querya +# auth source: admin collections: users, products, orders +# ───────────────────────────────────────────────────────────────────────── + +name: querya-dev + +services: + postgres: + image: postgres:16-alpine + container_name: querya-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-querya} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-querya} + POSTGRES_DB: ${POSTGRES_DB:-querya} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-querya} -d ${POSTGRES_DB:-querya}", + ] + interval: 5s + timeout: 5s + retries: 12 + + mysql: + image: mysql:8.4 + container_name: querya-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-querya} + MYSQL_DATABASE: ${MYSQL_DATABASE:-querya} + MYSQL_USER: ${MYSQL_USER:-querya} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-querya} + ports: + - "${MYSQL_PORT:-3306}:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./mysql/init:/docker-entrypoint-initdb.d:ro + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + healthcheck: + test: + [ + "CMD", + "mysqladmin", + "ping", + "-h", + "127.0.0.1", + "-u${MYSQL_USER:-querya}", + "-p${MYSQL_PASSWORD:-querya}", + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 30s + + redis: + image: redis:7-alpine + container_name: querya-redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 12 + + redis-seed: + image: redis:7-alpine + container_name: querya-redis-seed + depends_on: + redis: + condition: service_healthy + environment: + REDIS_HOST: redis + REDIS_PORT: "6379" + volumes: + - ./redis/seed.sh:/seed.sh:ro + entrypoint: ["/bin/sh", "/seed.sh"] + restart: "no" + + mongo: + image: mongo:7 + container_name: querya-mongo + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME:-querya} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:-querya} + ports: + - "${MONGO_PORT:-27017}:27017" + volumes: + - mongo_data:/data/db + - ./mongo/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: + [ + "CMD", + "mongosh", + "--quiet", + "-u", + "${MONGO_INITDB_ROOT_USERNAME:-querya}", + "-p", + "${MONGO_INITDB_ROOT_PASSWORD:-querya}", + "--authenticationDatabase", + "admin", + "--eval", + "db.adminCommand('ping').ok", + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 30s + +volumes: + postgres_data: + mysql_data: + redis_data: + mongo_data: diff --git a/docker/mongo/init/01_seed.js b/docker/mongo/init/01_seed.js new file mode 100644 index 00000000..b7dd34a8 --- /dev/null +++ b/docker/mongo/init/01_seed.js @@ -0,0 +1,83 @@ +// Demo data for Querya manual testing (MongoDB). +const appDb = db.getSiblingDB('querya'); + +appDb.users.drop(); +appDb.products.drop(); +appDb.orders.drop(); + +appDb.users.insertMany([ + { + name: 'Alice Martin', + email: 'alice@example.com', + role: 'admin', + city: 'Berlin', + tags: ['staff', 'beta'], + active: true, + }, + { + name: 'Bob Smith', + email: 'bob@example.com', + role: 'customer', + city: 'London', + tags: ['beta'], + active: true, + }, + { + name: 'Carla Ruiz', + email: 'carla@example.com', + role: 'customer', + city: 'Madrid', + tags: [], + active: false, + }, +]); + +appDb.products.insertMany([ + { sku: 'SKU-001', title: 'Wireless Mouse', price: 29.99, stock: 120 }, + { sku: 'SKU-002', title: 'Mechanical Keyboard', price: 89.0, stock: 45 }, + { sku: 'SKU-003', title: 'USB-C Hub', price: 45.5, stock: 80 }, + { sku: 'SKU-004', title: '27" Monitor', price: 329.0, stock: 15 }, +]); + +appDb.orders.insertMany([ + { + customerEmail: 'alice@example.com', + status: 'paid', + total: 164.49, + placedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), + lines: [ + { sku: 'SKU-001', qty: 1, unitPrice: 29.99 }, + { sku: 'SKU-003', qty: 1, unitPrice: 45.5 }, + { sku: 'SKU-002', qty: 1, unitPrice: 89.0 }, + ], + }, + { + customerEmail: 'bob@example.com', + status: 'shipped', + total: 404.0, + placedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), + lines: [{ sku: 'SKU-004', qty: 1, unitPrice: 329.0 }], + }, + { + customerEmail: 'carla@example.com', + status: 'new', + total: 29.99, + placedAt: new Date(), + lines: [{ sku: 'SKU-001', qty: 1, unitPrice: 29.99 }], + }, +]); + +appDb.users.createIndex({ email: 1 }, { unique: true }); +appDb.products.createIndex({ sku: 1 }, { unique: true }); +appDb.orders.createIndex({ status: 1, placedAt: -1 }); + +const analyticsDb = db.getSiblingDB('analytics'); +analyticsDb.metrics.drop(); +analyticsDb.metrics.insertMany([ + { day: new Date(), orders: 4, revenue: 203.99 }, + { + day: new Date(Date.now() - 24 * 60 * 60 * 1000), + orders: 9, + revenue: 615.0, + }, +]); diff --git a/docker/mysql/init/01_shop.sql b/docker/mysql/init/01_shop.sql new file mode 100644 index 00000000..3cd98457 --- /dev/null +++ b/docker/mysql/init/01_shop.sql @@ -0,0 +1,87 @@ +-- Demo schema for Querya manual testing (MySQL / MariaDB-compatible). +USE querya; + +CREATE TABLE customers ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + email VARCHAR(160) NOT NULL UNIQUE, + city VARCHAR(80), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +CREATE TABLE products ( + id INT AUTO_INCREMENT PRIMARY KEY, + sku VARCHAR(32) NOT NULL UNIQUE, + title VARCHAR(160) NOT NULL, + price DECIMAL(10, 2) NOT NULL +) ENGINE=InnoDB; + +CREATE TABLE orders ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_id INT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'new', + total DECIMAL(10, 2) NOT NULL DEFAULT 0, + placed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id) +) ENGINE=InnoDB; + +CREATE TABLE order_lines ( + order_id INT NOT NULL, + product_id INT NOT NULL, + qty INT NOT NULL, + unit_price DECIMAL(10, 2) NOT NULL, + PRIMARY KEY (order_id, product_id), + CONSTRAINT fk_lines_order FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE, + CONSTRAINT fk_lines_product FOREIGN KEY (product_id) REFERENCES products (id) +) ENGINE=InnoDB; + +INSERT INTO customers (name, email, city) VALUES + ('Alice Martin', 'alice@example.com', 'Berlin'), + ('Bob Smith', 'bob@example.com', 'London'), + ('Carla Ruiz', 'carla@example.com', 'Madrid'); + +INSERT INTO products (sku, title, price) VALUES + ('SKU-001', 'Wireless Mouse', 29.99), + ('SKU-002', 'Mechanical Keyboard', 89.00), + ('SKU-003', 'USB-C Hub', 45.50), + ('SKU-004', '27 inch Monitor', 329.00); + +INSERT INTO orders (customer_id, status, total, placed_at) VALUES + (1, 'paid', 164.49, NOW() - INTERVAL 2 DAY), + (2, 'shipped', 404.49, NOW() - INTERVAL 1 DAY), + (3, 'new', 29.99, NOW()); + +INSERT INTO order_lines (order_id, product_id, qty, unit_price) VALUES + (1, 1, 1, 29.99), + (1, 3, 1, 45.50), + (1, 2, 1, 89.00), + (2, 4, 1, 329.00), + (2, 1, 1, 29.99), + (2, 3, 1, 45.50), + (3, 1, 1, 29.99); + +CREATE VIEW customer_spending AS +SELECT + c.id, + c.name, + c.city, + COUNT(o.id) AS order_count, + COALESCE(SUM(o.total), 0) AS lifetime_total +FROM customers c +LEFT JOIN orders o ON o.customer_id = c.id +GROUP BY c.id, c.name, c.city; + +CREATE DATABASE IF NOT EXISTS analytics; + +USE analytics; + +CREATE TABLE daily_sales ( + day DATE PRIMARY KEY, + orders INT NOT NULL, + revenue DECIMAL(12, 2) NOT NULL +) ENGINE=InnoDB; + +INSERT INTO daily_sales (day, orders, revenue) VALUES + (CURDATE() - INTERVAL 2 DAY, 12, 842.50), + (CURDATE() - INTERVAL 1 DAY, 9, 615.00), + (CURDATE(), 4, 203.99); diff --git a/docker/postgres/init/01_shop.sql b/docker/postgres/init/01_shop.sql new file mode 100644 index 00000000..bc540a53 --- /dev/null +++ b/docker/postgres/init/01_shop.sql @@ -0,0 +1,77 @@ +-- Demo schema for Querya manual testing (PostgreSQL). +CREATE SCHEMA IF NOT EXISTS shop; + +CREATE TABLE shop.customers ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + city TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE shop.products ( + id SERIAL PRIMARY KEY, + sku TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + price NUMERIC(10, 2) NOT NULL CHECK (price >= 0) +); + +CREATE TABLE shop.orders ( + id SERIAL PRIMARY KEY, + customer_id INT NOT NULL REFERENCES shop.customers (id), + status TEXT NOT NULL DEFAULT 'new', + total NUMERIC(10, 2) NOT NULL DEFAULT 0, + placed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE shop.order_lines ( + order_id INT NOT NULL REFERENCES shop.orders (id) ON DELETE CASCADE, + product_id INT NOT NULL REFERENCES shop.products (id), + qty INT NOT NULL CHECK (qty > 0), + unit_price NUMERIC(10, 2) NOT NULL, + PRIMARY KEY (order_id, product_id) +); + +INSERT INTO shop.customers (name, email, city) VALUES + ('Alice Martin', 'alice@example.com', 'Berlin'), + ('Bob Smith', 'bob@example.com', 'London'), + ('Carla Ruiz', 'carla@example.com', 'Madrid'); + +INSERT INTO shop.products (sku, title, price) VALUES + ('SKU-001', 'Wireless Mouse', 29.99), + ('SKU-002', 'Mechanical Keyboard', 89.00), + ('SKU-003', 'USB-C Hub', 45.50), + ('SKU-004', '27" Monitor', 329.00); + +INSERT INTO shop.orders (customer_id, status, total, placed_at) VALUES + (1, 'paid', 164.49, NOW() - INTERVAL '2 days'), + (2, 'shipped', 404.49, NOW() - INTERVAL '1 day'), + (3, 'new', 29.99, NOW()); + +INSERT INTO shop.order_lines (order_id, product_id, qty, unit_price) VALUES + (1, 1, 1, 29.99), + (1, 3, 1, 45.50), + (1, 2, 1, 89.00), + (2, 4, 1, 329.00), + (2, 1, 1, 29.99), + (2, 3, 1, 45.50), + (3, 1, 1, 29.99); + +CREATE OR REPLACE VIEW shop.customer_spending AS +SELECT + c.id, + c.name, + c.city, + COUNT(o.id) AS order_count, + COALESCE(SUM(o.total), 0) AS lifetime_total +FROM shop.customers c +LEFT JOIN shop.orders o ON o.customer_id = c.id +GROUP BY c.id, c.name, c.city; + +CREATE OR REPLACE FUNCTION shop.order_count_for_customer(p_customer_id INT) +RETURNS INT +LANGUAGE sql +STABLE +AS $$ + SELECT COUNT(*)::INT FROM shop.orders WHERE customer_id = p_customer_id; +$$; diff --git a/docker/postgres/init/02_analytics.sql b/docker/postgres/init/02_analytics.sql new file mode 100644 index 00000000..9aa868f1 --- /dev/null +++ b/docker/postgres/init/02_analytics.sql @@ -0,0 +1,17 @@ +-- Second database to exercise PostgreSQL tree / database switching. +CREATE DATABASE analytics; + +\connect analytics + +CREATE SCHEMA metrics; + +CREATE TABLE metrics.daily_sales ( + day DATE PRIMARY KEY, + orders INT NOT NULL, + revenue NUMERIC(12, 2) NOT NULL +); + +INSERT INTO metrics.daily_sales (day, orders, revenue) VALUES + (CURRENT_DATE - 2, 12, 842.50), + (CURRENT_DATE - 1, 9, 615.00), + (CURRENT_DATE, 4, 203.99); diff --git a/docker/redis/seed.sh b/docker/redis/seed.sh new file mode 100755 index 00000000..0066be9c --- /dev/null +++ b/docker/redis/seed.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +HOST="${REDIS_HOST:-redis}" +PORT="${REDIS_PORT:-6379}" + +echo "Waiting for Redis at ${HOST}:${PORT}..." +until redis-cli -h "$HOST" -p "$PORT" ping | grep -q PONG; do + sleep 1 +done + +if redis-cli -h "$HOST" -p "$PORT" EXISTS querya:seed:marker | grep -q 1; then + echo "Redis seed marker present — skipping." + exit 0 +fi + +echo "Seeding Redis demo keys..." + +redis-cli -h "$HOST" -p "$PORT" <<'EOF' +SET querya:demo:string "Hello from Querya Docker stack" +SET querya:config:version "1" +HSET querya:user:1 name "Alice Martin" email "alice@example.com" city "Berlin" +HSET querya:user:2 name "Bob Smith" email "bob@example.com" city "London" +RPUSH querya:tasks:open "Review PR" "Write docs" "Test Redis key editor" +SADD querya:tags:popular redis docker mongodb postgresql mysql +ZADD querya:leaderboard 980 "player_alpha" 875 "player_beta" 640 "player_gamma" +SET querya:seed:marker "1" +EOF + +echo "Redis seed complete." diff --git a/lib/app/app.dart b/lib/app/app.dart index b14e41c4..75009613 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,6 +1,5 @@ import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; -import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -14,43 +13,48 @@ class QueryaApp extends StatelessWidget { @override Widget build(BuildContext context) { final themeController = ThemeController.instance; - final uiScaleController = UiScaleController.instance; return ListenableBuilder( - listenable: Listenable.merge([themeController, uiScaleController]), + listenable: themeController, builder: (context, _) { final queryaTheme = themeController.activeTheme; final colorScheme = queryaTheme.colorScheme; - final scale = uiScaleController.scale; - return ShadcnApp( - title: 'Querya', - theme: themeController.lightShadcnTheme, - darkTheme: themeController.darkShadcnTheme, - themeMode: themeController.themeMode, - materialTheme: materialThemeFromQuerya(colorScheme), - debugShowCheckedModeBanner: false, - enableThemeAnimation: themeController.themeAnimationEnabled, - enableScrollInterception: false, - // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. - builder: (context, child) { - final mq = MediaQuery.maybeOf(context); - return QueryaUiScaleScope( - scale: scale, - child: MediaQuery( - data: (mq ?? const MediaQueryData()).copyWith( - textScaler: TextScaler.linear(scale), - ), - child: QueryaThemeScope( - data: queryaTheme, - child: child ?? const SizedBox.shrink(), - ), + + return ListenableBuilder( + listenable: uiScaleController, + builder: (context, _) { + final scale = uiScaleController.scale; + return ShadcnApp( + title: 'Querya', + theme: themeController.lightShadcnTheme, + darkTheme: themeController.darkShadcnTheme, + themeMode: themeController.themeMode, + materialTheme: themeController.materialThemeFor(colorScheme), + debugShowCheckedModeBanner: false, + enableThemeAnimation: themeController.themeAnimationEnabled, + enableScrollInterception: false, + // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. + builder: (context, child) { + final mq = MediaQuery.maybeOf(context); + return QueryaUiScaleScope( + scale: scale, + child: MediaQuery( + data: (mq ?? const MediaQueryData()).copyWith( + textScaler: TextScaler.linear(scale), + ), + child: QueryaThemeScope( + data: queryaTheme, + child: child ?? const SizedBox.shrink(), + ), + ), + ); + }, + home: const AppLifecycleCleanup( + child: MainScreen(), ), ); }, - home: const AppLifecycleCleanup( - child: MainScreen(), - ), ); }, ); diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 68657f16..ce6ee2ba 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -330,4 +330,76 @@ class MysqlConnection { final rs = await execute('SELECT VERSION()'); return rs.rows.first.colAt(0) ?? ''; } + + /// Key metrics for the MySQL stats dashboard (`SHOW GLOBAL STATUS` / `VARIABLES`). + Future> serverStats() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final stats = {}; + + stats['version'] = await serverVersion(); + + final status = await _showKeyValueRows( + "SHOW GLOBAL STATUS WHERE Variable_name IN (" + "'Uptime','Threads_connected','Threads_running','Max_used_connections'," + "'Questions','Slow_queries','Bytes_received','Bytes_sent','Connections'," + "'Open_tables','Opened_tables','Aborted_connects'" + ')', + ); + stats['status'] = status; + stats['uptime_seconds'] = int.tryParse(status['Uptime'] ?? '') ?? 0; + + stats['variables'] = await _showKeyValueRows( + "SHOW GLOBAL VARIABLES WHERE Variable_name IN (" + "'max_connections','port','datadir','character_set_server'," + "'collation_server','innodb_buffer_pool_size','version'" + ')', + ); + + const systemSchemas = { + 'information_schema', + 'mysql', + 'performance_schema', + 'sys', + }; + final dbRs = await execute( + 'SELECT table_schema, ' + 'COALESCE(SUM(data_length + index_length), 0) AS size_bytes, ' + 'COUNT(*) AS table_count ' + 'FROM information_schema.tables ' + "WHERE table_schema NOT IN ('information_schema','mysql'," + "'performance_schema','sys') " + 'GROUP BY table_schema ' + 'ORDER BY table_schema', + ); + final databases = >[]; + for (final row in dbRs.rows) { + final name = row.colAt(0); + if (name == null || systemSchemas.contains(name.toLowerCase())) { + continue; + } + databases.add({ + 'name': name, + 'size': int.tryParse(row.colAt(1) ?? '') ?? 0, + 'tables': int.tryParse(row.colAt(2) ?? '') ?? 0, + }); + } + stats['databases'] = databases; + + return stats; + } + + Future> _showKeyValueRows(String sql) async { + final rs = await execute(sql); + final out = {}; + for (final row in rs.rows) { + final key = row.colAt(0); + final value = row.colAt(1); + if (key != null && value != null) { + out[key] = value; + } + } + return out; + } } diff --git a/lib/core/editor/querya_highlight_controller.dart b/lib/core/editor/querya_highlight_controller.dart index ae92387b..eb46c8e0 100644 --- a/lib/core/editor/querya_highlight_controller.dart +++ b/lib/core/editor/querya_highlight_controller.dart @@ -1,9 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:syntax_highlight/syntax_highlight.dart'; import 'syntax_highlight_isolate.dart'; +/// Debounce delay before scheduling syntax highlight work. +const Duration kSyntaxHighlightDebounce = Duration(milliseconds: 100); + /// [TextEditingController] that applies [Highlighter] in [buildTextSpan]. class QueryaHighlightController extends TextEditingController { QueryaHighlightController({ @@ -29,6 +34,7 @@ class QueryaHighlightController extends TextEditingController { String? _cachedText; Brightness? _cachedBrightness; int _highlightGeneration = 0; + Timer? _debounceTimer; @override TextSpan buildTextSpan({ @@ -37,20 +43,10 @@ class QueryaHighlightController extends TextEditingController { required bool withComposing, }) { final brightness = Theme.of(context).brightness; - final highlighter = brightness == Brightness.light - ? lightHighlighter - : darkHighlighter; final themeConfig = brightness == Brightness.light ? lightThemeConfig : darkThemeConfig; - if (text.length < kSyntaxHighlightIsolateThreshold) { - return TextSpan( - style: style, - children: [highlighter.highlight(text)], - ); - } - if (_cachedText == text && _cachedBrightness == brightness && _cachedSpan != null) { @@ -64,7 +60,9 @@ class QueryaHighlightController extends TextEditingController { style: style, ); - if (_cachedSpan != null && _cachedText == text) { + if (_cachedSpan != null && + _cachedText == text && + _cachedBrightness == brightness) { return TextSpan(style: style, children: [_cachedSpan!]); } @@ -76,6 +74,23 @@ class QueryaHighlightController extends TextEditingController { required Brightness brightness, required String themeConfig, required TextStyle? style, + }) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(kSyntaxHighlightDebounce, () { + _runIsolateHighlight( + text: text, + brightness: brightness, + themeConfig: themeConfig, + style: style, + ); + }); + } + + void _runIsolateHighlight({ + required String text, + required Brightness brightness, + required String themeConfig, + required TextStyle? style, }) { final generation = ++_highlightGeneration; final lang = switch (language) { @@ -103,6 +118,7 @@ class QueryaHighlightController extends TextEditingController { @override void dispose() { + _debounceTimer?.cancel(); _highlightGeneration++; super.dispose(); } diff --git a/lib/core/editor/syntax_highlight_isolate.dart b/lib/core/editor/syntax_highlight_isolate.dart index 4e1aa7ed..a0fece91 100644 --- a/lib/core/editor/syntax_highlight_isolate.dart +++ b/lib/core/editor/syntax_highlight_isolate.dart @@ -119,12 +119,9 @@ TextStyle? _styleFromSegment(HighlightSegment s, TextStyle? base) { ); } -/// Runs [syntaxHighlightInIsolate] off the UI thread when [code] is large. +/// Runs [syntaxHighlightInIsolate] off the UI thread. Future> highlightOffMainThread( SyntaxHighlightJob job, ) { - if (job.code.length < kSyntaxHighlightIsolateThreshold) { - return Future.value(syntaxHighlightInIsolate(job)); - } return compute(syntaxHighlightInIsolate, job); } diff --git a/lib/core/editor/syntax_highlight_service.dart b/lib/core/editor/syntax_highlight_service.dart index 14a20118..62a3b2a9 100644 --- a/lib/core/editor/syntax_highlight_service.dart +++ b/lib/core/editor/syntax_highlight_service.dart @@ -59,6 +59,29 @@ abstract final class SyntaxHighlightService { static HighlighterPair createPair({ required QueryaCodeLanguage language, required QueryaTheme queryaTheme, + }) { + final cacheKey = Object.hash( + language, + queryaTheme.editor, + Object.hashAll(queryaTheme.tokenColors), + ); + final cached = _pairCache[cacheKey]; + if (cached != null) return cached; + + final pair = _buildPair(language: language, queryaTheme: queryaTheme); + if (_pairCache.length >= _maxPairCacheEntries) { + _pairCache.remove(_pairCache.keys.first); + } + _pairCache[cacheKey] = pair; + return pair; + } + + static const _maxPairCacheEntries = 12; + static final Map _pairCache = {}; + + static HighlighterPair _buildPair({ + required QueryaCodeLanguage language, + required QueryaTheme queryaTheme, }) { final tokenColors = queryaTheme.tokenColors; return HighlighterPair( diff --git a/lib/core/layout/ui_scale_controller.dart b/lib/core/layout/ui_scale_controller.dart index ce318e9b..39142046 100644 --- a/lib/core/layout/ui_scale_controller.dart +++ b/lib/core/layout/ui_scale_controller.dart @@ -14,14 +14,6 @@ class UiScaleController extends ChangeNotifier { notifyListeners(); } - /// Live preview while dragging the scale slider (not persisted). - void setScalePreview(double value, {bool fine = false}) { - final next = _normalize(value, fine: fine); - if (next == _scale) return; - _scale = next; - notifyListeners(); - } - /// Persist scale to SQLite (called on slider release). Future commitScale(double value, {bool fine = false}) async { await AppSettings.instance.setUiScale(value, fine: fine); @@ -32,11 +24,13 @@ class UiScaleController extends ChangeNotifier { Future setScale(double value, {bool fine = false}) => commitScale(value, fine: fine); - double _normalize(double value, {required bool fine}) { + /// Normalizes a raw scale value (preset snap or 1% fine steps). + static double normalize(double value, {bool fine = false}) { final clamped = value.clamp(kMinUiScale, kMaxUiScale); if (fine) { final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); - return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); + return (kMinUiScale + steps * kUiScaleStep) + .clamp(kMinUiScale, kMaxUiScale); } return snapUiScaleToPreset(clamped); } diff --git a/lib/core/layout/vertical_split_pane.dart b/lib/core/layout/vertical_split_pane.dart new file mode 100644 index 00000000..36dc3cb2 --- /dev/null +++ b/lib/core/layout/vertical_split_pane.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Holds top/bottom panes for [VerticalSplitPane] [ValueListenableBuilder.child]. +class SplitPanePair extends StatelessWidget { + const SplitPanePair({super.key, required this.top, required this.bottom}); + + final Widget top; + final Widget bottom; + + @override + Widget build(BuildContext context) => top; +} + +/// Vertical split whose drag updates [fraction] without rebuilding [top]/[bottom]. +class VerticalSplitPane extends StatelessWidget { + const VerticalSplitPane({ + super.key, + required this.fraction, + required this.top, + required this.bottom, + this.minFraction = 0.2, + this.maxFraction = 0.8, + this.handleKey, + }); + + final ValueNotifier fraction; + final Widget top; + final Widget bottom; + final double minFraction; + final double maxFraction; + final Key? handleKey; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final totalHeight = constraints.maxHeight; + return ValueListenableBuilder( + valueListenable: fraction, + builder: (context, value, panes) { + final pair = panes! as SplitPanePair; + final topFlex = (value * 100).round().clamp(20, 80).toInt(); + final bottomFlex = 100 - topFlex; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: topFlex, child: pair.top), + _VerticalSplitHandle( + key: handleKey, + onDrag: (dy) { + if (totalHeight <= 0) return; + fraction.value = (fraction.value + dy / totalHeight) + .clamp(minFraction, maxFraction); + }, + ), + Expanded(flex: bottomFlex, child: pair.bottom), + ], + ); + }, + child: SplitPanePair(top: top, bottom: bottom), + ); + }, + ); + } +} + +class _VerticalSplitHandle extends StatelessWidget { + const _VerticalSplitHandle({super.key, required this.onDrag}); + + final void Function(double dy) onDrag; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).colorScheme; + return material.MouseRegion( + cursor: material.SystemMouseCursors.resizeRow, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onVerticalDragUpdate: (e) => onDrag(e.delta.dy), + child: material.Container( + height: 6, + color: theme.border.withValues(alpha: 0.15), + ), + ), + ); + } +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index ad8036c2..9530b38d 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -110,13 +110,20 @@ abstract final class AppSettingsKeys { static const uiScale = 'ui_scale'; } -/// Bumps [listenable] when any preference is persisted so open screens can reload. +/// Bumps [listenable] when any preference is persisted (theme, legacy listeners). abstract final class AppSettingsRevision { static final ValueNotifier listenable = ValueNotifier(0); static void bump() => listenable.value++; } +/// Bumps when SQL workspace preferences change (timeouts, grid, editor font, history). +abstract final class SqlWorkspaceSettingsRevision { + static final ValueNotifier listenable = ValueNotifier(0); + + static void bump() => listenable.value++; +} + /// User preferences backed by [LocalDb] (SQLite). class AppSettings { AppSettings._(); @@ -142,7 +149,7 @@ class AppSettings { seconds.toString(), ); } - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// `null` = use driver default for statement duration. @@ -165,7 +172,7 @@ class AppSettings { seconds.toString(), ); } - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// Max rows loaded into the result grid for PostgreSQL / MySQL workspaces. @@ -187,7 +194,7 @@ class AppSettings { AppSettingsKeys.sqlResultMaxRows, preset.toString(), ); - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// Editor font size in logical pixels. @@ -207,7 +214,7 @@ class AppSettings { AppSettingsKeys.sqlEditorFontSizePoints, clamped.toString(), ); - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// Global interface scale for typography and compact controls. @@ -225,7 +232,6 @@ class AppSettings { AppSettingsKeys.uiScale, normalized.toStringAsFixed(2), ); - AppSettingsRevision.bump(); } /// Max SQL history rows kept per connection + database (oldest trimmed). @@ -247,7 +253,7 @@ class AppSettings { AppSettingsKeys.sqlHistoryMaxEntries, preset.toString(), ); - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// UI theme mode (dark / light / system). diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index ac562091..8abdb638 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,4 +1,6 @@ +import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'parser/apply_token_colors_to_editor.dart'; @@ -25,6 +27,14 @@ class ThemeController extends ChangeNotifier { bool _loaded = false; bool _themeAnimationEnabled = false; + QueryaTheme? _cachedLightTheme; + QueryaTheme? _cachedDarkTheme; + QueryaTheme? _cachedActiveTheme; + ThemeData? _cachedLightShadcnTheme; + ThemeData? _cachedDarkShadcnTheme; + material.ThemeData? _cachedMaterialTheme; + ColorScheme? _cachedMaterialThemeScheme; + ThemeMode get themeMode => _themeMode; /// When true, [QueryaApp] enables ShadcnAnimatedTheme transitions. @@ -70,13 +80,41 @@ class ThemeController extends ChangeNotifier { } /// Workbench + editor tokens for the current preset/mode and overrides. - QueryaTheme get activeTheme => _themeForBrightness(_effectiveBrightness()); + QueryaTheme get activeTheme => + _cachedActiveTheme ??= _themeForBrightness(_effectiveBrightness()); + + ThemeData get lightShadcnTheme => _cachedLightShadcnTheme ??= + (_cachedLightTheme ??= _themeForBrightness(Brightness.light)) + .toShadcnThemeData(); + + ThemeData get darkShadcnTheme => _cachedDarkShadcnTheme ??= + (_cachedDarkTheme ??= _themeForBrightness(Brightness.dark)) + .toShadcnThemeData(); + + /// Cached Material theme for dialogs/dropdowns (avoids rebuild churn). + material.ThemeData materialThemeFor(ColorScheme scheme) { + if (_cachedMaterialTheme != null && + _cachedMaterialThemeScheme == scheme) { + return _cachedMaterialTheme!; + } + _cachedMaterialThemeScheme = scheme; + return _cachedMaterialTheme = materialThemeFromQuerya(scheme); + } - ThemeData get lightShadcnTheme => - _themeForBrightness(Brightness.light).toShadcnThemeData(); + void _invalidateThemeCache() { + _cachedLightTheme = null; + _cachedDarkTheme = null; + _cachedActiveTheme = null; + _cachedLightShadcnTheme = null; + _cachedDarkShadcnTheme = null; + _cachedMaterialTheme = null; + _cachedMaterialThemeScheme = null; + } - ThemeData get darkShadcnTheme => - _themeForBrightness(Brightness.dark).toShadcnThemeData(); + void _notifyThemeChanged() { + _invalidateThemeCache(); + notifyListeners(); + } Future load() async { final mode = await AppSettings.instance.getThemeMode(); @@ -106,13 +144,13 @@ class ThemeController extends ChangeNotifier { _themeAnimationEnabled = await AppSettings.instance.getThemeAnimationEnabled(); _loaded = true; - notifyListeners(); + _notifyThemeChanged(); } Future setThemeAnimationEnabled(bool enabled) async { _themeAnimationEnabled = enabled; await AppSettings.instance.setThemeAnimationEnabled(enabled); - notifyListeners(); + _notifyThemeChanged(); } Future setThemeMode(ThemeMode mode) async { @@ -124,7 +162,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemePreset(_preset); } await AppSettings.instance.setThemeMode(mode); - notifyListeners(); + _notifyThemeChanged(); } Future setPreset(QueryaThemePreset preset) async { @@ -141,7 +179,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemePreset(preset); await AppSettings.instance.setThemeMode(_themeMode); } - notifyListeners(); + _notifyThemeChanged(); } /// Parses a VS Code theme file, persists it, and activates the imported preset. @@ -165,7 +203,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemeImportPath(storedPath); await AppSettings.instance.setThemePreset(QueryaThemePreset.imported); await AppSettings.instance.setThemeMode(_themeMode); - notifyListeners(); + _notifyThemeChanged(); return result; case ThemeImportFailure(): return result; @@ -182,14 +220,14 @@ class ThemeController extends ChangeNotifier { } _userOverrides = Map.unmodifiable(next); await AppSettings.instance.setThemeColorOverrides(next); - notifyListeners(); + _notifyThemeChanged(); } /// Removes only the user override layer (keeps preset/imported theme). Future clearColorOverrides() async { _userOverrides = const {}; await AppSettings.instance.clearThemeColorOverrides(); - notifyListeners(); + _notifyThemeChanged(); } /// Clears imported theme file and settings; falls back to Querya Dark. @@ -205,7 +243,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemePreset(_preset); await AppSettings.instance.setThemeMode(_themeMode); } - notifyListeners(); + _notifyThemeChanged(); } Future resetToDefaults() async { @@ -218,7 +256,7 @@ class ThemeController extends ChangeNotifier { _userOverrides = const {}; _importedThemeName = null; _themeAnimationEnabled = false; - notifyListeners(); + _notifyThemeChanged(); } Brightness _effectiveBrightness() { diff --git a/lib/core/util/deep_collection_equals.dart b/lib/core/util/deep_collection_equals.dart new file mode 100644 index 00000000..99f7a273 --- /dev/null +++ b/lib/core/util/deep_collection_equals.dart @@ -0,0 +1,27 @@ +/// Deep equality for JSON-like trees (maps, lists, primitives). +bool deepCollectionEquals(Object? a, Object? b) { + if (identical(a, b)) return true; + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!b.containsKey(key)) return false; + if (!deepCollectionEquals(a[key], b[key])) return false; + } + return true; + } + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!deepCollectionEquals(a[i], b[i])) return false; + } + return true; + } + return a == b; +} + +/// Updates [current] when [next] differs; returns true if changed. +bool replaceIfChanged(T? current, T? next, void Function(T? value) apply) { + if (deepCollectionEquals(current, next)) return false; + apply(next); + return true; +} diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index 935ff472..9a43338e 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -7,22 +7,32 @@ 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'; +/// 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); + if (navigator != null && navigator.context.mounted) { + return navigator.context; + } + return context; +} + /// Picks a database type, opens the matching form, returns a saved row or null. Future promptCreateConnection( material.BuildContext context, { int? folderId, }) async { - final type = await showNewConnectionDialog(context); + final dialogContext = _dialogAnchorContext(context); + final type = await showNewConnectionDialog(dialogContext); if (type == null) return null; - if (!context.mounted) return null; + if (!dialogContext.mounted) return null; switch (type) { case ConnectionType.postgresql: - return await showPostgresConnectionForm(context, folderId: folderId); + return await showPostgresConnectionForm(dialogContext, folderId: folderId); case ConnectionType.mysql: - return await showMysqlConnectionForm(context, folderId: folderId); + return await showMysqlConnectionForm(dialogContext, folderId: folderId); case ConnectionType.mongodb: - return await showMongoConnectionForm(context, folderId: folderId); + return await showMongoConnectionForm(dialogContext, folderId: folderId); case ConnectionType.redis: - return await showRedisConnectionForm(context, folderId: folderId); + return await showRedisConnectionForm(dialogContext, folderId: folderId); } } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 59e4c43c..5cc49806 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, RepaintBoundary; +import 'package:flutter/material.dart' as material show AlertDialog, BoxConstraints, BuildContext, Column, ConstrainedBox, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, EdgeInsetsGeometry, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, TextStyle, CustomScrollView, SliverFillRemaining, SliverPadding, SliverList, SliverChildBuilderDelegate, GestureDetector, HitTestBehavior, SizedBox, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color, SelectableText, Padding, Widget, Navigator, ValueKey, FontWeight, VoidCallback, RepaintBoundary, ListView, ClampingScrollPhysics; 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'; @@ -33,6 +33,54 @@ typedef OnPostgresOpenSqlWorkspace = void Function( PostgresObjectKind? kind, }); +/// Typical height of a compact tree row (object leaf / table name). +const double kConnectionTreeRowExtent = 28; + +/// Build all rows inline when the list is short. +const int kConnectionTreeEagerThreshold = 24; + +/// Max rows visible before nested list scrolls (virtualized via [ListView.builder]). +const int kConnectionTreeMaxVisibleRows = 14; + +/// Builds a short [Column] or a height-capped [ListView.builder] for large lists. +material.Widget lazyConnectionTreeList({ + required material.BuildContext context, + required int itemCount, + required material.Widget Function(material.BuildContext context, int index) + itemBuilder, + double? itemExtent, + int eagerThreshold = kConnectionTreeEagerThreshold, + int maxVisibleRows = kConnectionTreeMaxVisibleRows, + material.EdgeInsetsGeometry? padding, +}) { + if (itemCount == 0) { + return const material.SizedBox.shrink(); + } + if (itemCount <= eagerThreshold) { + return material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < itemCount; i++) itemBuilder(context, i), + ], + ); + } + final rowExtent = itemExtent ?? kConnectionTreeRowExtent; + return material.ConstrainedBox( + constraints: material.BoxConstraints( + maxHeight: maxVisibleRows * rowExtent, + ), + child: material.ListView.builder( + padding: padding ?? material.EdgeInsets.zero, + shrinkWrap: true, + physics: const material.ClampingScrollPhysics(), + itemCount: itemCount, + itemExtent: itemExtent, + itemBuilder: itemBuilder, + ), + ); +} + /// Left panel: Browser tree (pgAdmin-style). Uses shadcn layout widgets. class ConnectionsPanel extends StatefulWidget { const ConnectionsPanel({ @@ -268,6 +316,9 @@ class ConnectionsPanelState extends State { // Connections without a folder final rootConnections = _connections.where((c) => c.folderId == null).toList(); + final showEmptyState = _connections.isEmpty && _folders.isEmpty; + final topLevelCount = + _folders.length + rootConnections.length + (showEmptyState ? 1 : 0); return material.Container( decoration: material.BoxDecoration( @@ -302,14 +353,20 @@ class ConnectionsPanelState extends State { slivers: [ material.SliverPadding( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 12), - sliver: material.SliverToBoxAdapter( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - // Folders - for (final name in _folders) - _FolderTile( + sliver: material.SliverList( + delegate: material.SliverChildBuilderDelegate( + (context, index) { + if (showEmptyState && index == 0) { + return const material.Padding( + padding: material.EdgeInsets.only(top: 8), + child: _EmptyState(message: 'No connections yet'), + ); + } + final folderOffset = showEmptyState ? 1 : 0; + final folderIndex = index - folderOffset; + if (folderIndex < _folders.length) { + final name = _folders[folderIndex]; + return _FolderTile( name: name, initiallyExpanded: _expandedFolders.contains(name), onExpansionCommitted: (folderName, expanded) { @@ -337,17 +394,12 @@ class ConnectionsPanelState extends State { onRedisDatabaseTap: widget.onRedisDatabaseSelected, onMongoDBDatabaseTap: widget.onMongoDBDatabaseSelected, buildConnectionTile: _buildConnectionTile, - ), - // Root connections (no folder) - for (final conn in rootConnections) - _buildConnectionTile(conn), - // Empty state - if (_connections.isEmpty && _folders.isEmpty) - const material.Padding( - padding: material.EdgeInsets.only(top: 8), - child: _EmptyState(message: 'No connections yet'), - ), - ], + ); + } + final connIndex = folderIndex - _folders.length; + return _buildConnectionTile(rootConnections[connIndex]); + }, + childCount: topLevelCount, ), ), ), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 61684b78..f6045237 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -289,14 +289,20 @@ class _MysqlDatabasesNode extends material.StatelessWidget { : (c, {database, schema, name, kind}) => onMysqlOpenSqlWorkspace!(c), ), - for (final db in databases) - _MysqlDatabaseNode( - key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), - connection: connection, - databaseName: db, - onMysqlObjectSelected: onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, - ), + lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _MysqlDatabaseNode( + key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), + connection: connection, + databaseName: db, + onMysqlObjectSelected: onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, + ); + }, + ), ], ), ); @@ -444,10 +450,17 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { onContextRefresh: _loadTables, onOpenSqlWorkspace: null, ), - for (final t in _tables) - material.Padding( - padding: const material.EdgeInsets.only(left: 12), - child: _PgTreeRow( + lazyConnectionTreeList( + context: context, + itemCount: _tables.length, + itemExtent: kConnectionTreeRowExtent, + padding: const material.EdgeInsets.only(left: 12), + itemBuilder: (context, index) { + final t = _tables[index]; + return _PgTreeRow( + key: material.ValueKey( + 'mysql-table-${widget.connection.id ?? 0}-${widget.databaseName}-$t', + ), label: t, icon: material.Icons.grid_on_rounded, iconSize: 12, @@ -468,8 +481,9 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { connection: widget.connection, onContextRefresh: null, onOpenSqlWorkspace: null, - ), - ), + ); + }, + ), ], if (_views.isNotEmpty) ...[ _PgTreeRow( @@ -487,10 +501,17 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { onContextRefresh: _loadTables, onOpenSqlWorkspace: null, ), - for (final v in _views) - material.Padding( - padding: const material.EdgeInsets.only(left: 12), - child: _PgTreeRow( + lazyConnectionTreeList( + context: context, + itemCount: _views.length, + itemExtent: kConnectionTreeRowExtent, + padding: const material.EdgeInsets.only(left: 12), + itemBuilder: (context, index) { + final v = _views[index]; + return _PgTreeRow( + key: material.ValueKey( + 'mysql-view-${widget.connection.id ?? 0}-${widget.databaseName}-$v', + ), label: v, icon: material.Icons.view_week_rounded, iconSize: 12, @@ -511,8 +532,9 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { connection: widget.connection, onContextRefresh: null, onOpenSqlWorkspace: null, - ), - ), + ); + }, + ), ], ], ), diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 2ff0fc92..d79fe824 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -25,7 +25,7 @@ class _PgDatabasesNode extends StatefulWidget { State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); } -/// Ellipsis label; tooltip only when text overflows (intrinsic width > slot). +/// Ellipsis label; tooltip when the name is long enough to likely truncate. class _PgTreeRowLabel extends material.StatelessWidget { const _PgTreeRowLabel({ required this.label, @@ -35,30 +35,21 @@ class _PgTreeRowLabel extends material.StatelessWidget { final String label; final material.TextStyle textStyle; + static const int _tooltipMinLength = 28; + @override material.Widget build(material.BuildContext context) { - return material.LayoutBuilder( - builder: (context, constraints) { - final tp = material.TextPainter( - text: material.TextSpan(text: label, style: textStyle), - maxLines: 1, - textDirection: material.TextDirection.ltr, - ); - tp.layout(maxWidth: double.infinity); - final overflow = tp.width > constraints.maxWidth + 0.5; - final text = material.Text( - label, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: textStyle, - ); - if (!overflow) return text; - return material.Tooltip( - message: label, - waitDuration: const Duration(milliseconds: 450), - child: text, - ); - }, + final text = material.Text( + label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: textStyle, + ); + if (label.length < _tooltipMinLength) return text; + return material.Tooltip( + message: label, + waitDuration: const Duration(milliseconds: 450), + child: text, ); } } @@ -66,6 +57,7 @@ class _PgTreeRowLabel extends material.StatelessWidget { /// Shared tree row: consistent ink hover, optional context menu, tooltips when truncated. class _PgTreeRow extends material.StatelessWidget { const _PgTreeRow({ + super.key, required this.label, this.leading, this.icon, @@ -242,14 +234,20 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) - for (final db in widget.databases) - _PgDatabaseNode( - key: material.ValueKey('pg-db-${widget.connection.id ?? 0}-$db'), - connection: widget.connection, - databaseName: db, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), + lazyConnectionTreeList( + context: context, + itemCount: widget.databases.length, + itemBuilder: (context, index) { + final db = widget.databases[index]; + return _PgDatabaseNode( + key: material.ValueKey('pg-db-${widget.connection.id ?? 0}-$db'), + connection: widget.connection, + databaseName: db, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ); + }, + ), ], ), ); @@ -531,17 +529,23 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) - for (final schema in widget.schemas) - _PgSchemaNode( - key: material.ValueKey( - 'pg-schema-${widget.connection.id ?? 0}-${widget.databaseName}-$schema', - ), - connection: widget.connection, - databaseName: widget.databaseName, - schemaName: schema, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), + lazyConnectionTreeList( + context: context, + itemCount: widget.schemas.length, + itemBuilder: (context, index) { + final schema = widget.schemas[index]; + return _PgSchemaNode( + key: material.ValueKey( + 'pg-schema-${widget.connection.id ?? 0}-${widget.databaseName}-$schema', + ), + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: schema, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ); + }, + ), ], ), ); @@ -955,10 +959,17 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) - for (final item in widget.items) - material.Padding( - padding: const material.EdgeInsets.only(left: 22), - child: _PgTreeRow( + lazyConnectionTreeList( + context: context, + itemCount: widget.items.length, + itemExtent: kConnectionTreeRowExtent, + padding: const material.EdgeInsets.only(left: 22), + itemBuilder: (context, index) { + final item = widget.items[index]; + return _PgTreeRow( + key: material.ValueKey( + 'pg-${widget.objectKind.name}-${widget.databaseName}-${widget.schemaName}-$item', + ), label: item, icon: widget.icon, iconSize: 12, @@ -979,8 +990,9 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { openSqlSchema: widget.schemaName, openSqlName: item, openSqlKind: widget.objectKind, - ), - ), + ); + }, + ), ], ), ); diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 0aae05be..f1994158 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -67,6 +67,7 @@ class _EmptyState extends StatelessWidget { /// Tile for a single connection in the sidebar. class _ConnectionTile extends StatelessWidget { const _ConnectionTile({ + super.key, required this.connection, this.isSelected = false, required this.icon, @@ -271,19 +272,28 @@ class _FolderTileState extends State<_FolderTile> { ), ), if (_expanded) - for (final conn in widget.connections) - material.Padding( - padding: const material.EdgeInsets.only(left: 24), - child: widget.buildConnectionTile != null - ? widget.buildConnectionTile!(conn) - : _ConnectionTile( - connection: conn, - icon: widget.iconForType(conn.type), - iconAsset: ConnectionsPanelState._iconAssetForType(conn.type), - onRemove: () => widget.onRemoveConnection(conn.id!), - onTap: () => widget.onConnectionTap?.call(conn), - ), + material.Padding( + padding: const material.EdgeInsets.only(left: 24), + child: lazyConnectionTreeList( + context: context, + itemCount: widget.connections.length, + itemBuilder: (context, index) { + final conn = widget.connections[index]; + return widget.buildConnectionTile != null + ? widget.buildConnectionTile!(conn) + : _ConnectionTile( + key: material.ValueKey('folder-conn-${conn.id}'), + connection: conn, + icon: widget.iconForType(conn.type), + iconAsset: ConnectionsPanelState._iconAssetForType( + conn.type, + ), + onRemove: () => widget.onRemoveConnection(conn.id!), + onTap: () => widget.onConnectionTap?.call(conn), + ); + }, ), + ), ], ), ), diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index a7461e5e..2250f616 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -117,9 +117,11 @@ class _MainScreenState extends State { await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); } - Future _onNewDatabaseConnectionFromMenu( - material.BuildContext menuContext) async { - final row = await promptCreateConnection(menuContext, folderId: null); + Future _onNewDatabaseConnectionFromMenu() async { + // Menu overlay context is torn down before the connection form opens. + await Future.delayed(const Duration(milliseconds: 100)); + if (!mounted) return; + final row = await promptCreateConnection(context, folderId: null); if (!mounted || row == null) return; await LocalDb.instance.addConnection(row); await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); @@ -207,67 +209,57 @@ class _MainContentSplitState extends State<_MainContentSplit> { static const double _maxLeftWidth = 500; static const double _minWorkspaceWidth = 64; static const double _resizeHandleWidth = 6; - double _leftPanelWidth = 260; + final ValueNotifier _leftPanelWidth = ValueNotifier(260); + + @override + void dispose() { + _leftPanelWidth.dispose(); + super.dispose(); + } + + double _clampLeftWidth(double raw, double maxWidth) { + final maxLeft = maxWidth - _resizeHandleWidth - _minWorkspaceWidth; + if (maxLeft <= 0) return 0; + if (maxLeft < _minLeftWidth) return raw.clamp(0, maxLeft); + return raw.clamp(_minLeftWidth, math.min(_maxLeftWidth, maxLeft)); + } @override material.Widget build(material.BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - 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, + ValueListenableBuilder( + valueListenable: _leftPanelWidth, + builder: (context, rawWidth, connectionsPanel) { + final leftW = _clampLeftWidth(rawWidth, constraints.maxWidth); + return SizedBox(width: leftW, child: connectionsPanel); + }, 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, - ); - }, + child: _ConnectionsPanelSlot( + connectionsPanelKey: widget.connectionsPanelKey, + workspace: widget.workspace, + 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), - ); - } - }); + final ml = constraints.maxWidth - + _resizeHandleWidth - + _minWorkspaceWidth; + if (ml <= 0) return; + _leftPanelWidth.value = _clampLeftWidth( + _leftPanelWidth.value + dx, + constraints.maxWidth, + ); }, ), Expanded( @@ -300,6 +292,84 @@ class _MainContentSplitState extends State<_MainContentSplit> { } } +/// Rebuilds [ConnectionsPanel] only when the selected connection id changes. +class _ConnectionsPanelSlot extends StatefulWidget { + const _ConnectionsPanelSlot({ + required this.connectionsPanelKey, + required this.workspace, + required this.onConnectionSelected, + required this.onPostgresObjectSelected, + required this.onMysqlObjectSelected, + required this.onRedisDatabaseSelected, + required this.onMongoDBDatabaseSelected, + required this.onPostgresOpenSqlWorkspace, + required this.onMysqlOpenSqlWorkspace, + }); + + 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 OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; + final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; + + @override + State<_ConnectionsPanelSlot> createState() => _ConnectionsPanelSlotState(); +} + +class _ConnectionsPanelSlotState extends State<_ConnectionsPanelSlot> { + int? _selectedConnectionId; + + @override + void initState() { + super.initState(); + _selectedConnectionId = widget.workspace.value.activeConnection?.id; + widget.workspace.addListener(_onWorkspaceChanged); + } + + @override + void dispose() { + widget.workspace.removeListener(_onWorkspaceChanged); + super.dispose(); + } + + void _onWorkspaceChanged() { + final next = widget.workspace.value.activeConnection?.id; + if (next != _selectedConnectionId) { + setState(() => _selectedConnectionId = next); + } + } + + @override + material.Widget build(material.BuildContext context) { + return ConnectionsPanel( + key: widget.connectionsPanelKey, + selectedConnectionId: _selectedConnectionId, + onConnectionSelected: widget.onConnectionSelected, + onRedisDatabaseSelected: widget.onRedisDatabaseSelected, + onMongoDBDatabaseSelected: widget.onMongoDBDatabaseSelected, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + ); + } +} + class _VerticalResizeHandle extends StatelessWidget { const _VerticalResizeHandle({required this.onDrag}); @@ -329,8 +399,7 @@ class _CustomTitleBar extends StatefulWidget { }); final ColorScheme theme; - final Future Function(material.BuildContext context) - onNewDatabaseConnection; + final Future Function() onNewDatabaseConnection; @override State<_CustomTitleBar> createState() => _CustomTitleBarState(); @@ -414,8 +483,8 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { material.Icons.add_link_rounded, size: 18), trailing: const Text('Shift+Ctrl+N').xSmall().muted(), - onPressed: (ctx) => - widget.onNewDatabaseConnection(ctx), + onPressed: (_) => + widget.onNewDatabaseConnection(), child: const Text('New Database Connection'), ), MenuButton( diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart new file mode 100644 index 00000000..eedeba8a --- /dev/null +++ b/lib/features/main_screen/result_grid_view.dart @@ -0,0 +1,318 @@ +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:shadcn_flutter/shadcn_flutter.dart'; + +/// Layout metrics for [VirtualResultGrid]. +abstract final class ResultGridMetrics { + static const double rowHeight = 36; + static const double headerHeight = 36; + static const double minColumnWidth = 120; + static const double maxColumnWidth = 280; + static const int columnWidthSampleRows = 40; + static const int tooltipMinLength = 48; +} + +/// Computes fixed column widths from headers and a sample of [rows]. +List computeResultGridColumnWidths({ + required List columns, + required List> rows, + double minWidth = ResultGridMetrics.minColumnWidth, + double maxWidth = ResultGridMetrics.maxColumnWidth, + int sampleRowCount = ResultGridMetrics.columnWidthSampleRows, +}) { + if (columns.isEmpty) return const []; + + final widths = List.filled(columns.length, minWidth); + final sample = rows.length < sampleRowCount ? rows.length : sampleRowCount; + + for (var c = 0; c < columns.length; c++) { + var maxChars = columns[c].length; + for (var r = 0; r < sample; r++) { + if (c < rows[r].length && rows[r][c].length > maxChars) { + maxChars = rows[r][c].length; + } + } + widths[c] = (maxChars * 7.5 + 24).clamp(minWidth, maxWidth); + } + return widths; +} + +/// Virtualized read-only grid for SQL query results. +class VirtualResultGrid extends material.StatefulWidget { + const VirtualResultGrid({ + super.key, + required this.columns, + required this.rows, + }); + + final List columns; + final List> rows; + + @override + material.State createState() => _VirtualResultGridState(); +} + +class _VirtualResultGridState extends material.State { + final _horizontalController = material.ScrollController(); + final _verticalController = material.ScrollController(); + + List _columnWidths = const []; + bool _widthsNeedUpdate = true; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_widthsNeedUpdate) { + _columnWidths = _computeColumnWidths(); + _widthsNeedUpdate = false; + } + } + + @override + void didUpdateWidget(VirtualResultGrid oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.columns != widget.columns || + oldWidget.rows != widget.rows) { + _widthsNeedUpdate = true; + } + } + + @override + void dispose() { + _horizontalController.dispose(); + _verticalController.dispose(); + super.dispose(); + } + + List _computeColumnWidths() { + return computeResultGridColumnWidths( + columns: widget.columns, + rows: widget.rows, + minWidth: context.scaled(ResultGridMetrics.minColumnWidth), + maxWidth: context.scaled(ResultGridMetrics.maxColumnWidth), + ); + } + + double get _tableWidth { + if (_columnWidths.isEmpty) return 0; + return _columnWidths.reduce((a, b) => a + b); + } + + double _scaledRowHeight(material.BuildContext context) => + context.scaled(ResultGridMetrics.rowHeight); + + double _scaledHeaderHeight(material.BuildContext context) => + context.scaled(ResultGridMetrics.headerHeight); + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final colCount = widget.columns.length; + final rowHeight = _scaledRowHeight(context); + final headerHeight = _scaledHeaderHeight(context); + + return material.RepaintBoundary( + child: material.LayoutBuilder( + builder: (context, constraints) { + final tableWidth = _tableWidth > constraints.maxWidth + ? _tableWidth + : constraints.maxWidth; + + return material.Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + notificationPredicate: (_) => true, + child: material.SingleChildScrollView( + controller: _horizontalController, + scrollDirection: material.Axis.horizontal, + child: material.SizedBox( + width: tableWidth, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _HeaderRow( + columns: widget.columns, + columnWidths: _columnWidths, + height: headerHeight, + colorScheme: cs, + ), + material.Expanded( + child: material.Scrollbar( + controller: _verticalController, + thumbVisibility: true, + child: material.ListView.builder( + controller: _verticalController, + itemCount: widget.rows.length, + itemExtent: rowHeight, + itemBuilder: (context, rowIndex) { + final row = widget.rows[rowIndex]; + final isEven = rowIndex.isEven; + return _DataRow( + key: ValueKey('result-row-$rowIndex'), + row: row, + columnWidths: _columnWidths, + columnCount: colCount, + height: rowHeight, + colorScheme: cs, + striped: !isEven, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} + +class _HeaderRow extends material.StatelessWidget { + const _HeaderRow({ + required this.columns, + required this.columnWidths, + required this.height, + required this.colorScheme, + }); + + final List columns; + final List columnWidths; + final double height; + final ColorScheme colorScheme; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + height: height, + decoration: material.BoxDecoration( + color: colorScheme.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.5), + ), + ), + ), + child: material.Row( + children: [ + for (var i = 0; i < columns.length; i++) + _GridCell( + text: columns[i], + width: columnWidths[i], + isHeader: true, + colorScheme: colorScheme, + ), + ], + ), + ); + } +} + +class _DataRow extends material.StatelessWidget { + const _DataRow({ + super.key, + required this.row, + required this.columnWidths, + required this.columnCount, + required this.height, + required this.colorScheme, + required this.striped, + }); + + final List row; + final List columnWidths; + final int columnCount; + final double height; + final ColorScheme colorScheme; + final bool striped; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + height: height, + decoration: material.BoxDecoration( + color: striped + ? colorScheme.muted.withValues(alpha: 0.12) + : material.Colors.transparent, + border: material.Border( + bottom: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.15), + ), + ), + ), + child: material.Row( + children: [ + for (var c = 0; c < columnCount; c++) + _GridCell( + text: c < row.length ? row[c] : '', + width: columnWidths[c], + colorScheme: colorScheme, + ), + ], + ), + ); + } +} + +class _GridCell extends material.StatelessWidget { + const _GridCell({ + required this.text, + required this.width, + required this.colorScheme, + this.isHeader = false, + }); + + final String text; + final double width; + final ColorScheme colorScheme; + final bool isHeader; + + @override + material.Widget build(material.BuildContext context) { + final isNull = !isHeader && text == 'NULL'; + final style = material.TextStyle( + fontSize: isHeader ? 12 : 12, + fontWeight: + isHeader ? material.FontWeight.w600 : material.FontWeight.normal, + fontFamily: isHeader ? null : 'monospace', + color: isNull + ? colorScheme.mutedForeground.withValues(alpha: 0.5) + : (isHeader ? colorScheme.foreground : colorScheme.foreground), + fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, + ); + + final cell = material.Container( + width: width, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Text( + text, + style: style, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ); + + if (isHeader) return cell; + + return material.Tooltip( + message: text.length >= ResultGridMetrics.tooltipMinLength ? text : '', + waitDuration: const Duration(milliseconds: 400), + child: material.GestureDetector( + onSecondaryTap: () => Clipboard.setData(ClipboardData(text: text)), + child: cell, + ), + ); + } +} diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index 36675758..c7f2fd47 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/csv/result_grid_csv.dart'; import 'package:querya_desktop/core/csv/save_result_grid_csv.dart'; import 'package:querya_desktop/core/json/result_grid_json.dart'; import 'package:querya_desktop/core/json/save_result_grid_json.dart'; +import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Query output: grid, loading, error, or placeholder. @@ -148,58 +149,7 @@ class ResultsTab extends StatelessWidget { ), ), material.Expanded( - child: material.Scrollbar( - child: material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.SingleChildScrollView( - child: material.Table( - border: material.TableBorder.all( - color: Theme.of(context) - .colorScheme - .border - .withValues(alpha: 0.35), - ), - defaultColumnWidth: const material.IntrinsicColumnWidth(), - children: [ - material.TableRow( - decoration: material.BoxDecoration( - color: Theme.of(context) - .colorScheme - .muted - .withValues(alpha: 0.35), - ), - children: columns - .map( - (c) => material.Padding( - padding: const material.EdgeInsets.all(8), - child: Text(c).semiBold().small(), - ), - ) - .toList(), - ), - ...rows.map( - (r) => material.TableRow( - children: r - .map( - (cell) => material.Padding( - padding: const material.EdgeInsets.all(8), - child: material.SelectableText( - cell, - style: const material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - ), - ), - ), - ) - .toList(), - ), - ), - ], - ), - ), - ), - ), + child: VirtualResultGrid(columns: columns, rows: rows), ), ], ); diff --git a/lib/features/main_screen/sql_query_history_dialog.dart b/lib/features/main_screen/sql_query_history_dialog.dart index bf5a1d98..2a8aa146 100644 --- a/lib/features/main_screen/sql_query_history_dialog.dart +++ b/lib/features/main_screen/sql_query_history_dialog.dart @@ -69,8 +69,10 @@ class _SqlQueryHistoryDialogContentState }); } + static final _whitespacePattern = RegExp(r'\s+'); + static String _previewOneLine(String sql) { - final collapsed = sql.replaceAll(RegExp(r'\s+'), ' ').trim(); + final collapsed = sql.replaceAll(_whitespacePattern, ' ').trim(); if (collapsed.length <= 96) return collapsed; return '${collapsed.substring(0, 93)}…'; } diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 7d777f3f..b61720cd 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -1,4 +1,5 @@ -import 'package:flutter/material.dart' as material show Alignment, Axis, Container, EdgeInsets, BoxDecoration, GestureDetector, Padding, BorderRadius, Center, CrossAxisAlignment, Icon, Icons, MouseRegion, AnimatedContainer, AnimatedScale, Curves, SystemMouseCursors, LayoutBuilder, HitTestBehavior, SizedBox, SingleChildScrollView, Row, MainAxisSize; +import 'package:flutter/material.dart' as material show Alignment, Axis, Container, EdgeInsets, BoxDecoration, GestureDetector, Padding, BorderRadius, Center, Icon, Icons, MouseRegion, AnimatedContainer, AnimatedScale, Curves, SystemMouseCursors, SizedBox, SingleChildScrollView, Row, MainAxisSize; +import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -73,7 +74,13 @@ class WorkspacePanel extends StatefulWidget { class _WorkspacePanelState extends State { int _editorTabIndex = 0; int _outputTabIndex = 0; - double _topFraction = 0.7; + final ValueNotifier _topFraction = ValueNotifier(0.7); + + @override + void dispose() { + _topFraction.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -186,104 +193,54 @@ class _WorkspacePanelState extends State { ); } - final topFlex = (_topFraction * 100).round().clamp(20, 80); - final bottomFlex = 100 - topFlex; - return material.Container( color: theme.colorScheme.background, - child: material.LayoutBuilder( - builder: (context, constraints) { - final totalHeight = constraints.maxHeight; - return Column( - children: [ - Expanded( - flex: topFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SectionBar( - title: 'Query', - tabs: const ['Query Editor', 'Query History'], - index: _editorTabIndex, - onTabChanged: (v) => setState(() => _editorTabIndex = v), - trailing: const _RunButton(), - ), - const Divider(height: 1), - Expanded( - child: IndexedStack( - index: _editorTabIndex, - children: const [ - QueryEditorTab(), - _PlaceholderTab(message: 'Query history'), - ], - ), - ), - ], - ), - ), - _HorizontalResizeHandle( - totalHeight: totalHeight, - onDrag: (dy) { - if (totalHeight <= 0) return; - setState(() { - _topFraction = (_topFraction + dy / totalHeight).clamp(0.2, 0.8); - }); - }, + child: VerticalSplitPane( + fraction: _topFraction, + handleKey: const Key('workspace_panel_resize_handle'), + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SectionBar( + title: 'Query', + tabs: const ['Query Editor', 'Query History'], + index: _editorTabIndex, + onTabChanged: (v) => setState(() => _editorTabIndex = v), + trailing: const _RunButton(), + ), + const Divider(height: 1), + Expanded( + child: IndexedStack( + index: _editorTabIndex, + children: const [ + QueryEditorTab(), + _PlaceholderTab(message: 'Query history'), + ], ), - Expanded( - flex: bottomFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SectionBar( - title: 'Output', - tabs: const ['Data Output', 'Messages', 'Notifications'], - index: _outputTabIndex, - onTabChanged: (v) => setState(() => _outputTabIndex = v), - ), - const Divider(height: 1), - Expanded( - child: IndexedStack( - index: _outputTabIndex, - children: const [ - ResultsTab(), - _PlaceholderTab(message: 'Messages'), - _PlaceholderTab(message: 'Notifications'), - ], - ), - ), - ], - ), + ), + ], + ), + bottom: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SectionBar( + title: 'Output', + tabs: const ['Data Output', 'Messages', 'Notifications'], + index: _outputTabIndex, + onTabChanged: (v) => setState(() => _outputTabIndex = v), + ), + const Divider(height: 1), + Expanded( + child: IndexedStack( + index: _outputTabIndex, + children: const [ + ResultsTab(), + _PlaceholderTab(message: 'Messages'), + _PlaceholderTab(message: 'Notifications'), + ], ), - ], - ); - }, - ), - ); - } -} - -class _HorizontalResizeHandle extends StatelessWidget { - const _HorizontalResizeHandle({ - required this.totalHeight, - required this.onDrag, - }); - - final double totalHeight; - final void Function(double dy) onDrag; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: material.SystemMouseCursors.resizeRow, - child: material.GestureDetector( - key: const Key('workspace_panel_resize_handle'), - behavior: material.HitTestBehavior.opaque, - onVerticalDragUpdate: (e) => onDrag(e.delta.dy), - child: material.Container( - height: 6, - color: theme.border.withValues(alpha: 0.15), + ), + ], ), ), ); diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 76f907ed..0bb4cb33 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -362,7 +362,7 @@ class _MongoCollectionsViewState extends material.State { // ─── Row widget ────────────────────────────────────────────────────────────── -class _CollectionRow extends StatefulWidget { +class _CollectionRow extends StatelessWidget { const _CollectionRow({ required this.collection, required this.colorScheme, @@ -375,41 +375,31 @@ class _CollectionRow extends StatefulWidget { final VoidCallback onView; final VoidCallback onDrop; - @override - State<_CollectionRow> createState() => _CollectionRowState(); -} - -class _CollectionRowState extends State<_CollectionRow> { - bool _hovered = false; - @override Widget build(BuildContext context) { - final cs = widget.colorScheme; - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, - color: _hovered - ? cs.muted.withValues(alpha: 0.15) - : Colors.transparent, - padding: const material.EdgeInsets.symmetric( - horizontal: 20, vertical: 10), - child: Row( - children: [ - _ActionButton( - label: 'View', - icon: material.Icons.visibility_rounded, - color: const Color(0xFF4CAF50), - onTap: widget.onView, - ), - const Gap(16), - material.Expanded( - child: material.InkWell( - onTap: widget.onView, + final cs = colorScheme; + return material.Material( + color: Colors.transparent, + child: material.InkWell( + onTap: onView, + hoverColor: cs.muted.withValues(alpha: 0.15), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + child: Row( + children: [ + _ActionButton( + label: 'View', + icon: material.Icons.visibility_rounded, + color: const Color(0xFF4CAF50), + onTap: onView, + ), + const Gap(16), + Expanded( child: Text( - widget.collection.name, + collection.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, style: material.TextStyle( color: cs.primary, fontSize: 14, @@ -417,26 +407,24 @@ class _CollectionRowState extends State<_CollectionRow> { ), ), ), - ), - material.SizedBox( - width: 100, - child: Text(widget.collection.documentCount?.toString() ?? '—') - .muted() - .small(), - ), - material.SizedBox( - width: 100, - child: Text(_formatSize(widget.collection.size)) - .muted() - .small(), - ), - _ActionButton( - label: 'Del', - icon: material.Icons.delete_rounded, - color: const Color(0xFFEF5350), - onTap: widget.onDrop, - ), - ], + SizedBox( + width: 100, + child: Text(collection.documentCount?.toString() ?? '—') + .muted() + .small(), + ), + SizedBox( + width: 100, + child: Text(_formatSize(collection.size)).muted().small(), + ), + _ActionButton( + label: 'Del', + icon: material.Icons.delete_rounded, + color: const Color(0xFFEF5350), + onTap: onDrop, + ), + ], + ), ), ), ); @@ -455,7 +443,7 @@ class _CollectionRowState extends State<_CollectionRow> { } } -class _ActionButton extends StatefulWidget { +class _ActionButton extends StatelessWidget { const _ActionButton({ required this.label, required this.icon, @@ -468,43 +456,29 @@ class _ActionButton extends StatefulWidget { final Color color; final VoidCallback onTap; - @override - State<_ActionButton> createState() => _ActionButtonState(); -} - -class _ActionButtonState extends State<_ActionButton> { - bool _hovered = false; - @override Widget build(BuildContext context) { - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + return material.Material( + color: Colors.transparent, child: material.InkWell( - onTap: widget.onTap, + onTap: onTap, borderRadius: material.BorderRadius.circular(6), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, + child: material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 12, vertical: 6), decoration: material.BoxDecoration( - color: _hovered - ? widget.color.withValues(alpha: 0.9) - : widget.color.withValues(alpha: 0.75), + color: color.withValues(alpha: 0.8), borderRadius: material.BorderRadius.circular(6), ), child: Row( mainAxisSize: material.MainAxisSize.min, children: [ - material.Icon(widget.icon, - size: 14, color: material.Colors.white), + material.Icon(icon, size: 14, color: Colors.white), const Gap(5), Text( - widget.label, + label, style: const material.TextStyle( - color: material.Colors.white, + color: Colors.white, fontSize: 12, fontWeight: material.FontWeight.w500, ), diff --git a/lib/features/mongodb/mongo_databases_view.dart b/lib/features/mongodb/mongo_databases_view.dart index 8630995b..087ac0dc 100644 --- a/lib/features/mongodb/mongo_databases_view.dart +++ b/lib/features/mongodb/mongo_databases_view.dart @@ -349,7 +349,7 @@ class _MongoDatabasesViewState extends State { // ─── Helper widgets ────────────────────────────────────────────────────────── -class _DatabaseRow extends StatefulWidget { +class _DatabaseRow extends StatelessWidget { const _DatabaseRow({ required this.database, required this.colorScheme, @@ -362,43 +362,31 @@ class _DatabaseRow extends StatefulWidget { final VoidCallback onView; final VoidCallback onDrop; - @override - State<_DatabaseRow> createState() => _DatabaseRowState(); -} - -class _DatabaseRowState extends State<_DatabaseRow> { - bool _hovered = false; - @override Widget build(BuildContext context) { - final cs = widget.colorScheme; - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, - color: _hovered - ? cs.muted.withValues(alpha: 0.15) - : Colors.transparent, - padding: const material.EdgeInsets.symmetric( - horizontal: 20, vertical: 10), - child: Row( - children: [ - // View button - _ActionButton( - label: 'View', - icon: material.Icons.visibility_rounded, - color: const Color(0xFF4CAF50), - onTap: widget.onView, - ), - const Gap(16), - // Database name - material.Expanded( - child: material.InkWell( - onTap: widget.onView, + final cs = colorScheme; + return material.Material( + color: Colors.transparent, + child: material.InkWell( + onTap: onView, + hoverColor: cs.muted.withValues(alpha: 0.15), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + child: Row( + children: [ + _ActionButton( + label: 'View', + icon: material.Icons.visibility_rounded, + color: const Color(0xFF4CAF50), + onTap: onView, + ), + const Gap(16), + Expanded( child: Text( - widget.database.name, + database.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, style: material.TextStyle( color: cs.primary, fontSize: 14, @@ -406,22 +394,18 @@ class _DatabaseRowState extends State<_DatabaseRow> { ), ), ), - ), - // Size - material.SizedBox( - width: 120, - child: Text(_formatSize(widget.database.sizeOnDisk)) - .muted() - .small(), - ), - // Delete button - _ActionButton( - label: 'Del', - icon: material.Icons.delete_rounded, - color: const Color(0xFFEF5350), - onTap: widget.onDrop, - ), - ], + SizedBox( + width: 120, + child: Text(_formatSize(database.sizeOnDisk)).muted().small(), + ), + _ActionButton( + label: 'Del', + icon: material.Icons.delete_rounded, + color: const Color(0xFFEF5350), + onTap: onDrop, + ), + ], + ), ), ), ); @@ -440,7 +424,7 @@ class _DatabaseRowState extends State<_DatabaseRow> { } } -class _ActionButton extends StatefulWidget { +class _ActionButton extends StatelessWidget { const _ActionButton({ required this.label, required this.icon, @@ -453,43 +437,29 @@ class _ActionButton extends StatefulWidget { final Color color; final VoidCallback onTap; - @override - State<_ActionButton> createState() => _ActionButtonState(); -} - -class _ActionButtonState extends State<_ActionButton> { - bool _hovered = false; - @override Widget build(BuildContext context) { - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + return material.Material( + color: Colors.transparent, child: material.InkWell( - onTap: widget.onTap, + onTap: onTap, borderRadius: material.BorderRadius.circular(6), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, + child: material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 12, vertical: 6), decoration: material.BoxDecoration( - color: _hovered - ? widget.color.withValues(alpha: 0.9) - : widget.color.withValues(alpha: 0.75), + color: color.withValues(alpha: 0.8), borderRadius: material.BorderRadius.circular(6), ), child: Row( mainAxisSize: material.MainAxisSize.min, children: [ - material.Icon(widget.icon, - size: 14, color: material.Colors.white), + material.Icon(icon, size: 14, color: Colors.white), const Gap(5), Text( - widget.label, + label, style: const material.TextStyle( - color: material.Colors.white, + color: Colors.white, fontSize: 12, fontWeight: material.FontWeight.w500, ), diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index e1d1a3f2..a5b98262 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _defaultLimit = 25; +const _prettyJsonEncoder = JsonEncoder.withIndent(' '); /// Paginated document browser for a MongoDB collection. class MongoDocumentsView extends material.StatefulWidget { @@ -393,92 +394,111 @@ class _DocumentCard extends StatefulWidget { } class _DocumentCardState extends State<_DocumentCard> { - bool _hovered = false; bool _expanded = false; + late String _keysPreviewText; + String? _prettyJsonCache; + + @override + void initState() { + super.initState(); + _keysPreviewText = _computeKeysPreview(widget.document); + } + + @override + void didUpdateWidget(covariant _DocumentCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.document, widget.document)) { + _keysPreviewText = _computeKeysPreview(widget.document); + _prettyJsonCache = null; + } + } + + void _toggleExpanded() { + setState(() { + _expanded = !_expanded; + if (_expanded && _prettyJsonCache == null) { + _prettyJsonCache = _encodePrettyJson(widget.document); + } + }); + } @override Widget build(BuildContext context) { final cs = widget.colorScheme; final scs = widget.shadcnCs; final idStr = widget.document['_id']?.toString() ?? '—'; - final keysPreview = _keysPreview(widget.document); - - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - decoration: material.BoxDecoration( - color: _hovered - ? scs.muted.withValues(alpha: 0.15) - : cs.card, - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.3), width: 1), - ), + + return material.Container( + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.3), width: 1), + ), + clipBehavior: material.Clip.antiAlias, + child: material.Material( + color: cs.card, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, mainAxisSize: material.MainAxisSize.min, children: [ - // Header row - material.InkWell( - onTap: widget.onView, - borderRadius: material.BorderRadius.circular(8), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 16, vertical: 10), - child: Row( - children: [ - material.Icon(material.Icons.description_rounded, - size: 16, color: scs.mutedForeground), - const Gap(8), - Text( - idStr, - style: material.TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: material.FontWeight.w500), - ), - const Spacer(), - // Expand toggle - material.InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon( - _expanded - ? material.Icons.expand_less_rounded - : material.Icons.expand_more_rounded, - size: 18, - color: scs.mutedForeground, + material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: widget.onView, + hoverColor: scs.muted.withValues(alpha: 0.15), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + child: Row( + children: [ + material.Icon(material.Icons.description_rounded, + size: 16, color: scs.mutedForeground), + const Gap(8), + Text( + idStr, + style: material.TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: material.FontWeight.w500), + ), + const Spacer(), + material.InkWell( + onTap: _toggleExpanded, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon( + _expanded + ? material.Icons.expand_less_rounded + : material.Icons.expand_more_rounded, + size: 18, + color: scs.mutedForeground, + ), ), ), - ), - const Gap(8), - // View - _SmallActionButton( - icon: material.Icons.edit_rounded, - color: const Color(0xFF42A5F5), - onTap: widget.onView, - ), - const Gap(4), - // Delete - _SmallActionButton( - icon: material.Icons.delete_rounded, - color: const Color(0xFFEF5350), - onTap: widget.onDelete, - ), - ], + const Gap(8), + _SmallActionButton( + icon: material.Icons.edit_rounded, + color: const Color(0xFF42A5F5), + onTap: widget.onView, + ), + const Gap(4), + _SmallActionButton( + icon: material.Icons.delete_rounded, + color: const Color(0xFFEF5350), + onTap: widget.onDelete, + ), + ], + ), ), ), ), - // Preview / expanded JSON material.Padding( padding: const material.EdgeInsets.only( left: 16, right: 16, bottom: 10), child: _expanded ? material.SelectableText( - _prettyJson(widget.document), + _prettyJsonCache ?? '', style: material.TextStyle( fontSize: 12, fontFamily: 'monospace', @@ -486,7 +506,7 @@ class _DocumentCardState extends State<_DocumentCard> { ), ) : Text( - keysPreview, + _keysPreviewText, overflow: TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( @@ -502,16 +522,15 @@ class _DocumentCardState extends State<_DocumentCard> { ); } - /// Returns a compact list of top-level keys (excluding _id). - String _keysPreview(Map doc) { + static String _computeKeysPreview(Map doc) { final keys = doc.keys.where((k) => k != '_id').toList(); if (keys.isEmpty) return '{ }'; return keys.join(', '); } - String _prettyJson(Map doc) { + static String _encodePrettyJson(Map doc) { try { - return const JsonEncoder.withIndent(' ').convert(doc); + return _prettyJsonEncoder.convert(doc); } catch (_) { return doc.toString(); } @@ -520,7 +539,7 @@ class _DocumentCardState extends State<_DocumentCard> { // ─── Small icon-only action button ────────────────────────────────────────── -class _SmallActionButton extends StatefulWidget { +class _SmallActionButton extends StatelessWidget { const _SmallActionButton({ required this.icon, required this.color, @@ -531,34 +550,14 @@ class _SmallActionButton extends StatefulWidget { final Color color; final VoidCallback onTap; - @override - State<_SmallActionButton> createState() => _SmallActionButtonState(); -} - -class _SmallActionButtonState extends State<_SmallActionButton> { - bool _hovered = false; - @override Widget build(BuildContext context) { - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.InkWell( - onTap: widget.onTap, - borderRadius: material.BorderRadius.circular(4), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - padding: const material.EdgeInsets.all(5), - decoration: material.BoxDecoration( - color: _hovered - ? widget.color.withValues(alpha: 0.15) - : material.Colors.transparent, - borderRadius: material.BorderRadius.circular(4), - ), - child: material.Icon(widget.icon, size: 15, color: widget.color), - ), - ), + return material.IconButton( + onPressed: onTap, + icon: material.Icon(icon, size: 15, color: color), + padding: const material.EdgeInsets.all(5), + constraints: const material.BoxConstraints(minWidth: 28, minHeight: 28), + splashRadius: 18, ); } } diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index 70d465ec..b7baea71 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -419,7 +419,7 @@ class _BreadcrumbBar extends StatelessWidget { } } -class _CrumbChip extends StatefulWidget { +class _CrumbChip extends material.StatelessWidget { const _CrumbChip({ required this.label, required this.isLast, @@ -428,44 +428,37 @@ class _CrumbChip extends StatefulWidget { final String label; final bool isLast; - final VoidCallback? onTap; - - @override - material.State<_CrumbChip> createState() => _CrumbChipState(); -} - -class _CrumbChipState extends material.State<_CrumbChip> { - bool _hovered = false; + final material.VoidCallback? onTap; @override material.Widget build(material.BuildContext context) { final cs = shadcn.Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: widget.onTap != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.GestureDetector( - onTap: widget.onTap, - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + final child = isLast + ? Text(label).semiBold().small() + : Text(label, + style: material.TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: material.FontWeight.w500)) + .small(); + + if (onTap == null) { + return material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: child, + ); + } + + return material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + hoverColor: cs.primary.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: material.BoxDecoration( - color: _hovered && widget.onTap != null - ? cs.primary.withValues(alpha: 0.1) - : material.Colors.transparent, - borderRadius: material.BorderRadius.circular(4), - ), - child: widget.isLast - ? Text(widget.label).semiBold().small() - : Text(widget.label, - style: material.TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: material.FontWeight.w500)) - .small(), + child: child, ), ), ); diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index 4b7be2e6..d8418cbf 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -8,8 +9,8 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _defaultAutoRefresh = Duration(seconds: 3); -const _summaryChipHeight = 72.0; -const _gridCardHeight = 220.0; +const _summaryChipHeight = 88.0; +const _gridCardMinHeight = 220.0; class MongoStatsView extends material.StatefulWidget { const MongoStatsView({ @@ -117,10 +118,12 @@ class _MongoStatsViewState extends material.State { {'serverStatus': 1}, ); if (!mounted) return; + final changed = + replaceIfChanged(_serverStatus, status, (v) => _serverStatus = v); + if (!changed && !_loading) return; setState(() { - _serverStatus = status; _loading = false; - _lastFetchedAt = DateTime.now(); + if (changed) _lastFetchedAt = DateTime.now(); }); } catch (e) { if (mounted) { @@ -157,10 +160,10 @@ class _MongoStatsViewState extends material.State { {'serverStatus': 1}, ); if (!mounted) return; - setState(() { - _serverStatus = status; - _lastFetchedAt = DateTime.now(); - }); + if (!replaceIfChanged(_serverStatus, status, (v) => _serverStatus = v)) { + return; + } + setState(() => _lastFetchedAt = DateTime.now()); } catch (_) { // Keep last good snapshot on transient errors during auto-refresh. } @@ -245,29 +248,47 @@ class _MongoStatsViewState extends material.State { const Gap(24), _summaryChips(context, status), const Gap(24), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded(child: _memoryCard(context, status)), - const Gap(16), - material.Expanded(child: _operationsCard(context, status)), - ], + _gridRow( + _memoryCard(context, status), + _operationsCard(context, status), ), const Gap(16), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded(child: _connectionsCard(context, status)), - const Gap(16), - material.Expanded(child: _networkCard(context, status)), - ], + _gridRow( + _connectionsCard(context, status), + _networkCard(context, status), ), const Gap(24), _sectionCard(context, 'Server', _extractServerInfo(status)), const Gap(12), - _sectionCard(context, 'Storage', _extractStorageInfo(status)), - const Gap(12), - _sectionCard(context, 'Replication', _extractReplicationInfo(status)), + material.LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 900; + final storage = _extractStorageInfo(status); + final replication = _extractReplicationInfo(status); + if (!wide) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _sectionCard(context, 'Storage', storage), + const Gap(12), + _sectionCard(context, 'Replication', replication), + ], + ); + } + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: _sectionCard(context, 'Storage', storage), + ), + const Gap(16), + material.Expanded( + child: _sectionCard(context, 'Replication', replication), + ), + ], + ); + }, + ), const Gap(12), _sectionCard(context, 'WiredTiger', _extractWiredTigerInfo(status)), ], @@ -281,90 +302,124 @@ class _MongoStatsViewState extends material.State { material.Widget _header(material.BuildContext context) { final cs = shadcn.Theme.of(context).colorScheme; final last = _lastFetchedAt; - return material.Wrap( - crossAxisAlignment: material.WrapCrossAlignment.center, - spacing: 8, - runSpacing: 10, + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - material.Container( - padding: const material.EdgeInsets.all(10), - decoration: material.BoxDecoration( - color: cs.primary.withValues(alpha: 0.12), - borderRadius: material.BorderRadius.circular(12), - ), - child: material.Icon(material.Icons.eco_rounded, size: 28, color: cs.primary), - ), - material.ConstrainedBox( - constraints: const material.BoxConstraints(minWidth: 160, maxWidth: 400), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - Text(widget.connectionRow.name).large().semiBold(), - const Gap(4), - Text('${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 27017}') - .muted() - .small(), - if (last != null) ...[ - const Gap(4), - Text('Last updated ${_formatClock(last)}').muted().xSmall(), - ], - ], - ), - ), - if (widget.onBack != null) - OutlineButton( - onPressed: widget.onBack, - leading: const material.Icon( - material.Icons.grid_view_rounded, - size: 18), - child: const Text('Explorer'), - ), - OutlineButton( - onPressed: _manualRefreshing ? null : _refreshNow, - leading: _manualRefreshing - ? const material.SizedBox( - width: 16, - height: 16, - child: material.CircularProgressIndicator(strokeWidth: 2), - ) - : const material.Icon(material.Icons.refresh_rounded, size: 18), - child: const Text('Refresh now'), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(12), + ), + child: material.Icon(material.Icons.eco_rounded, size: 28, color: cs.primary), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(widget.connectionRow.name).large().semiBold(), + const Gap(4), + Text('${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 27017}') + .muted() + .small(), + if (last != null) ...[ + const Gap(4), + Text('Last updated ${_formatClock(last)}').muted().xSmall(), + ], + ], + ), + ), + ], ), - material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 4), - child: QueryaDropdown( - width: 132, - value: _autoRefreshInterval, - items: const [ - QueryaDropdownItem(value: null, label: 'Auto: off'), - QueryaDropdownItem(value: Duration(seconds: 3), label: 'Auto: 3 s'), - QueryaDropdownItem(value: Duration(seconds: 10), label: 'Auto: 10 s'), - QueryaDropdownItem(value: Duration(seconds: 30), label: 'Auto: 30 s'), - QueryaDropdownItem(value: Duration(seconds: 60), label: 'Auto: 60 s'), - ], - onSelected: (value) { - setState(() => _autoRefreshInterval = value); - _startTimer(); - }, - ), + const Gap(12), + material.Wrap( + spacing: 8, + runSpacing: 8, + alignment: material.WrapAlignment.end, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + if (widget.onBack != null) + OutlineButton( + onPressed: widget.onBack, + leading: const material.Icon( + material.Icons.grid_view_rounded, + size: 18), + child: const Text('Explorer'), + ), + OutlineButton( + onPressed: _manualRefreshing ? null : _refreshNow, + leading: _manualRefreshing + ? const material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator(strokeWidth: 2), + ) + : const material.Icon(material.Icons.refresh_rounded, size: 18), + child: const Text('Refresh now'), + ), + material.SizedBox( + width: 132, + child: QueryaDropdown( + expandToParent: true, + value: _autoRefreshInterval, + items: const [ + QueryaDropdownItem(value: null, label: 'Auto: off'), + QueryaDropdownItem(value: Duration(seconds: 3), label: 'Auto: 3 s'), + QueryaDropdownItem(value: Duration(seconds: 10), label: 'Auto: 10 s'), + QueryaDropdownItem(value: Duration(seconds: 30), label: 'Auto: 30 s'), + QueryaDropdownItem(value: Duration(seconds: 60), label: 'Auto: 60 s'), + ], + onSelected: (value) { + setState(() => _autoRefreshInterval = value); + _startTimer(); + }, + ), + ), + ], ), ], ); } + material.Widget _gridRow(material.Widget left, material.Widget right) { + return material.IntrinsicHeight( + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Expanded(child: left), + const Gap(16), + material.Expanded(child: right), + ], + ), + ); + } + + String _formatUptime(int seconds) { + if (seconds <= 0) return '0m'; + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + if (days > 0) return '${days}d ${hours}h'; + if (hours > 0) return '${hours}h ${minutes}m'; + return '${minutes}m'; + } + material.Widget _summaryChips(material.BuildContext context, Map status) { final cs = shadcn.Theme.of(context).colorScheme; final version = _getString(status, 'version') ?? '—'; - final uptime = _getInt(status, 'uptime') ?? 0; - final uptimeDays = (uptime / 86400).toStringAsFixed(1); + final uptime = _formatUptime(_getInt(status, 'uptime') ?? 0); final connections = _getNestedInt(status, 'connections', 'current') ?? 0; - final maxConnections = _getNestedInt(status, 'connections', 'available') ?? 0; + final available = _getNestedInt(status, 'connections', 'available') ?? 0; final ops = _getNestedInt(status, 'opcounters', 'query') ?? 0; 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), decoration: material.BoxDecoration( @@ -373,8 +428,12 @@ class _MongoStatsViewState extends material.State { border: material.Border.all(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( @@ -384,7 +443,17 @@ class _MongoStatsViewState 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, + ), + ), ], ), ), @@ -395,23 +464,32 @@ class _MongoStatsViewState extends material.State { ); } return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, children: [ chip('Version', version, material.Icons.tag_rounded), const Gap(12), - chip('Uptime', '$uptimeDays days', material.Icons.schedule_rounded), + chip('Uptime', uptime, material.Icons.schedule_rounded), const Gap(12), - chip('Connections', '$connections / $maxConnections', material.Icons.people_outline_rounded), + chip('Connections', '$connections / $available', material.Icons.people_outline_rounded), const Gap(12), chip('Queries', '$ops', material.Icons.speed_rounded), ], ); } - material.Widget _card(material.BuildContext context, String title, material.Widget body, {double? height}) { + material.Widget _card( + material.BuildContext context, + String title, + material.Widget body, { + double? minHeight, + bool stretchBody = false, + }) { 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, @@ -419,17 +497,35 @@ class _MongoStatsViewState extends material.State { border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), ), child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.start, + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Text(title).semiBold(), const Gap(12), - body, + if (stretchBody) material.Expanded(child: body) else body, ], ), ); } + material.Widget _metricList( + material.BuildContext context, + List rows, { + bool stretch = false, + }) { + final column = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: rows, + ); + if (!stretch) return column; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + column, + const material.Spacer(), + ], + ); + } + material.Widget _memoryCard(material.BuildContext context, Map status) { final mem = status['mem'] as Map?; final resident = _getInt(mem, 'resident') ?? 0; @@ -439,17 +535,18 @@ class _MongoStatsViewState extends material.State { return _card( context, 'Memory', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Resident', _formatBytes(resident)), - _row(context, 'Virtual', _formatBytes(virtual)), - _row(context, 'Mapped', _formatBytes(mapped)), - _row(context, 'Mapped + Journal', _formatBytes(mappedWithJournal)), + _metricList( + context, + [ + _metricRow(context, 'Resident', _formatBytes(resident)), + _metricRow(context, 'Virtual', _formatBytes(virtual)), + _metricRow(context, 'Mapped', _formatBytes(mapped)), + _metricRow(context, 'Mapped + Journal', _formatBytes(mappedWithJournal)), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -462,17 +559,18 @@ class _MongoStatsViewState extends material.State { return _card( context, 'Operations', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Inserts', '$inserts'), - _row(context, 'Queries', '$queries'), - _row(context, 'Updates', '$updates'), - _row(context, 'Deletes', '$deletes'), + _metricList( + context, + [ + _metricRow(context, 'Inserts', '$inserts'), + _metricRow(context, 'Queries', '$queries'), + _metricRow(context, 'Updates', '$updates'), + _metricRow(context, 'Deletes', '$deletes'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -484,16 +582,17 @@ class _MongoStatsViewState extends material.State { return _card( context, 'Connections', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Current', '$current'), - _row(context, 'Available', '$available'), - _row(context, 'Active clients', '$active'), + _metricList( + context, + [ + _metricRow(context, 'Current', '$current'), + _metricRow(context, 'Available', '$available'), + _metricRow(context, 'Active clients', '$active'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -505,16 +604,17 @@ class _MongoStatsViewState extends material.State { return _card( context, 'Network', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Bytes in', _formatBytes(bytesIn)), - _row(context, 'Bytes out', _formatBytes(bytesOut)), - _row(context, 'Requests', '$numRequests'), + _metricList( + context, + [ + _metricRow(context, 'Bytes in', _formatBytes(bytesIn)), + _metricRow(context, 'Bytes out', _formatBytes(bytesOut)), + _metricRow(context, 'Requests', '$numRequests'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -523,23 +623,76 @@ class _MongoStatsViewState extends material.State { return _card( context, title, - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [for (final e in data.entries) _row(context, e.key, e.value)], + _twoColumnMetrics( + context, + data.entries.map((e) => MapEntry(e.key, e.value)).toList(), ), ); } - material.Widget _row(material.BuildContext context, String key, String value) { + material.Widget _twoColumnMetrics( + material.BuildContext context, + List> entries, + ) { + if (entries.length <= 4) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in entries) _metricRow(context, e.key, e.value)], + ); + } + final mid = (entries.length / 2).ceil(); + final left = entries.sublist(0, mid); + final right = entries.sublist(mid); + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in left) _metricRow(context, e.key, e.value)], + ), + ), + const Gap(24), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in right) _metricRow(context, e.key, e.value)], + ), + ), + ], + ); + } + + material.Widget _metricRow(material.BuildContext context, String label, String value) { final cs = shadcn.Theme.of(context).colorScheme; return material.Padding( - padding: const material.EdgeInsets.symmetric(vertical: 4), + padding: const material.EdgeInsets.symmetric(vertical: 6), child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - material.SizedBox(width: 160, child: Text(key).muted().small()), - material.Expanded(child: material.SelectableText(value, style: material.TextStyle(fontSize: 13, color: cs.foreground))), + material.Expanded( + flex: 3, + child: Text( + label, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ).muted().small(), + ), + const Gap(12), + material.Flexible( + flex: 2, + child: material.SelectableText( + value, + textAlign: material.TextAlign.right, + maxLines: 2, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ), ], ), ); @@ -582,8 +735,7 @@ class _MongoStatsViewState extends material.State { if (status['process'] != null) result['Process'] = status['process'].toString(); final uptime = _getInt(status, 'uptime'); if (uptime != null) { - final days = (uptime / 86400).toStringAsFixed(1); - result['Uptime'] = '$days days ($uptime seconds)'; + result['Uptime'] = '${_formatUptime(uptime)} ($uptime s)'; } return result; } diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index 0382846f..ab5e0362 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/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// MongoDB connection form data. @@ -79,28 +80,35 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo bool _isTesting = false; String? _testResult; Timer? _dismissTimer; + late final FormValidityNotifier _formValidNotifier; @override void initState() { super.initState(); - // Rebuild on every keystroke so Save button reacts to validity - _nameController.addListener(_onFieldChanged); - _hostController.addListener(_onFieldChanged); - _portController.addListener(_onFieldChanged); - _connectionStringController.addListener(_onFieldChanged); - } - - void _onFieldChanged() { - setState(() {}); + _formValidNotifier = FormValidityNotifier(() => _formData.isValid); + for (final c in [ + _nameController, + _hostController, + _portController, + _connectionStringController, + ]) { + _formValidNotifier.listenTo(c); + } + _formValidNotifier.seed(); } @override void dispose() { _dismissTimer?.cancel(); - _nameController.removeListener(_onFieldChanged); - _hostController.removeListener(_onFieldChanged); - _portController.removeListener(_onFieldChanged); - _connectionStringController.removeListener(_onFieldChanged); + for (final c in [ + _nameController, + _hostController, + _portController, + _connectionStringController, + ]) { + _formValidNotifier.unlistenFrom(c); + } + _formValidNotifier.dispose(); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); @@ -255,7 +263,10 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo children: [ material.Checkbox( value: _useConnectionString, - onChanged: (v) => setState(() => _useConnectionString = v ?? false), + onChanged: (v) { + setState(() => _useConnectionString = v ?? false); + _formValidNotifier.seed(); + }, ), const Gap(8), const Text('Use connection string').small(), @@ -469,43 +480,53 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo ), material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: material.Row( - children: [ - OutlineButton( - onPressed: _formData.isValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, - ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, - color: _formData.isValid ? theme.primary : theme.mutedForeground, + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Row( + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: formValid + ? theme.primary + : theme.mutedForeground, ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: _formData.isValid ? theme.primary : theme.mutedForeground, + ), ), - ), - ), - const material.Spacer(), - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( - onPressed: _formData.isValid ? _save : null, - child: const Text('Save'), - ), - ], + const material.Spacer(), + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], + ); + }, ), ), ], diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 525215b9..a143e511 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/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows MySQL / MariaDB connection form dialog. @@ -47,26 +48,31 @@ class _MysqlConnectionFormContentState bool _isTesting = false; String? _testResult; Timer? _dismissTimer; + late final FormValidityNotifier _formValidNotifier; @override void initState() { super.initState(); - _nameController.addListener(_onFieldChanged); - _hostController.addListener(_onFieldChanged); - _portController.addListener(_onFieldChanged); - _databaseController.addListener(_onFieldChanged); - _usernameController.addListener(_onFieldChanged); - _connectionStringController.addListener(_onFieldChanged); + _formValidNotifier = FormValidityNotifier(_computeFormValid); + for (final c in [ + _nameController, + _hostController, + _portController, + _databaseController, + _usernameController, + _connectionStringController, + ]) { + _formValidNotifier.listenTo(c); + } + _formValidNotifier.seed(); } - void _onFieldChanged() => setState(() {}); - bool _looksLikeMysqlUri(String s) { final t = s.trim().toLowerCase(); return t.startsWith('mysql://') || t.startsWith('mariadb://'); } - bool get _formValid { + bool _computeFormValid() { final uri = _connectionStringController.text.trim(); if (uri.isNotEmpty) { return _looksLikeMysqlUri(uri); @@ -93,7 +99,7 @@ class _MysqlConnectionFormContentState } Future _testConnection() async { - if (!_formValid) return; + if (!_formValidNotifier.value) return; _dismissTimer?.cancel(); _dismissTimer = null; setState(() { @@ -128,7 +134,7 @@ class _MysqlConnectionFormContentState } void _save() { - if (!_formValid) return; + if (!_formValidNotifier.value) return; final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 3306; @@ -163,12 +169,17 @@ class _MysqlConnectionFormContentState @override void dispose() { _dismissTimer?.cancel(); - _nameController.removeListener(_onFieldChanged); - _hostController.removeListener(_onFieldChanged); - _portController.removeListener(_onFieldChanged); - _databaseController.removeListener(_onFieldChanged); - _usernameController.removeListener(_onFieldChanged); - _connectionStringController.removeListener(_onFieldChanged); + for (final c in [ + _nameController, + _hostController, + _portController, + _databaseController, + _usernameController, + _connectionStringController, + ]) { + _formValidNotifier.unlistenFrom(c); + } + _formValidNotifier.dispose(); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); @@ -431,54 +442,61 @@ class _MysqlConnectionFormContentState material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 24, vertical: 16), - child: material.Wrap( - spacing: 12, - runSpacing: 8, - alignment: material.WrapAlignment.spaceBetween, - children: [ - OutlineButton( - onPressed: - _formValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, - ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, - color: _formValid + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.spaceBetween, + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: formValid ? theme.primary : theme.mutedForeground, ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: - _formValid ? theme.primary : theme.mutedForeground, - ), - ), - ), - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), + ), ), - const Gap(12), - PrimaryButton( - onPressed: _formValid ? _save : null, - child: const Text('Save'), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + GhostButton( + onPressed: () => + material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], ), ], - ), - ], + ); + }, ), ), ], diff --git a/lib/features/mysql/mysql_result_utils.dart b/lib/features/mysql/mysql_result_utils.dart new file mode 100644 index 00000000..98e71dee --- /dev/null +++ b/lib/features/mysql/mysql_result_utils.dart @@ -0,0 +1,19 @@ +/// Serializable row batch for [convertMysqlResultRowsToStrings] in a worker isolate. +class MysqlResultConvertJob { + const MysqlResultConvertJob({ + required this.rowValues, + }); + + final List> rowValues; +} + +/// Converts MySQL result cell values to display strings off the UI thread. +List> convertMysqlResultRowsToStrings(MysqlResultConvertJob job) { + return job.rowValues + .map( + (row) => row + .map((value) => value == null ? 'NULL' : value.toString()) + .toList(), + ) + .toList(); +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 42386bdc..580c93b6 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/mysql_service.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'; @@ -12,6 +14,7 @@ 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'; import 'package:querya_desktop/features/main_screen/sql_query_history_dialog.dart'; +import 'package:querya_desktop/features/mysql/mysql_result_utils.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Ad-hoc SQL editor + results for MySQL / MariaDB. @@ -30,7 +33,7 @@ class MysqlSqlWorkspace extends material.StatefulWidget { class _MysqlSqlWorkspaceState extends material.State { final _sqlController = material.TextEditingController(); - double _topFractionState = 0.65; + final ValueNotifier _topFraction = ValueNotifier(0.65); MysqlLease? _lease; @@ -55,7 +58,7 @@ class _MysqlSqlWorkspaceState extends material.State { _appSettingsListener = () { unawaited(_loadWorkspaceSettings()); }; - AppSettingsRevision.listenable.addListener(_appSettingsListener); + SqlWorkspaceSettingsRevision.listenable.addListener(_appSettingsListener); material.WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_loadWorkspaceSettings()); }); @@ -103,7 +106,8 @@ class _MysqlSqlWorkspaceState extends material.State { @override void dispose() { - AppSettingsRevision.listenable.removeListener(_appSettingsListener); + SqlWorkspaceSettingsRevision.listenable.removeListener(_appSettingsListener); + _topFraction.dispose(); if (_running) { MysqlService.instance.interrupt( widget.connectionRow, @@ -152,20 +156,22 @@ class _MysqlSqlWorkspaceState extends material.State { cols.add(c.name.isNotEmpty ? c.name : 'col_${cols.length}'); } - final outRows = >[]; + final rawRows = >[]; var n = 0; final cap = _resultMaxRows; for (final row in rs.rows) { if (n >= cap) break; - outRows.add( - List.generate( - row.numOfColumns, - (i) => row.colAt(i) ?? 'NULL', - ), + rawRows.add( + List.generate(row.numOfColumns, (i) => row.colAt(i)), ); n++; } + final outRows = await compute( + convertMysqlResultRowsToStrings, + MysqlResultConvertJob(rowValues: rawRows), + ); + int? affected; if (cols.isEmpty && outRows.isEmpty) { affected = _affectedInt(rs.affectedRows); @@ -224,12 +230,9 @@ class _MysqlSqlWorkspaceState extends material.State { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); - final topFlex = (_topFractionState * 100).round().clamp(20, 80); - final bottomFlex = 100 - topFlex; return material.LayoutBuilder( builder: (context, constraints) { - final totalHeight = constraints.maxHeight; return material.CallbackShortcuts( bindings: { const material.SingleActivator(LogicalKeyboardKey.f5): () { @@ -240,85 +243,65 @@ class _MysqlSqlWorkspaceState extends material.State { }, child: material.Focus( autofocus: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: topFlex, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _MysqlSqlToolbar( - onExecute: _running ? null : _execute, - running: _running, - queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: _onStmtTimeoutChanged, - onOpenPreferences: () => - showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && - !_running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: - widget.connectionRow.databaseName, - sqlController: _sqlController, - ); - } - : null, - ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab( - controller: _sqlController, - fontSize: _editorFontSize, - ), - ), - ], + child: VerticalSplitPane( + fraction: _topFraction, + maxFraction: 0.85, + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MysqlSqlToolbar( + onExecute: _running ? null : _execute, + running: _running, + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, + onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: widget.connectionRow.databaseName, + sqlController: _sqlController, + ); + } + : null, ), - ), - _HorizontalResizeHandle( - totalHeight: totalHeight, - onDrag: (dy) { - if (totalHeight <= 0) return; - setState(() { - _topFractionState = (_topFractionState + dy / totalHeight) - .clamp(0.2, 0.85); - }); - }, - ), - Expanded( - flex: bottomFlex, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - material.Container( - height: 44, - padding: const material.EdgeInsets.symmetric( - horizontal: 12, - ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), - ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - Expanded( - child: ResultsTab( - columns: _columns, - rows: _rows, - errorMessage: _error, - isLoading: _running, - affectedRows: _affectedRows, - statusLine: _statusLine, - ), - ), - ], + const Divider(height: 1), + Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), - ), - ], + ], + ), + bottom: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + ), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + alignment: material.Alignment.centerLeft, + child: const Text('Data Output').semiBold().small(), + ), + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), + ), + ], + ), ), ), ); @@ -423,29 +406,3 @@ class _MysqlSqlToolbar extends material.StatelessWidget { ); } } - -class _HorizontalResizeHandle extends material.StatelessWidget { - const _HorizontalResizeHandle({ - required this.totalHeight, - required this.onDrag, - }); - - final double totalHeight; - final void Function(double dy) onDrag; - - @override - material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: material.SystemMouseCursors.resizeRow, - child: material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onVerticalDragUpdate: (e) => onDrag(e.delta.dy), - child: material.Container( - height: 6, - color: theme.border.withValues(alpha: 0.15), - ), - ), - ); - } -} diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index b04729b8..5617644a 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -1,9 +1,18 @@ +import 'dart:async'; + import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +const _pollInterval = Duration(seconds: 5); +const _summaryChipHeight = 88.0; +const _gridCardMinHeight = 220.0; +final _mysqlVersionPattern = RegExp(r'(\d+\.\d+(?:\.\d+)?)'); -/// Summary when a MySQL connection is selected without a tree object. +/// Server dashboard when a MySQL connection is selected without a tree object. class MysqlStatsView extends material.StatefulWidget { const MysqlStatsView({ super.key, @@ -18,10 +27,10 @@ class MysqlStatsView extends material.StatefulWidget { class _MysqlStatsViewState extends material.State { MysqlLease? _lease; - String? _version; - int? _databaseCount; + Map? _stats; bool _loading = true; String? _error; + Timer? _timer; @override void initState() { @@ -33,6 +42,7 @@ class _MysqlStatsViewState extends material.State { void didUpdateWidget(covariant MysqlStatsView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _timer?.cancel(); _disconnect(); _load(); } @@ -40,6 +50,7 @@ class _MysqlStatsViewState extends material.State { @override void dispose() { + _timer?.cancel(); _disconnect(); super.dispose(); } @@ -50,13 +61,13 @@ class _MysqlStatsViewState extends material.State { } Future _load() async { + _timer?.cancel(); _disconnect(); if (!mounted) return; setState(() { _loading = true; _error = null; - _version = null; - _databaseCount = null; + _stats = null; }); try { final lease = await MysqlService.instance.acquire( @@ -69,14 +80,25 @@ class _MysqlStatsViewState extends material.State { return; } _lease = lease; - final v = await lease.connection.serverVersion(); - final dbs = await lease.connection.listDatabases(); + await _fetch(); + if (mounted) _startTimer(); + } catch (e) { if (!mounted) return; setState(() { - _version = v; - _databaseCount = dbs.length; + _error = e.toString(); _loading = false; }); + } + } + + Future _fetch() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) return; + try { + final stats = await conn.serverStats(); + if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() => _loading = false); } catch (e) { if (!mounted) return; setState(() { @@ -86,32 +108,44 @@ class _MysqlStatsViewState extends material.State { } } + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(_pollInterval, (_) async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) return; + try { + final stats = await conn.serverStats(); + if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() {}); + } catch (_) {} + }); + } + @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; + final cs = Theme.of(context).colorScheme; + final width = material.MediaQuery.sizeOf(context).width; if (_loading) { return material.Center( child: material.Column( mainAxisSize: material.MainAxisSize.min, children: [ - material.SizedBox( - width: 28, - height: 28, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: cs.primary, - ), + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), ), - const Gap(12), - const Text('Loading server info...').muted().small(), + const Gap(16), + const Text('Connecting...').muted().small(), ], ), ); } - if (_error != null) { + final err = _error; + if (err != null) { return material.Center( child: material.Padding( padding: const material.EdgeInsets.all(32), @@ -124,22 +158,16 @@ class _MysqlStatsViewState extends material.State { color: cs.destructive, ), const Gap(16), - const Text('Could not load server info').large().semiBold(), + const Text('Connection Error').large().semiBold(), const Gap(8), material.SelectableText( - _error!, - style: material.TextStyle( - color: cs.mutedForeground, - fontSize: 13, - ), + err, + style: material.TextStyle(color: cs.mutedForeground, fontSize: 13), ), const Gap(24), OutlineButton( onPressed: _load, - leading: const material.Icon( - material.Icons.refresh_rounded, - size: 18, - ), + leading: const material.Icon(material.Icons.refresh_rounded, size: 18), child: const Text('Retry'), ), ], @@ -148,49 +176,433 @@ class _MysqlStatsViewState extends material.State { ); } - return material.SingleChildScrollView( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, + final stats = _stats; + if (stats == null) return material.Container(color: cs.background); + + return material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: width, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, stats), + const Gap(24), + _gridRow( + _connectionsCard(context, stats), + _queriesCard(context, stats), + ), + const Gap(16), + _gridRow( + _networkCard(context, stats), + _settingsCard(context, stats), + ), + const Gap(24), + _databasesCard(context, stats), + ], + ), + ), + ), + ), + ); + } + + material.Widget _header(material.BuildContext context) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(12), + ), + child: material.SizedBox( + width: 28, + height: 28, + child: material.Image.asset( + 'assets/images/mysql_icon.png', + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + material.Icons.storage_rounded, + size: 28, + color: cs.primary, + ), + ), + ), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(widget.connectionRow.name).large().semiBold(), + const Gap(4), + Text( + '${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 3306}', + ).muted().small(), + ], + ), + ), + OutlineButton( + onPressed: _load, + leading: const material.Icon(material.Icons.refresh_rounded, size: 18), + child: const Text('Refresh'), + ), + ], + ); + } + + material.Widget _gridRow(material.Widget left, material.Widget right) { + return material.IntrinsicHeight( + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - const Text('Server').large().semiBold(), + material.Expanded(child: left), const Gap(16), - material.Container( - width: double.infinity, - padding: const material.EdgeInsets.all(16), + material.Expanded(child: right), + ], + ), + ); + } + + String _formatUptime(int seconds) { + if (seconds <= 0) return '0m'; + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + if (days > 0) return '${days}d ${hours}h'; + if (hours > 0) return '${hours}h ${minutes}m'; + return '${minutes}m'; + } + + String _status(Map stats, String key) => + (stats['status'] as Map?)?[key] ?? '—'; + + String _variable(Map stats, String key) => + (stats['variables'] as Map?)?[key] ?? '—'; + + material.Widget _summaryChips( + material.BuildContext context, Map stats) { + final cs = shadcn.Theme.of(context).colorScheme; + final versionFull = stats['version'] as String? ?? '—'; + final versionShort = _extractMysqlVersion(versionFull); + final uptimeSec = stats['uptime_seconds'] as int? ?? 0; + final connected = _status(stats, 'Threads_connected'); + final maxConn = _variable(stats, 'max_connections'); + final questions = _status(stats, 'Questions'); + + material.Widget chip(String label, String value, material.IconData icon) { + return material.Expanded( + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(minHeight: _summaryChipHeight), + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.35), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), + color: cs.card, + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all(color: cs.border.withValues(alpha: 0.5)), ), - child: material.Column( + child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - const Text('Version').small().muted(), - const Gap(4), - material.SelectableText( - _version ?? '—', - style: material.TextStyle( - fontSize: 13, - color: cs.foreground, - ), + material.Padding( + padding: const material.EdgeInsets.only(top: 2), + child: material.Icon(icon, size: 20, color: cs.primary), ), - const Gap(16), - const Text('User databases (approx.)').small().muted(), - const Gap(4), - Text( - '${_databaseCount ?? 0}', - style: material.TextStyle( - fontSize: 20, - fontWeight: material.FontWeight.w600, - color: cs.foreground, + const Gap(12), + material.Expanded( + child: material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(label).muted().xSmall(), + const Gap(2), + material.Text( + value, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ], ), ), ], ), ), + ), + ); + } + + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + chip('Version', versionShort, material.Icons.tag_rounded), + const Gap(12), + chip('Uptime', _formatUptime(uptimeSec), material.Icons.schedule_rounded), + const Gap(12), + chip('Connections', '$connected / $maxConn', material.Icons.people_outline_rounded), + const Gap(12), + chip('Queries', questions, material.Icons.speed_rounded), + ], + ); + } + + material.Widget _card( + material.BuildContext context, + String title, + material.Widget body, { + double? minHeight, + bool stretchBody = false, + }) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Container( + width: double.infinity, + constraints: minHeight != null + ? material.BoxConstraints(minHeight: minHeight) + : const material.BoxConstraints(), + padding: const material.EdgeInsets.all(20), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(12), + border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text(title).semiBold(), + const Gap(12), + if (stretchBody) material.Expanded(child: body) else body, + ], + ), + ); + } + + material.Widget _metricList( + material.BuildContext context, + List rows, { + bool stretch = false, + }) { + final column = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: rows, + ); + if (!stretch) return column; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + column, + const material.Spacer(), + ], + ); + } + + material.Widget _connectionsCard( + material.BuildContext context, Map stats) { + return _card( + context, + 'Connections', + _metricList( + context, + [ + _metricRow(context, 'Connected', _status(stats, 'Threads_connected')), + _metricRow(context, 'Running', _status(stats, 'Threads_running')), + _metricRow(context, 'Max used', _status(stats, 'Max_used_connections')), + _metricRow(context, 'Max allowed', _variable(stats, 'max_connections')), + ], + stretch: true, + ), + minHeight: _gridCardMinHeight, + stretchBody: true, + ); + } + + material.Widget _queriesCard( + material.BuildContext context, Map stats) { + return _card( + context, + 'Queries', + _metricList( + context, + [ + _metricRow(context, 'Questions', _status(stats, 'Questions')), + _metricRow(context, 'Slow queries', _status(stats, 'Slow_queries')), + _metricRow(context, 'Open tables', _status(stats, 'Open_tables')), + _metricRow(context, 'Aborted connects', _status(stats, 'Aborted_connects')), + ], + stretch: true, + ), + minHeight: _gridCardMinHeight, + stretchBody: true, + ); + } + + material.Widget _networkCard( + material.BuildContext context, Map stats) { + final bytesIn = int.tryParse(_status(stats, 'Bytes_received')) ?? 0; + final bytesOut = int.tryParse(_status(stats, 'Bytes_sent')) ?? 0; + return _card( + context, + 'Network', + _metricList( + context, + [ + _metricRow(context, 'Bytes in', _formatBytes(bytesIn)), + _metricRow(context, 'Bytes out', _formatBytes(bytesOut)), + _metricRow(context, 'Total connects', _status(stats, 'Connections')), + ], + stretch: true, + ), + minHeight: _gridCardMinHeight, + stretchBody: true, + ); + } + + material.Widget _settingsCard( + material.BuildContext context, Map stats) { + final pool = int.tryParse(_variable(stats, 'innodb_buffer_pool_size')) ?? 0; + return _card( + context, + 'Server', + _metricList( + context, + [ + _metricRow(context, 'InnoDB buffer pool', _formatBytes(pool)), + _metricRow(context, 'Charset', _variable(stats, 'character_set_server')), + _metricRow(context, 'Collation', _variable(stats, 'collation_server')), + _metricRow(context, 'Port', _variable(stats, 'port')), + ], + stretch: true, + ), + minHeight: _gridCardMinHeight, + stretchBody: true, + ); + } + + material.Widget _databasesCard( + material.BuildContext context, Map stats) { + final databases = + stats['databases'] as List>? ?? []; + if (databases.isEmpty) return const material.SizedBox.shrink(); + + final cs = shadcn.Theme.of(context).colorScheme; + return _card( + context, + 'Databases', + material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.only(bottom: 8), + child: material.Row( + children: [ + material.SizedBox( + width: 180, + child: const Text('Name').muted().xSmall(), + ), + material.SizedBox( + width: 100, + child: const Text('Size').muted().xSmall(), + ), + material.Expanded( + child: const Text('Tables').muted().xSmall(), + ), + ], + ), + ), + material.Divider(height: 1, color: cs.border.withValues(alpha: 0.3)), + for (final db in databases) + material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 5), + child: material.Row( + children: [ + material.SizedBox( + width: 180, + child: material.Text( + '${db['name']}', + style: material.TextStyle(fontSize: 13, color: cs.foreground), + overflow: material.TextOverflow.ellipsis, + ), + ), + material.SizedBox( + width: 100, + child: Text(_formatBytes((db['size'] as int?) ?? 0)) + .muted() + .xSmall(), + ), + material.Expanded( + child: Text('${db['tables'] ?? 0}').muted().xSmall(), + ), + ], + ), + ), + ], + ), + ); + } + + material.Widget _metricRow( + material.BuildContext context, String label, String value) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 6), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + flex: 3, + child: Text( + label, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ).muted().small(), + ), + const Gap(12), + material.Flexible( + flex: 2, + child: material.SelectableText( + value, + textAlign: material.TextAlign.right, + maxLines: 2, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ), ], ), ); } + + String _extractMysqlVersion(String full) { + final match = _mysqlVersionPattern.firstMatch(full); + return match?.group(1) ?? full; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } } diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index f870a4c7..f4c5fd1d 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:postgres/postgres.dart' as pg; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.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'; @@ -54,7 +55,7 @@ class PostgresSqlWorkspace extends material.StatefulWidget { class _PostgresSqlWorkspaceState extends material.State { final _sqlController = material.TextEditingController(); - double _topFractionState = 0.65; + final ValueNotifier _topFraction = ValueNotifier(0.65); PgLease? _lease; @@ -92,7 +93,7 @@ class _PostgresSqlWorkspaceState extends material.State { _appSettingsListener = () { unawaited(_loadWorkspaceSettings()); }; - AppSettingsRevision.listenable.addListener(_appSettingsListener); + SqlWorkspaceSettingsRevision.listenable.addListener(_appSettingsListener); material.WidgetsBinding.instance.addPostFrameCallback((_) { _syncPostgresSqlTreeContext(); unawaited(_loadWorkspaceSettings()); @@ -243,7 +244,8 @@ class _PostgresSqlWorkspaceState extends material.State { @override void dispose() { - AppSettingsRevision.listenable.removeListener(_appSettingsListener); + SqlWorkspaceSettingsRevision.listenable.removeListener(_appSettingsListener); + _topFraction.dispose(); if (_running) { PostgresService.instance.interrupt( widget.connectionRow, @@ -366,12 +368,9 @@ class _PostgresSqlWorkspaceState extends material.State { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); - final topFlex = (_topFractionState * 100).round().clamp(20, 80); - final bottomFlex = 100 - topFlex; return material.LayoutBuilder( builder: (context, constraints) { - final totalHeight = constraints.maxHeight; return material.CallbackShortcuts( bindings: { const material.SingleActivator(LogicalKeyboardKey.f5): () { @@ -380,98 +379,76 @@ class _PostgresSqlWorkspaceState extends material.State { }, child: material.Focus( autofocus: true, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: topFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SqlToolbar( - sessionDatabase: _effectiveSessionDatabase(), - onExecute: _running ? null : _execute, - running: _running, - autocommit: _autocommit, - onAutocommitChanged: (v) => - setState(() => _autocommit = v), - queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: _onStmtTimeoutChanged, - onOpenPreferences: () => - showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && - !_running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: _effectiveSessionDatabase(), - sqlController: _sqlController, - ); - } - : null, - txOpen: _txOpen, - onBegin: _running - ? null - : () => _runTxCommand('BEGIN'), - onCommit: _running - ? null - : () => _runTxCommand('COMMIT'), - onRollback: _running - ? null - : () => _runTxCommand('ROLLBACK'), - ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab( - controller: _sqlController, - fontSize: _editorFontSize, - ), - ), - ], + child: VerticalSplitPane( + fraction: _topFraction, + maxFraction: 0.85, + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SqlToolbar( + sessionDatabase: _effectiveSessionDatabase(), + onExecute: _running ? null : _execute, + running: _running, + autocommit: _autocommit, + onAutocommitChanged: (v) => + setState(() => _autocommit = v), + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, + onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: _effectiveSessionDatabase(), + sqlController: _sqlController, + ); + } + : null, + txOpen: _txOpen, + onBegin: + _running ? null : () => _runTxCommand('BEGIN'), + onCommit: + _running ? null : () => _runTxCommand('COMMIT'), + onRollback: + _running ? null : () => _runTxCommand('ROLLBACK'), ), - ), - _HorizontalResizeHandle( - totalHeight: totalHeight, - onDrag: (dy) { - if (totalHeight <= 0) return; - setState(() { - _topFractionState = (_topFractionState + dy / totalHeight) - .clamp(0.2, 0.85); - }); - }, - ), - Expanded( - flex: bottomFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Container( - height: 44, - padding: const material.EdgeInsets.symmetric( - horizontal: 12, - ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), - ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - Expanded( - child: ResultsTab( - columns: _columns, - rows: _rows, - errorMessage: _error, - isLoading: _running, - affectedRows: _affectedRows, - statusLine: _statusLine, - ), - ), - ], + const Divider(height: 1), + Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), - ), - ], + ], + ), + bottom: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + ), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + alignment: material.Alignment.centerLeft, + child: const Text('Data Output').semiBold().small(), + ), + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), + ), + ], + ), ), ), ); @@ -623,29 +600,3 @@ class _SqlToolbar extends material.StatelessWidget { ); } } - -class _HorizontalResizeHandle extends material.StatelessWidget { - const _HorizontalResizeHandle({ - required this.totalHeight, - required this.onDrag, - }); - - final double totalHeight; - final void Function(double dy) onDrag; - - @override - material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: material.SystemMouseCursors.resizeRow, - child: material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onVerticalDragUpdate: (e) => onDrag(e.delta.dy), - child: material.Container( - height: 6, - color: theme.border.withValues(alpha: 0.15), - ), - ), - ); - } -} diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 4c8483ae..31c253ba 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -10,6 +11,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _pollInterval = Duration(seconds: 5); const _summaryChipHeight = 88.0; const _gridCardMinHeight = 220.0; +final _pgVersionPattern = RegExp(r'PostgreSQL\s+([\d.]+)'); class PostgresStatsView extends material.StatefulWidget { const PostgresStatsView({ @@ -105,8 +107,8 @@ class _PostgresStatsViewState extends material.State { try { final stats = await c.serverStats(); if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; setState(() { - _stats = stats; _loading = false; }); } catch (e) { @@ -127,7 +129,8 @@ class _PostgresStatsViewState extends material.State { try { final stats = await c.serverStats(); if (!mounted) return; - setState(() => _stats = stats); + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() {}); } catch (_) {} }); } @@ -553,7 +556,7 @@ class _PostgresStatsViewState extends material.State { } String _extractPgVersion(String full) { - final match = RegExp(r'PostgreSQL\s+([\d.]+)').firstMatch(full); + final match = _pgVersionPattern.firstMatch(full); return match?.group(1) ?? full; } diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 06119d65..e89f990f 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -4,6 +4,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/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows PostgreSQL connection form dialog. @@ -47,26 +48,26 @@ class _PostgresConnectionFormContentState bool _isTesting = false; String? _testResult; Timer? _dismissTimer; + late final FormValidityNotifier _formValidNotifier; @override void initState() { super.initState(); - _nameController.addListener(_onFieldChanged); - _hostController.addListener(_onFieldChanged); - _portController.addListener(_onFieldChanged); - _databaseController.addListener(_onFieldChanged); - _usernameController.addListener(_onFieldChanged); - _connectionStringController.addListener(_onFieldChanged); - } - - void _onFieldChanged() => setState(() {}); - - bool _looksLikePostgresUri(String s) { - final t = s.trim().toLowerCase(); - return t.startsWith('postgres://') || t.startsWith('postgresql://'); + _formValidNotifier = FormValidityNotifier(_computeFormValid); + for (final c in [ + _nameController, + _hostController, + _portController, + _databaseController, + _usernameController, + _connectionStringController, + ]) { + _formValidNotifier.listenTo(c); + } + _formValidNotifier.seed(); } - bool get _formValid { + bool _computeFormValid() { final uri = _connectionStringController.text.trim(); if (uri.isNotEmpty) { return _looksLikePostgresUri(uri); @@ -76,6 +77,11 @@ class _PostgresConnectionFormContentState return host.isNotEmpty && db.isNotEmpty; } + bool _looksLikePostgresUri(String s) { + final t = s.trim().toLowerCase(); + return t.startsWith('postgres://') || t.startsWith('postgresql://'); + } + void _showTestResult(String result) { _dismissTimer?.cancel(); setState(() { @@ -94,7 +100,7 @@ class _PostgresConnectionFormContentState } Future _testConnection() async { - if (!_formValid) return; + if (!_formValidNotifier.value) return; _dismissTimer?.cancel(); _dismissTimer = null; setState(() { @@ -130,7 +136,7 @@ class _PostgresConnectionFormContentState } void _save() { - if (!_formValid) return; + if (!_formValidNotifier.value) return; final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 5432; @@ -163,12 +169,17 @@ class _PostgresConnectionFormContentState @override void dispose() { _dismissTimer?.cancel(); - _nameController.removeListener(_onFieldChanged); - _hostController.removeListener(_onFieldChanged); - _portController.removeListener(_onFieldChanged); - _databaseController.removeListener(_onFieldChanged); - _usernameController.removeListener(_onFieldChanged); - _connectionStringController.removeListener(_onFieldChanged); + for (final c in [ + _nameController, + _hostController, + _portController, + _databaseController, + _usernameController, + _connectionStringController, + ]) { + _formValidNotifier.unlistenFrom(c); + } + _formValidNotifier.dispose(); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); @@ -443,54 +454,61 @@ class _PostgresConnectionFormContentState material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 24, vertical: 16), - child: material.Wrap( - spacing: 12, - runSpacing: 8, - alignment: material.WrapAlignment.spaceBetween, - children: [ - OutlineButton( - onPressed: - _formValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, - ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, - color: _formValid + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.spaceBetween, + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: formValid ? theme.primary : theme.mutedForeground, ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: - _formValid ? theme.primary : theme.mutedForeground, - ), - ), - ), - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), + ), ), - const Gap(12), - PrimaryButton( - onPressed: _formValid ? _save : null, - child: const Text('Save'), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + GhostButton( + onPressed: () => + material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], ), ], - ), - ], + ); + }, ), ), ], diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index e2dbb48e..2e4cceca 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/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows Redis connection form dialog. Returns ConnectionRow if saved, null if cancelled. @@ -41,20 +42,22 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo bool _isTesting = false; String? _testResult; Timer? _dismissTimer; + late final FormValidityNotifier _formValidNotifier; @override void initState() { super.initState(); - _nameController.addListener(_onFieldChanged); - _hostController.addListener(_onFieldChanged); - _portController.addListener(_onFieldChanged); + _formValidNotifier = FormValidityNotifier(_computeFormValid); + for (final c in [_nameController, _hostController, _portController]) { + _formValidNotifier.listenTo(c); + } + _formValidNotifier.seed(); } - void _onFieldChanged() => setState(() {}); - - bool get _formValid { + bool _computeFormValid() { final host = _hostController.text.trim(); - return host.isNotEmpty && (_nameController.text.trim().isNotEmpty || host.isNotEmpty); + return host.isNotEmpty && + (_nameController.text.trim().isNotEmpty || host.isNotEmpty); } void _showTestResult(String result) { @@ -75,7 +78,7 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo } Future _testConnection() async { - if (!_formValid) return; + if (!_formValidNotifier.value) return; _dismissTimer?.cancel(); _dismissTimer = null; setState(() { @@ -99,7 +102,7 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo } void _save() { - if (!_formValid) return; + if (!_formValidNotifier.value) return; final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 6379; @@ -120,9 +123,10 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo @override void dispose() { _dismissTimer?.cancel(); - _nameController.removeListener(_onFieldChanged); - _hostController.removeListener(_onFieldChanged); - _portController.removeListener(_onFieldChanged); + for (final c in [_nameController, _hostController, _portController]) { + _formValidNotifier.unlistenFrom(c); + } + _formValidNotifier.dispose(); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); @@ -327,43 +331,53 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo ), material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: material.Row( - children: [ - OutlineButton( - onPressed: _formValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, - ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, - color: _formValid ? theme.primary : theme.mutedForeground, + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Row( + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: formValid + ? theme.primary + : theme.mutedForeground, ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: _formValid ? theme.primary : theme.mutedForeground, + ), ), - ), - ), - const material.Spacer(), - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( - onPressed: _formValid ? _save : null, - child: const Text('Save'), - ), - ], + const material.Spacer(), + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], + ); + }, ), ), ], diff --git a/lib/features/redis/redis_databases_view.dart b/lib/features/redis/redis_databases_view.dart index 5d35d6bd..3c4b46de 100644 --- a/lib/features/redis/redis_databases_view.dart +++ b/lib/features/redis/redis_databases_view.dart @@ -237,7 +237,7 @@ class _DbInfo { // ─── Tile widget ──────────────────────────────────────────────────────────── -class _DatabaseTile extends StatefulWidget { +class _DatabaseTile extends material.StatelessWidget { const _DatabaseTile({ required this.db, required this.colorScheme, @@ -248,34 +248,21 @@ class _DatabaseTile extends StatefulWidget { final _DbInfo db; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; - final VoidCallback onTap; - - @override - material.State<_DatabaseTile> createState() => _DatabaseTileState(); -} - -class _DatabaseTileState extends material.State<_DatabaseTile> { - bool _hovered = false; + final material.VoidCallback onTap; @override material.Widget build(material.BuildContext context) { - final cs = widget.colorScheme; - final scs = widget.shadcnCs; - final db = widget.db; + final cs = colorScheme; + final db = this.db; - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + return material.Material( + color: material.Colors.transparent, child: material.InkWell( - onTap: widget.onTap, - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + onTap: onTap, + hoverColor: shadcnCs.primary.withValues(alpha: 0.06), + child: material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 20, vertical: 10), - color: _hovered - ? scs.primary.withValues(alpha: 0.06) - : material.Colors.transparent, child: material.Row( children: [ material.Icon( @@ -283,7 +270,7 @@ class _DatabaseTileState extends material.State<_DatabaseTile> { ? material.Icons.dns_rounded : material.Icons.dns_outlined, size: 18, - color: db.hasData ? scs.primary : scs.mutedForeground, + color: db.hasData ? shadcnCs.primary : shadcnCs.mutedForeground, ), const Gap(12), material.Expanded( @@ -302,7 +289,7 @@ class _DatabaseTileState extends material.State<_DatabaseTile> { '${db.keys} keys • ${db.expires} with TTL', style: material.TextStyle( fontSize: 12, - color: scs.mutedForeground, + color: shadcnCs.mutedForeground, ), ), ], @@ -313,7 +300,7 @@ class _DatabaseTileState extends material.State<_DatabaseTile> { padding: const material.EdgeInsets.symmetric( horizontal: 8, vertical: 3), decoration: material.BoxDecoration( - color: scs.primary.withValues(alpha: 0.12), + color: shadcnCs.primary.withValues(alpha: 0.12), borderRadius: material.BorderRadius.circular(10), ), child: Text( @@ -321,13 +308,13 @@ class _DatabaseTileState extends material.State<_DatabaseTile> { style: material.TextStyle( fontSize: 11, fontWeight: material.FontWeight.w600, - color: scs.primary, + color: shadcnCs.primary, ), ), ), const Gap(8), material.Icon(material.Icons.chevron_right_rounded, - size: 18, color: scs.mutedForeground), + size: 18, color: shadcnCs.mutedForeground), ], ), ), diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index a8a7e35c..08c960f9 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -349,7 +349,7 @@ class _BreadcrumbBar extends StatelessWidget { } } -class _CrumbChip extends StatefulWidget { +class _CrumbChip extends material.StatelessWidget { const _CrumbChip({ required this.label, required this.isLast, @@ -358,44 +358,37 @@ class _CrumbChip extends StatefulWidget { final String label; final bool isLast; - final VoidCallback? onTap; - - @override - material.State<_CrumbChip> createState() => _CrumbChipState(); -} - -class _CrumbChipState extends material.State<_CrumbChip> { - bool _hovered = false; + final material.VoidCallback? onTap; @override material.Widget build(material.BuildContext context) { final cs = shadcn.Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: widget.onTap != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.GestureDetector( - onTap: widget.onTap, - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + final child = isLast + ? Text(label).semiBold().small() + : Text(label, + style: material.TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: material.FontWeight.w500)) + .small(); + + if (onTap == null) { + return material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: child, + ); + } + + return material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + hoverColor: cs.primary.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: material.BoxDecoration( - color: _hovered && widget.onTap != null - ? cs.primary.withValues(alpha: 0.1) - : material.Colors.transparent, - borderRadius: material.BorderRadius.circular(4), - ), - child: widget.isLast - ? Text(widget.label).semiBold().small() - : Text(widget.label, - style: material.TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: material.FontWeight.w500)) - .small(), + child: child, ), ), ); diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index a3d9acef..5987b07d 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -312,9 +312,13 @@ class _RedisKeyEditorState extends material.State { const Divider(height: 1), // Content material.Expanded( - child: material.SingleChildScrollView( + child: material.Padding( padding: const material.EdgeInsets.all(16), - child: _buildContent(cs, shadcnCs), + child: widget.keyType == 'string' + ? material.SingleChildScrollView( + child: _buildContent(cs, shadcnCs), + ) + : _buildContent(cs, shadcnCs), ), ), ], @@ -499,7 +503,6 @@ class _RedisKeyEditorState extends material.State { children: [ Text('Hash fields (${entries.length})').semiBold(), const Gap(8), - // Add field row material.Row( children: [ material.Expanded( @@ -531,16 +534,24 @@ class _RedisKeyEditorState extends material.State { ], ), const Gap(12), - for (final entry in entries) ...[ - _FieldRow( - field: entry.key, - value: entry.value, - onDelete: () => _hashDel(entry.key), - colorScheme: cs, - shadcnCs: scs, - ), - const Gap(4), - ], + material.Expanded( + child: entries.isEmpty + ? material.Center(child: const Text('No fields').muted()) + : material.ListView.separated( + itemCount: entries.length, + separatorBuilder: (_, __) => const Gap(4), + itemBuilder: (context, index) { + final entry = entries[index]; + return _FieldRow( + field: entry.key, + value: entry.value, + onDelete: () => _hashDel(entry.key), + colorScheme: cs, + shadcnCs: scs, + ); + }, + ), + ), ], ); } @@ -576,15 +587,20 @@ class _RedisKeyEditorState extends material.State { ], ), const Gap(12), - for (var i = 0; i < _listValue.length; i++) ...[ - _IndexedValueRow( - index: i, - value: _listValue[i], - colorScheme: cs, - shadcnCs: scs, - ), - const Gap(4), - ], + material.Expanded( + child: _listValue.isEmpty + ? material.Center(child: const Text('No items').muted()) + : material.ListView.separated( + itemCount: _listValue.length, + separatorBuilder: (_, __) => const Gap(4), + itemBuilder: (context, i) => _IndexedValueRow( + index: i, + value: _listValue[i], + colorScheme: cs, + shadcnCs: scs, + ), + ), + ), ], ); } @@ -620,15 +636,20 @@ class _RedisKeyEditorState extends material.State { ], ), const Gap(12), - for (final member in _setValue) ...[ - _MemberRow( - member: member, - onDelete: () => _setRemove(member), - colorScheme: cs, - shadcnCs: scs, - ), - const Gap(4), - ], + material.Expanded( + child: _setValue.isEmpty + ? material.Center(child: const Text('No members').muted()) + : material.ListView.separated( + itemCount: _setValue.length, + separatorBuilder: (_, __) => const Gap(4), + itemBuilder: (context, index) => _MemberRow( + member: _setValue[index], + onDelete: () => _setRemove(_setValue[index]), + colorScheme: cs, + shadcnCs: scs, + ), + ), + ), ], ); } @@ -675,16 +696,24 @@ class _RedisKeyEditorState extends material.State { ], ), const Gap(12), - for (final (member, score) in _zsetValue) ...[ - _ScoredMemberRow( - member: member, - score: score, - onDelete: () => _zsetRemove(member), - colorScheme: cs, - shadcnCs: scs, - ), - const Gap(4), - ], + material.Expanded( + child: _zsetValue.isEmpty + ? material.Center(child: const Text('No members').muted()) + : material.ListView.separated( + itemCount: _zsetValue.length, + separatorBuilder: (_, __) => const Gap(4), + itemBuilder: (context, index) { + final (member, score) = _zsetValue[index]; + return _ScoredMemberRow( + member: member, + score: score, + onDelete: () => _zsetRemove(member), + colorScheme: cs, + shadcnCs: scs, + ); + }, + ), + ), ], ); } @@ -692,43 +721,12 @@ class _RedisKeyEditorState extends material.State { // ─── TTL dialog ───────────────────────────────────────────────────────── void _showTtlDialog() { - final controller = - material.TextEditingController(text: _ttl > 0 ? '$_ttl' : ''); - showAppDialog( + showAppDialog( context: context, - builder: (ctx) { - return AlertDialog( - title: const Text('Set TTL'), - content: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Enter TTL in seconds (0 to remove)').muted().small(), - const Gap(8), - TextField( - controller: controller, - placeholder: const Text('Seconds'), - ), - ], - ), - actions: [ - GhostButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Cancel'), - ), - PrimaryButton( - onPressed: () { - final val = int.tryParse(controller.text.trim()); - if (val != null) { - _setTtl(val); - } - Navigator.of(ctx).pop(); - }, - child: const Text('Apply'), - ), - ], - ); - }, + builder: (ctx) => _RedisTtlDialogContent( + initialTtl: _ttl, + onApply: _setTtl, + ), ); } @@ -769,6 +767,73 @@ class _RedisKeyEditorState extends material.State { } } +class _RedisTtlDialogContent extends material.StatefulWidget { + const _RedisTtlDialogContent({ + required this.initialTtl, + required this.onApply, + }); + + final int initialTtl; + final Future Function(int seconds) onApply; + + @override + material.State<_RedisTtlDialogContent> createState() => + _RedisTtlDialogContentState(); +} + +class _RedisTtlDialogContentState extends material.State<_RedisTtlDialogContent> { + late final material.TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController( + text: widget.initialTtl > 0 ? '${widget.initialTtl}' : '', + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + return AlertDialog( + title: const Text('Set TTL'), + content: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Enter TTL in seconds (0 to remove)').muted().small(), + const Gap(8), + TextField( + controller: _controller, + placeholder: const Text('Seconds'), + ), + ], + ), + actions: [ + GhostButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + PrimaryButton( + onPressed: () { + final val = int.tryParse(_controller.text.trim()); + if (val != null) { + widget.onApply(val); + } + Navigator.of(context).pop(); + }, + child: const Text('Apply'), + ), + ], + ); + } +} + // ─── Shared row widgets ───────────────────────────────────────────────────── class _FieldRow extends StatelessWidget { diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 8cbcf4ba..afaedb4b 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -320,7 +320,7 @@ class _KeyInfo { // ─── Key tile widget ──────────────────────────────────────────────────────── -class _KeyTile extends StatefulWidget { +class _KeyTile extends material.StatelessWidget { const _KeyTile({ required this.keyInfo, required this.colorScheme, @@ -332,34 +332,27 @@ class _KeyTile extends StatefulWidget { final _KeyInfo keyInfo; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; - final VoidCallback onTap; - final VoidCallback onDelete; + final material.VoidCallback onTap; + final material.VoidCallback onDelete; - @override - material.State<_KeyTile> createState() => _KeyTileState(); -} - -class _KeyTileState extends material.State<_KeyTile> { - bool _hovered = false; - - Color _typeColor(String type) { + static Color _typeColor(String type, shadcn.ColorScheme scs) { switch (type) { case 'string': - return const Color(0xFF42A5F5); + return const material.Color(0xFF42A5F5); case 'hash': - return const Color(0xFFAB47BC); + return const material.Color(0xFFAB47BC); case 'list': - return const Color(0xFF66BB6A); + return const material.Color(0xFF66BB6A); case 'set': - return const Color(0xFFFFA726); + return const material.Color(0xFFFFA726); case 'zset': - return const Color(0xFFEF5350); + return const material.Color(0xFFEF5350); default: - return widget.shadcnCs.mutedForeground; + return scs.mutedForeground; } } - material.IconData _typeIcon(String type) { + static material.IconData _typeIcon(String type) { switch (type) { case 'string': return material.Icons.text_fields_rounded; @@ -376,7 +369,7 @@ class _KeyTileState extends material.State<_KeyTile> { } } - String _formatTtl(int ttl) { + static String _formatTtl(int ttl) { if (ttl == -1) return 'No TTL'; if (ttl == -2) return 'Missing'; if (ttl < 60) return '${ttl}s'; @@ -387,35 +380,25 @@ class _KeyTileState extends material.State<_KeyTile> { @override material.Widget build(material.BuildContext context) { - final cs = widget.colorScheme; - final scs = widget.shadcnCs; - final ki = widget.keyInfo; - final typeCol = _typeColor(ki.type); - - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + final cs = colorScheme; + final ki = keyInfo; + final typeCol = _typeColor(ki.type, shadcnCs); + + return material.Material( + color: cs.card, + borderRadius: material.BorderRadius.circular(8), + clipBehavior: material.Clip.antiAlias, child: material.InkWell( - onTap: widget.onTap, + onTap: onTap, + hoverColor: shadcnCs.muted.withValues(alpha: 0.15), borderRadius: material.BorderRadius.circular(8), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + child: material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 16, vertical: 10), - decoration: material.BoxDecoration( - color: _hovered - ? scs.muted.withValues(alpha: 0.15) - : cs.card, - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.3), width: 1), - ), child: material.Row( children: [ material.Icon(_typeIcon(ki.type), size: 16, color: typeCol), const Gap(10), - // Type badge material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 6, vertical: 2), @@ -423,7 +406,7 @@ class _KeyTileState extends material.State<_KeyTile> { color: typeCol.withValues(alpha: 0.12), borderRadius: material.BorderRadius.circular(4), ), - child: Text( + child: material.Text( ki.type.toUpperCase(), style: material.TextStyle( fontSize: 10, @@ -434,7 +417,6 @@ class _KeyTileState extends material.State<_KeyTile> { ), ), const Gap(10), - // Key name material.Expanded( child: material.Text( ki.name, @@ -448,40 +430,35 @@ class _KeyTileState extends material.State<_KeyTile> { ), ), const Gap(8), - // TTL if (ki.ttl >= 0) material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: material.BoxDecoration( - color: scs.muted.withValues(alpha: 0.3), + color: shadcnCs.muted.withValues(alpha: 0.3), borderRadius: material.BorderRadius.circular(4), ), - child: Text( + child: material.Text( 'TTL ${_formatTtl(ki.ttl)}', style: material.TextStyle( fontSize: 10, - color: scs.mutedForeground, + color: shadcnCs.mutedForeground, ), ), ), - const Gap(8), - // Delete button (only on hover) - material.AnimatedOpacity( - opacity: _hovered ? 1.0 : 0.0, - duration: const Duration(milliseconds: 120), - child: material.InkWell( - onTap: widget.onDelete, - borderRadius: material.BorderRadius.circular(4), - child: const material.Padding( - padding: material.EdgeInsets.all(4), - child: material.Icon(material.Icons.delete_rounded, - size: 15, color: Color(0xFFEF5350)), - ), - ), + const Gap(4), + material.IconButton( + onPressed: onDelete, + icon: const material.Icon(material.Icons.delete_rounded, + size: 15, color: material.Color(0xFFEF5350)), + padding: const material.EdgeInsets.all(4), + constraints: + const material.BoxConstraints(minWidth: 28, minHeight: 28), + splashRadius: 18, + tooltip: 'Delete key', ), material.Icon(material.Icons.chevron_right_rounded, - size: 18, color: scs.mutedForeground), + size: 18, color: shadcnCs.mutedForeground), ], ), ), diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 2d6023a4..1e100dbd 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; @@ -9,8 +10,30 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _pollInterval = Duration(seconds: 3); -const _summaryChipHeight = 72.0; -const _gridCardHeight = 220.0; +const _summaryChipHeight = 88.0; +const _gridCardMinHeight = 220.0; + +const _redisFieldLabels = { + 'redis_version': 'Version', + 'redis_mode': 'Mode', + 'os': 'OS', + 'tcp_port': 'Port', + 'uptime_in_days': 'Uptime (days)', + 'config_file': 'Config file', + 'connected_clients': 'Connected', + 'blocked_clients': 'Blocked', + 'maxclients': 'Max clients', + 'client_recent_max_input_buffer': 'Max input buffer', + 'client_recent_max_output_buffer': 'Max output buffer', + 'rdb_bgsave_in_progress': 'BGSAVE in progress', + 'rdb_last_save_time': 'Last RDB save', + 'rdb_last_bgsave_status': 'Last BGSAVE status', + 'aof_enabled': 'AOF enabled', + 'aof_last_rewrite_time_sec': 'Last AOF rewrite', + 'role': 'Role', + 'connected_slaves': 'Connected replicas', + 'master_repl_offset': 'Repl offset', +}; class RedisView extends material.StatefulWidget { const RedisView({ @@ -119,10 +142,9 @@ class _RedisViewState extends material.State { final raw = await c.info(); final info = parseRedisInfo(raw); if (!mounted) return; - setState(() { - _info = info; - _loading = false; - }); + final changed = replaceIfChanged(_info, info, (v) => _info = v); + if (!changed && !_loading) return; + setState(() => _loading = false); } void _startTimer() { @@ -134,7 +156,8 @@ class _RedisViewState extends material.State { final raw = await c.info(); final info = parseRedisInfo(raw); if (!mounted) return; - setState(() => _info = info); + if (!replaceIfChanged(_info, info, (v) => _info = v)) return; + setState(() {}); } catch (_) {} }); } @@ -206,40 +229,57 @@ class _RedisViewState extends material.State { const Gap(24), _summaryChips(context, info), const Gap(24), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded(child: _memoryCard(context, info)), - const Gap(16), - material.Expanded(child: _statsCard(context, info)), - ], + _gridRow( + _memoryCard(context, info), + _statsCard(context, info), ), const Gap(16), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded(child: _keyspaceCard(context, info)), - const Gap(16), - material.Expanded(child: _cpuCard(context, info)), - ], + _gridRow( + _cpuCard(context, info), + _keyspaceCard(context, info), ), const Gap(24), _sectionCard(context, 'Server', info['Server'], keys: const [ 'redis_version', 'redis_mode', 'os', 'tcp_port', 'uptime_in_days', 'config_file', ]), const Gap(12), - _sectionCard(context, 'Clients', info['Clients']), - const Gap(12), - _sectionCard(context, 'Persistence', info['Persistence'], keys: const [ - 'rdb_bgsave_in_progress', 'rdb_last_save_time', 'rdb_last_bgsave_status', - 'aof_enabled', 'aof_last_rewrite_time_sec', - ]), + material.LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 900; + if (!wide) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _sectionCard(context, 'Clients', info['Clients']), + const Gap(12), + _sectionCard(context, 'Persistence', info['Persistence'], keys: const [ + 'rdb_bgsave_in_progress', 'rdb_last_save_time', 'rdb_last_bgsave_status', + 'aof_enabled', 'aof_last_rewrite_time_sec', + ]), + ], + ); + } + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: _sectionCard(context, 'Clients', info['Clients']), + ), + const Gap(16), + material.Expanded( + child: _sectionCard(context, 'Persistence', info['Persistence'], keys: const [ + 'rdb_bgsave_in_progress', 'rdb_last_save_time', 'rdb_last_bgsave_status', + 'aof_enabled', 'aof_last_rewrite_time_sec', + ]), + ), + ], + ); + }, + ), const Gap(12), _sectionCard(context, 'Replication', info['Replication']), const Gap(12), _errorStatsCard(context, info), - const Gap(12), - _sectionCard(context, 'Keyspace', info['Keyspace']), ], ), ), @@ -292,17 +332,43 @@ class _RedisViewState extends material.State { ); } + material.Widget _gridRow(material.Widget left, material.Widget right) { + return material.IntrinsicHeight( + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Expanded(child: left), + const Gap(16), + material.Expanded(child: right), + ], + ), + ); + } + + String _formatUptime(RedisInfoSections info) { + final seconds = sectionInt(info, 'Server', 'uptime_in_seconds'); + if (seconds == null || seconds <= 0) return '0m'; + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + if (days > 0) return '${days}d ${hours}h'; + if (hours > 0) return '${hours}h ${minutes}m'; + return '${minutes}m'; + } + + String _labelFor(String key) => _redisFieldLabels[key] ?? key.replaceAll('_', ' '); + material.Widget _summaryChips(material.BuildContext context, RedisInfoSections info) { final cs = shadcn.Theme.of(context).colorScheme; final version = sectionValue(info, 'Server', 'redis_version') ?? '—'; - final uptime = sectionInt(info, 'Server', 'uptime_in_days') ?? 0; + final uptime = _formatUptime(info); final clients = sectionInt(info, 'Clients', 'connected_clients') ?? 0; final maxClients = sectionInt(info, 'Clients', 'maxclients') ?? 0; final ops = sectionInt(info, 'Stats', 'instantaneous_ops_per_sec') ?? 0; 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), decoration: material.BoxDecoration( @@ -311,8 +377,12 @@ class _RedisViewState extends material.State { border: material.Border.all(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( @@ -322,7 +392,17 @@ class _RedisViewState 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, + ), + ), ], ), ), @@ -333,10 +413,11 @@ class _RedisViewState extends material.State { ); } return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, children: [ chip('Version', version, material.Icons.tag_rounded), const Gap(12), - chip('Uptime', '$uptime days', material.Icons.schedule_rounded), + chip('Uptime', uptime, material.Icons.schedule_rounded), const Gap(12), chip('Clients', '$clients / $maxClients', material.Icons.people_outline_rounded), const Gap(12), @@ -345,11 +426,19 @@ class _RedisViewState extends material.State { ); } - material.Widget _card(material.BuildContext context, String title, material.Widget body, {double? height}) { + material.Widget _card( + material.BuildContext context, + String title, + material.Widget body, { + double? minHeight, + bool stretchBody = false, + }) { 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, @@ -357,17 +446,35 @@ class _RedisViewState extends material.State { border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), ), child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.start, + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Text(title).semiBold(), const Gap(12), - body, + if (stretchBody) material.Expanded(child: body) else body, ], ), ); } + material.Widget _metricList( + material.BuildContext context, + List rows, { + bool stretch = false, + }) { + final column = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: rows, + ); + if (!stretch) return column; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + column, + const material.Spacer(), + ], + ); + } + material.Widget _memoryCard(material.BuildContext context, RedisInfoSections info) { final usedHuman = sectionValue(info, 'Memory', 'used_memory_human') ?? '—'; final peakHuman = sectionValue(info, 'Memory', 'used_memory_peak_human') ?? '—'; @@ -376,17 +483,18 @@ class _RedisViewState extends material.State { return _card( context, 'Memory', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Used memory', usedHuman), - _row(context, 'Peak', peakHuman), - _row(context, 'RSS', rss), - _row(context, 'Fragmentation', '${frag.toStringAsFixed(2)}x'), + _metricList( + context, + [ + _metricRow(context, 'Used', usedHuman), + _metricRow(context, 'Peak', peakHuman), + _metricRow(context, 'RSS', rss), + _metricRow(context, 'Fragmentation', '${frag.toStringAsFixed(2)}×'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -399,58 +507,96 @@ class _RedisViewState extends material.State { final hitRate = total > 0 ? (keyspaceHits / total * 100).toStringAsFixed(1) : '—'; return _card( context, - 'Stats', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Ops/s', '$ops'), - if (totalOps != null) _row(context, 'Total commands', '$totalOps'), - _row(context, 'Keyspace hits', '$keyspaceHits'), - _row(context, 'Keyspace misses', '$keyspaceMisses'), - _row(context, 'Hit rate', '$hitRate%'), + 'Performance', + _metricList( + context, + [ + _metricRow(context, 'Ops/s', '$ops'), + _metricRow(context, 'Total commands', totalOps?.toString() ?? '—'), + _metricRow(context, 'Hits / misses', '$keyspaceHits / $keyspaceMisses'), + _metricRow(context, 'Hit rate', hitRate == '—' ? hitRate : '$hitRate%'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } material.Widget _keyspaceCard(material.BuildContext context, RedisInfoSections info) { - final hits = sectionInt(info, 'Stats', 'keyspace_hits') ?? 0; - final misses = sectionInt(info, 'Stats', 'keyspace_misses') ?? 0; - final total = hits + misses; - final hitPct = total > 0 ? (hits / total * 100).toStringAsFixed(1) : '—'; + final keyspace = info['Keyspace']; + final rows = []; + if (keyspace != null && keyspace.isNotEmpty) { + final entries = keyspace.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + for (final entry in entries) { + rows.add(_metricRow(context, entry.key, _formatKeyspaceEntry(entry.value))); + } + } else { + rows.add( + material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 8), + child: const Text('No keys in any database').muted().small(), + ), + ); + } return _card( context, - 'Keyspace hits / misses', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Hits', '$hits'), - _row(context, 'Misses', '$misses'), - _row(context, 'Hit rate', '$hitPct%'), - ], - ), - height: _gridCardHeight, + 'Keyspace', + _metricList(context, rows, stretch: true), + minHeight: _gridCardMinHeight, + stretchBody: true, ); } + String _formatKeyspaceEntry(String raw) { + var keys = 0; + var expires = 0; + int? avgTtlMs; + for (final part in raw.split(',')) { + final kv = part.split('='); + if (kv.length != 2) continue; + final name = kv[0].trim(); + final value = kv[1].trim(); + switch (name) { + case 'keys': + keys = int.tryParse(value) ?? 0; + case 'expires': + expires = int.tryParse(value) ?? 0; + case 'avg_ttl': + avgTtlMs = int.tryParse(value); + } + } + final ttlPart = (avgTtlMs != null && avgTtlMs > 0) + ? ' · avg TTL ${_formatDurationMs(avgTtlMs)}' + : ''; + return '$keys keys · $expires with TTL$ttlPart'; + } + + String _formatDurationMs(int ms) { + final seconds = ms ~/ 1000; + if (seconds < 60) return '${seconds}s'; + if (seconds < 3600) return '${seconds ~/ 60}m'; + if (seconds < 86400) return '${(seconds / 3600).toStringAsFixed(1)}h'; + return '${(seconds / 86400).toStringAsFixed(1)}d'; + } + material.Widget _cpuCard(material.BuildContext context, RedisInfoSections info) { final sys = sectionDouble(info, 'CPU', 'used_cpu_sys_main_thread'); final user = sectionDouble(info, 'CPU', 'used_cpu_user_main_thread'); return _card( context, 'CPU (main thread)', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - if (sys != null) _row(context, 'System', sys.toStringAsFixed(2)), - if (user != null) _row(context, 'User', user.toStringAsFixed(2)), + _metricList( + context, + [ + _metricRow(context, 'System', sys?.toStringAsFixed(2) ?? '—'), + _metricRow(context, 'User', user?.toStringAsFixed(2) ?? '—'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -463,14 +609,46 @@ class _RedisViewState extends material.State { return _card( context, title, - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [for (final e in entries) _row(context, e.key, e.value)], + _twoColumnMetrics( + context, + entries.map((e) => MapEntry(_labelFor(e.key), e.value)).toList(), ), ); } + material.Widget _twoColumnMetrics( + material.BuildContext context, + List> entries, + ) { + if (entries.length <= 4) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in entries) _metricRow(context, e.key, e.value)], + ); + } + final mid = (entries.length / 2).ceil(); + final left = entries.sublist(0, mid); + final right = entries.sublist(mid); + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in left) _metricRow(context, e.key, e.value)], + ), + ), + const Gap(24), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in right) _metricRow(context, e.key, e.value)], + ), + ), + ], + ); + } + material.Widget _errorStatsCard(material.BuildContext context, RedisInfoSections info) { final data = info['Errorstats']; if (data == null || data.isEmpty) return const material.SizedBox.shrink(); @@ -478,22 +656,45 @@ class _RedisViewState extends material.State { context, 'Error stats', material.Column( - mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [for (final e in data.entries) _row(context, e.key, e.value)], + children: [ + for (final e in data.entries) + _metricRow(context, _labelFor(e.key), e.value), + ], ), ); } - material.Widget _row(material.BuildContext context, String key, String value) { + material.Widget _metricRow(material.BuildContext context, String label, String value) { final cs = shadcn.Theme.of(context).colorScheme; return material.Padding( - padding: const material.EdgeInsets.symmetric(vertical: 4), + padding: const material.EdgeInsets.symmetric(vertical: 6), child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - material.SizedBox(width: 160, child: Text(key).muted().small()), - material.Expanded(child: material.SelectableText(value, style: material.TextStyle(fontSize: 13, color: cs.foreground))), + material.Expanded( + flex: 3, + child: Text( + label, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ).muted().small(), + ), + const Gap(12), + material.Flexible( + flex: 2, + child: material.SelectableText( + value, + textAlign: material.TextAlign.right, + maxLines: 2, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ), ], ), ); diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 7c464a92..c5706448 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -2,7 +2,6 @@ import 'dart:async' show unawaited; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; @@ -21,7 +20,6 @@ class PreferencesAppearanceSection extends material.StatefulWidget { class _PreferencesAppearanceSectionState extends material.State { final _controller = ThemeController.instance; - final _uiScale = UiScaleController.instance; String? _importError; bool _importing = false; @@ -29,13 +27,11 @@ class _PreferencesAppearanceSectionState void initState() { super.initState(); _controller.addListener(_onThemeChanged); - _uiScale.addListener(_onThemeChanged); } @override void dispose() { _controller.removeListener(_onThemeChanged); - _uiScale.removeListener(_onThemeChanged); super.dispose(); } @@ -153,11 +149,11 @@ class _PreferencesAppearanceSectionState ), ), const material.SizedBox(height: 12), - PreferencesFieldRow( + const PreferencesFieldRow( label: 'Interface scale', hint: 'Snap to presets (75%, 85%, 90%, 100% …). Hold Shift for 1% fine control.', - control: InterfaceScaleSlider(scale: _uiScale.scale), + control: InterfaceScaleSlider(), ), const material.SizedBox(height: 12), PreferencesFieldRow( diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index a72022ce..ad30a932 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -77,10 +77,14 @@ class PreferencesFieldRow extends StatelessWidget { } /// Interface scale slider: fixed presets by default; hold Shift for 1% fine steps. +/// +/// Drag updates the label locally; [UiScaleController.commitScale] runs on release +/// so the rest of the app rebuilds once, not on every slider tick. class InterfaceScaleSlider extends material.StatefulWidget { - const InterfaceScaleSlider({super.key, required this.scale}); + const InterfaceScaleSlider({super.key, this.scale}); - final double scale; + /// When set (e.g. in tests), overrides [UiScaleController.instance.scale]. + final double? scale; @override material.State createState() => @@ -89,17 +93,55 @@ class InterfaceScaleSlider extends material.StatefulWidget { class _InterfaceScaleSliderState extends material.State { bool _fineControl = false; + double? _dragScale; static int get _fineDivisions => ((kMaxUiScale - kMinUiScale) / kUiScaleStep).round(); bool get _shiftHeld => HardwareKeyboard.instance.isShiftPressed; - void _syncModifierKeys() { + double get _committedScale => + widget.scale ?? UiScaleController.instance.scale; + + double get _displayScale => _dragScale ?? _committedScale; + + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_onKeyEvent); + if (widget.scale == null) { + UiScaleController.instance.addListener(_onCommittedScaleChanged); + } + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_onKeyEvent); + if (widget.scale == null) { + UiScaleController.instance.removeListener(_onCommittedScaleChanged); + } + super.dispose(); + } + + @override + void didUpdateWidget(InterfaceScaleSlider oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.scale != widget.scale) { + _dragScale = null; + } + } + + void _onCommittedScaleChanged() { + if (_dragScale != null || !mounted) return; + setState(() {}); + } + + bool _onKeyEvent(KeyEvent event) { final fine = _shiftHeld; if (fine != _fineControl) { setState(() => _fineControl = fine); } + return false; } double _sliderPosition(double scale, {required bool fine}) { @@ -116,18 +158,16 @@ class _InterfaceScaleSliderState extends material.State { @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; - final pct = (widget.scale * 100).round(); + final displayScale = _displayScale; + final pct = (displayScale * 100).round(); final fine = _fineControl; - return material.Listener( - onPointerDown: (_) => _syncModifierKeys(), - onPointerMove: (_) => _syncModifierKeys(), - child: material.Row( + return material.Row( children: [ material.Expanded( child: Slider( value: SliderValue.single( - _sliderPosition(widget.scale, fine: fine), + _sliderPosition(displayScale, fine: fine), ), min: fine ? kMinUiScale : 0, max: fine @@ -136,21 +176,18 @@ class _InterfaceScaleSliderState extends material.State { divisions: fine ? _fineDivisions : kUiScalePresets.length - 1, hintValue: const SliderValue.single(kDefaultUiScale), onChanged: (value) { - _syncModifierKeys(); final next = _scaleFromSlider( value.value, fine: _shiftHeld, ); - UiScaleController.instance.setScalePreview( - next, - fine: _shiftHeld, - ); + setState(() => _dragScale = next); }, onChangeEnd: (value) { final next = _scaleFromSlider( value.value, fine: _shiftHeld, ); + setState(() => _dragScale = null); unawaited( UiScaleController.instance.commitScale( next, @@ -174,8 +211,7 @@ class _InterfaceScaleSliderState extends material.State { ), ), ], - ), - ); + ); } } diff --git a/lib/shared/widgets/form_validity_notifier.dart b/lib/shared/widgets/form_validity_notifier.dart new file mode 100644 index 00000000..24d2297e --- /dev/null +++ b/lib/shared/widgets/form_validity_notifier.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' as material; + +/// Notifies when a derived form-valid flag changes (avoids full-form [setState]). +class FormValidityNotifier { + FormValidityNotifier(this._compute); + + final bool Function() _compute; + final ValueNotifier listenable = ValueNotifier(false); + + bool get value => listenable.value; + + void listenTo(material.TextEditingController controller) { + controller.addListener(_onChanged); + } + + void unlistenFrom(material.TextEditingController controller) { + controller.removeListener(_onChanged); + } + + void _onChanged() { + final next = _compute(); + if (next != listenable.value) { + listenable.value = next; + } + } + + void seed() => _onChanged(); + + void dispose() { + listenable.dispose(); + } +} diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index 3480ec79..cfcd5860 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; @@ -56,6 +57,9 @@ class QueryaDropdown extends material.StatefulWidget { class _QueryaDropdownState extends material.State> { late material.MenuController _controller; bool _triggerHovered = false; + List? _cachedMenuChildren; + List>? _cachedMenuItems; + T? _cachedMenuValue; @override void initState() { @@ -69,6 +73,23 @@ class _QueryaDropdownState extends material.State> { if (widget.controller != oldWidget.controller) { _controller = widget.controller ?? material.MenuController(); } + if (!listEquals(oldWidget.items, widget.items) || + oldWidget.value != widget.value) { + _cachedMenuChildren = null; + } + } + + List _menuChildren(ColorScheme cs) { + if (_cachedMenuChildren != null && + listEquals(_cachedMenuItems, widget.items) && + _cachedMenuValue == widget.value) { + return _cachedMenuChildren!; + } + _cachedMenuItems = List>.from(widget.items); + _cachedMenuValue = widget.value; + _cachedMenuChildren = + widget.items.map((item) => _menuItem(item, cs)).toList(); + return _cachedMenuChildren!; } material.Widget _triggerLabelText({ @@ -148,7 +169,7 @@ class _QueryaDropdownState extends material.State> { ), child: material.Row( mainAxisAlignment: material.MainAxisAlignment.spaceBetween, - mainAxisSize: widget.expandToParent + mainAxisSize: (widget.expandToParent || fieldWidth != null) ? material.MainAxisSize.max : material.MainAxisSize.min, children: [ @@ -156,7 +177,7 @@ class _QueryaDropdownState extends material.State> { context: context, label: label, cs: cs, - expand: widget.expandToParent, + expand: widget.expandToParent || fieldWidth != null, ), material.SizedBox(width: chevronGap), material.Icon( @@ -198,7 +219,7 @@ class _QueryaDropdownState extends material.State> { final fieldWidth = widget.expandToParent ? null : (widget.width != null ? context.scaled(widget.width!) : null); - final menuChildren = widget.items.map((item) => _menuItem(item, cs)).toList(); + final menuChildren = _menuChildren(cs); final scaledMaxHeight = context.scaled(widget.menuMaxHeight); final effectiveMaxHeight = widget.items.length > QueryaDropdownTokens.menuScrollItemThreshold diff --git a/test/core/editor/querya_code_editor_test.dart b/test/core/editor/querya_code_editor_test.dart index 33f1dc22..4c376dab 100644 --- a/test/core/editor/querya_code_editor_test.dart +++ b/test/core/editor/querya_code_editor_test.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import '../../support/pump_syntax_highlight.dart'; import '../../support/querya_theme_test_shell.dart'; void main() { @@ -74,11 +75,14 @@ void main() { ), ); await tester.pumpAndSettle(); + await pumpSyntaxHighlightDebounce(tester); await tester.enterText(find.byType(material.EditableText), 'SELECT 1'); await tester.pump(); + await pumpSyntaxHighlightDebounce(tester); expect(external.text, 'SELECT 1'); external.text = 'UPDATE x'; await tester.pump(); + await pumpSyntaxHighlightDebounce(tester); expect( tester.widget(find.byType(material.EditableText)).controller.text, 'UPDATE x', diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index cb0d5f6a..62e6b05d 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -256,16 +256,30 @@ void main() { expect(AppSettingsRevision.listenable.value, start + 1); }); - test('mutating AppSettings notifies listenable', () async { + test('mutating SQL workspace settings notifies SqlWorkspaceSettingsRevision', + () async { var calls = 0; void listener() => calls++; - AppSettingsRevision.listenable.addListener(listener); - final before = AppSettingsRevision.listenable.value; + SqlWorkspaceSettingsRevision.listenable.addListener(listener); + final before = SqlWorkspaceSettingsRevision.listenable.value; await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(45); - expect(AppSettingsRevision.listenable.value, greaterThan(before)); + expect(SqlWorkspaceSettingsRevision.listenable.value, greaterThan(before)); expect(calls, greaterThan(0)); - AppSettingsRevision.listenable.removeListener(listener); + SqlWorkspaceSettingsRevision.listenable.removeListener(listener); + }); + + test('mutating theme settings does not notify SqlWorkspaceSettingsRevision', + () async { + var sqlCalls = 0; + void listener() => sqlCalls++; + + SqlWorkspaceSettingsRevision.listenable.addListener(listener); + final before = SqlWorkspaceSettingsRevision.listenable.value; + await AppSettings.instance.setThemeMode(ThemeMode.light); + expect(SqlWorkspaceSettingsRevision.listenable.value, before); + expect(sqlCalls, 0); + SqlWorkspaceSettingsRevision.listenable.removeListener(listener); }); }); } diff --git a/test/core/util/deep_collection_equals_test.dart b/test/core/util/deep_collection_equals_test.dart new file mode 100644 index 00000000..69938137 --- /dev/null +++ b/test/core/util/deep_collection_equals_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; +import 'package:querya_desktop/features/mysql/mysql_result_utils.dart'; + +void main() { + group('deepCollectionEquals', () { + test('compares nested maps and lists', () { + const a = { + 'x': 1, + 'y': [1, 2, {'z': 'ok'}], + }; + const b = { + 'x': 1, + 'y': [1, 2, {'z': 'ok'}], + }; + const c = { + 'x': 1, + 'y': [1, 2, {'z': 'nope'}], + }; + expect(deepCollectionEquals(a, b), isTrue); + expect(deepCollectionEquals(a, c), isFalse); + }); + + test('replaceIfChanged skips identical snapshots', () { + var value = {'a': 1}; + var applyCount = 0; + expect( + replaceIfChanged(value, {'a': 1}, (v) { + applyCount++; + value = v!; + }), + isFalse, + ); + expect(applyCount, 0); + }); + }); + + group('convertMysqlResultRowsToStrings', () { + test('null cells become NULL', () { + final out = convertMysqlResultRowsToStrings( + const MysqlResultConvertJob( + rowValues: [ + [1, null, 'x'], + ], + ), + ); + expect(out, [ + ['1', 'NULL', 'x'], + ]); + }); + }); +} diff --git a/test/features/connections/lazy_connection_tree_list_test.dart b/test/features/connections/lazy_connection_tree_list_test.dart new file mode 100644 index 00000000..8feed90e --- /dev/null +++ b/test/features/connections/lazy_connection_tree_list_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart'; + +void main() { + testWidgets('lazyConnectionTreeList uses ListView for large lists', (tester) async { + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: material.Builder( + builder: (context) => lazyConnectionTreeList( + context: context, + itemCount: 50, + itemExtent: kConnectionTreeRowExtent, + itemBuilder: (context, index) => material.SizedBox( + height: kConnectionTreeRowExtent, + child: material.Text('item $index'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsOneWidget); + }); + + testWidgets('lazyConnectionTreeList uses Column for small lists', (tester) async { + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: material.Builder( + builder: (context) => lazyConnectionTreeList( + context: context, + itemCount: 5, + itemBuilder: (context, index) => material.Text('item $index'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsNothing); + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item 4'), findsOneWidget); + }); +} diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart new file mode 100644 index 00000000..647f9c16 --- /dev/null +++ b/test/features/main_screen/results_tab_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; +import 'package:querya_desktop/features/main_screen/results_tab.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('computeResultGridColumnWidths', () { + test('returns empty list for no columns', () { + expect( + computeResultGridColumnWidths(columns: const [], rows: const []), + isEmpty, + ); + }); + + test('respects min and max width bounds', () { + final widths = computeResultGridColumnWidths( + columns: const ['id', 'note'], + rows: [ + ['1', 'x'], + ['2', 'y'], + ], + minWidth: 100, + maxWidth: 150, + ); + expect(widths, hasLength(2)); + for (final w in widths) { + expect(w, inInclusiveRange(100, 150)); + } + }); + + test('widens columns for long sampled values', () { + final short = computeResultGridColumnWidths( + columns: const ['payload'], + rows: [ + ['a'], + ], + ).single; + final long = computeResultGridColumnWidths( + columns: const ['payload'], + rows: [ + ['x' * 80], + ], + ).single; + expect(long, greaterThan(short)); + }); + }); + + group('ResultsTab', () { + testWidgets('uses virtualized grid instead of Table', (tester) async { + final rows = List.generate( + 120, + (i) => ['$i', 'value-$i'], + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ResultsTab( + columns: const ['id', 'name'], + rows: rows, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsOneWidget); + expect(find.byType(material.Table), findsNothing); + expect(find.byType(VirtualResultGrid), findsOneWidget); + }); + + testWidgets('virtualizes rows — does not build all row widgets at once', + (tester) async { + final rows = List.generate( + 500, + (i) => ['$i', 'value-$i'], + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + height: 400, + width: 600, + child: VirtualResultGrid( + columns: const ['id', 'name'], + rows: rows, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Header + only visible rows (not all 500). + final dataRowWidgets = tester.widgetList(find.byType(material.Row)).length; + expect(dataRowWidgets, lessThan(80)); + }); + }); +} diff --git a/test/features/mongodb/mongo_document_editor_test.dart b/test/features/mongodb/mongo_document_editor_test.dart index 64f23985..6915c118 100644 --- a/test/features/mongodb/mongo_document_editor_test.dart +++ b/test/features/mongodb/mongo_document_editor_test.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/mongodb/mongo_document_editor.dart'; +import '../../support/pump_syntax_highlight.dart'; import '../../support/querya_theme_test_shell.dart'; void main() { @@ -37,6 +38,7 @@ void main() { ), ); await tester.pumpAndSettle(); + await pumpSyntaxHighlightDebounce(tester); } testWidgets('Format pretty-prints valid JSON', (tester) async { @@ -48,6 +50,7 @@ void main() { await tester.pump(); await tester.tap(find.text('Format')); await tester.pump(); + await pumpSyntaxHighlightDebounce(tester); final editable = tester.widget( find.byType(material.EditableText), @@ -63,6 +66,7 @@ void main() { await tester.pump(); await tester.tap(find.text('Format')); await tester.pump(); + await pumpSyntaxHighlightDebounce(tester); expect(find.textContaining('Invalid JSON'), findsOneWidget); expect(find.byType(material.EditableText), findsOneWidget); diff --git a/test/features/settings/interface_scale_slider_test.dart b/test/features/settings/interface_scale_slider_test.dart index 7e346486..300c9c6d 100644 --- a/test/features/settings/interface_scale_slider_test.dart +++ b/test/features/settings/interface_scale_slider_test.dart @@ -23,14 +23,10 @@ void main() { expect(find.byType(Slider), findsOneWidget); }); - test('preview snaps to presets unless fine mode', () { - final controller = UiScaleController.instance; - final before = controller.scale; - controller.setScalePreview(1.15); - expect(controller.scale, 1.1); - controller.setScalePreview(1.15, fine: true); - expect(controller.scale, closeTo(1.15, 0.001)); - controller.setScalePreview(before, fine: true); + test('UiScaleController.normalize snaps to presets unless fine mode', () { + expect(UiScaleController.normalize(1.15), 1.1); + expect(UiScaleController.normalize(1.15, fine: true), closeTo(1.15, 0.001)); + expect(UiScaleController.normalize(0.88), 0.9); }); }); diff --git a/test/support/pump_syntax_highlight.dart b/test/support/pump_syntax_highlight.dart new file mode 100644 index 00000000..da1c7970 --- /dev/null +++ b/test/support/pump_syntax_highlight.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/editor/querya_highlight_controller.dart'; + +/// Flushes the syntax-highlight debounce timer and pending isolate work. +Future pumpSyntaxHighlightDebounce(WidgetTester tester) async { + await tester.pump(kSyntaxHighlightDebounce); + await tester.pump(); +}