From f842ddb664572312032ac9f625a38f67d5d8f540 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 24 Sep 2026 07:47:17 +0300 Subject: [PATCH] fix(redis): guard string key Refresh and crumb navigation against unsaved edits Add _savedStringText to track the server-loaded value and _isStringDirty to detect unsaved edits in the string TextField. Wrap the Refresh button with _confirmDiscardStringEdits() so the user is prompted before their changes are thrown away. Introduce RedisKeyEditorController with canNavigateAway() so that RedisExplorerView can guard crumb/back navigation in the same way. _navigateToKeys() now awaits the controller before clearing the selected key. Tests: 4 new cases in redis_key_editor_test.dart covering clean/dirty Refresh and clean/dirty canNavigateAway(). Closes #873 --- lib/features/redis/redis_explorer_view.dart | 21 ++- lib/features/redis/redis_key_editor.dart | 67 +++++++- .../features/redis/redis_key_editor_test.dart | 144 ++++++++++++++++++ 3 files changed, 226 insertions(+), 6 deletions(-) diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index 1503893..8456957 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -57,6 +57,9 @@ class _RedisExplorerViewState extends material.State { RedisBulkValue? _selectedKey; String? _selectedKeyType; + // Key used to reach _RedisKeyEditorState.canNavigateAway() from the explorer. + RedisKeyEditorController? _keyEditorController; + @override void initState() { super.initState(); @@ -136,19 +139,27 @@ class _RedisExplorerViewState extends material.State { setState(() { _selectedKey = key; _selectedKeyType = type; + _keyEditorController = RedisKeyEditorController(); }); } - void _navigateToKeys() { + Future _navigateToKeys() async { + if (_keyEditorController != null && + !await _keyEditorController!.canNavigateAway()) { + return; + } + if (!mounted) return; setState(() { _selectedKey = null; _selectedKeyType = null; + _keyEditorController = null; }); } void _onKeyRenamed(RedisBulkValue newKey) { setState(() { _selectedKey = newKey; + _keyEditorController = RedisKeyEditorController(); _refreshEpoch++; }); } @@ -175,7 +186,7 @@ class _RedisExplorerViewState extends material.State { } switch (crumb.level) { case _Level.keys: - _navigateToKeys(); + unawaited(_navigateToKeys()); case _Level.key: break; case _Level.stats: @@ -277,16 +288,18 @@ class _RedisExplorerViewState extends material.State { material.Widget _buildContent(RedisConnection conn) { // Key editor if (_selectedKey != null) { + final controller = _keyEditorController ??= RedisKeyEditorController(); return RedisKeyEditor( key: ValueKey( 'key_${widget.database}_${_selectedKey!.label}_$_refreshEpoch'), + controller: controller, connection: conn, database: widget.database, keyName: _selectedKey!.label, keyArg: _selectedKey!.commandArg, keyType: _selectedKeyType ?? 'unknown', - onBack: _navigateToKeys, - onKeyDeleted: _navigateToKeys, + onBack: () => unawaited(_navigateToKeys()), + onKeyDeleted: () => unawaited(_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 8a6abd0..b6dd6cc 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -16,6 +16,7 @@ class RedisKeyEditor extends material.StatefulWidget { required this.keyName, required this.keyType, this.keyArg, + this.controller, this.onBack, this.onKeyDeleted, this.onKeyRenamed, @@ -29,6 +30,8 @@ class RedisKeyEditor extends material.StatefulWidget { /// Wire key for GET/SET/DEL when [keyName] is only a UTF-8/hex label. final Object? keyArg; + /// Optional controller for querying dirty-state from the parent widget. + final RedisKeyEditorController? controller; final VoidCallback? onBack; final VoidCallback? onKeyDeleted; final ValueChanged? onKeyRenamed; @@ -38,6 +41,22 @@ class RedisKeyEditor extends material.StatefulWidget { material.State createState() => _RedisKeyEditorState(); } +/// Controller that lets the parent widget ask whether it is safe to navigate +/// away from this editor (i.e. no unsaved string value edits). +class RedisKeyEditorController { + _RedisKeyEditorState? _state; + + /// Returns `true` when navigation is safe (not dirty, or user confirmed + /// discarding their edits). + Future canNavigateAway() => + _state?._confirmDiscardStringEdits() ?? Future.value(true); + + void _attach(_RedisKeyEditorState state) => _state = state; + void _detach(_RedisKeyEditorState state) { + if (_state == state) _state = null; + } +} + class _RedisKeyEditorState extends material.State { bool _loading = true; String? _error; @@ -47,6 +66,8 @@ class _RedisKeyEditorState extends material.State { // String value RedisBulkValue? _stringValue; final _stringController = material.TextEditingController(); + /// The text that was last loaded from the server (null = not yet loaded). + String? _savedStringText; // Hash value Map _hashValue = {}; @@ -75,6 +96,7 @@ class _RedisKeyEditorState extends material.State { @override void initState() { super.initState(); + widget.controller?._attach(this); _currentKeyName = widget.keyName; _currentKeyArg = widget.keyArg; _effectiveType = _normalizedType(widget.keyType); @@ -93,6 +115,7 @@ class _RedisKeyEditorState extends material.State { @override void dispose() { + widget.controller?._detach(this); _stringController.dispose(); _newFieldController.dispose(); _newValueController.dispose(); @@ -106,6 +129,41 @@ class _RedisKeyEditorState extends material.State { _stringValue != null && !_stringValue!.isUtf8; + /// True when the user has unsaved edits in the string text field. + bool get _isStringDirty => + _effectiveType == 'string' && + !_stringIsBinary && + _savedStringText != null && + _stringController.text != _savedStringText; + + /// Shows a discard-confirmation dialog if there are unsaved string edits. + /// Returns `true` when it is safe to proceed (either not dirty or confirmed). + Future _confirmDiscardStringEdits() async { + if (!_isStringDirty) return true; + if (!mounted) return false; + final confirmed = await showAppDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Unsaved changes'), + content: const Text( + 'You have unsaved edits to this string value. ' + 'Do you want to discard them?', + ), + actions: [ + OutlineButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Discard'), + ), + ], + ), + ); + return confirmed == true; + } + String _normalizedType(String type) { final t = type.trim().toLowerCase(); if (t.isEmpty) return 'unknown'; @@ -141,7 +199,9 @@ class _RedisKeyEditorState extends material.State { switch (_effectiveType) { case 'string': _stringValue = await widget.connection.get(_cmdKey); - _stringController.text = _stringValue?.text ?? ''; + final loaded = _stringValue?.text ?? ''; + _stringController.text = loaded; + _savedStringText = loaded; case 'hash': case 'list': case 'set': @@ -730,7 +790,10 @@ class _RedisKeyEditorState extends material.State { material.Tooltip( message: 'Refresh', child: material.InkWell( - onTap: _load, + onTap: () async { + if (!await _confirmDiscardStringEdits()) return; + await _load(); + }, borderRadius: material.BorderRadius.circular(4), child: material.Padding( padding: const material.EdgeInsets.all(4), diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart index 51b9844..ed4de7c 100644 --- a/test/features/redis/redis_key_editor_test.dart +++ b/test/features/redis/redis_key_editor_test.dart @@ -473,4 +473,148 @@ void main() { expect(find.text('No items'), findsOneWidget); await fake.disconnect(); }); + testWidgets( + 'RedisKeyEditor Refresh proceeds without dialog when string is unedited', + (tester) async { + final fake = RedisConnectionTestFake(getResult: 'original'); + await fake.connect(); + + await pumpEditor(tester, fake: fake, isReadOnly: false); + // One GET on initial load + expect(fake.sentCommands.where((c) => c == 'GET').length, 1); + + await tester.tap(find.byTooltip('Refresh')); + await tester.pumpAndSettle(); + + // No discard dialog, GET called again + expect(find.text('Unsaved changes'), findsNothing); + expect(fake.sentCommands.where((c) => c == 'GET').length, 2); + await fake.disconnect(); + }); + + testWidgets( + 'RedisKeyEditor Refresh shows discard dialog when string is dirty', + (tester) async { + final fake = RedisConnectionTestFake(getResult: 'original'); + await fake.connect(); + + await pumpEditor(tester, fake: fake, isReadOnly: false); + expect(fake.sentCommands.where((c) => c == 'GET').length, 1); + + // Edit the string value + final textField = find.byType(material.TextField).first; + await tester.tap(textField); + await tester.enterText(textField, 'edited'); + await tester.pump(); + + await tester.tap(find.byTooltip('Refresh')); + await tester.pumpAndSettle(); + + // Discard dialog should appear + expect(find.text('Unsaved changes'), findsOneWidget); + + // Cancel — no reload + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(fake.sentCommands.where((c) => c == 'GET').length, 1); + + // Try refresh again and confirm discard + await tester.tap(find.byTooltip('Refresh')); + await tester.pumpAndSettle(); + expect(find.text('Unsaved changes'), findsOneWidget); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + + expect(fake.sentCommands.where((c) => c == 'GET').length, 2); + await fake.disconnect(); + }); + + testWidgets( + 'RedisKeyEditorController.canNavigateAway returns true when string is clean', + (tester) async { + final fake = RedisConnectionTestFake(getResult: 'hello'); + await fake.connect(); + final controller = RedisKeyEditorController(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: RedisKeyEditor( + connection: fake, + database: 0, + keyName: 'session:1', + keyType: 'string', + isReadOnly: false, + controller: controller, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final canLeave = await controller.canNavigateAway(); + expect(canLeave, isTrue); + await fake.disconnect(); + }); + + testWidgets( + 'RedisKeyEditorController.canNavigateAway shows dialog when string is dirty', + (tester) async { + final fake = RedisConnectionTestFake(getResult: 'hello'); + await fake.connect(); + final controller = RedisKeyEditorController(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: RedisKeyEditor( + connection: fake, + database: 0, + keyName: 'session:1', + keyType: 'string', + isReadOnly: false, + controller: controller, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Dirty the field + final textField = find.byType(material.TextField).first; + await tester.tap(textField); + await tester.enterText(textField, 'dirty value'); + await tester.pump(); + + // Trigger navigation guard — dialog should appear + bool? result; + final future = controller.canNavigateAway().then((v) => result = v); + await tester.pumpAndSettle(); + + expect(find.text('Unsaved changes'), findsOneWidget); + + // Dismiss with Cancel — guard returns false + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + await future; + expect(result, isFalse); + + // Trigger again — dismiss with Discard — guard returns true + bool? result2; + final future2 = controller.canNavigateAway().then((v) => result2 = v); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + await future2; + expect(result2, isTrue); + await fake.disconnect(); + }); }