Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions lib/features/redis/redis_explorer_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ class _RedisExplorerViewState extends material.State<RedisExplorerView> {
RedisBulkValue? _selectedKey;
String? _selectedKeyType;

// Key used to reach _RedisKeyEditorState.canNavigateAway() from the explorer.
RedisKeyEditorController? _keyEditorController;

@override
void initState() {
super.initState();
Expand Down Expand Up @@ -136,19 +139,27 @@ class _RedisExplorerViewState extends material.State<RedisExplorerView> {
setState(() {
_selectedKey = key;
_selectedKeyType = type;
_keyEditorController = RedisKeyEditorController();
});
}

void _navigateToKeys() {
Future<void> _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++;
});
}
Expand All @@ -175,7 +186,7 @@ class _RedisExplorerViewState extends material.State<RedisExplorerView> {
}
switch (crumb.level) {
case _Level.keys:
_navigateToKeys();
unawaited(_navigateToKeys());
case _Level.key:
break;
case _Level.stats:
Expand Down Expand Up @@ -277,16 +288,18 @@ class _RedisExplorerViewState extends material.State<RedisExplorerView> {
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,
);
Expand Down
67 changes: 65 additions & 2 deletions lib/features/redis/redis_key_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<RedisBulkValue>? onKeyRenamed;
Expand All @@ -38,6 +41,22 @@ class RedisKeyEditor extends material.StatefulWidget {
material.State<RedisKeyEditor> 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<bool> 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<RedisKeyEditor> {
bool _loading = true;
String? _error;
Expand All @@ -47,6 +66,8 @@ class _RedisKeyEditorState extends material.State<RedisKeyEditor> {
// 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<RedisBulkValue, RedisBulkValue> _hashValue = {};
Expand Down Expand Up @@ -75,6 +96,7 @@ class _RedisKeyEditorState extends material.State<RedisKeyEditor> {
@override
void initState() {
super.initState();
widget.controller?._attach(this);
_currentKeyName = widget.keyName;
_currentKeyArg = widget.keyArg;
_effectiveType = _normalizedType(widget.keyType);
Expand All @@ -93,6 +115,7 @@ class _RedisKeyEditorState extends material.State<RedisKeyEditor> {

@override
void dispose() {
widget.controller?._detach(this);
_stringController.dispose();
_newFieldController.dispose();
_newValueController.dispose();
Expand All @@ -106,6 +129,41 @@ class _RedisKeyEditorState extends material.State<RedisKeyEditor> {
_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<bool> _confirmDiscardStringEdits() async {
if (!_isStringDirty) return true;
if (!mounted) return false;
final confirmed = await showAppDialog<bool>(
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';
Expand Down Expand Up @@ -141,7 +199,9 @@ class _RedisKeyEditorState extends material.State<RedisKeyEditor> {
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':
Expand Down Expand Up @@ -730,7 +790,10 @@ class _RedisKeyEditorState extends material.State<RedisKeyEditor> {
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),
Expand Down
144 changes: 144 additions & 0 deletions test/features/redis/redis_key_editor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
Loading