From 9024bead8af93adeea9b3e1d017f4d8bb10b4e42 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 8 Mar 2026 12:03:23 +0300 Subject: [PATCH 1/4] feat: Redis data explorer with Stats/Data split - Extended RedisConnection with data commands (SCAN, TYPE, TTL, GET, SET, HGETALL, LRANGE, SMEMBERS, ZRANGE, DEL, RENAME, EXPIRE, etc.) - Created RedisExplorerView with breadcrumb navigation (like MongoDB) - Created RedisDatabasesView showing db0-db15 with key counts and TTL info - Created RedisKeysView with SCAN-based pagination, pattern search, type badges, TTL display, and inline delete - Created RedisKeyEditor with type-aware viewer/editor: * String: text editor with save * Hash: field/value table with HSET/HDEL * List: indexed items with RPUSH * Set: members with SADD/SREM * Sorted Set: scored members with ZADD/ZREM - Added TTL management dialog (set/remove expiry) - Modified RedisView (stats) to accept optional connection/onBack - Wired RedisExplorerView into WorkspacePanel - Stats button in breadcrumb bar for easy switching --- lib/core/database/redis_connection.dart | 225 ++++ lib/features/main_screen/workspace_panel.dart | 6 +- lib/features/redis/redis_databases_view.dart | 337 ++++++ lib/features/redis/redis_explorer_view.dart | 428 +++++++ lib/features/redis/redis_key_editor.dart | 1017 +++++++++++++++++ lib/features/redis/redis_keys_view.dart | 495 ++++++++ lib/features/redis/redis_view.dart | 57 +- 7 files changed, 2553 insertions(+), 12 deletions(-) create mode 100644 lib/features/redis/redis_databases_view.dart create mode 100644 lib/features/redis/redis_explorer_view.dart create mode 100644 lib/features/redis/redis_key_editor.dart create mode 100644 lib/features/redis/redis_keys_view.dart 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/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 3d440e06..cd5d5efa 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -3,7 +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_view.dart'; +import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'query_editor_tab.dart'; import 'results_tab.dart'; @@ -44,13 +44,13 @@ 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, show the Redis explorer if (widget.activeConnection != null && widget.activeConnection!.type == 'redis') { return material.Container( color: theme.colorScheme.background, child: material.SizedBox.expand( - child: RedisView( + child: RedisExplorerView( 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..8fb74e7f --- /dev/null +++ b/lib/features/redis/redis_explorer_view.dart @@ -0,0 +1,428 @@ +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_databases_view.dart'; +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 { databases, keys, key } + +// ─── Main explorer widget ─────────────────────────────────────────────────── + +/// Root widget for Redis data browsing. +/// Manages navigation state (breadcrumbs) and the active connection. +class RedisExplorerView extends material.StatefulWidget { + const RedisExplorerView({super.key, required this.connectionRow}); + final ConnectionRow connectionRow; + + @override + material.State createState() => _RedisExplorerViewState(); +} + +class _RedisExplorerViewState extends material.State { + RedisConnection? _connection; + bool _connecting = true; + String? _error; + + // View mode + bool _showStats = false; + + // Navigation state + int? _selectedDb; + 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) { + _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; + _selectedDb = 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 _navigateToDb(int db) { + setState(() { + _selectedDb = db; + _selectedKey = null; + _selectedKeyType = null; + }); + } + + void _navigateToKey(String key, String type) { + setState(() { + _selectedKey = key; + _selectedKeyType = type; + }); + } + + void _navigateToDatabases() { + setState(() { + _selectedDb = null; + _selectedKey = null; + _selectedKeyType = null; + }); + } + + void _navigateToKeys() { + setState(() { + _selectedKey = null; + _selectedKeyType = null; + }); + } + + // ─── Breadcrumbs ──────────────────────────────────────────────────────── + + List<_Crumb> get _crumbs { + final list = <_Crumb>[ + _Crumb(widget.connectionRow.name, _Level.databases), + ]; + if (_selectedDb != null) { + list.add(_Crumb('db$_selectedDb', _Level.keys)); + } + if (_selectedKey != null) { + list.add(_Crumb(_selectedKey!, _Level.key)); + } + return list; + } + + void _onCrumbTap(_Crumb crumb) { + switch (crumb.level) { + case _Level.databases: + _navigateToDatabases(); + 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 && _selectedDb != null) { + return RedisKeyEditor( + key: ValueKey('key_${_selectedDb}_$_selectedKey'), + connection: conn, + database: _selectedDb!, + keyName: _selectedKey!, + keyType: _selectedKeyType ?? 'string', + onBack: _navigateToKeys, + onKeyDeleted: _navigateToKeys, + ); + } + + // Keys list + if (_selectedDb != null) { + return RedisKeysView( + key: ValueKey('keys_$_selectedDb'), + connection: conn, + database: _selectedDb!, + onKeyTap: _navigateToKey, + ); + } + + // Databases list + return RedisDatabasesView( + key: ValueKey(widget.connectionRow.id), + connection: conn, + connectionRow: widget.connectionRow, + onDatabaseTap: _navigateToDb, + ); + } +} + +// ─── 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'), ), ], From 58e3c6e647d6e882ba134ddf0609356ad55fb4e7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 8 Mar 2026 13:00:41 +0300 Subject: [PATCH 2/4] refactor: move Redis DB selection to sidebar tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redis connections in sidebar are now expandable with a tree arrow - Expanding a Redis connection fetches active databases (via INFO keyspace) - Click connection name в†’ shows statistics view in workspace - Click specific db (db0, db1...) в†’ shows key browser for that database - Sidebar uses temporary connection for DB probe (doesn't kill main view) - RedisExplorerView simplified: takes required database parameter - Database list view removed from main area (now in sidebar tree) - Stats toggle still available in the breadcrumb toolbar - Works both at root level and inside folders --- .../connections/connections_panel.dart | 390 +++++++++++++++++- lib/features/main_screen/main_screen.dart | 13 + lib/features/main_screen/workspace_panel.dart | 25 +- lib/features/redis/redis_explorer_view.dart | 69 +--- 4 files changed, 427 insertions(+), 70 deletions(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 91e15748..0c531135 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(); } @@ -171,31 +178,50 @@ class _ConnectionsPanelState extends State { }); }, connections: _connections - .where((c) => c.folderId == _folderIdByName[name]) + .where((c) => + c.folderId == _folderIdByName[name]) .toList(), onRemove: () async { await FoldersStorage.instance.remove(name); await _loadData(); }, onNewConnection: (folderName) async { - final type = await showNewConnectionDialog(context); + final type = + await showNewConnectionDialog(context); if (type == null || !mounted) return; - final folderId = await LocalDb.instance.getFolderIdByName(folderName); - await _createConnection(type, folderId: folderId); + final folderId = await LocalDb.instance + .getFolderIdByName(folderName); + await _createConnection(type, + folderId: folderId); }, 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 +411,7 @@ class _FolderTile extends StatelessWidget { required this.iconForType, required this.onRemoveConnection, this.onConnectionTap, + this.onRedisDatabaseTap, }); final String name; @@ -396,6 +423,8 @@ 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 +490,342 @@ 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; + 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 (not registered in RedisService) 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'] ?? {}; + + final dbs = <({int index, int keys})>[]; + for (final entry in keyspace.entries) { + final match = RegExp(r'db(\d+)').firstMatch(entry.key); + if (match == null) continue; + final idx = int.parse(match.group(1)!); + int keys = 0; + for (final part in entry.value.split(',')) { + final kv = part.split('='); + if (kv.length == 2 && kv[0].trim() == 'keys') { + keys = int.tryParse(kv[1].trim()) ?? 0; + } + } + dbs.add((index: idx, keys: keys)); + } + dbs.sort((a, b) => a.index.compareTo(b.index)); + + 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 + 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), ), ), + if (_databases.isEmpty && !_loading && _error == null) + material.Padding( + padding: const material.EdgeInsets.only( + left: 28, top: 4, bottom: 4), + child: const Text('No active databases').muted().xSmall(), + ), + 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: + theme.colorScheme.primary.withValues(alpha: 0.7)), + const Gap(8), + material.Expanded( + child: material.Text( + 'db$index', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground), + ), + ), + 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..7a1282ba 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; + }); + } + + 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 cd5d5efa..a375997c 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -4,6 +4,7 @@ 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 explorer + // 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: RedisExplorerView( - 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_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index 8fb74e7f..a8a7e35c 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -5,7 +5,6 @@ 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_databases_view.dart'; import 'redis_keys_view.dart'; import 'redis_key_editor.dart'; import 'redis_view.dart'; @@ -18,15 +17,22 @@ class _Crumb { final _Level level; } -enum _Level { databases, keys, key } +enum _Level { keys, key } // ─── Main explorer widget ─────────────────────────────────────────────────── -/// Root widget for Redis data browsing. -/// Manages navigation state (breadcrumbs) and the active connection. +/// 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}); + const RedisExplorerView({ + super.key, + required this.connectionRow, + required this.database, + }); + final ConnectionRow connectionRow; + final int database; @override material.State createState() => _RedisExplorerViewState(); @@ -41,7 +47,6 @@ class _RedisExplorerViewState extends material.State { bool _showStats = false; // Navigation state - int? _selectedDb; String? _selectedKey; String? _selectedKeyType; @@ -54,7 +59,8 @@ class _RedisExplorerViewState extends material.State { @override void didUpdateWidget(covariant RedisExplorerView oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.connectionRow.id != widget.connectionRow.id) { + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.database != widget.database) { _disconnectCurrent(); _connect(); } @@ -80,7 +86,6 @@ class _RedisExplorerViewState extends material.State { setState(() { _connecting = true; _error = null; - _selectedDb = null; _selectedKey = null; _selectedKeyType = null; _showStats = false; @@ -109,14 +114,6 @@ class _RedisExplorerViewState extends material.State { // ─── Navigation helpers ───────────────────────────────────────────────── - void _navigateToDb(int db) { - setState(() { - _selectedDb = db; - _selectedKey = null; - _selectedKeyType = null; - }); - } - void _navigateToKey(String key, String type) { setState(() { _selectedKey = key; @@ -124,14 +121,6 @@ class _RedisExplorerViewState extends material.State { }); } - void _navigateToDatabases() { - setState(() { - _selectedDb = null; - _selectedKey = null; - _selectedKeyType = null; - }); - } - void _navigateToKeys() { setState(() { _selectedKey = null; @@ -143,11 +132,9 @@ class _RedisExplorerViewState extends material.State { List<_Crumb> get _crumbs { final list = <_Crumb>[ - _Crumb(widget.connectionRow.name, _Level.databases), + _Crumb('${widget.connectionRow.name} › db${widget.database}', + _Level.keys), ]; - if (_selectedDb != null) { - list.add(_Crumb('db$_selectedDb', _Level.keys)); - } if (_selectedKey != null) { list.add(_Crumb(_selectedKey!, _Level.key)); } @@ -156,8 +143,6 @@ class _RedisExplorerViewState extends material.State { void _onCrumbTap(_Crumb crumb) { switch (crumb.level) { - case _Level.databases: - _navigateToDatabases(); case _Level.keys: _navigateToKeys(); case _Level.key: @@ -252,11 +237,11 @@ class _RedisExplorerViewState extends material.State { material.Widget _buildContent(RedisConnection conn) { // Key editor - if (_selectedKey != null && _selectedDb != null) { + if (_selectedKey != null) { return RedisKeyEditor( - key: ValueKey('key_${_selectedDb}_$_selectedKey'), + key: ValueKey('key_${widget.database}_$_selectedKey'), connection: conn, - database: _selectedDb!, + database: widget.database, keyName: _selectedKey!, keyType: _selectedKeyType ?? 'string', onBack: _navigateToKeys, @@ -265,21 +250,11 @@ class _RedisExplorerViewState extends material.State { } // Keys list - if (_selectedDb != null) { - return RedisKeysView( - key: ValueKey('keys_$_selectedDb'), - connection: conn, - database: _selectedDb!, - onKeyTap: _navigateToKey, - ); - } - - // Databases list - return RedisDatabasesView( - key: ValueKey(widget.connectionRow.id), + return RedisKeysView( + key: ValueKey('keys_${widget.database}'), connection: conn, - connectionRow: widget.connectionRow, - onDatabaseTap: _navigateToDb, + database: widget.database, + onKeyTap: _navigateToKey, ); } } From 5f84b3400b373c3f9947582becaa95b1008734ed Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 8 Mar 2026 13:02:34 +0300 Subject: [PATCH 3/4] autogen --- .flutter-plugins-dependencies | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From eeb8dface91ad4c12012461cbdc60f29629874cf Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 8 Mar 2026 13:14:16 +0300 Subject: [PATCH 4/4] feat: show all 16 Redis databases in sidebar tree --- lib/core/database/redis_connection.dart | 162 +++ .../connections/connections_panel.dart | 367 +++++- lib/features/main_screen/main_screen.dart | 13 + lib/features/main_screen/workspace_panel.dart | 25 +- lib/features/redis/redis_explorer_view.dart | 1014 +++++++++++++++++ 5 files changed, 1562 insertions(+), 19 deletions(-) create mode 100644 lib/features/redis/redis_explorer_view.dart diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index d894de38..df76648a 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -65,6 +65,168 @@ class RedisConnection { return result?.toString() ?? ''; } + /// Send an arbitrary command and return the raw result. + Future sendCommand(List args) async { + if (!isConnected || _command == null) { + throw StateError('Not connected to Redis'); + } + return _command!.send_object(args); + } + + /// SELECT a database by index. + Future select(int db) async { + await sendCommand(['SELECT', db.toString()]); + } + + /// SCAN keys with optional pattern. Returns `(cursor, keys)`. + Future<(String, List)> scan( + String cursor, { + String? pattern, + int count = 100, + }) async { + final args = ['SCAN', cursor]; + if (pattern != null && pattern.isNotEmpty) { + args.addAll(['MATCH', pattern]); + } + args.addAll(['COUNT', count.toString()]); + final result = await sendCommand(args); + if (result is List && result.length == 2) { + final nextCursor = result[0].toString(); + final keys = (result[1] as List).map((e) => e.toString()).toList(); + return (nextCursor, keys); + } + return ('0', []); + } + + /// GET a string value. + Future get(String key) async { + final result = await sendCommand(['GET', key]); + return result?.toString(); + } + + /// SET a string value. + Future set(String key, String value) async { + await sendCommand(['SET', key, value]); + } + + /// DEL one or more keys. + Future del(List keys) async { + final result = await sendCommand(['DEL', ...keys]); + return int.tryParse(result.toString()) ?? 0; + } + + /// TYPE of a key. + Future type(String key) async { + final result = await sendCommand(['TYPE', key]); + return result.toString(); + } + + /// TTL of a key in seconds (-1 = no expiry, -2 = key missing). + Future ttl(String key) async { + final result = await sendCommand(['TTL', key]); + return int.tryParse(result.toString()) ?? -2; + } + + /// EXPIRE — set TTL in seconds. + Future expire(String key, int seconds) async { + await sendCommand(['EXPIRE', key, seconds.toString()]); + } + + /// PERSIST — remove TTL. + Future persist(String key) async { + await sendCommand(['PERSIST', key]); + } + + /// HGETALL — returns map of field:value. + 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 a field. + Future hset(String key, String field, String value) async { + await sendCommand(['HSET', key, field, value]); + } + + /// HDEL a field. + Future hdel(String key, String field) async { + await sendCommand(['HDEL', key, field]); + } + + /// LRANGE — list slice. + Future> lrange(String key, int start, int stop) async { + final result = await sendCommand(['LRANGE', key, start.toString(), stop.toString()]); + if (result is List) return result.map((e) => e.toString()).toList(); + return []; + } + + /// LLEN — list length. + Future llen(String key) async { + final result = await sendCommand(['LLEN', key]); + return int.tryParse(result.toString()) ?? 0; + } + + /// RPUSH — append to list. + Future rpush(String key, String value) async { + await sendCommand(['RPUSH', key, value]); + } + + /// SMEMBERS — set members. + Future> smembers(String key) async { + final result = await sendCommand(['SMEMBERS', key]); + if (result is List) return result.map((e) => e.toString()).toList(); + return []; + } + + /// SADD — add to set. + Future sadd(String key, String value) async { + await sendCommand(['SADD', key, value]); + } + + /// SREM — remove from set. + Future srem(String key, String value) async { + await sendCommand(['SREM', key, value]); + } + + /// ZRANGE with scores (WITHSCORES). Returns list of (member, score). + Future> zrangeWithScores(String key, int start, int stop) async { + final result = await sendCommand(['ZRANGE', key, start.toString(), stop.toString(), 'WITHSCORES']); + final list = <(String, double)>[]; + if (result is List) { + for (var i = 0; i + 1 < result.length; i += 2) { + list.add((result[i].toString(), double.tryParse(result[i + 1].toString()) ?? 0)); + } + } + return list; + } + + /// ZCARD — sorted set cardinality. + Future zcard(String key) async { + final result = await sendCommand(['ZCARD', key]); + return int.tryParse(result.toString()) ?? 0; + } + + /// ZADD — add to sorted set. + Future zadd(String key, double score, String member) async { + await sendCommand(['ZADD', key, score.toString(), member]); + } + + /// ZREM — remove from sorted set. + Future zrem(String key, String member) async { + await sendCommand(['ZREM', key, member]); + } + + /// RENAME a key. + Future rename(String oldKey, String newKey) async { + await sendCommand(['RENAME', oldKey, newKey]); + } + Future testConnection() async { try { await connect(); 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_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart new file mode 100644 index 00000000..613b604e --- /dev/null +++ b/lib/features/redis/redis_explorer_view.dart @@ -0,0 +1,1014 @@ +import 'dart:convert'; + +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'; + +/// Redis data explorer for a specific database. +/// Shows keys with search / SCAN pagination and a key editor. +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 _loading = true; + String? _error; + + // Keys list + List _keys = []; + String _cursor = '0'; + bool _hasMore = false; + String _searchPattern = ''; + final material.TextEditingController _searchCtrl = material.TextEditingController(); + + // Selected key + String? _selectedKey; + String? _selectedKeyType; + bool _showEditor = false; + + @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) { + _disconnect(); + _connect(); + } + } + + @override + void dispose() { + _searchCtrl.dispose(); + _disconnect(); + super.dispose(); + } + + void _disconnect() { + final c = _connection; + _connection = null; + if (c != null) c.disconnect(); + } + + Future _connect() async { + _disconnect(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _keys = []; + _cursor = '0'; + _hasMore = false; + _selectedKey = null; + _showEditor = false; + }); + try { + final conn = RedisService.instance.createConnection(widget.connectionRow); + await conn.connect(); + await conn.select(widget.database); + if (!mounted) { + conn.disconnect(); + return; + } + _connection = conn; + await _scanKeys(reset: true); + } catch (e) { + if (mounted) setState(() { _error = e.toString(); _loading = false; }); + } + } + + Future _scanKeys({bool reset = false}) async { + final conn = _connection; + if (conn == null || !conn.isConnected) return; + if (reset) { + _cursor = '0'; + _keys = []; + } + try { + final pattern = _searchPattern.isNotEmpty ? '*$_searchPattern*' : null; + final (nextCursor, keys) = await conn.scan(_cursor, pattern: pattern); + if (!mounted) return; + setState(() { + _keys = reset ? keys : [..._keys, ...keys]; + _cursor = nextCursor; + _hasMore = nextCursor != '0'; + _loading = false; + }); + } catch (e) { + if (mounted) setState(() { _error = e.toString(); _loading = false; }); + } + } + + Future _selectKey(String key) async { + final conn = _connection; + if (conn == null) return; + try { + final t = await conn.type(key); + if (!mounted) return; + setState(() { + _selectedKey = key; + _selectedKeyType = t; + _showEditor = true; + }); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } + } + + Future _deleteKey(String key) async { + final conn = _connection; + if (conn == null) return; + await conn.del([key]); + if (!mounted) return; + setState(() { + _keys.remove(key); + if (_selectedKey == key) { + _selectedKey = null; + _showEditor = false; + } + }); + } + + Future _addKey(String key, String type, String value) async { + final conn = _connection; + if (conn == null) return; + switch (type) { + case 'string': + await conn.set(key, value); + break; + case 'hash': + await conn.hset(key, 'field1', value); + break; + case 'list': + await conn.rpush(key, value); + break; + case 'set': + await conn.sadd(key, value); + break; + case 'zset': + await conn.zadd(key, 0, value); + break; + } + await _scanKeys(reset: true); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final cs = theme.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('Connecting...').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), + const Text('Connection Error').large().semiBold(), + const Gap(8), + material.SelectableText( + _error!, + 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'), + ), + ], + ), + ), + ); + } + + return material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Top bar + _buildTopBar(context), + const Divider(height: 1), + // Content + material.Expanded( + child: _showEditor && _selectedKey != null + ? _KeyEditorPanel( + connection: _connection!, + keyName: _selectedKey!, + keyType: _selectedKeyType ?? 'string', + onBack: () => setState(() => _showEditor = false), + onDeleted: () => _deleteKey(_selectedKey!), + ) + : _buildKeysList(context), + ), + ], + ), + ); + } + + material.Widget _buildTopBar(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + return material.Container( + height: 52, + 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.dns_rounded, size: 18, color: cs.primary), + const Gap(10), + Text('db${widget.database}').semiBold(), + const Gap(8), + Text('${_keys.length}${_hasMore ? '+' : ''} keys').muted().small(), + const material.Spacer(), + // Search + material.SizedBox( + width: 220, + height: 32, + child: material.TextField( + controller: _searchCtrl, + style: material.TextStyle(fontSize: 13, color: cs.foreground), + decoration: material.InputDecoration( + hintText: 'Search keys...', + hintStyle: material.TextStyle(fontSize: 13, color: cs.mutedForeground), + prefixIcon: material.Icon(material.Icons.search_rounded, size: 16, color: cs.mutedForeground), + filled: true, + fillColor: cs.background, + contentPadding: const material.EdgeInsets.symmetric(horizontal: 12), + border: material.OutlineInputBorder( + borderRadius: material.BorderRadius.circular(8), + borderSide: material.BorderSide(color: cs.border), + ), + enabledBorder: material.OutlineInputBorder( + borderRadius: material.BorderRadius.circular(8), + borderSide: material.BorderSide(color: cs.border.withValues(alpha: 0.5)), + ), + focusedBorder: material.OutlineInputBorder( + borderRadius: material.BorderRadius.circular(8), + borderSide: material.BorderSide(color: cs.primary), + ), + ), + onSubmitted: (v) { + _searchPattern = v.trim(); + _scanKeys(reset: true); + }, + ), + ), + const Gap(8), + OutlineButton( + onPressed: () => _scanKeys(reset: true), + size: ButtonSize.small, + leading: const material.Icon(material.Icons.refresh_rounded, size: 16), + child: const Text('Refresh'), + ), + const Gap(8), + OutlineButton( + onPressed: () => _showAddKeyDialog(context), + size: ButtonSize.small, + leading: const material.Icon(material.Icons.add_rounded, size: 16), + child: const Text('Add Key'), + ), + ], + ), + ); + } + + void _showAddKeyDialog(material.BuildContext context) { + final keyCtrl = material.TextEditingController(); + final valCtrl = material.TextEditingController(); + String selectedType = 'string'; + + showDialog( + context: context, + builder: (ctx) { + return material.StatefulBuilder( + builder: (ctx, setDialogState) { + return AlertDialog( + title: const Text('Add Key'), + content: material.SizedBox( + width: 360, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.TextField( + controller: keyCtrl, + decoration: const material.InputDecoration(labelText: 'Key name'), + ), + const Gap(12), + material.DropdownButtonFormField( + initialValue: selectedType, + decoration: const material.InputDecoration(labelText: 'Type'), + items: const [ + material.DropdownMenuItem(value: 'string', child: material.Text('String')), + material.DropdownMenuItem(value: 'hash', child: material.Text('Hash')), + material.DropdownMenuItem(value: 'list', child: material.Text('List')), + material.DropdownMenuItem(value: 'set', child: material.Text('Set')), + material.DropdownMenuItem(value: 'zset', child: material.Text('Sorted Set')), + ], + onChanged: (v) => setDialogState(() => selectedType = v ?? 'string'), + ), + const Gap(12), + material.TextField( + controller: valCtrl, + decoration: const material.InputDecoration(labelText: 'Value'), + maxLines: 3, + ), + ], + ), + ), + actions: [ + material.TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const material.Text('Cancel'), + ), + material.ElevatedButton( + onPressed: () { + final key = keyCtrl.text.trim(); + final val = valCtrl.text; + if (key.isNotEmpty) { + _addKey(key, selectedType, val); + Navigator.of(ctx).pop(); + } + }, + child: const material.Text('Create'), + ), + ], + ); + }, + ); + }, + ); + } + + material.Widget _buildKeysList(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_keys.isEmpty) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.vpn_key_off_rounded, size: 48, color: cs.mutedForeground), + const Gap(16), + const Text('No keys found').muted(), + ], + ), + ); + } + + return material.ListView.builder( + padding: const material.EdgeInsets.all(16), + itemCount: _keys.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == _keys.length) { + // "Load more" button + return material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 8), + child: material.Center( + child: OutlineButton( + onPressed: () => _scanKeys(), + size: ButtonSize.small, + child: const Text('Load more...'), + ), + ), + ); + } + + final key = _keys[index]; + final isSelected = key == _selectedKey; + + return material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Material( + color: isSelected + ? cs.primary.withValues(alpha: 0.1) + : material.Colors.transparent, + borderRadius: material.BorderRadius.circular(8), + child: material.InkWell( + onTap: () => _selectKey(key), + borderRadius: material.BorderRadius.circular(8), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: material.Row( + children: [ + material.Icon(material.Icons.vpn_key_rounded, size: 14, color: cs.mutedForeground), + const Gap(10), + material.Expanded( + child: material.Text( + key, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: cs.foreground, + fontFamily: 'monospace', + ), + ), + ), + material.IconButton( + icon: material.Icon( + material.Icons.delete_outline_rounded, + size: 16, + color: cs.destructive.withValues(alpha: 0.7), + ), + onPressed: () => _deleteKey(key), + splashRadius: 16, + tooltip: 'Delete key', + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +// ─── Key Editor Panel ─────────────────────────────────────────────────────── + +class _KeyEditorPanel extends material.StatefulWidget { + const _KeyEditorPanel({ + required this.connection, + required this.keyName, + required this.keyType, + required this.onBack, + required this.onDeleted, + }); + + final RedisConnection connection; + final String keyName; + final String keyType; + final material.VoidCallback onBack; + final material.VoidCallback onDeleted; + + @override + material.State<_KeyEditorPanel> createState() => _KeyEditorPanelState(); +} + +class _KeyEditorPanelState extends material.State<_KeyEditorPanel> { + bool _loading = true; + String? _error; + int _ttl = -1; + + // String + String _stringValue = ''; + final material.TextEditingController _stringCtrl = material.TextEditingController(); + + // Hash + Map _hashValue = {}; + + // List + List _listValue = []; + + // Set + List _setValue = []; + + // Sorted set + List<(String, double)> _zsetValue = []; + + @override + void initState() { + super.initState(); + _loadValue(); + } + + @override + void didUpdateWidget(covariant _KeyEditorPanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.keyName != widget.keyName) { + _loadValue(); + } + } + + @override + void dispose() { + _stringCtrl.dispose(); + super.dispose(); + } + + Future _loadValue() async { + if (!mounted) return; + setState(() { _loading = true; _error = null; }); + try { + final conn = widget.connection; + _ttl = await conn.ttl(widget.keyName); + + switch (widget.keyType) { + case 'string': + _stringValue = await conn.get(widget.keyName) ?? ''; + _stringCtrl.text = _stringValue; + break; + case 'hash': + _hashValue = await conn.hgetall(widget.keyName); + break; + case 'list': + final len = await conn.llen(widget.keyName); + _listValue = await conn.lrange(widget.keyName, 0, len.clamp(0, 500) - 1); + break; + case 'set': + _setValue = await conn.smembers(widget.keyName); + break; + case 'zset': + final card = await conn.zcard(widget.keyName); + _zsetValue = await conn.zrangeWithScores(widget.keyName, 0, card.clamp(0, 500) - 1); + break; + } + if (mounted) setState(() => _loading = false); + } catch (e) { + if (mounted) setState(() { _error = e.toString(); _loading = false; }); + } + } + + Future _saveString() async { + try { + await widget.connection.set(widget.keyName, _stringCtrl.text); + if (mounted) { + material.ScaffoldMessenger.of(context).showSnackBar( + const material.SnackBar(content: material.Text('Saved'), duration: Duration(seconds: 1)), + ); + } + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } + } + + Future _setTtl(int seconds) async { + try { + if (seconds > 0) { + await widget.connection.expire(widget.keyName, seconds); + } else { + await widget.connection.persist(widget.keyName); + } + _ttl = await widget.connection.ttl(widget.keyName); + if (mounted) setState(() {}); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.2), + border: material.Border( + bottom: material.BorderSide(color: cs.border.withValues(alpha: 0.3)), + ), + ), + child: material.Row( + children: [ + material.IconButton( + icon: material.Icon(material.Icons.arrow_back_rounded, size: 18, color: cs.foreground), + onPressed: widget.onBack, + splashRadius: 16, + tooltip: 'Back to keys', + ), + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.keyName, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 14, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + fontFamily: 'monospace', + ), + ), + const Gap(2), + material.Row( + children: [ + _TypeBadge(type: widget.keyType), + const Gap(12), + material.Text( + 'TTL: ${_ttl == -1 ? 'No expiry' : _ttl == -2 ? 'Key missing' : '${_ttl}s'}', + style: material.TextStyle(fontSize: 11, color: cs.mutedForeground), + ), + ], + ), + ], + ), + ), + OutlineButton( + onPressed: _loadValue, + size: ButtonSize.small, + leading: const material.Icon(material.Icons.refresh_rounded, size: 16), + child: const Text('Refresh'), + ), + const Gap(8), + OutlineButton( + onPressed: () => _showTtlDialog(context), + size: ButtonSize.small, + leading: const material.Icon(material.Icons.timer_outlined, size: 16), + child: const Text('TTL'), + ), + const Gap(8), + OutlineButton( + onPressed: () { + widget.onDeleted(); + widget.onBack(); + }, + size: ButtonSize.small, + leading: material.Icon(material.Icons.delete_outline_rounded, size: 16, color: cs.destructive), + child: Text('Delete', style: material.TextStyle(color: cs.destructive)), + ), + ], + ), + ), + // Body + material.Expanded( + child: _loading + ? const material.Center( + child: material.SizedBox( + width: 24, height: 24, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + ) + : _error != null + ? material.Center(child: material.Text(_error!, style: material.TextStyle(color: cs.destructive))) + : _buildValueView(context), + ), + ], + ); + } + + void _showTtlDialog(material.BuildContext context) { + final ttlCtrl = material.TextEditingController(text: _ttl > 0 ? '$_ttl' : ''); + showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + title: const Text('Set TTL'), + content: material.SizedBox( + width: 300, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.TextField( + controller: ttlCtrl, + decoration: const material.InputDecoration( + labelText: 'TTL in seconds (0 = persist)', + ), + keyboardType: material.TextInputType.number, + ), + ], + ), + ), + actions: [ + material.TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const material.Text('Cancel'), + ), + material.ElevatedButton( + onPressed: () { + final val = int.tryParse(ttlCtrl.text.trim()) ?? 0; + _setTtl(val); + Navigator.of(ctx).pop(); + }, + child: const material.Text('Apply'), + ), + ], + ); + }, + ); + } + + material.Widget _buildValueView(material.BuildContext context) { + switch (widget.keyType) { + case 'string': + return _buildStringView(context); + case 'hash': + return _buildHashView(context); + case 'list': + return _buildListView(context); + case 'set': + return _buildSetView(context); + case 'zset': + return _buildZsetView(context); + default: + return material.Center(child: Text('Unsupported type: ${widget.keyType}').muted()); + } + } + + material.Widget _buildStringView(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + // Try to format as JSON + String? formattedJson; + try { + final parsed = jsonDecode(_stringCtrl.text); + formattedJson = const JsonEncoder.withIndent(' ').convert(parsed); + } catch (_) { + // Not JSON, show as plain text + } + + return material.Padding( + padding: const material.EdgeInsets.all(16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + children: [ + const Text('Value').semiBold().small(), + const material.Spacer(), + OutlineButton( + onPressed: _saveString, + size: ButtonSize.small, + leading: const material.Icon(material.Icons.save_outlined, size: 16), + child: const Text('Save'), + ), + ], + ), + const Gap(8), + material.Expanded( + child: material.TextField( + controller: _stringCtrl, + maxLines: null, + expands: true, + textAlignVertical: material.TextAlignVertical.top, + style: material.TextStyle( + fontSize: 13, + color: cs.foreground, + fontFamily: 'monospace', + ), + decoration: material.InputDecoration( + filled: true, + fillColor: cs.card, + border: material.OutlineInputBorder( + borderRadius: material.BorderRadius.circular(8), + borderSide: material.BorderSide(color: cs.border), + ), + ), + ), + ), + if (formattedJson != null) ...[ + const Gap(8), + const Text('Formatted JSON').muted().xSmall(), + ], + ], + ), + ); + } + + material.Widget _buildHashView(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final entries = _hashValue.entries.toList(); + return material.ListView.builder( + padding: const material.EdgeInsets.all(16), + itemCount: entries.length, + itemBuilder: (context, index) { + final e = entries[index]; + return material.Container( + margin: const material.EdgeInsets.only(bottom: 4), + padding: const material.EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: cs.border.withValues(alpha: 0.3)), + ), + child: material.Row( + children: [ + material.SizedBox( + width: 180, + child: material.Text( + e.key, + style: material.TextStyle( + fontSize: 13, color: cs.primary, + fontWeight: material.FontWeight.w600, + fontFamily: 'monospace', + ), + overflow: material.TextOverflow.ellipsis, + ), + ), + const Gap(16), + material.Expanded( + child: material.Text( + e.value, + style: material.TextStyle(fontSize: 13, color: cs.foreground, fontFamily: 'monospace'), + overflow: material.TextOverflow.ellipsis, + maxLines: 2, + ), + ), + material.IconButton( + icon: material.Icon(material.Icons.delete_outline_rounded, size: 16, color: cs.destructive.withValues(alpha: 0.7)), + splashRadius: 16, + onPressed: () async { + await widget.connection.hdel(widget.keyName, e.key); + _loadValue(); + }, + ), + ], + ), + ); + }, + ); + } + + material.Widget _buildListView(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + return material.ListView.builder( + padding: const material.EdgeInsets.all(16), + itemCount: _listValue.length, + itemBuilder: (context, index) { + return material.Container( + margin: const material.EdgeInsets.only(bottom: 4), + padding: const material.EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: cs.border.withValues(alpha: 0.3)), + ), + child: material.Row( + children: [ + material.Text( + '[$index]', + style: material.TextStyle(fontSize: 12, color: cs.mutedForeground, fontFamily: 'monospace'), + ), + const Gap(12), + material.Expanded( + child: material.Text( + _listValue[index], + style: material.TextStyle(fontSize: 13, color: cs.foreground, fontFamily: 'monospace'), + overflow: material.TextOverflow.ellipsis, + maxLines: 2, + ), + ), + ], + ), + ); + }, + ); + } + + material.Widget _buildSetView(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + return material.ListView.builder( + padding: const material.EdgeInsets.all(16), + itemCount: _setValue.length, + itemBuilder: (context, index) { + return material.Container( + margin: const material.EdgeInsets.only(bottom: 4), + padding: const material.EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: cs.border.withValues(alpha: 0.3)), + ), + child: material.Row( + children: [ + material.Expanded( + child: material.Text( + _setValue[index], + style: material.TextStyle(fontSize: 13, color: cs.foreground, fontFamily: 'monospace'), + overflow: material.TextOverflow.ellipsis, + maxLines: 2, + ), + ), + material.IconButton( + icon: material.Icon(material.Icons.delete_outline_rounded, size: 16, color: cs.destructive.withValues(alpha: 0.7)), + splashRadius: 16, + onPressed: () async { + await widget.connection.srem(widget.keyName, _setValue[index]); + _loadValue(); + }, + ), + ], + ), + ); + }, + ); + } + + material.Widget _buildZsetView(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + return material.ListView.builder( + padding: const material.EdgeInsets.all(16), + itemCount: _zsetValue.length, + itemBuilder: (context, index) { + final (member, score) = _zsetValue[index]; + return material.Container( + margin: const material.EdgeInsets.only(bottom: 4), + padding: const material.EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: cs.border.withValues(alpha: 0.3)), + ), + child: material.Row( + children: [ + material.SizedBox( + width: 80, + child: material.Text( + score.toString(), + style: material.TextStyle(fontSize: 12, color: cs.primary, fontFamily: 'monospace'), + ), + ), + const Gap(12), + material.Expanded( + child: material.Text( + member, + style: material.TextStyle(fontSize: 13, color: cs.foreground, fontFamily: 'monospace'), + overflow: material.TextOverflow.ellipsis, + maxLines: 2, + ), + ), + material.IconButton( + icon: material.Icon(material.Icons.delete_outline_rounded, size: 16, color: cs.destructive.withValues(alpha: 0.7)), + splashRadius: 16, + onPressed: () async { + await widget.connection.zrem(widget.keyName, member); + _loadValue(); + }, + ), + ], + ), + ); + }, + ); + } +} + +// ─── Type badge ────────────────────────────────────────────────────────────── + +class _TypeBadge extends material.StatelessWidget { + const _TypeBadge({required this.type}); + + final String type; + + @override + material.Widget build(material.BuildContext context) { + final color = switch (type) { + 'string' => const material.Color(0xFF4CAF50), + 'hash' => const material.Color(0xFF2196F3), + 'list' => const material.Color(0xFFF9A825), + 'set' => const material.Color(0xFF9C27B0), + 'zset' => const material.Color(0xFFFF5722), + _ => const material.Color(0xFF9E9E9E), + }; + + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: material.BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(4), + border: material.Border.all(color: color.withValues(alpha: 0.3)), + ), + child: material.Text( + type.toUpperCase(), + style: material.TextStyle(fontSize: 10, fontWeight: material.FontWeight.w600, color: color), + ), + ); + } +}