From 8bb2e9352dad842f5d821f511231aacb4cc45024 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 5 Oct 2026 07:03:06 +0300 Subject: [PATCH 1/4] feat(redis): interactive Redis CLI Terminal Console (#1029) --- .../database/destructive_sql_detector.dart | 22 +- lib/features/redis/redis_cli_workspace.dart | 883 ++++++++++++++++++ lib/features/redis/redis_explorer_view.dart | 90 +- lib/features/redis/redis_view.dart | 13 + .../redis/redis_cli_workspace_test.dart | 398 ++++++++ 5 files changed, 1397 insertions(+), 9 deletions(-) create mode 100644 lib/features/redis/redis_cli_workspace.dart create mode 100644 test/features/redis/redis_cli_workspace_test.dart diff --git a/lib/core/database/destructive_sql_detector.dart b/lib/core/database/destructive_sql_detector.dart index 513bbd6e..9fa29bc5 100644 --- a/lib/core/database/destructive_sql_detector.dart +++ b/lib/core/database/destructive_sql_detector.dart @@ -16,7 +16,11 @@ enum DestructiveSqlType { redisSrem, redisZrem, redisRename, - redisLrem; + redisLrem, + redisFlushAll, + redisFlushDb, + redisShutdown, + redisKeys; String get label => switch (this) { DestructiveSqlType.dropDatabase => 'DROP DATABASE', @@ -35,6 +39,10 @@ enum DestructiveSqlType { DestructiveSqlType.redisZrem => 'ZREM', DestructiveSqlType.redisRename => 'RENAME', DestructiveSqlType.redisLrem => 'LREM', + DestructiveSqlType.redisFlushAll => 'FLUSHALL', + DestructiveSqlType.redisFlushDb => 'FLUSHDB', + DestructiveSqlType.redisShutdown => 'SHUTDOWN', + DestructiveSqlType.redisKeys => 'KEYS *', }; String get riskLevel => switch (this) { @@ -52,6 +60,10 @@ enum DestructiveSqlType { DestructiveSqlType.redisZrem => 'HIGH', DestructiveSqlType.redisRename => 'HIGH', DestructiveSqlType.redisLrem => 'HIGH', + DestructiveSqlType.redisFlushAll => 'CRITICAL', + DestructiveSqlType.redisFlushDb => 'CRITICAL', + DestructiveSqlType.redisShutdown => 'CRITICAL', + DestructiveSqlType.redisKeys => 'HIGH', DestructiveSqlType.dropMaterializedView => 'MEDIUM', DestructiveSqlType.dropView => 'MEDIUM', }; @@ -101,6 +113,14 @@ class DestructiveSqlOperation { 'Renaming will overwrite existing key "$targetName". The existing value will be permanently lost.', DestructiveSqlType.redisLrem => 'Permanently removes list element "$targetName".', + DestructiveSqlType.redisFlushAll => + 'Permanently deletes all keys across all Redis databases! This cannot be undone.', + DestructiveSqlType.redisFlushDb => + 'Permanently deletes all keys in the current Redis database ($targetName).', + DestructiveSqlType.redisShutdown => + 'Shuts down the Redis server instance. All clients will be disconnected.', + DestructiveSqlType.redisKeys => + 'Scanning all keys with "KEYS $targetName" can block Redis on large production instances. Consider SCAN instead.', }; } diff --git a/lib/features/redis/redis_cli_workspace.dart b/lib/features/redis/redis_cli_workspace.dart new file mode 100644 index 00000000..81703f3b --- /dev/null +++ b/lib/features/redis/redis_cli_workspace.dart @@ -0,0 +1,883 @@ +import 'dart:async' show unawaited; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; +import 'package:querya_desktop/shared/widgets/app_toast.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Known Redis commands for auto-completion. +const List kRedisCommands = [ + 'APPEND', 'AUTH', 'BGREWRITEAOF', 'BGSAVE', 'BITCOUNT', 'BITFIELD', 'BITOP', 'BITPOS', + 'BLMOVE', 'BLPOP', 'BRPOP', 'BZPOPMIN', 'BZPOPMAX', + 'CLIENT', 'COMMAND', 'CONFIG', 'DBSIZE', 'DECR', 'DECRBY', 'DEL', 'DISCARD', 'DUMP', + 'ECHO', 'EVAL', 'EVALSHA', 'EXEC', 'EXISTS', 'EXPIRE', 'EXPIREAT', + 'FLUSHALL', 'FLUSHDB', 'FUNCTION', + 'GET', 'GETBIT', 'GETDEL', 'GETRANGE', 'GETSET', + 'HDEL', 'HEXISTS', 'HGET', 'HGETALL', 'HINCRBY', 'HINCRBYFLOAT', 'HKEYS', 'HLEN', + 'HMGET', 'HMSET', 'HRANDFIELD', 'HSCAN', 'HSET', 'HSETNX', 'HSTRLEN', 'HVALS', + 'INCR', 'INCRBY', 'INCRBYFLOAT', 'INFO', + 'KEYS', 'LASTSAVE', 'LINDEX', 'LINSERT', 'LLEN', 'LMOVE', 'LPOP', 'LPOS', 'LPUSH', + 'LPUSHX', 'LRANGE', 'LREM', 'LSET', 'LTRIM', + 'MEMORY', 'MGET', 'MONITOR', 'MSET', 'MSETNX', 'MULTI', + 'OBJECT', 'PERSIST', 'PEXPIRE', 'PEXPIREAT', 'PFADD', 'PFCOUNT', 'PFMERGE', + 'PING', 'PSETEX', 'PSUBSCRIBE', 'PTTL', 'PUBLISH', 'PUBSUB', 'PUNSUBSCRIBE', + 'QUIT', 'RANDOMKEY', 'READONLY', 'READWRITE', 'RENAME', 'RENAMENX', 'RESTORE', 'ROLE', + 'RPOP', 'RPOPLPUSH', 'RPUSH', 'RPUSHX', + 'SADD', 'SAVE', 'SCAN', 'SCARD', 'SCRIPT', 'SDIFF', 'SDIFFSTORE', 'SELECT', + 'SET', 'SETBIT', 'SETEX', 'SETNX', 'SETRANGE', 'SHUTDOWN', 'SINTER', 'SINTERCARD', + 'SINTERSTORE', 'SISMEMBER', 'SLOWLOG', 'SMEMBERS', 'SMISMEMBER', 'SMOVE', 'SPOP', + 'SRANDMEMBER', 'SREM', 'SSCAN', 'STRLEN', 'SUBSCRIBE', 'SUNION', 'SUNIONSTORE', + 'TIME', 'TOUCH', 'TTL', 'TYPE', 'UNSUBSCRIBE', 'UNWATCH', 'WAIT', 'WATCH', + 'XACK', 'XADD', 'XAUTOCLAIM', 'XCLAIM', 'XDEL', 'XGROUP', 'XINFO', 'XLEN', + 'XPENDING', 'XRANGE', 'XREAD', 'XREADGROUP', 'XREVRANGE', 'XTRIM', + 'ZADD', 'ZCARD', 'ZCOUNT', 'ZDIFF', 'ZINCRBY', 'ZINTER', 'ZLEXCOUNT', 'ZMSCORE', + 'ZPOPMAX', 'ZPOPMIN', 'ZRANDMEMBER', 'ZRANGE', 'ZRANGEBYLEX', 'ZRANGEBYSCORE', + 'ZRANK', 'ZREM', 'ZREMRANGEBYLEX', 'ZREMRANGEBYRANK', 'ZREMRANGEBYSCORE', + 'ZREVRANGE', 'ZREVRANGEBYLEX', 'ZREVRANGEBYSCORE', 'ZREVRANK', 'ZSCAN', 'ZSCORE', +]; + +/// Splits command line into arguments while respecting quotes and escapes. +List parseRedisCliCommand(String input) { + final args = []; + final buffer = StringBuffer(); + var inDoubleQuote = false; + var inSingleQuote = false; + var escaped = false; + + for (var i = 0; i < input.length; i++) { + final char = input[i]; + + if (escaped) { + buffer.write(char); + escaped = false; + continue; + } + + if (char == r'\') { + escaped = true; + continue; + } + + if (char == '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (char == "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (char.trim().isEmpty && !inDoubleQuote && !inSingleQuote) { + if (buffer.isNotEmpty) { + args.add(buffer.toString()); + buffer.clear(); + } + continue; + } + + buffer.write(char); + } + + if (buffer.isNotEmpty) { + args.add(buffer.toString()); + } + + return args; +} + +/// Dangerous Redis commands that require confirmation. +class RedisCliDangerousCommand { + final DestructiveSqlType type; + final String targetName; + final String message; + + const RedisCliDangerousCommand({ + required this.type, + required this.targetName, + required this.message, + }); +} + +/// Inspects command arguments for destructive or dangerous operations. +RedisCliDangerousCommand? checkDangerousRedisCommand( + List args, int currentDb) { + if (args.isEmpty) return null; + final cmd = args.first.toUpperCase(); + + if (cmd == 'FLUSHALL') { + return const RedisCliDangerousCommand( + type: DestructiveSqlType.redisFlushAll, + targetName: 'all databases', + message: 'FLUSHALL will delete every key across all databases!', + ); + } + if (cmd == 'FLUSHDB') { + return RedisCliDangerousCommand( + type: DestructiveSqlType.redisFlushDb, + targetName: 'db$currentDb', + message: + 'FLUSHDB will delete all keys in the current database (db$currentDb)!', + ); + } + if (cmd == 'SHUTDOWN') { + return const RedisCliDangerousCommand( + type: DestructiveSqlType.redisShutdown, + targetName: 'server', + message: 'SHUTDOWN will terminate the Redis server process!', + ); + } + if (cmd == 'KEYS') { + final pattern = args.length > 1 ? args[1] : '*'; + if (pattern.contains('*') || pattern.contains('?')) { + return RedisCliDangerousCommand( + type: DestructiveSqlType.redisKeys, + targetName: pattern, + message: + 'Running "KEYS $pattern" can block Redis on large databases. Use SCAN instead.', + ); + } + } + return null; +} + +/// Formatted Redis CLI execution result. +class RedisCliEntry { + final String command; + final int database; + final String output; + final bool isError; + final int? latencyMs; + final DateTime timestamp; + + const RedisCliEntry({ + required this.command, + required this.database, + required this.output, + this.isError = false, + this.latencyMs, + required this.timestamp, + }); +} + +/// Formats any Redis reply into a standard RESP string representation. +String formatRespReply(Object? reply) { + if (reply == null) return '(nil)'; + if (reply is int) return '(integer) $reply'; + if (reply is bool) return reply ? '(integer) 1' : '(integer) 0'; + if (reply is RedisBulkValue) { + return reply.isUtf8 ? '"${reply.text}"' : reply.label; + } + if (reply is Uint8List) { + try { + final decoded = utf8.decode(reply); + return '"$decoded"'; + } catch (_) { + final bulk = RedisBulkValue.fromReply(reply); + return bulk.label; + } + } + if (reply is List) { + if (reply.isEmpty) return '(empty array)'; + final buf = StringBuffer(); + _formatRespArray(reply, '', buf); + return buf.toString().trimRight(); + } + final s = reply.toString(); + if (s == 'OK' || s == 'PONG') return s; + return '"$s"'; +} + +void _formatRespArray(List items, String indent, StringBuffer buf) { + for (var i = 0; i < items.length; i++) { + final numPrefix = '${indent}${i + 1}) '; + final item = items[i]; + if (item is List) { + if (item.isEmpty) { + buf.writeln('$numPrefix(empty array)'); + } else { + buf.writeln(numPrefix); + final nextIndent = ' $indent'; + _formatRespArray(item, nextIndent, buf); + } + } else { + buf.writeln('$numPrefix${formatRespReply(item)}'); + } + } +} + +/// Interactive Redis CLI Terminal Console workspace. +class RedisCliWorkspace extends material.StatefulWidget { + const RedisCliWorkspace({ + super.key, + required this.connectionRow, + required this.connection, + this.database = 0, + this.onBack, + }); + + final ConnectionRow connectionRow; + final RedisConnection connection; + final int database; + final material.VoidCallback? onBack; + + @override + material.State createState() => _RedisCliWorkspaceState(); +} + +class _RedisCliWorkspaceState extends material.State { + final List _entries = []; + final List _history = []; + int _historyIndex = -1; + String _draftCommand = ''; + + late int _currentDb; + bool _executing = false; + + final material.TextEditingController _inputController = + material.TextEditingController(); + final material.FocusNode _inputFocusNode = material.FocusNode(); + final material.ScrollController _scrollController = + material.ScrollController(); + + List _suggestions = []; + + String get _historyStorageKey => + 'redis_cli_history_${widget.connectionRow.id ?? widget.connection.id}'; + + @override + void initState() { + super.initState(); + _currentDb = widget.database; + unawaited(_loadHistory()); + _addInitialBanner(); + } + + void _addInitialBanner() { + _entries.add( + RedisCliEntry( + command: 'INFO', + database: _currentDb, + output: + 'Connected to Redis at ${widget.connection.host}:${widget.connection.port} (db$_currentDb).\n' + 'Type any Redis command (e.g. GET, HGETALL, INFO, PING). Tab for autocomplete. Type "clear" to clear.', + timestamp: DateTime.now(), + ), + ); + } + + Future _loadHistory() async { + try { + final raw = await LocalDb.instance.getAppSetting(_historyStorageKey); + if (raw != null && raw.isNotEmpty && mounted) { + final decoded = jsonDecode(raw); + if (decoded is List) { + setState(() { + _history.clear(); + _history.addAll(decoded.map((e) => e.toString())); + }); + } + } + } catch (_) {} + } + + Future _saveHistory() async { + try { + final capped = _history.length > 200 + ? _history.sublist(_history.length - 200) + : _history; + await LocalDb.instance.setAppSetting( + _historyStorageKey, + jsonEncode(capped), + ); + } catch (_) {} + } + + @override + void dispose() { + _inputController.dispose(); + _inputFocusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToBottom() { + material.WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 150), + curve: material.Curves.easeOut, + ); + } + }); + } + + void _handleHistoryNavigation(bool isUp) { + if (_history.isEmpty) return; + + if (isUp) { + if (_historyIndex == -1) { + _draftCommand = _inputController.text; + } + if (_historyIndex < _history.length - 1) { + setState(() { + _historyIndex++; + final cmd = _history[_history.length - 1 - _historyIndex]; + _inputController.text = cmd; + _inputController.selection = material.TextSelection.collapsed( + offset: cmd.length, + ); + }); + } + } else { + if (_historyIndex > 0) { + setState(() { + _historyIndex--; + final cmd = _history[_history.length - 1 - _historyIndex]; + _inputController.text = cmd; + _inputController.selection = material.TextSelection.collapsed( + offset: cmd.length, + ); + }); + } else if (_historyIndex == 0) { + setState(() { + _historyIndex = -1; + _inputController.text = _draftCommand; + _inputController.selection = material.TextSelection.collapsed( + offset: _draftCommand.length, + ); + }); + } + } + } + + void _handleTabCompletion() { + final text = _inputController.text; + if (text.isEmpty) return; + + final tokens = parseRedisCliCommand(text); + if (tokens.isEmpty) return; + + final firstToken = tokens.first.toUpperCase(); + final matches = kRedisCommands + .where((cmd) => cmd.startsWith(firstToken)) + .toList(); + + if (matches.isEmpty) { + setState(() => _suggestions = []); + return; + } + + if (matches.length == 1) { + final completed = matches.first; + final remainder = text.substring(tokens.first.length); + final newText = '$completed$remainder '; + setState(() { + _inputController.text = newText; + _inputController.selection = material.TextSelection.collapsed( + offset: newText.length, + ); + _suggestions = []; + }); + } else { + setState(() { + _suggestions = matches.take(15).toList(); + }); + } + } + + Future _submitCommand() async { + final rawText = _inputController.text.trim(); + if (rawText.isEmpty || _executing) return; + + setState(() { + _suggestions = []; + _historyIndex = -1; + _draftCommand = ''; + }); + + _inputController.clear(); + + // Check special client commands + final lower = rawText.toLowerCase(); + if (lower == 'clear') { + setState(() { + _entries.clear(); + }); + return; + } + if (lower == 'help') { + setState(() { + _entries.add( + RedisCliEntry( + command: rawText, + database: _currentDb, + output: + 'Querya Redis CLI Console\n' + 'Supported: any RESP command (GET, SET, HGETALL, XADD, EVAL, etc.)\n' + 'Navigation: Up/Down for command history\n' + 'Autocomplete: Tab to complete command names\n' + 'Commands: "clear" (clear screen), "help" (this help)', + timestamp: DateTime.now(), + ), + ); + }); + _scrollToBottom(); + return; + } + + // Record history + if (_history.isEmpty || _history.last != rawText) { + _history.add(rawText); + unawaited(_saveHistory()); + } + + final args = parseRedisCliCommand(rawText); + if (args.isEmpty) return; + + // Dangerous command check + final dangerous = checkDangerousRedisCommand(args, _currentDb); + if (dangerous != null) { + final confirmed = await confirmDestructiveAction( + context: context, + type: dangerous.type, + targetName: dangerous.targetName, + commandPreview: rawText, + connectionName: widget.connection.name, + ); + if (!confirmed) { + setState(() { + _entries.add( + RedisCliEntry( + command: rawText, + database: _currentDb, + output: '(cancelled: operation aborted by user)', + timestamp: DateTime.now(), + ), + ); + }); + _scrollToBottom(); + return; + } + } + + setState(() => _executing = true); + + final stopwatch = Stopwatch()..start(); + try { + // Connect if needed + if (!widget.connection.isConnected) { + await widget.connection.connect(); + } + + // SELECT tracking + if (args.first.toUpperCase() == 'SELECT' && args.length > 1) { + final targetDb = int.tryParse(args[1]); + if (targetDb != null) { + await widget.connection.selectDatabase(targetDb); + stopwatch.stop(); + if (mounted) { + setState(() { + _currentDb = targetDb; + _entries.add( + RedisCliEntry( + command: rawText, + database: _currentDb, + output: 'OK', + latencyMs: stopwatch.elapsedMilliseconds, + timestamp: DateTime.now(), + ), + ); + _executing = false; + }); + _scrollToBottom(); + } + return; + } + } + + final reply = await widget.connection.sendCommand(args); + stopwatch.stop(); + + final formatted = formatRespReply(reply); + if (mounted) { + setState(() { + _entries.add( + RedisCliEntry( + command: rawText, + database: _currentDb, + output: formatted, + latencyMs: stopwatch.elapsedMilliseconds, + timestamp: DateTime.now(), + ), + ); + _executing = false; + }); + _scrollToBottom(); + } + } catch (e) { + stopwatch.stop(); + if (mounted) { + final errText = e.toString().replaceFirst('Exception: ', ''); + setState(() { + _entries.add( + RedisCliEntry( + command: rawText, + database: _currentDb, + output: '(error) $errText', + isError: true, + latencyMs: stopwatch.elapsedMilliseconds, + timestamp: DateTime.now(), + ), + ); + _executing = false; + }); + _scrollToBottom(); + } + } + } + + void _applySuggestion(String commandName) { + final text = _inputController.text; + final tokens = parseRedisCliCommand(text); + final remainder = tokens.isNotEmpty + ? text.substring(tokens.first.length).trimLeft() + : ''; + final newText = remainder.isEmpty ? '$commandName ' : '$commandName $remainder'; + setState(() { + _inputController.text = newText; + _inputController.selection = material.TextSelection.collapsed( + offset: newText.length, + ); + _suggestions = []; + }); + _inputFocusNode.requestFocus(); + } + + Future _copyText(String text, String label) async { + await Clipboard.setData(ClipboardData(text: text)); + if (mounted) { + showAppToast( + context: context, + message: '$label copied to clipboard', + variant: AppToastVariant.info, + ); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return material.Container( + color: cs.card, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header Toolbar + material.Container( + height: 40, + padding: const material.EdgeInsets.symmetric(horizontal: 16), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.15), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.3), + ), + ), + ), + child: Row( + children: [ + material.Icon( + material.Icons.terminal_rounded, + size: 18, + color: cs.primary, + ), + const Gap(8), + const Text('Redis CLI Console').semiBold().small(), + const Gap(8), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.25), + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + '${widget.connection.host}:${widget.connection.port} [db$_currentDb]', + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: cs.mutedForeground, + ), + ), + ), + const Spacer(), + OutlineButton( + onPressed: () { + setState(() { + _entries.clear(); + }); + }, + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.cleaning_services_rounded, + size: 13, + ), + child: const Text('Clear'), + ), + if (widget.onBack != null) ...[ + const Gap(8), + OutlineButton( + onPressed: widget.onBack, + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.close_rounded, + size: 14, + ), + child: const Text('Close CLI'), + ), + ], + ], + ), + ), + // Output Area + material.Expanded( + child: material.ListView.builder( + controller: _scrollController, + padding: const material.EdgeInsets.all(12), + itemCount: _entries.length, + itemBuilder: (context, i) { + return _buildEntryItem(_entries[i], cs); + }, + ), + ), + // Suggestion Chips (Tab Autocomplete) + if (_suggestions.isNotEmpty) + material.Container( + height: 34, + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.2), + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.2), + ), + ), + ), + child: material.ListView.separated( + scrollDirection: material.Axis.horizontal, + itemCount: _suggestions.length, + separatorBuilder: (_, __) => const Gap(6), + itemBuilder: (context, i) { + final suggestion = _suggestions[i]; + return material.Center( + child: material.InkWell( + onTap: () => _applySuggestion(suggestion), + borderRadius: material.BorderRadius.circular(4), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(4), + border: material.Border.all( + color: cs.primary.withValues(alpha: 0.35), + ), + ), + child: Text( + suggestion, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 11, + fontWeight: material.FontWeight.w600, + color: cs.primary, + ), + ), + ), + ), + ); + }, + ), + ), + // Command Input Bar + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.1), + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.3), + ), + ), + ), + child: Row( + children: [ + Text( + 'db$_currentDb >', + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.primary, + ), + ), + const Gap(8), + material.Expanded( + child: Focus( + onKeyEvent: (node, event) { + if (event is material.KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + _handleHistoryNavigation(true); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + _handleHistoryNavigation(false); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.tab) { + _handleTabCompletion(); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: _inputController, + focusNode: _inputFocusNode, + autofocus: true, + placeholder: const Text('Enter Redis command (Tab to autocomplete)...'), + onSubmitted: (_) => _submitCommand(), + ), + ), + ), + const Gap(8), + PrimaryButton( + onPressed: _executing ? null : _submitCommand, + size: ButtonSize.small, + leading: _executing + ? const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator(strokeWidth: 2), + ) + : const material.Icon( + material.Icons.play_arrow_rounded, + size: 16, + ), + child: const Text('Run'), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildEntryItem(RedisCliEntry entry, ColorScheme cs) { + return material.Container( + margin: const material.EdgeInsets.only(bottom: 10), + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.08), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.2), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Command Line Prompt Header + Row( + children: [ + Text( + 'db${entry.database} > ', + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: cs.primary, + ), + ), + material.Expanded( + child: Text( + entry.command, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + fontWeight: material.FontWeight.w600, + ), + ), + ), + if (entry.latencyMs != null) ...[ + const Gap(8), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 5, + vertical: 1, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.2), + borderRadius: material.BorderRadius.circular(3), + ), + child: Text( + '${entry.latencyMs} ms', + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: cs.mutedForeground, + ), + ), + ), + ], + const Gap(8), + material.InkWell( + onTap: () => _copyText(entry.output, 'Output'), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.Icon( + material.Icons.copy_rounded, + size: 13, + color: cs.mutedForeground, + ), + ), + ), + ], + ), + const Gap(6), + // Output Text + material.SelectableText( + entry.output, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: entry.isError + ? cs.destructive + : (entry.output.startsWith('(nil)') + ? const Color(0xFFF97316) // Amber + : cs.foreground), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index 84569571..d6e74c7f 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -12,6 +12,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; import 'redis_keys_view.dart'; import 'redis_key_editor.dart'; import 'redis_view.dart'; +import 'redis_cli_workspace.dart'; // ─── Navigation path model ────────────────────────────────────────────────── @@ -21,7 +22,7 @@ class _Crumb { final _Level level; } -enum _Level { keys, key, stats } +enum _Level { keys, key, stats, cli } // ─── Main explorer widget ─────────────────────────────────────────────────── @@ -51,6 +52,7 @@ class _RedisExplorerViewState extends material.State { // View mode bool _showStats = false; + bool _showCli = false; int _refreshEpoch = 0; // Navigation state @@ -176,13 +178,20 @@ class _RedisExplorerViewState extends material.State { } if (_showStats) { list.add(const _Crumb('Statistics', _Level.stats)); + } else if (_showCli) { + list.add(const _Crumb('CLI Console', _Level.cli)); } return list; } void _onCrumbTap(_Crumb crumb) { - if (_showStats && crumb.level != _Level.stats) { - setState(() => _showStats = false); + if ((_showStats || _showCli) && + crumb.level != _Level.stats && + crumb.level != _Level.cli) { + setState(() { + _showStats = false; + _showCli = false; + }); } switch (crumb.level) { case _Level.keys: @@ -191,6 +200,8 @@ class _RedisExplorerViewState extends material.State { break; case _Level.stats: break; + case _Level.cli: + break; } } @@ -251,6 +262,15 @@ class _RedisExplorerViewState extends material.State { final conn = _connection; if (conn == null) return const material.SizedBox.shrink(); + final int bodyIndex; + if (_showCli) { + bodyIndex = 2; + } else if (_showStats) { + bodyIndex = 1; + } else { + bodyIndex = 0; + } + return material.Container( color: cs.background, child: material.Column( @@ -260,15 +280,24 @@ class _RedisExplorerViewState extends material.State { crumbs: _crumbs, onCrumbTap: _onCrumbTap, onRefresh: () => setState(() => _refreshEpoch++), - onStats: () => setState(() => _showStats = !_showStats), + onStats: () => setState(() { + _showStats = !_showStats; + if (_showStats) _showCli = false; + }), + onCli: () => setState(() { + _showCli = !_showCli; + if (_showCli) _showStats = false; + }), + isCliActive: _showCli, + isStatsActive: _showStats, isReadOnly: widget.isReadOnly, ), const Divider(height: 1), - // Content with fluid cross-fade morph between keys and stats + // Content with fluid cross-fade morph between keys, stats and CLI material.Expanded( child: QueryaSwitchingBody( slide: material.Offset.zero, - index: _showStats ? 1 : 0, + index: bodyIndex, children: [ _buildContent(conn), RedisView( @@ -276,6 +305,18 @@ class _RedisExplorerViewState extends material.State { connectionRow: widget.connectionRow, connection: conn, onBack: () => setState(() => _showStats = false), + onCli: () => setState(() { + _showStats = false; + _showCli = true; + }), + ), + RedisCliWorkspace( + key: ValueKey( + 'cli_${widget.connectionRow.id}_${widget.database}'), + connectionRow: widget.connectionRow, + connection: conn, + database: widget.database, + onBack: () => setState(() => _showCli = false), ), ], ), @@ -324,6 +365,9 @@ class _BreadcrumbBar extends StatelessWidget { required this.onCrumbTap, required this.onRefresh, required this.onStats, + required this.onCli, + this.isCliActive = false, + this.isStatsActive = false, this.isReadOnly = false, }); @@ -331,6 +375,9 @@ class _BreadcrumbBar extends StatelessWidget { final void Function(_Crumb) onCrumbTap; final VoidCallback onRefresh; final VoidCallback onStats; + final VoidCallback onCli; + final bool isCliActive; + final bool isStatsActive; final bool isReadOnly; @override @@ -385,15 +432,42 @@ class _BreadcrumbBar extends StatelessWidget { ), ), const Gap(8), + material.Tooltip( + message: 'Redis CLI Console', + child: material.InkWell( + onTap: onCli, + borderRadius: material.BorderRadius.circular(6), + child: material.Container( + decoration: isCliActive + ? material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(6), + ) + : null, + padding: const material.EdgeInsets.all(6), + child: material.Icon(material.Icons.terminal_rounded, + size: 18, + color: isCliActive ? cs.primary : cs.mutedForeground), + ), + ), + ), + const Gap(4), material.Tooltip( message: 'Statistics', child: material.InkWell( onTap: onStats, borderRadius: material.BorderRadius.circular(6), - child: material.Padding( + child: material.Container( + decoration: isStatsActive + ? material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(6), + ) + : null, padding: const material.EdgeInsets.all(6), child: material.Icon(material.Icons.bar_chart_rounded, - size: 18, color: cs.mutedForeground), + size: 18, + color: isStatsActive ? cs.primary : cs.mutedForeground), ), ), ), diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index fa990ff9..aacfa46d 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -43,6 +43,7 @@ class RedisView extends material.StatefulWidget { required this.connectionRow, this.connection, this.onBack, + this.onCli, this.lastSelectedRedisDb, this.onRestoreLastSelectedObject, }); @@ -56,6 +57,9 @@ class RedisView extends material.StatefulWidget { /// Called when the user taps the "back to explorer" button. final material.VoidCallback? onBack; + /// Called when the user taps the "CLI Console" button. + final material.VoidCallback? onCli; + /// Remembers the last visited database index for 1-click return from stats. final int? lastSelectedRedisDb; final material.VoidCallback? onRestoreLastSelectedObject; @@ -434,6 +438,15 @@ class _RedisViewState extends material.State { ), const Gap(8), ], + if (widget.onCli != null) ...[ + OutlineButton( + onPressed: widget.onCli, + leading: + const material.Icon(material.Icons.terminal_rounded, size: 18), + child: const Text('CLI Console'), + ), + const Gap(8), + ], OutlineButton( onPressed: () => unawaited(_refresh()), leading: diff --git a/test/features/redis/redis_cli_workspace_test.dart b/test/features/redis/redis_cli_workspace_test.dart new file mode 100644 index 00000000..480725e2 --- /dev/null +++ b/test/features/redis/redis_cli_workspace_test.dart @@ -0,0 +1,398 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/redis/redis_cli_workspace.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +class _CliTestRedisConnection extends RedisConnectionTestFake { + _CliTestRedisConnection({this.onCommand}); + + final Future Function(List args)? onCommand; + + @override + Future sendCommand(List args) async { + sentCommands.add(args.first.toString().toUpperCase()); + if (onCommand != null) { + return onCommand!(args); + } + final op = args.first.toString().toUpperCase(); + if (op == 'PING') return 'PONG'; + if (op == 'GET') return 'hello-world'; + if (op == 'SELECT') return 'OK'; + return super.sendCommand(args); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('parseRedisCliCommand', () { + test('parses simple commands without arguments', () { + expect(parseRedisCliCommand('PING'), ['PING']); + expect(parseRedisCliCommand(' INFO '), ['INFO']); + }); + + test('parses simple command with arguments', () { + expect(parseRedisCliCommand('GET my_key'), ['GET', 'my_key']); + expect( + parseRedisCliCommand('SET foo bar'), + ['SET', 'foo', 'bar'], + ); + }); + + test('handles multiple spaces between tokens', () { + expect( + parseRedisCliCommand(' HSET myhash field1 value1 '), + ['HSET', 'myhash', 'field1', 'value1'], + ); + }); + + test('handles double-quoted strings with spaces', () { + expect( + parseRedisCliCommand('SET "my key name" "hello world"'), + ['SET', 'my key name', 'hello world'], + ); + }); + + test('handles single-quoted strings with spaces', () { + expect( + parseRedisCliCommand("HSET 'my hash' 'full name' 'John Doe'"), + ['HSET', 'my hash', 'full name', 'John Doe'], + ); + }); + + test('handles escaped quotes inside quotes', () { + expect( + parseRedisCliCommand(r'SET mykey "hello \"escaped\" world"'), + ['SET', 'mykey', 'hello "escaped" world'], + ); + }); + + test('handles empty input', () { + expect(parseRedisCliCommand(''), isEmpty); + expect(parseRedisCliCommand(' '), isEmpty); + }); + }); + + group('formatRespReply', () { + test('formats null as (nil)', () { + expect(formatRespReply(null), '(nil)'); + }); + + test('formats integer', () { + expect(formatRespReply(0), '(integer) 0'); + expect(formatRespReply(42), '(integer) 42'); + expect(formatRespReply(-1), '(integer) -1'); + }); + + test('formats boolean as integer', () { + expect(formatRespReply(true), '(integer) 1'); + expect(formatRespReply(false), '(integer) 0'); + }); + + test('formats OK and PONG as simple strings', () { + expect(formatRespReply('OK'), 'OK'); + expect(formatRespReply('PONG'), 'PONG'); + }); + + test('formats arbitrary string with quotes', () { + expect(formatRespReply('foobar'), '"foobar"'); + }); + + test('formats UTF-8 Uint8List bytes', () { + final bytes = Uint8List.fromList('redis data'.codeUnits); + expect(formatRespReply(bytes), '"redis data"'); + }); + + test('formats empty list as (empty array)', () { + expect(formatRespReply([]), '(empty array)'); + }); + + test('formats flat list as 1-based indexed lines', () { + final list = ['first', 42, null]; + final res = formatRespReply(list); + expect(res, '1) "first"\n2) (integer) 42\n3) (nil)'); + }); + + test('formats nested list structure', () { + final nested = [ + ['itemA', 'itemB'], + 'itemC', + ]; + final res = formatRespReply(nested); + expect(res, contains('1)')); + expect(res, contains('"itemA"')); + expect(res, contains('"itemB"')); + expect(res, contains('2) "itemC"')); + }); + }); + + group('checkDangerousRedisCommand', () { + test('detects FLUSHALL as critical destructive command', () { + final res = checkDangerousRedisCommand(['FLUSHALL'], 0); + expect(res, isNotNull); + expect(res!.type, DestructiveSqlType.redisFlushAll); + expect(res.targetName, 'all databases'); + expect(res.message, contains('FLUSHALL will delete every key')); + }); + + test('detects flushall case-insensitively', () { + final res = checkDangerousRedisCommand(['flushall', 'async'], 2); + expect(res, isNotNull); + expect(res!.type, DestructiveSqlType.redisFlushAll); + }); + + test('detects FLUSHDB for current database', () { + final res = checkDangerousRedisCommand(['FLUSHDB'], 3); + expect(res, isNotNull); + expect(res!.type, DestructiveSqlType.redisFlushDb); + expect(res.targetName, 'db3'); + expect(res.message, contains('db3')); + }); + + test('detects SHUTDOWN', () { + final res = checkDangerousRedisCommand(['SHUTDOWN', 'NOSAVE'], 0); + expect(res, isNotNull); + expect(res!.type, DestructiveSqlType.redisShutdown); + expect(res.targetName, 'server'); + }); + + test('detects KEYS with wildcard', () { + final res1 = checkDangerousRedisCommand(['KEYS', '*'], 0); + expect(res1, isNotNull); + expect(res1!.type, DestructiveSqlType.redisKeys); + expect(res1.targetName, '*'); + + final res2 = checkDangerousRedisCommand(['KEYS', 'user:*'], 0); + expect(res2, isNotNull); + expect(res2!.type, DestructiveSqlType.redisKeys); + expect(res2.targetName, 'user:*'); + + final res3 = checkDangerousRedisCommand(['KEYS', 'user:?'], 0); + expect(res3, isNotNull); + expect(res3!.type, DestructiveSqlType.redisKeys); + expect(res3.targetName, 'user:?'); + }); + + test('allows non-destructive and exact KEYS commands', () { + expect(checkDangerousRedisCommand(['GET', 'mykey'], 0), isNull); + expect(checkDangerousRedisCommand(['KEYS', 'exact_key'], 0), isNull); + expect(checkDangerousRedisCommand(['HGETALL', 'myhash'], 0), isNull); + expect(checkDangerousRedisCommand([], 0), isNull); + }); + }); + + group('DestructiveSqlType metadata for Redis', () { + test('verifies label, riskLevel, and description for Redis types', () { + expect(DestructiveSqlType.redisFlushAll.label, 'FLUSHALL'); + expect(DestructiveSqlType.redisFlushAll.riskLevel, 'CRITICAL'); + expect(DestructiveSqlType.redisFlushAll.description, + contains('all Redis databases')); + + expect(DestructiveSqlType.redisFlushDb.label, 'FLUSHDB'); + expect(DestructiveSqlType.redisFlushDb.riskLevel, 'CRITICAL'); + expect(DestructiveSqlType.redisFlushDb.description, + contains('current Redis database')); + + expect(DestructiveSqlType.redisShutdown.label, 'SHUTDOWN'); + expect(DestructiveSqlType.redisShutdown.riskLevel, 'CRITICAL'); + expect(DestructiveSqlType.redisShutdown.description, + contains('Redis server process')); + + expect(DestructiveSqlType.redisKeys.label, 'KEYS *'); + expect(DestructiveSqlType.redisKeys.riskLevel, 'HIGH'); + expect(DestructiveSqlType.redisKeys.description, + contains('KEYS')); + }); + }); + + group('RedisCliWorkspace Widget', () { + testWidgets('renders CLI terminal workspace with prompt and banner', + (tester) async { + final fake = _CliTestRedisConnection(); + await fake.connect(); + + const connRow = ConnectionRow( + id: 10, + name: 'Test Redis', + type: 'redis', + host: '127.0.0.1', + port: 6379, + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 900, + height: 700, + child: RedisCliWorkspace( + connectionRow: connRow, + connection: fake, + database: 0, + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('Redis CLI Console'), findsOneWidget); + expect(find.textContaining('127.0.0.1:6379 [db0]'), findsOneWidget); + expect(find.text('Clear'), findsOneWidget); + expect(find.text('db0 >'), findsOneWidget); + expect(find.text('Run'), findsOneWidget); + + await fake.disconnect(); + }); + + testWidgets('executes command and displays formatted result with latency', + (tester) async { + final fake = _CliTestRedisConnection(); + await fake.connect(); + + const connRow = ConnectionRow( + id: 11, + name: 'Test Redis', + type: 'redis', + host: '127.0.0.1', + port: 6379, + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 900, + height: 700, + child: RedisCliWorkspace( + connectionRow: connRow, + connection: fake, + database: 0, + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Enter PING + final inputField = find.byType(TextField); + expect(inputField, findsOneWidget); + await tester.enterText(inputField, 'PING'); + await tester.pump(); + + // Tap Run button + final runBtn = find.text('Run'); + await tester.tap(runBtn); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Verify PONG is in the output + expect(find.text('PONG'), findsOneWidget); + expect(find.textContaining('ms'), findsWidgets); + + await fake.disconnect(); + }); + + testWidgets('handles "help" command locally', (tester) async { + final fake = _CliTestRedisConnection(); + await fake.connect(); + + const connRow = ConnectionRow( + id: 12, + name: 'Test Redis', + type: 'redis', + host: '127.0.0.1', + port: 6379, + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 900, + height: 700, + child: RedisCliWorkspace( + connectionRow: connRow, + connection: fake, + database: 0, + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final inputField = find.byType(TextField); + await tester.enterText(inputField, 'help'); + await tester.pump(); + + final runBtn = find.text('Run'); + await tester.tap(runBtn); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.textContaining('Querya Redis CLI Console'), findsOneWidget); + + await fake.disconnect(); + }); + + testWidgets('clears output on "Clear" button tap', (tester) async { + final fake = _CliTestRedisConnection(); + await fake.connect(); + + const connRow = ConnectionRow( + id: 13, + name: 'Test Redis', + type: 'redis', + host: '127.0.0.1', + port: 6379, + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 900, + height: 700, + child: RedisCliWorkspace( + connectionRow: connRow, + connection: fake, + database: 0, + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Banner is visible initially + expect(find.textContaining('Connected to Redis'), findsOneWidget); + + // Tap Clear + final clearBtn = find.text('Clear'); + await tester.tap(clearBtn); + await tester.pump(); + + // Banner should be cleared + expect(find.textContaining('Connected to Redis'), findsNothing); + + await fake.disconnect(); + }); + }); +} From 28f18208ce131d36a22ddd8d0b5b58eead276bcc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 5 Oct 2026 07:11:32 +0300 Subject: [PATCH 2/4] fix(redis): resolve analysis and compilation issues in redis cli workspace --- lib/features/redis/redis_cli_workspace.dart | 5 +- .../redis/redis_cli_workspace_test.dart | 47 +++++++++++++------ 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/lib/features/redis/redis_cli_workspace.dart b/lib/features/redis/redis_cli_workspace.dart index 81703f3b..d9135fd6 100644 --- a/lib/features/redis/redis_cli_workspace.dart +++ b/lib/features/redis/redis_cli_workspace.dart @@ -1,6 +1,5 @@ import 'dart:async' show unawaited; import 'dart:convert'; -import 'dart:typed_data'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; @@ -197,7 +196,7 @@ String formatRespReply(Object? reply) { void _formatRespArray(List items, String indent, StringBuffer buf) { for (var i = 0; i < items.length; i++) { - final numPrefix = '${indent}${i + 1}) '; + final numPrefix = '$indent${i + 1}) '; final item = items[i]; if (item is List) { if (item.isEmpty) { @@ -741,7 +740,7 @@ class _RedisCliWorkspaceState extends material.State { material.Expanded( child: Focus( onKeyEvent: (node, event) { - if (event is material.KeyDownEvent) { + if (event is KeyDownEvent) { if (event.logicalKey == LogicalKeyboardKey.arrowUp) { _handleHistoryNavigation(true); return KeyEventResult.handled; diff --git a/test/features/redis/redis_cli_workspace_test.dart b/test/features/redis/redis_cli_workspace_test.dart index 480725e2..4a51b135 100644 --- a/test/features/redis/redis_cli_workspace_test.dart +++ b/test/features/redis/redis_cli_workspace_test.dart @@ -11,16 +11,9 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/querya_theme_test_shell.dart'; class _CliTestRedisConnection extends RedisConnectionTestFake { - _CliTestRedisConnection({this.onCommand}); - - final Future Function(List args)? onCommand; - @override Future sendCommand(List args) async { sentCommands.add(args.first.toString().toUpperCase()); - if (onCommand != null) { - return onCommand!(args); - } final op = args.first.toString().toUpperCase(); if (op == 'PING') return 'PONG'; if (op == 'GET') return 'hello-world'; @@ -192,23 +185,43 @@ void main() { test('verifies label, riskLevel, and description for Redis types', () { expect(DestructiveSqlType.redisFlushAll.label, 'FLUSHALL'); expect(DestructiveSqlType.redisFlushAll.riskLevel, 'CRITICAL'); - expect(DestructiveSqlType.redisFlushAll.description, - contains('all Redis databases')); + expect( + const DestructiveSqlOperation( + type: DestructiveSqlType.redisFlushAll, + targetName: 'all databases', + ).description, + contains('all Redis databases'), + ); expect(DestructiveSqlType.redisFlushDb.label, 'FLUSHDB'); expect(DestructiveSqlType.redisFlushDb.riskLevel, 'CRITICAL'); - expect(DestructiveSqlType.redisFlushDb.description, - contains('current Redis database')); + expect( + const DestructiveSqlOperation( + type: DestructiveSqlType.redisFlushDb, + targetName: 'db0', + ).description, + contains('current Redis database'), + ); expect(DestructiveSqlType.redisShutdown.label, 'SHUTDOWN'); expect(DestructiveSqlType.redisShutdown.riskLevel, 'CRITICAL'); - expect(DestructiveSqlType.redisShutdown.description, - contains('Redis server process')); + expect( + const DestructiveSqlOperation( + type: DestructiveSqlType.redisShutdown, + targetName: 'server', + ).description, + contains('Redis server process'), + ); expect(DestructiveSqlType.redisKeys.label, 'KEYS *'); expect(DestructiveSqlType.redisKeys.riskLevel, 'HIGH'); - expect(DestructiveSqlType.redisKeys.description, - contains('KEYS')); + expect( + const DestructiveSqlOperation( + type: DestructiveSqlType.redisKeys, + targetName: '*', + ).description, + contains('KEYS'), + ); }); }); @@ -224,6 +237,7 @@ void main() { type: 'redis', host: '127.0.0.1', port: 6379, + createdAt: '2026-01-01T00:00:00Z', ); await tester.pumpWidget( @@ -265,6 +279,7 @@ void main() { type: 'redis', host: '127.0.0.1', port: 6379, + createdAt: '2026-01-01T00:00:00Z', ); await tester.pumpWidget( @@ -315,6 +330,7 @@ void main() { type: 'redis', host: '127.0.0.1', port: 6379, + createdAt: '2026-01-01T00:00:00Z', ); await tester.pumpWidget( @@ -360,6 +376,7 @@ void main() { type: 'redis', host: '127.0.0.1', port: 6379, + createdAt: '2026-01-01T00:00:00Z', ); await tester.pumpWidget( From a42ad54d5c393d02b066dba1509fd591bed977cd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 5 Oct 2026 07:19:44 +0300 Subject: [PATCH 3/4] fix(redis): supply required rawStatement parameter in test --- test/features/redis/redis_cli_workspace_test.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/features/redis/redis_cli_workspace_test.dart b/test/features/redis/redis_cli_workspace_test.dart index 4a51b135..5ca2adf4 100644 --- a/test/features/redis/redis_cli_workspace_test.dart +++ b/test/features/redis/redis_cli_workspace_test.dart @@ -189,6 +189,7 @@ void main() { const DestructiveSqlOperation( type: DestructiveSqlType.redisFlushAll, targetName: 'all databases', + rawStatement: 'FLUSHALL', ).description, contains('all Redis databases'), ); @@ -199,6 +200,7 @@ void main() { const DestructiveSqlOperation( type: DestructiveSqlType.redisFlushDb, targetName: 'db0', + rawStatement: 'FLUSHDB', ).description, contains('current Redis database'), ); @@ -209,6 +211,7 @@ void main() { const DestructiveSqlOperation( type: DestructiveSqlType.redisShutdown, targetName: 'server', + rawStatement: 'SHUTDOWN', ).description, contains('Redis server process'), ); @@ -219,6 +222,7 @@ void main() { const DestructiveSqlOperation( type: DestructiveSqlType.redisKeys, targetName: '*', + rawStatement: 'KEYS *', ).description, contains('KEYS'), ); From 4587f8c51afcd2ad2ecfcfa5bdc589f8ecef3026 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 5 Oct 2026 07:24:45 +0300 Subject: [PATCH 4/4] test(redis): fix assertions in redis cli workspace test --- test/features/redis/redis_cli_workspace_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/features/redis/redis_cli_workspace_test.dart b/test/features/redis/redis_cli_workspace_test.dart index 5ca2adf4..c4d1a4e2 100644 --- a/test/features/redis/redis_cli_workspace_test.dart +++ b/test/features/redis/redis_cli_workspace_test.dart @@ -213,7 +213,7 @@ void main() { targetName: 'server', rawStatement: 'SHUTDOWN', ).description, - contains('Redis server process'), + contains('Redis server'), ); expect(DestructiveSqlType.redisKeys.label, 'KEYS *'); @@ -264,7 +264,7 @@ void main() { await tester.pump(const Duration(milliseconds: 50)); expect(find.text('Redis CLI Console'), findsOneWidget); - expect(find.textContaining('127.0.0.1:6379 [db0]'), findsOneWidget); + expect(find.textContaining('localhost:6379 [db0]'), findsOneWidget); expect(find.text('Clear'), findsOneWidget); expect(find.text('db0 >'), findsOneWidget); expect(find.text('Run'), findsOneWidget);