From eeb8dface91ad4c12012461cbdc60f29629874cf Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 8 Mar 2026 13:14:16 +0300 Subject: [PATCH] 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), + ), + ); + } +}