diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 18d87a5c..038e6a28 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"path_provider_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.22\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.2+2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_macos-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_linux-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_windows-0.1.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-03-07 10:36:50.253535","version":"3.38.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"path_provider_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.22\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.2+2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_macos-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_linux-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_windows-0.1.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-03-08 13:00:51.851139","version":"3.38.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index d894de38..dd52a23e 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -75,6 +75,231 @@ class RedisConnection { await disconnect(); } } + + // ─── Data commands ───────────────────────────────────────────────────── + + /// Raw command helper. + Future sendCommand(List args) async { + if (!isConnected || _command == null) { + throw StateError('Not connected to Redis'); + } + return _command!.send_object(args); + } + + /// SELECT database index. + Future selectDatabase(int db) async { + await sendCommand(['SELECT', db]); + } + + /// DBSIZE — number of keys in the currently selected database. + Future dbSize() async { + final result = await sendCommand(['DBSIZE']); + return result is int ? result : int.tryParse(result.toString()) ?? 0; + } + + /// CONFIG GET databases — max number of databases. + Future getMaxDatabases() async { + try { + final result = await sendCommand(['CONFIG', 'GET', 'databases']); + if (result is List && result.length >= 2) { + return int.tryParse(result[1].toString()) ?? 16; + } + } catch (_) { + // Some Redis instances don't allow CONFIG; fall back. + } + return 16; + } + + /// SCAN cursor [MATCH pattern] [COUNT count]. + /// Returns (nextCursor, keys). + Future<(int, List)> scan({ + int cursor = 0, + String? match, + int count = 100, + }) async { + final args = ['SCAN', cursor]; + if (match != null && match.isNotEmpty) { + args.addAll(['MATCH', match]); + } + args.addAll(['COUNT', count]); + final result = await sendCommand(args); + if (result is List && result.length == 2) { + final nextCursor = int.tryParse(result[0].toString()) ?? 0; + final keys = (result[1] as List?) + ?.map((e) => e.toString()) + .toList() ?? + []; + return (nextCursor, keys); + } + return (0, []); + } + + /// TYPE key. + Future keyType(String key) async { + final result = await sendCommand(['TYPE', key]); + return result?.toString() ?? 'none'; + } + + /// TTL key (returns -1 if no expiry, -2 if missing). + Future ttl(String key) async { + final result = await sendCommand(['TTL', key]); + return result is int ? result : int.tryParse(result.toString()) ?? -1; + } + + /// GET (string). + Future get(String key) async { + final result = await sendCommand(['GET', key]); + return result?.toString(); + } + + /// SET key value [EX seconds]. + Future set(String key, String value, {int? ttlSeconds}) async { + if (ttlSeconds != null && ttlSeconds > 0) { + await sendCommand(['SET', key, value, 'EX', ttlSeconds]); + } else { + await sendCommand(['SET', key, value]); + } + } + + /// HGETALL key. Returns a `Map`. + Future> hgetall(String key) async { + final result = await sendCommand(['HGETALL', key]); + final map = {}; + if (result is List) { + for (var i = 0; i + 1 < result.length; i += 2) { + map[result[i].toString()] = result[i + 1].toString(); + } + } + return map; + } + + /// HSET key field value. + Future hset(String key, String field, String value) async { + await sendCommand(['HSET', key, field, value]); + } + + /// HDEL key field. + Future hdel(String key, String field) async { + await sendCommand(['HDEL', key, field]); + } + + /// LRANGE key start stop. + Future> lrange(String key, int start, int stop) async { + final result = await sendCommand(['LRANGE', key, start, stop]); + if (result is List) { + return result.map((e) => e.toString()).toList(); + } + return []; + } + + /// LLEN key. + Future llen(String key) async { + final result = await sendCommand(['LLEN', key]); + return result is int ? result : int.tryParse(result.toString()) ?? 0; + } + + /// RPUSH key value. + Future rpush(String key, String value) async { + await sendCommand(['RPUSH', key, value]); + } + + /// SMEMBERS key. + Future> smembers(String key) async { + final result = await sendCommand(['SMEMBERS', key]); + if (result is List) { + return result.map((e) => e.toString()).toList(); + } + return []; + } + + /// SCARD key. + Future scard(String key) async { + final result = await sendCommand(['SCARD', key]); + return result is int ? result : int.tryParse(result.toString()) ?? 0; + } + + /// SADD key member. + Future sadd(String key, String member) async { + await sendCommand(['SADD', key, member]); + } + + /// SREM key member. + Future srem(String key, String member) async { + await sendCommand(['SREM', key, member]); + } + + /// ZRANGE key start stop WITHSCORES → list of (member, score). + Future> zrangeWithScores( + String key, int start, int stop) async { + final result = + await sendCommand(['ZRANGE', key, start, stop, 'WITHSCORES']); + final list = <(String, double)>[]; + if (result is List) { + for (var i = 0; i + 1 < result.length; i += 2) { + final member = result[i].toString(); + final score = double.tryParse(result[i + 1].toString()) ?? 0; + list.add((member, score)); + } + } + return list; + } + + /// ZCARD key. + Future zcard(String key) async { + final result = await sendCommand(['ZCARD', key]); + return result is int ? result : int.tryParse(result.toString()) ?? 0; + } + + /// ZADD key score member. + Future zadd(String key, double score, String member) async { + await sendCommand(['ZADD', key, score, member]); + } + + /// ZREM key member. + Future zrem(String key, String member) async { + await sendCommand(['ZREM', key, member]); + } + + /// DEL key. + Future del(String key) async { + final result = await sendCommand(['DEL', key]); + return result is int ? result : int.tryParse(result.toString()) ?? 0; + } + + /// RENAME old new. + Future rename(String oldKey, String newKey) async { + await sendCommand(['RENAME', oldKey, newKey]); + } + + /// EXPIRE key seconds. + Future expire(String key, int seconds) async { + await sendCommand(['EXPIRE', key, seconds]); + } + + /// PERSIST key (remove TTL). + Future persist(String key) async { + await sendCommand(['PERSIST', key]); + } + + /// STRLEN / LLEN / SCARD / ZCARD / HLEN — get size for any type. + Future keySize(String key, String type) async { + switch (type) { + case 'string': + final r = await sendCommand(['STRLEN', key]); + return r is int ? r : int.tryParse(r.toString()) ?? 0; + case 'list': + return llen(key); + case 'set': + return scard(key); + case 'zset': + return zcard(key); + case 'hash': + final r = await sendCommand(['HLEN', key]); + return r is int ? r : int.tryParse(r.toString()) ?? 0; + default: + return 0; + } + } } class RedisConnectionException implements Exception { diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 91e15748..6d79ec72 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,4 +1,6 @@ -import 'package:flutter/material.dart' as material show Padding, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, DefaultTextStyle, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, Column, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded; +import 'package:flutter/material.dart' as material show Padding, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, DefaultTextStyle, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, Column, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -13,11 +15,16 @@ class ConnectionsPanel extends StatefulWidget { const ConnectionsPanel({ super.key, this.onConnectionSelected, + this.onRedisDatabaseSelected, }); /// Called when the user taps a connection tile. final void Function(ConnectionRow connection)? onConnectionSelected; + /// Called when the user taps a Redis database node in the tree. + final void Function(ConnectionRow connection, int database)? + onRedisDatabaseSelected; + @override State createState() => _ConnectionsPanelState(); } @@ -186,16 +193,26 @@ class _ConnectionsPanelState extends State { iconForType: _iconForType, onRemoveConnection: _removeConnection, onConnectionTap: widget.onConnectionSelected, + onRedisDatabaseTap: widget.onRedisDatabaseSelected, ), // Root connections (no folder) for (final conn in rootConnections) - _ConnectionTile( - connection: conn, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), - onRemove: () => _removeConnection(conn.id!), - onTap: () => widget.onConnectionSelected?.call(conn), - ), + conn.type == 'redis' + ? _RedisConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), + ) + : _ConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + ), // Empty state if (_connections.isEmpty && _folders.isEmpty) const material.Padding( @@ -385,6 +402,7 @@ class _FolderTile extends StatelessWidget { required this.iconForType, required this.onRemoveConnection, this.onConnectionTap, + this.onRedisDatabaseTap, }); final String name; @@ -396,6 +414,7 @@ class _FolderTile extends StatelessWidget { final material.IconData Function(String type) iconForType; final Future Function(int id) onRemoveConnection; final void Function(ConnectionRow connection)? onConnectionTap; + final void Function(ConnectionRow connection, int database)? onRedisDatabaseTap; @override Widget build(BuildContext context) { @@ -461,17 +480,337 @@ class _FolderTile extends StatelessWidget { for (final conn in connections) material.Padding( padding: const material.EdgeInsets.only(left: 24), - child: _ConnectionTile( - connection: conn, - icon: iconForType(conn.type), - iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), - onRemove: () => onRemoveConnection(conn.id!), - onTap: () => onConnectionTap?.call(conn), + child: conn.type == 'redis' + ? _RedisConnectionTile( + connection: conn, + icon: iconForType(conn.type), + iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), + onRemove: () => onRemoveConnection(conn.id!), + onTap: () => onConnectionTap?.call(conn), + onDatabaseTap: (db) => onRedisDatabaseTap?.call(conn, db), + ) + : _ConnectionTile( + connection: conn, + icon: iconForType(conn.type), + iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), + onRemove: () => onRemoveConnection(conn.id!), + onTap: () => onConnectionTap?.call(conn), + ), + ), + ], + ), + ), + ); + } +} + +// ─── Redis connection tile with expandable database tree ──────────────────── + +class _RedisConnectionTile extends StatefulWidget { + const _RedisConnectionTile({ + required this.connection, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + this.onDatabaseTap, + }); + + final ConnectionRow connection; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + final void Function(int database)? onDatabaseTap; + + @override + State<_RedisConnectionTile> createState() => _RedisConnectionTileState(); +} + +class _RedisConnectionTileState extends State<_RedisConnectionTile> { + bool _expanded = false; + bool _loading = false; + String? _error; + // All 16 databases (db0–db15) with key counts + List<({int index, int keys})> _databases = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _databases.isEmpty && !_loading) { + _loadDatabases(); + } + } + + Future _loadDatabases() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + // Use a temporary connection so we don't kill the main view's connection. + final c = widget.connection; + final conn = RedisConnection( + id: -1, + name: 'sidebar_probe', + host: c.host ?? 'localhost', + port: c.port ?? 6379, + username: c.username, + password: c.password, + ); + await conn.connect(); + final raw = await conn.info(); + await conn.disconnect(); + + final info = parseRedisInfo(raw); + final keyspace = info['Keyspace'] ?? {}; + + // Build all 16 databases with their key counts + final dbs = <({int index, int keys})>[]; + for (var i = 0; i < 16; i++) { + final dbKey = 'db$i'; + final dbInfo = keyspace[dbKey]; + int keys = 0; + if (dbInfo != null) { + for (final part in dbInfo.split(',')) { + final kv = part.split('='); + if (kv.length == 2 && kv[0].trim() == 'keys') { + keys = int.tryParse(kv[1].trim()) ?? 0; + } + } + } + dbs.add((index: i, keys: keys)); + } + + if (!mounted) return; + setState(() { + _databases = dbs; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconWidget = widget.iconAsset != null + ? material.Image.asset( + widget.iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ), + ) + : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); + + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.refresh_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) { + _databases = []; + _loadDatabases(); + }, + child: const Text('Refresh databases'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + // Connection row + material.Row( + children: [ + // Expand/collapse arrow + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ), + // Connection name — clickable for stats + material.Expanded( + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: widget.onTap, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + // Expanded database children — ALL 16 databases + if (_expanded) ...[ + if (_loading) + material.Padding( + padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Row( + children: [ + const material.SizedBox( + width: 12, + height: 12, + child: material.CircularProgressIndicator(strokeWidth: 1.5), + ), + const Gap(8), + const Text('Loading...').muted().xSmall(), + ], + ), + ), + if (_error != null) + material.Padding( + padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Text( + 'Error', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, color: theme.colorScheme.destructive), ), ), + for (final db in _databases) + _RedisDatabaseNode( + index: db.index, + keys: db.keys, + onTap: () => widget.onDatabaseTap?.call(db.index), + ), + ], ], ), ), ); } } + +class _RedisDatabaseNode extends StatelessWidget { + const _RedisDatabaseNode({ + required this.index, + required this.keys, + required this.onTap, + }); + + final int index; + final int keys; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 24), + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: onTap, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, vertical: 5), + child: material.Row( + children: [ + material.Icon( + material.Icons.dns_rounded, + size: 14, + color: keys > 0 + ? theme.colorScheme.primary.withValues(alpha: 0.7) + : theme.colorScheme.mutedForeground.withValues(alpha: 0.5), + ), + const Gap(8), + material.Expanded( + child: material.Text( + 'db$index', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + color: keys > 0 + ? theme.colorScheme.foreground + : theme.colorScheme.mutedForeground, + ), + ), + ), + if (keys > 0) + material.Text( + '$keys', + style: material.TextStyle( + fontSize: 10, + color: theme.colorScheme.mutedForeground), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 24068bb0..43272e96 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -24,9 +24,20 @@ class _MainScreenState extends State { /// Currently selected connection (null = no connection selected). ConnectionRow? _activeConnection; + /// Currently selected Redis database (null = show stats). + int? _activeRedisDb; + void _onConnectionSelected(ConnectionRow connection) { setState(() { _activeConnection = connection; + _activeRedisDb = null; // Reset: connection click → stats + }); + } + + void _onRedisDatabaseSelected(ConnectionRow connection, int database) { + setState(() { + _activeConnection = connection; + _activeRedisDb = database; }); } @@ -51,6 +62,7 @@ class _MainScreenState extends State { width: _leftPanelWidth, child: ConnectionsPanel( onConnectionSelected: _onConnectionSelected, + onRedisDatabaseSelected: _onRedisDatabaseSelected, ), ), _VerticalResizeHandle( @@ -64,6 +76,7 @@ class _MainScreenState extends State { Expanded( child: WorkspacePanel( activeConnection: _activeConnection, + selectedRedisDb: _activeRedisDb, ), ), ], diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 3d440e06..a375997c 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -3,6 +3,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mongodb/mongo_explorer_view.dart'; +import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; import 'query_editor_tab.dart'; import 'results_tab.dart'; @@ -12,11 +13,16 @@ class WorkspacePanel extends StatefulWidget { const WorkspacePanel({ super.key, this.activeConnection, + this.selectedRedisDb, }); /// Currently selected connection from the sidebar. final ConnectionRow? activeConnection; + /// When set, the user selected a specific Redis database in the sidebar tree. + /// null = show stats, non-null = show data explorer for that db. + final int? selectedRedisDb; + @override State createState() => _WorkspacePanelState(); } @@ -44,16 +50,25 @@ class _WorkspacePanelState extends State { ); } - // If a Redis connection is selected, show the Redis view (wrapped so it gets bounded constraints) + // If a Redis connection is selected if (widget.activeConnection != null && widget.activeConnection!.type == 'redis') { + final redisDb = widget.selectedRedisDb; return material.Container( color: theme.colorScheme.background, child: material.SizedBox.expand( - child: RedisView( - key: ValueKey(widget.activeConnection!.id), - connectionRow: widget.activeConnection!, - ), + // DB selected → data explorer; no DB → stats + child: redisDb != null + ? RedisExplorerView( + key: ValueKey( + 'redis_${widget.activeConnection!.id}_db_$redisDb'), + connectionRow: widget.activeConnection!, + database: redisDb, + ) + : RedisView( + key: ValueKey(widget.activeConnection!.id), + connectionRow: widget.activeConnection!, + ), ), ); } diff --git a/lib/features/redis/redis_databases_view.dart b/lib/features/redis/redis_databases_view.dart new file mode 100644 index 00000000..5d35d6bd --- /dev/null +++ b/lib/features/redis/redis_databases_view.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/database/redis_info.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +/// Shows all Redis databases (db0–dbN) with key counts. +class RedisDatabasesView extends material.StatefulWidget { + const RedisDatabasesView({ + super.key, + required this.connection, + required this.connectionRow, + this.onDatabaseTap, + }); + + final RedisConnection connection; + final ConnectionRow connectionRow; + final ValueChanged? onDatabaseTap; + + @override + material.State createState() => + _RedisDatabasesViewState(); +} + +class _RedisDatabasesViewState extends material.State { + List<_DbInfo> _databases = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final conn = widget.connection; + + // Get max databases + final maxDbs = await conn.getMaxDatabases(); + + // Get keyspace info from INFO command + final raw = await conn.info(); + final info = parseRedisInfo(raw); + final keyspace = info['Keyspace'] ?? {}; + + // Build database list + final dbs = <_DbInfo>[]; + for (var i = 0; i < maxDbs; i++) { + final dbKey = 'db$i'; + final data = keyspace[dbKey]; + int keys = 0; + int expires = 0; + if (data != null) { + // Parse "keys=X,expires=Y,avg_ttl=Z" + for (final part in data.split(',')) { + final kv = part.split('='); + if (kv.length == 2) { + if (kv[0].trim() == 'keys') { + keys = int.tryParse(kv[1].trim()) ?? 0; + } + if (kv[0].trim() == 'expires') { + expires = int.tryParse(kv[1].trim()) ?? 0; + } + } + } + } + dbs.add(_DbInfo(index: i, keys: keys, expires: expires, hasData: data != null)); + } + + if (!mounted) return; + setState(() { + _databases = dbs; + _loading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final shadcnCs = shadcn.Theme.of(context).colorScheme; + + if (_loading) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(16), + const Text('Loading databases...').muted().small(), + ], + ), + ); + } + + if (_error != null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + Text(_error!, + style: material.TextStyle( + color: cs.destructive, fontSize: 13)), + const Gap(16), + OutlineButton( + onPressed: _load, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + final dbsWithData = _databases.where((d) => d.hasData).toList(); + final dbsEmpty = _databases.where((d) => !d.hasData).toList(); + final totalKeys = + _databases.fold(0, (sum, d) => sum + d.keys); + + return material.SingleChildScrollView( + padding: const material.EdgeInsets.all(16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 14), + decoration: material.BoxDecoration( + color: shadcnCs.muted.withValues(alpha: 0.3), + borderRadius: const material.BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + ), + child: Row( + children: [ + material.Icon(material.Icons.storage_rounded, + size: 18, color: shadcnCs.primary), + const Gap(10), + Text('Databases (${_databases.length})').semiBold(), + const Spacer(), + Text('Total keys: $totalKeys').muted().small(), + ], + ), + ), + + // Databases with data + material.Container( + decoration: material.BoxDecoration( + border: material.Border.all( + color: cs.border.withValues(alpha: 0.3)), + borderRadius: const material.BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + if (dbsWithData.isNotEmpty) ...[ + material.Padding( + padding: const material.EdgeInsets.only( + left: 20, top: 12, bottom: 4), + child: const Text('Active databases').muted().xSmall(), + ), + for (final db in dbsWithData) + _DatabaseTile( + db: db, + colorScheme: cs, + shadcnCs: shadcnCs, + onTap: () => widget.onDatabaseTap?.call(db.index), + ), + ], + if (dbsEmpty.isNotEmpty) ...[ + material.Padding( + padding: const material.EdgeInsets.only( + left: 20, top: 12, bottom: 4), + child: const Text('Empty databases').muted().xSmall(), + ), + for (final db in dbsEmpty) + _DatabaseTile( + db: db, + colorScheme: cs, + shadcnCs: shadcnCs, + onTap: () => widget.onDatabaseTap?.call(db.index), + ), + ], + const Gap(8), + ], + ), + ), + ], + ), + ); + } +} + +// ─── Model ────────────────────────────────────────────────────────────────── + +class _DbInfo { + const _DbInfo({ + required this.index, + required this.keys, + required this.expires, + required this.hasData, + }); + final int index; + final int keys; + final int expires; + final bool hasData; +} + +// ─── Tile widget ──────────────────────────────────────────────────────────── + +class _DatabaseTile extends StatefulWidget { + const _DatabaseTile({ + required this.db, + required this.colorScheme, + required this.shadcnCs, + required this.onTap, + }); + + 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; + + @override + material.Widget build(material.BuildContext context) { + final cs = widget.colorScheme; + final scs = widget.shadcnCs; + final db = widget.db; + + return material.MouseRegion( + cursor: material.SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: material.InkWell( + onTap: widget.onTap, + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 120), + 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( + db.hasData + ? material.Icons.dns_rounded + : material.Icons.dns_outlined, + size: 18, + color: db.hasData ? scs.primary : scs.mutedForeground, + ), + const Gap(12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text('db${db.index}', + style: material.TextStyle( + fontSize: 14, + fontWeight: material.FontWeight.w500, + color: cs.foreground, + )), + if (db.hasData) + Text( + '${db.keys} keys • ${db.expires} with TTL', + style: material.TextStyle( + fontSize: 12, + color: scs.mutedForeground, + ), + ), + ], + ), + ), + if (db.hasData) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: material.BoxDecoration( + color: scs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(10), + ), + child: Text( + '${db.keys}', + style: material.TextStyle( + fontSize: 11, + fontWeight: material.FontWeight.w600, + color: scs.primary, + ), + ), + ), + const Gap(8), + material.Icon(material.Icons.chevron_right_rounded, + size: 18, color: scs.mutedForeground), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart new file mode 100644 index 00000000..a8a7e35c --- /dev/null +++ b/lib/features/redis/redis_explorer_view.dart @@ -0,0 +1,403 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/database/redis_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +import 'redis_keys_view.dart'; +import 'redis_key_editor.dart'; +import 'redis_view.dart'; + +// ─── Navigation path model ────────────────────────────────────────────────── + +class _Crumb { + const _Crumb(this.label, this.level); + final String label; + final _Level level; +} + +enum _Level { keys, key } + +// ─── Main explorer widget ─────────────────────────────────────────────────── + +/// Redis data explorer for a specific database. +/// Database selection is handled in the sidebar tree; this widget only shows +/// keys and key editor for the given [database]. +class RedisExplorerView extends material.StatefulWidget { + const RedisExplorerView({ + super.key, + required this.connectionRow, + required this.database, + }); + + final ConnectionRow connectionRow; + final int database; + + @override + material.State createState() => _RedisExplorerViewState(); +} + +class _RedisExplorerViewState extends material.State { + RedisConnection? _connection; + bool _connecting = true; + String? _error; + + // View mode + bool _showStats = false; + + // Navigation state + String? _selectedKey; + String? _selectedKeyType; + + @override + void initState() { + super.initState(); + _connect(); + } + + @override + void didUpdateWidget(covariant RedisExplorerView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.database != widget.database) { + _disconnectCurrent(); + _connect(); + } + } + + @override + void dispose() { + _disconnectCurrent(); + super.dispose(); + } + + void _disconnectCurrent() { + final conn = _connection; + _connection = null; + if (conn != null) { + conn.disconnect(); + } + } + + Future _connect() async { + _disconnectCurrent(); + if (!mounted) return; + setState(() { + _connecting = true; + _error = null; + _selectedKey = null; + _selectedKeyType = null; + _showStats = false; + }); + try { + final conn = + RedisService.instance.createConnection(widget.connectionRow); + await conn.connect(); + if (!mounted) { + conn.disconnect(); + return; + } + setState(() { + _connection = conn; + _connecting = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _connecting = false; + }); + } + } + } + + // ─── Navigation helpers ───────────────────────────────────────────────── + + void _navigateToKey(String key, String type) { + setState(() { + _selectedKey = key; + _selectedKeyType = type; + }); + } + + void _navigateToKeys() { + setState(() { + _selectedKey = null; + _selectedKeyType = null; + }); + } + + // ─── Breadcrumbs ──────────────────────────────────────────────────────── + + List<_Crumb> get _crumbs { + final list = <_Crumb>[ + _Crumb('${widget.connectionRow.name} › db${widget.database}', + _Level.keys), + ]; + if (_selectedKey != null) { + list.add(_Crumb(_selectedKey!, _Level.key)); + } + return list; + } + + void _onCrumbTap(_Crumb crumb) { + switch (crumb.level) { + case _Level.keys: + _navigateToKeys(); + case _Level.key: + break; + } + } + + // ─── Build ────────────────────────────────────────────────────────────── + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + // Loading state + if (_connecting) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(16), + const Text('Connecting...').muted().small(), + ], + ), + ); + } + + // Error state + final err = _error; + if (err != null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + const Text('Connection Error').large().semiBold(), + const Gap(8), + material.SelectableText(err, + style: material.TextStyle( + color: cs.mutedForeground, fontSize: 13)), + const Gap(24), + OutlineButton( + onPressed: _connect, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + final conn = _connection; + if (conn == null) return const material.SizedBox.shrink(); + + // Statistics mode + if (_showStats) { + return RedisView( + key: ValueKey('stats_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + connection: conn, + onBack: () => setState(() => _showStats = false), + ); + } + + return material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _BreadcrumbBar( + crumbs: _crumbs, + onCrumbTap: _onCrumbTap, + onRefresh: () => setState(() {}), + onStats: () => setState(() => _showStats = true), + ), + const Divider(height: 1), + material.Expanded(child: _buildContent(conn)), + ], + ), + ); + } + + material.Widget _buildContent(RedisConnection conn) { + // Key editor + if (_selectedKey != null) { + return RedisKeyEditor( + key: ValueKey('key_${widget.database}_$_selectedKey'), + connection: conn, + database: widget.database, + keyName: _selectedKey!, + keyType: _selectedKeyType ?? 'string', + onBack: _navigateToKeys, + onKeyDeleted: _navigateToKeys, + ); + } + + // Keys list + return RedisKeysView( + key: ValueKey('keys_${widget.database}'), + connection: conn, + database: widget.database, + onKeyTap: _navigateToKey, + ); + } +} + +// ─── Breadcrumb bar ───────────────────────────────────────────────────────── + +class _BreadcrumbBar extends StatelessWidget { + const _BreadcrumbBar({ + required this.crumbs, + required this.onCrumbTap, + required this.onRefresh, + required this.onStats, + }); + + final List<_Crumb> crumbs; + final void Function(_Crumb) onCrumbTap; + final VoidCallback onRefresh; + final VoidCallback onStats; + + @override + material.Widget build(material.BuildContext context) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric(horizontal: 16), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.3), + ), + child: material.Row( + children: [ + material.Icon(material.Icons.memory_rounded, + size: 18, color: cs.primary), + const Gap(10), + material.Expanded( + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + for (var i = 0; i < crumbs.length; i++) ...[ + if (i > 0) ...[ + material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 6), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: cs.mutedForeground), + ), + ], + _CrumbChip( + label: crumbs[i].label, + isLast: i == crumbs.length - 1, + onTap: i < crumbs.length - 1 + ? () => onCrumbTap(crumbs[i]) + : null, + ), + ], + ], + ), + ), + ), + const Gap(8), + material.Tooltip( + message: 'Statistics', + child: material.InkWell( + onTap: onStats, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.all(6), + child: material.Icon(material.Icons.bar_chart_rounded, + size: 18, color: cs.mutedForeground), + ), + ), + ), + const Gap(4), + material.Tooltip( + message: 'Refresh', + child: material.InkWell( + onTap: onRefresh, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.all(6), + child: material.Icon(material.Icons.refresh_rounded, + size: 18, color: cs.mutedForeground), + ), + ), + ), + ], + ), + ); + } +} + +class _CrumbChip extends StatefulWidget { + const _CrumbChip({ + required this.label, + required this.isLast, + this.onTap, + }); + + final String label; + final bool isLast; + final VoidCallback? onTap; + + @override + material.State<_CrumbChip> createState() => _CrumbChipState(); +} + +class _CrumbChipState extends material.State<_CrumbChip> { + bool _hovered = false; + + @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), + 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(), + ), + ), + ); + } +} diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart new file mode 100644 index 00000000..11d7b0ac --- /dev/null +++ b/lib/features/redis/redis_key_editor.dart @@ -0,0 +1,1017 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +/// Viewer / editor for a single Redis key. Type-aware: string, hash, +/// list, set, zset. +class RedisKeyEditor extends material.StatefulWidget { + const RedisKeyEditor({ + super.key, + required this.connection, + required this.database, + required this.keyName, + required this.keyType, + this.onBack, + this.onKeyDeleted, + }); + + final RedisConnection connection; + final int database; + final String keyName; + final String keyType; + final VoidCallback? onBack; + final VoidCallback? onKeyDeleted; + + @override + material.State createState() => _RedisKeyEditorState(); +} + +class _RedisKeyEditorState extends material.State { + bool _loading = true; + String? _error; + String? _success; + int _ttl = -1; + + // String value + String? _stringValue; + final _stringController = material.TextEditingController(); + + // Hash value + Map _hashValue = {}; + + // List value + List _listValue = []; + + // Set value + List _setValue = []; + + // Sorted set value + List<(String, double)> _zsetValue = []; + + // For adding new items + final _newFieldController = material.TextEditingController(); + final _newValueController = material.TextEditingController(); + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _stringController.dispose(); + _newFieldController.dispose(); + _newValueController.dispose(); + super.dispose(); + } + + Future _load() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _success = null; + }); + try { + await widget.connection.selectDatabase(widget.database); + _ttl = await widget.connection.ttl(widget.keyName); + + switch (widget.keyType) { + case 'string': + _stringValue = await widget.connection.get(widget.keyName); + _stringController.text = _stringValue ?? ''; + case 'hash': + _hashValue = await widget.connection.hgetall(widget.keyName); + case 'list': + _listValue = + await widget.connection.lrange(widget.keyName, 0, -1); + case 'set': + _setValue = await widget.connection.smembers(widget.keyName); + _setValue.sort(); + case 'zset': + _zsetValue = await widget.connection + .zrangeWithScores(widget.keyName, 0, -1); + default: + _stringValue = await widget.connection.get(widget.keyName); + _stringController.text = _stringValue ?? ''; + } + + if (!mounted) return; + setState(() => _loading = false); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + Future _saveString() async { + try { + await widget.connection.selectDatabase(widget.database); + await widget.connection.set(widget.keyName, _stringController.text); + setState(() => _success = 'Value saved'); + _clearSuccessAfterDelay(); + } catch (e) { + setState(() => _error = 'Save failed: $e'); + } + } + + Future _deleteKey() async { + try { + await widget.connection.selectDatabase(widget.database); + await widget.connection.del(widget.keyName); + widget.onKeyDeleted?.call(); + } catch (e) { + setState(() => _error = 'Delete failed: $e'); + } + } + + Future _setTtl(int seconds) async { + try { + await widget.connection.selectDatabase(widget.database); + if (seconds > 0) { + await widget.connection.expire(widget.keyName, seconds); + } else { + await widget.connection.persist(widget.keyName); + } + _ttl = await widget.connection.ttl(widget.keyName); + setState(() { + _success = seconds > 0 ? 'TTL set to $seconds seconds' : 'TTL removed'; + }); + _clearSuccessAfterDelay(); + } catch (e) { + setState(() => _error = 'TTL failed: $e'); + } + } + + // Hash operations + Future _hashSet(String field, String value) async { + try { + await widget.connection.selectDatabase(widget.database); + await widget.connection.hset(widget.keyName, field, value); + await _load(); + } catch (e) { + setState(() => _error = 'HSET failed: $e'); + } + } + + Future _hashDel(String field) async { + try { + await widget.connection.selectDatabase(widget.database); + await widget.connection.hdel(widget.keyName, field); + await _load(); + } catch (e) { + setState(() => _error = 'HDEL failed: $e'); + } + } + + // List operations + Future _listPush(String value) async { + try { + await widget.connection.selectDatabase(widget.database); + await widget.connection.rpush(widget.keyName, value); + await _load(); + } catch (e) { + setState(() => _error = 'RPUSH failed: $e'); + } + } + + // Set operations + Future _setAdd(String member) async { + try { + await widget.connection.selectDatabase(widget.database); + await widget.connection.sadd(widget.keyName, member); + await _load(); + } catch (e) { + setState(() => _error = 'SADD failed: $e'); + } + } + + Future _setRemove(String member) async { + try { + await widget.connection.selectDatabase(widget.database); + await widget.connection.srem(widget.keyName, member); + await _load(); + } catch (e) { + setState(() => _error = 'SREM failed: $e'); + } + } + + // ZSet operations + Future _zsetAdd(String member, double score) async { + try { + await widget.connection.selectDatabase(widget.database); + await widget.connection.zadd(widget.keyName, score, member); + await _load(); + } catch (e) { + setState(() => _error = 'ZADD failed: $e'); + } + } + + Future _zsetRemove(String member) async { + try { + await widget.connection.selectDatabase(widget.database); + await widget.connection.zrem(widget.keyName, member); + await _load(); + } catch (e) { + setState(() => _error = 'ZREM failed: $e'); + } + } + + void _clearSuccessAfterDelay() { + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _success = null); + }); + } + + String _formatTtl(int ttl) { + if (ttl == -1) return 'No expiry'; + if (ttl == -2) return 'Key missing'; + if (ttl < 60) return '${ttl}s'; + if (ttl < 3600) return '${(ttl / 60).toStringAsFixed(0)}m ${ttl % 60}s'; + if (ttl < 86400) { + return '${(ttl / 3600).toStringAsFixed(0)}h ${((ttl % 3600) / 60).toStringAsFixed(0)}m'; + } + return '${(ttl / 86400).toStringAsFixed(0)}d ${((ttl % 86400) / 3600).toStringAsFixed(0)}h'; + } + + // ─── Build ────────────────────────────────────────────────────────────── + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final shadcnCs = shadcn.Theme.of(context).colorScheme; + + if (_loading) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(16), + const Text('Loading key...').muted().small(), + ], + ), + ); + } + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Status banners + if (_error != null) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + color: cs.destructive.withValues(alpha: 0.1), + child: Row( + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 16, color: cs.destructive), + const Gap(8), + material.Expanded( + child: Text(_error!, + style: material.TextStyle( + color: cs.destructive, fontSize: 13)), + ), + material.InkWell( + onTap: () => setState(() => _error = null), + child: material.Icon(material.Icons.close_rounded, + size: 16, color: cs.destructive), + ), + ], + ), + ), + if (_success != null) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + color: const Color(0xFF66BB6A).withValues(alpha: 0.1), + child: Row( + children: [ + const material.Icon(material.Icons.check_circle_rounded, + size: 16, color: Color(0xFF66BB6A)), + const Gap(8), + Text(_success!, + style: const material.TextStyle( + color: Color(0xFF66BB6A), fontSize: 13)), + ], + ), + ), + // Header + _buildHeader(cs, shadcnCs), + const Divider(height: 1), + // Content + material.Expanded( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(16), + child: _buildContent(cs, shadcnCs), + ), + ), + ], + ); + } + + Widget _buildHeader(ColorScheme cs, shadcn.ColorScheme scs) { + final typeCol = _typeColor(widget.keyType); + return material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: material.BoxDecoration( + color: scs.muted.withValues(alpha: 0.15), + ), + child: material.Row( + children: [ + material.Icon(_typeIcon(widget.keyType), + size: 18, color: typeCol), + const Gap(8), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: material.BoxDecoration( + color: typeCol.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + widget.keyType.toUpperCase(), + style: material.TextStyle( + fontSize: 10, + fontWeight: material.FontWeight.w600, + color: typeCol, + ), + ), + ), + const Gap(10), + material.Expanded( + child: material.Text( + widget.keyName, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 14, + fontFamily: 'monospace', + fontWeight: material.FontWeight.w500, + color: cs.foreground, + ), + ), + ), + const Gap(8), + // TTL badge + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: material.BoxDecoration( + color: scs.muted.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(6), + ), + child: Text( + _formatTtl(_ttl), + style: material.TextStyle( + fontSize: 11, + color: scs.mutedForeground, + ), + ), + ), + const Gap(8), + // TTL button + material.Tooltip( + message: 'Set TTL', + child: material.InkWell( + onTap: () => _showTtlDialog(), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.timer_rounded, + size: 16, color: scs.mutedForeground), + ), + ), + ), + const Gap(4), + // Refresh + material.Tooltip( + message: 'Refresh', + child: material.InkWell( + onTap: _load, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.refresh_rounded, + size: 16, color: scs.mutedForeground), + ), + ), + ), + const Gap(4), + // Delete + material.Tooltip( + message: 'Delete key', + child: material.InkWell( + onTap: _deleteKey, + borderRadius: material.BorderRadius.circular(4), + child: const material.Padding( + padding: material.EdgeInsets.all(4), + child: material.Icon(material.Icons.delete_rounded, + size: 16, color: Color(0xFFEF5350)), + ), + ), + ), + ], + ), + ); + } + + Widget _buildContent(ColorScheme cs, shadcn.ColorScheme scs) { + switch (widget.keyType) { + case 'string': + return _buildStringEditor(cs, scs); + case 'hash': + return _buildHashEditor(cs, scs); + case 'list': + return _buildListEditor(cs, scs); + case 'set': + return _buildSetEditor(cs, scs); + case 'zset': + return _buildZsetEditor(cs, scs); + default: + return _buildStringEditor(cs, scs); + } + } + + // ─── String ───────────────────────────────────────────────────────────── + + Widget _buildStringEditor(ColorScheme cs, shadcn.ColorScheme scs) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Text('Value').semiBold(), + const Spacer(), + PrimaryButton( + onPressed: _saveString, + size: ButtonSize.small, + leading: const material.Icon(material.Icons.save_rounded, + size: 14), + child: const Text('Save'), + ), + ], + ), + const Gap(8), + material.Container( + constraints: + const material.BoxConstraints(minHeight: 200), + decoration: material.BoxDecoration( + border: material.Border.all( + color: cs.border.withValues(alpha: 0.3)), + borderRadius: material.BorderRadius.circular(8), + ), + child: material.TextField( + controller: _stringController, + maxLines: null, + style: const material.TextStyle( + fontSize: 13, + fontFamily: 'monospace', + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + contentPadding: material.EdgeInsets.all(12), + ), + ), + ), + ], + ); + } + + // ─── Hash ─────────────────────────────────────────────────────────────── + + Widget _buildHashEditor(ColorScheme cs, shadcn.ColorScheme scs) { + final entries = _hashValue.entries.toList(); + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text('Hash fields (${entries.length})').semiBold(), + const Gap(8), + // Add field row + material.Row( + children: [ + material.Expanded( + child: TextField( + controller: _newFieldController, + placeholder: const Text('Field'), + ), + ), + const Gap(8), + material.Expanded( + child: TextField( + controller: _newValueController, + placeholder: const Text('Value'), + ), + ), + const Gap(8), + PrimaryButton( + onPressed: () { + final f = _newFieldController.text.trim(); + final v = _newValueController.text; + if (f.isEmpty) return; + _hashSet(f, v); + _newFieldController.clear(); + _newValueController.clear(); + }, + size: ButtonSize.small, + child: const Text('HSET'), + ), + ], + ), + 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), + ], + ], + ); + } + + // ─── List ─────────────────────────────────────────────────────────────── + + Widget _buildListEditor(ColorScheme cs, shadcn.ColorScheme scs) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text('List items (${_listValue.length})').semiBold(), + const Gap(8), + // Add item + material.Row( + children: [ + material.Expanded( + child: TextField( + controller: _newValueController, + placeholder: const Text('New item'), + ), + ), + const Gap(8), + PrimaryButton( + onPressed: () { + final v = _newValueController.text; + if (v.isEmpty) return; + _listPush(v); + _newValueController.clear(); + }, + size: ButtonSize.small, + child: const Text('RPUSH'), + ), + ], + ), + const Gap(12), + for (var i = 0; i < _listValue.length; i++) ...[ + _IndexedValueRow( + index: i, + value: _listValue[i], + colorScheme: cs, + shadcnCs: scs, + ), + const Gap(4), + ], + ], + ); + } + + // ─── Set ──────────────────────────────────────────────────────────────── + + Widget _buildSetEditor(ColorScheme cs, shadcn.ColorScheme scs) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text('Set members (${_setValue.length})').semiBold(), + const Gap(8), + // Add member + material.Row( + children: [ + material.Expanded( + child: TextField( + controller: _newValueController, + placeholder: const Text('New member'), + ), + ), + const Gap(8), + PrimaryButton( + onPressed: () { + final v = _newValueController.text.trim(); + if (v.isEmpty) return; + _setAdd(v); + _newValueController.clear(); + }, + size: ButtonSize.small, + child: const Text('SADD'), + ), + ], + ), + const Gap(12), + for (final member in _setValue) ...[ + _MemberRow( + member: member, + onDelete: () => _setRemove(member), + colorScheme: cs, + shadcnCs: scs, + ), + const Gap(4), + ], + ], + ); + } + + // ─── Sorted Set ───────────────────────────────────────────────────────── + + Widget _buildZsetEditor(ColorScheme cs, shadcn.ColorScheme scs) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text('Sorted set (${_zsetValue.length})').semiBold(), + const Gap(8), + // Add member + material.Row( + children: [ + material.Expanded( + flex: 2, + child: TextField( + controller: _newValueController, + placeholder: const Text('Member'), + ), + ), + const Gap(8), + material.Expanded( + child: TextField( + controller: _newFieldController, + placeholder: const Text('Score'), + ), + ), + const Gap(8), + PrimaryButton( + onPressed: () { + final m = _newValueController.text.trim(); + final s = + double.tryParse(_newFieldController.text.trim()); + if (m.isEmpty || s == null) return; + _zsetAdd(m, s); + _newValueController.clear(); + _newFieldController.clear(); + }, + size: ButtonSize.small, + child: const Text('ZADD'), + ), + ], + ), + const Gap(12), + for (final (member, score) in _zsetValue) ...[ + _ScoredMemberRow( + member: member, + score: score, + onDelete: () => _zsetRemove(member), + colorScheme: cs, + shadcnCs: scs, + ), + const Gap(4), + ], + ], + ); + } + + // ─── TTL dialog ───────────────────────────────────────────────────────── + + void _showTtlDialog() { + final controller = + material.TextEditingController(text: _ttl > 0 ? '$_ttl' : ''); + showDialog( + 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'), + ), + ], + ); + }, + ); + } + + // ─── Helpers ──────────────────────────────────────────────────────────── + + Color _typeColor(String type) { + switch (type) { + case 'string': + return const Color(0xFF42A5F5); + case 'hash': + return const Color(0xFFAB47BC); + case 'list': + return const Color(0xFF66BB6A); + case 'set': + return const Color(0xFFFFA726); + case 'zset': + return const Color(0xFFEF5350); + default: + return const Color(0xFF90A4AE); + } + } + + material.IconData _typeIcon(String type) { + switch (type) { + case 'string': + return material.Icons.text_fields_rounded; + case 'hash': + return material.Icons.tag_rounded; + case 'list': + return material.Icons.format_list_numbered_rounded; + case 'set': + return material.Icons.scatter_plot_rounded; + case 'zset': + return material.Icons.sort_rounded; + default: + return material.Icons.help_outline_rounded; + } + } +} + +// ─── Shared row widgets ───────────────────────────────────────────────────── + +class _FieldRow extends StatelessWidget { + const _FieldRow({ + required this.field, + required this.value, + required this.onDelete, + required this.colorScheme, + required this.shadcnCs, + }); + + final String field; + final String value; + final VoidCallback onDelete; + final ColorScheme colorScheme; + final shadcn.ColorScheme shadcnCs; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: material.BoxDecoration( + color: colorScheme.card, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: colorScheme.border.withValues(alpha: 0.3)), + ), + child: material.Row( + children: [ + material.SizedBox( + width: 160, + child: material.Text( + field, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + fontWeight: material.FontWeight.w600, + color: shadcnCs.primary, + ), + ), + ), + const Gap(12), + material.Expanded( + child: material.SelectableText( + value, + style: material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: colorScheme.foreground, + ), + ), + ), + const Gap(8), + material.InkWell( + onTap: onDelete, + borderRadius: material.BorderRadius.circular(4), + child: const material.Padding( + padding: material.EdgeInsets.all(4), + child: material.Icon(material.Icons.close_rounded, + size: 14, color: Color(0xFFEF5350)), + ), + ), + ], + ), + ); + } +} + +class _IndexedValueRow extends StatelessWidget { + const _IndexedValueRow({ + required this.index, + required this.value, + required this.colorScheme, + required this.shadcnCs, + }); + + final int index; + final String value; + final ColorScheme colorScheme; + final shadcn.ColorScheme shadcnCs; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: material.BoxDecoration( + color: colorScheme.card, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: colorScheme.border.withValues(alpha: 0.3)), + ), + child: material.Row( + children: [ + material.SizedBox( + width: 40, + child: Text( + '$index', + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: shadcnCs.mutedForeground, + ), + ), + ), + const Gap(12), + material.Expanded( + child: material.SelectableText( + value, + style: material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: colorScheme.foreground, + ), + ), + ), + ], + ), + ); + } +} + +class _MemberRow extends StatelessWidget { + const _MemberRow({ + required this.member, + required this.onDelete, + required this.colorScheme, + required this.shadcnCs, + }); + + final String member; + final VoidCallback onDelete; + final ColorScheme colorScheme; + final shadcn.ColorScheme shadcnCs; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: material.BoxDecoration( + color: colorScheme.card, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: colorScheme.border.withValues(alpha: 0.3)), + ), + child: material.Row( + children: [ + material.Expanded( + child: material.SelectableText( + member, + style: material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: colorScheme.foreground, + ), + ), + ), + const Gap(8), + material.InkWell( + onTap: onDelete, + borderRadius: material.BorderRadius.circular(4), + child: const material.Padding( + padding: material.EdgeInsets.all(4), + child: material.Icon(material.Icons.close_rounded, + size: 14, color: Color(0xFFEF5350)), + ), + ), + ], + ), + ); + } +} + +class _ScoredMemberRow extends StatelessWidget { + const _ScoredMemberRow({ + required this.member, + required this.score, + required this.onDelete, + required this.colorScheme, + required this.shadcnCs, + }); + + final String member; + final double score; + final VoidCallback onDelete; + final ColorScheme colorScheme; + final shadcn.ColorScheme shadcnCs; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: material.BoxDecoration( + color: colorScheme.card, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: colorScheme.border.withValues(alpha: 0.3)), + ), + child: material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: material.BoxDecoration( + color: shadcnCs.muted.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + score.toStringAsFixed(score == score.roundToDouble() ? 0 : 2), + style: material.TextStyle( + fontSize: 11, + fontWeight: material.FontWeight.w600, + color: shadcnCs.primary, + ), + ), + ), + const Gap(12), + material.Expanded( + child: material.SelectableText( + member, + style: material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: colorScheme.foreground, + ), + ), + ), + const Gap(8), + material.InkWell( + onTap: onDelete, + borderRadius: material.BorderRadius.circular(4), + child: const material.Padding( + padding: material.EdgeInsets.all(4), + child: material.Icon(material.Icons.close_rounded, + size: 14, color: Color(0xFFEF5350)), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart new file mode 100644 index 00000000..27980faf --- /dev/null +++ b/lib/features/redis/redis_keys_view.dart @@ -0,0 +1,495 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +/// Paginated key browser for a Redis database. +class RedisKeysView extends material.StatefulWidget { + const RedisKeysView({ + super.key, + required this.connection, + required this.database, + this.onKeyTap, + }); + + final RedisConnection connection; + final int database; + final void Function(String key, String type)? onKeyTap; + + @override + material.State createState() => _RedisKeysViewState(); +} + +class _RedisKeysViewState extends material.State { + List<_KeyInfo> _keys = []; + bool _loading = true; + bool _loadingMore = false; + String? _error; + int _cursor = 0; + bool _hasMore = true; + int _dbSize = 0; + + final _filterController = material.TextEditingController(); + String _matchPattern = '*'; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _filterController.dispose(); + super.dispose(); + } + + Future _load() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _keys = []; + _cursor = 0; + _hasMore = true; + }); + try { + await widget.connection.selectDatabase(widget.database); + _dbSize = await widget.connection.dbSize(); + await _scanBatch(); + if (!mounted) return; + setState(() => _loading = false); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + Future _scanBatch() async { + final (nextCursor, keyNames) = await widget.connection.scan( + cursor: _cursor, + match: _matchPattern.isEmpty ? null : _matchPattern, + count: 100, + ); + + // Fetch type and TTL for each key + final infos = <_KeyInfo>[]; + for (final name in keyNames) { + try { + final type = await widget.connection.keyType(name); + final ttl = await widget.connection.ttl(name); + infos.add(_KeyInfo(name: name, type: type, ttl: ttl)); + } catch (_) { + infos.add(_KeyInfo(name: name, type: 'unknown', ttl: -1)); + } + } + + if (!mounted) return; + setState(() { + _keys.addAll(infos); + _cursor = nextCursor; + _hasMore = nextCursor != 0; + }); + } + + Future _loadMore() async { + if (_loadingMore || !_hasMore) return; + setState(() => _loadingMore = true); + try { + await _scanBatch(); + } catch (e) { + if (mounted) { + setState(() => _error = e.toString()); + } + } finally { + if (mounted) setState(() => _loadingMore = false); + } + } + + void _applyFilter() { + final text = _filterController.text.trim(); + _matchPattern = text.isEmpty ? '*' : text; + _load(); + } + + void _clearFilter() { + _filterController.clear(); + _matchPattern = '*'; + _load(); + } + + Future _deleteKey(_KeyInfo keyInfo) async { + try { + await widget.connection.selectDatabase(widget.database); + await widget.connection.del(keyInfo.name); + setState(() { + _keys.removeWhere((k) => k.name == keyInfo.name); + _dbSize = (_dbSize - 1).clamp(0, _dbSize); + }); + } catch (e) { + if (mounted) setState(() => _error = 'Delete failed: $e'); + } + } + + // ─── Build ────────────────────────────────────────────────────────────── + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_loading && _keys.isEmpty) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(16), + const Text('Scanning keys...').muted().small(), + ], + ), + ); + } + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _buildFilterBar(cs), + const Divider(height: 1), + if (_error != null) _buildErrorBanner(cs), + material.Expanded( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(16), + child: _buildKeysList(cs), + ), + ), + _buildStatusBar(cs), + ], + ); + } + + Widget _buildFilterBar(ColorScheme cs) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; + return material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: material.BoxDecoration( + color: shadcnCs.muted.withValues(alpha: 0.15), + ), + child: Row( + children: [ + material.Icon(material.Icons.search_rounded, + size: 18, color: shadcnCs.mutedForeground), + const Gap(10), + material.Expanded( + child: TextField( + controller: _filterController, + placeholder: const Text('Pattern e.g. user:* or session:*'), + onSubmitted: (_) => _applyFilter(), + ), + ), + const Gap(8), + OutlineButton( + onPressed: _applyFilter, + size: ButtonSize.small, + child: const Text('Search'), + ), + const Gap(4), + GhostButton( + onPressed: _clearFilter, + size: ButtonSize.small, + child: const Text('Clear'), + ), + ], + ), + ); + } + + Widget _buildErrorBanner(ColorScheme cs) { + return material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: cs.destructive.withValues(alpha: 0.1), + child: Row( + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 16, color: cs.destructive), + const Gap(8), + material.Expanded( + child: Text( + _error!, + style: + material.TextStyle(color: cs.destructive, fontSize: 13), + ), + ), + material.InkWell( + onTap: () => setState(() => _error = null), + child: material.Icon(material.Icons.close_rounded, + size: 16, color: cs.destructive), + ), + ], + ), + ); + } + + Widget _buildKeysList(ColorScheme cs) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; + if (_keys.isEmpty) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(48), + child: const Text('No keys found').muted(), + ), + ); + } + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < _keys.length; i++) ...[ + if (i > 0) const Gap(4), + _KeyTile( + keyInfo: _keys[i], + colorScheme: cs, + shadcnCs: shadcnCs, + onTap: () => + widget.onKeyTap?.call(_keys[i].name, _keys[i].type), + onDelete: () => _deleteKey(_keys[i]), + ), + ], + if (_hasMore) ...[ + const Gap(12), + material.Center( + child: OutlineButton( + onPressed: _loadingMore ? null : _loadMore, + size: ButtonSize.small, + child: _loadingMore + ? const Text('Loading...') + : Text('Load more (${_keys.length} / $_dbSize)'), + ), + ), + ], + ], + ); + } + + Widget _buildStatusBar(ColorScheme cs) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; + return material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric(horizontal: 16), + decoration: material.BoxDecoration( + color: shadcnCs.muted.withValues(alpha: 0.15), + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.2), width: 1), + ), + ), + child: Row( + children: [ + Text('db${widget.database}').muted().small(), + const Gap(16), + Text('$_dbSize total keys').muted().small(), + const Spacer(), + Text('${_keys.length} loaded').muted().small(), + if (_hasMore) ...[ + const Gap(8), + const Text('• more available').muted().xSmall(), + ], + ], + ), + ); + } +} + +// ─── Key info model ───────────────────────────────────────────────────────── + +class _KeyInfo { + const _KeyInfo({ + required this.name, + required this.type, + required this.ttl, + }); + final String name; + final String type; + final int ttl; // -1 = no expiry, -2 = key doesn't exist +} + +// ─── Key tile widget ──────────────────────────────────────────────────────── + +class _KeyTile extends StatefulWidget { + const _KeyTile({ + required this.keyInfo, + required this.colorScheme, + required this.shadcnCs, + required this.onTap, + required this.onDelete, + }); + + final _KeyInfo keyInfo; + final ColorScheme colorScheme; + final shadcn.ColorScheme shadcnCs; + final VoidCallback onTap; + final VoidCallback onDelete; + + @override + material.State<_KeyTile> createState() => _KeyTileState(); +} + +class _KeyTileState extends material.State<_KeyTile> { + bool _hovered = false; + + Color _typeColor(String type) { + switch (type) { + case 'string': + return const Color(0xFF42A5F5); + case 'hash': + return const Color(0xFFAB47BC); + case 'list': + return const Color(0xFF66BB6A); + case 'set': + return const Color(0xFFFFA726); + case 'zset': + return const Color(0xFFEF5350); + default: + return widget.shadcnCs.mutedForeground; + } + } + + material.IconData _typeIcon(String type) { + switch (type) { + case 'string': + return material.Icons.text_fields_rounded; + case 'hash': + return material.Icons.tag_rounded; + case 'list': + return material.Icons.format_list_numbered_rounded; + case 'set': + return material.Icons.scatter_plot_rounded; + case 'zset': + return material.Icons.sort_rounded; + default: + return material.Icons.help_outline_rounded; + } + } + + String _formatTtl(int ttl) { + if (ttl == -1) return 'No TTL'; + if (ttl == -2) return 'Missing'; + if (ttl < 60) return '${ttl}s'; + if (ttl < 3600) return '${(ttl / 60).toStringAsFixed(0)}m'; + if (ttl < 86400) return '${(ttl / 3600).toStringAsFixed(1)}h'; + return '${(ttl / 86400).toStringAsFixed(1)}d'; + } + + @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), + child: material.InkWell( + onTap: widget.onTap, + borderRadius: material.BorderRadius.circular(8), + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 120), + 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), + decoration: material.BoxDecoration( + color: typeCol.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + ki.type.toUpperCase(), + style: material.TextStyle( + fontSize: 10, + fontWeight: material.FontWeight.w600, + color: typeCol, + letterSpacing: 0.5, + ), + ), + ), + const Gap(10), + // Key name + material.Expanded( + child: material.Text( + ki.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: cs.foreground, + ), + ), + ), + 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), + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + 'TTL ${_formatTtl(ki.ttl)}', + style: material.TextStyle( + fontSize: 10, + color: scs.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)), + ), + ), + ), + material.Icon(material.Icons.chevron_right_rounded, + size: 18, color: scs.mutedForeground), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index d219dbc7..2d6023a4 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -13,9 +13,22 @@ const _summaryChipHeight = 72.0; const _gridCardHeight = 220.0; class RedisView extends material.StatefulWidget { - const RedisView({super.key, required this.connectionRow}); + const RedisView({ + super.key, + required this.connectionRow, + this.connection, + this.onBack, + }); + final ConnectionRow connectionRow; + /// An already-open [RedisConnection]. When provided the view re-uses it + /// instead of creating (and potentially killing) a shared one. + final RedisConnection? connection; + + /// Called when the user taps the "back to explorer" button. + final material.VoidCallback? onBack; + @override material.State createState() => _RedisViewState(); } @@ -50,13 +63,17 @@ class _RedisViewState extends material.State { super.dispose(); } + /// Whether this view owns its connection (created it itself). + bool _ownsConnection = false; + /// Safely disconnects and clears the current Redis connection. void _disconnectCurrent() { final conn = _connection; _connection = null; - if (conn != null) { - conn.disconnect(); // fire-and-forget; disconnect handles errors + if (conn != null && _ownsConnection) { + conn.disconnect(); } + _ownsConnection = false; } Future _load() async { @@ -69,18 +86,30 @@ class _RedisViewState extends material.State { _info = null; }); try { - final conn = RedisService.instance.createConnection(widget.connectionRow); - await conn.connect(); + final supplied = widget.connection; + RedisConnection conn; + if (supplied != null && supplied.isConnected) { + conn = supplied; + _ownsConnection = false; + } else { + conn = RedisService.instance.createConnection(widget.connectionRow); + await conn.connect(); + _ownsConnection = true; + } if (!mounted) { - // Widget was disposed while connecting — clean up immediately. - conn.disconnect(); + if (_ownsConnection) conn.disconnect(); return; } _connection = conn; await _fetch(); if (mounted) _startTimer(); } catch (e) { - if (mounted) setState(() { _error = e.toString(); _loading = false; }); + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } } } @@ -244,9 +273,19 @@ class _RedisViewState extends material.State { ], ), ), + if (widget.onBack != null) ...[ + OutlineButton( + onPressed: widget.onBack, + leading: const material.Icon( + material.Icons.grid_view_rounded, size: 18), + child: const Text('Explorer'), + ), + const Gap(8), + ], 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('Refresh'), ), ],