diff --git a/lib/core/database/destructive_sql_detector.dart b/lib/core/database/destructive_sql_detector.dart index 0ca1283..f4ca175 100644 --- a/lib/core/database/destructive_sql_detector.dart +++ b/lib/core/database/destructive_sql_detector.dart @@ -13,7 +13,8 @@ enum DestructiveSqlType { redisDel, redisHdel, redisSrem, - redisZrem; + redisZrem, + redisRename; String get label => switch (this) { DestructiveSqlType.dropDatabase => 'DROP DATABASE', @@ -29,6 +30,7 @@ enum DestructiveSqlType { DestructiveSqlType.redisHdel => 'HDEL', DestructiveSqlType.redisSrem => 'SREM', DestructiveSqlType.redisZrem => 'ZREM', + DestructiveSqlType.redisRename => 'RENAME', }; String get riskLevel => switch (this) { @@ -43,6 +45,7 @@ enum DestructiveSqlType { DestructiveSqlType.redisHdel => 'HIGH', DestructiveSqlType.redisSrem => 'HIGH', DestructiveSqlType.redisZrem => 'HIGH', + DestructiveSqlType.redisRename => 'HIGH', DestructiveSqlType.dropMaterializedView => 'MEDIUM', DestructiveSqlType.dropView => 'MEDIUM', }; @@ -86,6 +89,8 @@ class DestructiveSqlOperation { 'Permanently removes set member "$targetName".', DestructiveSqlType.redisZrem => 'Permanently removes sorted-set member "$targetName".', + DestructiveSqlType.redisRename => + 'Renaming will overwrite existing key "$targetName". The existing value will be permanently lost.', }; } diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index 3684e11..8252f9a 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -545,9 +545,23 @@ class RedisConnection { } /// RENAME old new. - Future rename(String oldKey, String newKey) async { + Future rename(Object oldKey, String newKey) async { _assertWritable(); - await sendCommand(['RENAME', oldKey, newKey]); + await sendCommand(['RENAME', redisCommandArg(oldKey), newKey]); + } + + /// RENAMENX old new. Returns 1 if key was renamed, 0 if newKey already exists. + Future renamenx(Object oldKey, String newKey) async { + _assertWritable(); + final result = + await sendCommand(['RENAMENX', redisCommandArg(oldKey), newKey]); + return redisReplyInt(result); + } + + /// EXISTS key. Returns 1 if key exists, 0 if missing. + Future exists(Object key) async { + final result = await sendCommand(['EXISTS', redisCommandArg(key)]); + return redisReplyInt(result); } /// EXPIRE key seconds. @@ -752,6 +766,16 @@ class RedisConnectionTestFake extends RedisConnection { case 'READONLY': case 'READWRITE': return 'OK'; + case 'EXISTS': + final target = args[1].toString(); + final allKeys = [...firstScanKeys, ...secondScanKeys]; + return allKeys.contains(target) ? 1 : 0; + case 'RENAME': + return 'OK'; + case 'RENAMENX': + final target = args[2].toString(); + final allKeys = [...firstScanKeys, ...secondScanKeys]; + return allKeys.contains(target) ? 0 : 1; default: return null; } diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index af409d6..1503893 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -146,6 +146,13 @@ class _RedisExplorerViewState extends material.State { }); } + void _onKeyRenamed(RedisBulkValue newKey) { + setState(() { + _selectedKey = newKey; + _refreshEpoch++; + }); + } + // ─── Breadcrumbs ──────────────────────────────────────────────────────── List<_Crumb> get _crumbs { @@ -280,6 +287,7 @@ class _RedisExplorerViewState extends material.State { keyType: _selectedKeyType ?? 'unknown', onBack: _navigateToKeys, onKeyDeleted: _navigateToKeys, + onKeyRenamed: _onKeyRenamed, isReadOnly: widget.isReadOnly, ); } diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index db6a51c..3e1e63f 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -18,6 +18,7 @@ class RedisKeyEditor extends material.StatefulWidget { this.keyArg, this.onBack, this.onKeyDeleted, + this.onKeyRenamed, this.isReadOnly = false, }); @@ -30,6 +31,7 @@ class RedisKeyEditor extends material.StatefulWidget { final Object? keyArg; final VoidCallback? onBack; final VoidCallback? onKeyDeleted; + final ValueChanged? onKeyRenamed; final bool isReadOnly; @override @@ -63,6 +65,8 @@ class _RedisKeyEditorState extends material.State { bool _hasMore = false; bool _loadingMore = false; late String _effectiveType; + late String _currentKeyName; + Object? _currentKeyArg; // For adding new items final _newFieldController = material.TextEditingController(); @@ -71,10 +75,22 @@ class _RedisKeyEditorState extends material.State { @override void initState() { super.initState(); + _currentKeyName = widget.keyName; + _currentKeyArg = widget.keyArg; _effectiveType = _normalizedType(widget.keyType); _load(); } + @override + void didUpdateWidget(covariant RedisKeyEditor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.keyName != widget.keyName || + oldWidget.keyArg != widget.keyArg) { + _currentKeyName = widget.keyName; + _currentKeyArg = widget.keyArg; + } + } + @override void dispose() { _stringController.dispose(); @@ -83,7 +99,7 @@ class _RedisKeyEditorState extends material.State { super.dispose(); } - Object get _cmdKey => widget.keyArg ?? widget.keyName; + Object get _cmdKey => _currentKeyArg ?? _currentKeyName; bool get _stringIsBinary => _effectiveType == 'string' && @@ -271,13 +287,56 @@ class _RedisKeyEditorState extends material.State { } } + Future _renameKey() async { + if (widget.isReadOnly) return; + final targetName = await showAppDialog( + context: context, + builder: (ctx) => _RedisRenameDialogContent( + initialKey: _currentKeyName, + ), + ); + if (targetName == null || targetName.trim().isEmpty) return; + final trimmedNew = targetName.trim(); + if (trimmedNew == _currentKeyName) return; + + try { + await widget.connection.selectDatabase(widget.database); + final exists = (await widget.connection.exists(trimmedNew)) > 0; + if (exists && mounted) { + final confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisRename, + targetName: trimmedNew, + commandPreview: 'RENAME $_currentKeyName $trimmedNew', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; + } + + await widget.connection.rename(_cmdKey, trimmedNew); + if (!mounted) return; + final newBulk = RedisBulkValue.fromReply(trimmedNew); + setState(() { + _currentKeyName = trimmedNew; + _currentKeyArg = trimmedNew; + _success = 'Key renamed to $trimmedNew'; + }); + _clearSuccessAfterDelay(); + widget.onKeyRenamed?.call(newBulk); + } catch (e) { + if (mounted) { + setState(() => _error = 'Rename failed: $e'); + } + } + } + Future _deleteKey() async { if (widget.isReadOnly) return; final confirmed = await confirmDestructiveAction( context: context, type: DestructiveSqlType.redisDel, - targetName: '${widget.keyName} ($_effectiveType)', - commandPreview: 'DEL ${widget.keyName}', + targetName: '$_currentKeyName ($_effectiveType)', + commandPreview: 'DEL $_currentKeyName', connectionName: widget.connection.name, ); if (!mounted || !confirmed) return; @@ -571,7 +630,7 @@ class _RedisKeyEditorState extends material.State { const Gap(10), material.Expanded( child: material.Text( - widget.keyName, + _currentKeyName, overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( @@ -600,6 +659,24 @@ class _RedisKeyEditorState extends material.State { ), ), const Gap(8), + if (!widget.isReadOnly) ...[ + material.Tooltip( + message: 'Rename key', + child: material.InkWell( + onTap: _renameKey, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon( + material.Icons.drive_file_rename_outline_rounded, + size: 16, + color: scs.mutedForeground, + ), + ), + ), + ), + const Gap(4), + ], if (!widget.isReadOnly) material.Tooltip( message: 'Set TTL', @@ -1118,6 +1195,69 @@ class _RedisTtlDialogContentState } } +class _RedisRenameDialogContent extends material.StatefulWidget { + const _RedisRenameDialogContent({ + required this.initialKey, + }); + + final String initialKey; + + @override + material.State<_RedisRenameDialogContent> createState() => + _RedisRenameDialogContentState(); +} + +class _RedisRenameDialogContentState + extends material.State<_RedisRenameDialogContent> { + late final material.TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController(text: widget.initialKey); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + return AlertDialog( + title: const Text('Rename Key'), + content: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Enter new key name').muted().small(), + const Gap(8), + TextField( + controller: _controller, + placeholder: const Text('New key name'), + ), + ], + ), + actions: [ + GhostButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + PrimaryButton( + onPressed: () { + final val = _controller.text.trim(); + if (val.isNotEmpty) { + Navigator.of(context).pop(val); + } + }, + child: const Text('Rename'), + ), + ], + ); + } +} + // ─── Shared row widgets ───────────────────────────────────────────────────── class _FieldRow extends StatelessWidget { diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart index 9bc5788..cf54649 100644 --- a/test/features/redis/redis_key_editor_test.dart +++ b/test/features/redis/redis_key_editor_test.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/features/redis/redis_key_editor.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; import '../../support/querya_theme_test_shell.dart'; @@ -13,6 +15,7 @@ void main() { required RedisConnectionTestFake fake, required bool isReadOnly, material.VoidCallback? onKeyDeleted, + material.ValueChanged? onKeyRenamed, material.Size size = const material.Size(800, 600), String keyType = 'string', String keyName = 'session:1', @@ -30,6 +33,7 @@ void main() { keyType: keyType, isReadOnly: isReadOnly, onKeyDeleted: onKeyDeleted, + onKeyRenamed: onKeyRenamed, ), ), ), @@ -261,4 +265,108 @@ void main() { expect(find.text('[255, 254, 1]'), findsNothing); await fake.disconnect(); }); + + testWidgets('RedisKeyEditor hides Rename key when read-only', (tester) async { + final fake = RedisConnectionTestFake(getResult: 'hello'); + await fake.connect(); + + await pumpEditor(tester, fake: fake, isReadOnly: true); + + expect(find.byTooltip('Rename key'), findsNothing); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor renames key when target does not exist', + (tester) async { + final fake = RedisConnectionTestFake(getResult: 'hello'); + await fake.connect(); + RedisBulkValue? renamedKey; + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + onKeyRenamed: (k) => renamedKey = k, + ); + + expect(find.byTooltip('Rename key'), findsOneWidget); + await tester.tap(find.byTooltip('Rename key')); + await tester.pumpAndSettle(); + + expect(find.text('Rename Key'), findsOneWidget); + // Enter new key name + final textField = find.byType(shadcn.TextField); + await tester.enterText(textField, 'session:renamed'); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Rename')); + await tester.pumpAndSettle(); + + expect(find.text('session:renamed'), findsOneWidget); + expect(renamedKey?.label, 'session:renamed'); + expect(fake.sentCommands.contains('EXISTS'), isTrue); + expect(fake.sentCommands.contains('RENAME'), isTrue); + await tester.pump(const Duration(seconds: 3)); + await fake.disconnect(); + }); + + testWidgets( + 'RedisKeyEditor prompts overwrite confirmation when target key exists', + (tester) async { + final fake = RedisConnectionTestFake( + getResult: 'hello', + firstScanKeys: const ['session:exists'], + ); + await fake.connect(); + RedisBulkValue? renamedKey; + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + onKeyRenamed: (k) => renamedKey = k, + ); + + await tester.tap(find.byTooltip('Rename key')); + await tester.pumpAndSettle(); + + final textField = find.byType(shadcn.TextField); + await tester.enterText(textField, 'session:exists'); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Rename')); + await tester.pumpAndSettle(); + + // Destructive confirmation dialog should appear + expect(find.text('RENAME session:1 session:exists'), findsOneWidget); + + // Cancel first + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(renamedKey, isNull); + expect(find.text('session:1'), findsOneWidget); + + // Now try again and confirm + await tester.tap(find.byTooltip('Rename key')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(shadcn.TextField), 'session:exists'); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Rename')); + await tester.pumpAndSettle(); + + expect(find.text('RENAME session:1 session:exists'), findsOneWidget); + // Destructive dialog requires acknowledging checkbox for high risk + await tester.tap(find.byType(material.Checkbox)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + + expect(renamedKey?.label, 'session:exists'); + expect(find.text('session:exists'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await fake.disconnect(); + }); }