diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index b2b15a7..b28ad7b 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -458,6 +458,16 @@ class RedisConnection { await sendCommand(['RPUSH', redisCommandArg(key), value]); } + /// LINDEX key index. + Future lindex(Object key, int index) async { + final result = + await sendCommand(['LINDEX', redisCommandArg(key), index]); + if (result == null || _isRedisNil(result)) { + return null; + } + return RedisBulkValue.fromReply(result); + } + /// LSET key index value. Future lset(Object key, int index, String value) async { _assertWritable(); @@ -618,7 +628,7 @@ class RedisConnectionTestFake extends RedisConnection { this.secondScanKeys = const [], this.dbSizeResult = 2, this.getResult, - List? listItems, + List? listItems, this.llenResult, this.hashFirstPage = const {}, this.hashSecondPage = const {}, @@ -633,7 +643,7 @@ class RedisConnectionTestFake extends RedisConnection { this.getBytesResult, this.binaryScanKeys = const >[], }) : listItems = - listItems != null ? List.from(listItems) : [], + listItems != null ? List.from(listItems) : [], super( id: -1, name: 'test-fake', @@ -645,7 +655,7 @@ class RedisConnectionTestFake extends RedisConnection { final List secondScanKeys; final int dbSizeResult; final String? getResult; - final List listItems; + final List listItems; final int? llenResult; final Map hashFirstPage; final Map hashSecondPage; @@ -795,6 +805,12 @@ class RedisConnectionTestFake extends RedisConnection { case 'RPUSH': listItems.add(args[2].toString()); return 'OK'; + case 'LINDEX': + final lindexIdx = int.tryParse(args[2].toString()) ?? 0; + if (lindexIdx >= 0 && lindexIdx < listItems.length) { + return listItems[lindexIdx]; + } + return null; case 'LSET': final idx = int.tryParse(args[2].toString()) ?? 0; final val = args[3].toString(); @@ -832,7 +848,7 @@ class RedisConnectionTestFake extends RedisConnection { } } - List _sliceList(List items, List args) { + List _sliceList(List items, List args) { if (items.isEmpty) return const []; final start = int.tryParse(args[2].toString()) ?? 0; var stop = int.tryParse(args[3].toString()) ?? -1; diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index b6dd6cc..ecd2079 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -291,7 +291,16 @@ class _RedisKeyEditorState extends material.State { start, start + redisCollectionPageSize - 1, ); - _listValue.addAll(chunk); + if (!widget.isReadOnly) { + for (final item in chunk) { + if (_isOrphanedDeleteSentinel(item)) { + widget.connection.lrem(_cmdKey, 0, item.commandArg).ignore(); + } + } + } + final cleanChunk = + chunk.where((item) => !_isOrphanedDeleteSentinel(item)).toList(); + _listValue.addAll(cleanChunk); _hasMore = _listValue.length < _collectionTotal; case 'set': if (reset) { @@ -477,10 +486,24 @@ class _RedisKeyEditorState extends material.State { } } - Future _listSet(int index, String newValue) async { + Future _listSet( + int index, + String newValue, { + RedisBulkValue? expectedCurrent, + }) async { if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); + if (expectedCurrent != null) { + final current = await widget.connection.lindex(_cmdKey, index); + if (current == null || current != expectedCurrent) { + await _load(); + if (!mounted) return; + setState(() => _error = + 'List item at index $index changed concurrently; reloaded latest.'); + return; + } + } await widget.connection.lset(_cmdKey, index, newValue); await _load(); } catch (e) { @@ -501,10 +524,25 @@ class _RedisKeyEditorState extends material.State { if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); + final current = await widget.connection.lindex(_cmdKey, index); + if (current == null || current != item) { + await _load(); + if (!mounted) return; + setState(() => _error = + 'List item at index $index changed concurrently; reloaded latest.'); + return; + } final sentinel = '__QUERYA_DEL_${DateTime.now().microsecondsSinceEpoch}__'; - await widget.connection.lset(_cmdKey, index, sentinel); - await widget.connection.lrem(_cmdKey, 1, sentinel); + try { + await widget.connection.lset(_cmdKey, index, sentinel); + await widget.connection.lrem(_cmdKey, 1, sentinel); + } catch (e) { + try { + await widget.connection.lrem(_cmdKey, 1, sentinel); + } catch (_) {} + rethrow; + } await _load(); } catch (e) { if (!mounted) return; @@ -512,6 +550,13 @@ class _RedisKeyEditorState extends material.State { } } + bool _isOrphanedDeleteSentinel(RedisBulkValue value) { + final text = value.text; + return text != null && + text.startsWith('__QUERYA_DEL_') && + text.endsWith('__'); + } + // Set operations Future _setAdd(String member) async { if (widget.isReadOnly) return; @@ -1044,19 +1089,22 @@ class _RedisKeyEditorState extends material.State { itemBuilder: (context, i) => _IndexedValueRow( index: i, value: _listValue[i].label, - onEdit: widget.isReadOnly + isBinary: !_listValue[i].isUtf8, + onEdit: widget.isReadOnly || !_listValue[i].isUtf8 ? null : () async { + final initialText = + _listValue[i].text ?? _listValue[i].label; final edited = await showAppDialog( context: context, builder: (ctx) => _RedisEditListDialogContent( index: i, - initialValue: _listValue[i].label, + initialValue: initialText, ), ); - if (edited != null && - edited != _listValue[i].label) { - await _listSet(i, edited); + if (edited != null && edited != initialText) { + await _listSet(i, edited, + expectedCurrent: _listValue[i]); } }, onDelete: widget.isReadOnly @@ -1510,6 +1558,7 @@ class _IndexedValueRow extends StatelessWidget { const _IndexedValueRow({ required this.index, required this.value, + this.isBinary = false, this.onEdit, this.onDelete, required this.colorScheme, @@ -1518,6 +1567,7 @@ class _IndexedValueRow extends StatelessWidget { final int index; final String value; + final bool isBinary; final VoidCallback? onEdit; final VoidCallback? onDelete; final ColorScheme colorScheme; @@ -1557,6 +1607,25 @@ class _IndexedValueRow extends StatelessWidget { ), ), ), + if (isBinary) ...[ + const Gap(8), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 5, vertical: 1.5), + decoration: material.BoxDecoration( + color: shadcnCs.muted, + borderRadius: material.BorderRadius.circular(3), + ), + child: Text( + 'binary', + style: material.TextStyle( + fontSize: 10, + fontFamily: 'monospace', + color: shadcnCs.mutedForeground, + ), + ), + ), + ], if (onEdit != null) ...[ const Gap(8), material.Tooltip( diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart index ed4de7c..5367f07 100644 --- a/test/features/redis/redis_key_editor_test.dart +++ b/test/features/redis/redis_key_editor_test.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; @@ -473,6 +475,138 @@ void main() { expect(find.text('No items'), findsOneWidget); await fake.disconnect(); }); + + testWidgets( + 'RedisKeyEditor list item edit aborts when element changed concurrently', + (tester) async { + final fake = RedisConnectionTestFake( + listItems: ['task1'], + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + keyType: 'list', + keyName: 'jobs', + ); + + expect(find.text('task1'), findsOneWidget); + await tester.tap(find.byTooltip('Edit item')); + await tester.pumpAndSettle(); + + // Concurrent writer modifies item at index 0 before user saves + fake.listItems[0] = 'task1_concurrent_edit'; + + final field = find.descendant( + of: find.byType(shadcn.AlertDialog), + matching: find.byType(shadcn.TextField), + ); + await tester.enterText(field, 'task1_my_edit'); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // LSET should not have overwritten the concurrent edit + expect(fake.listItems[0], 'task1_concurrent_edit'); + expect( + find.textContaining('changed concurrently; reloaded latest.'), + findsOneWidget, + ); + expect(find.text('task1_concurrent_edit'), findsOneWidget); + await fake.disconnect(); + }); + + testWidgets( + 'RedisKeyEditor list item delete aborts when element changed concurrently', + (tester) async { + final fake = RedisConnectionTestFake( + listItems: ['task1'], + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + keyType: 'list', + keyName: 'jobs', + ); + + await tester.tap(find.byTooltip('Delete item')); + await tester.pumpAndSettle(); + + // Concurrent writer modifies item at index 0 before user confirms deletion + fake.listItems[0] = 'task1_concurrent_shift'; + + await tester.tap(find.byType(material.Checkbox)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + + // Deletion aborted; concurrent item preserved + expect(fake.listItems, ['task1_concurrent_shift']); + expect( + find.textContaining('changed concurrently; reloaded latest.'), + findsOneWidget, + ); + expect(find.text('task1_concurrent_shift'), findsOneWidget); + await fake.disconnect(); + }); + + testWidgets( + 'RedisKeyEditor binary list item hides edit button and shows binary badge', + (tester) async { + final fake = RedisConnectionTestFake( + listItems: [ + Uint8List.fromList([0x00, 0xff, 0xfe]), + ], + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + keyType: 'list', + keyName: 'bin_jobs', + ); + + expect(find.text('binary'), findsOneWidget); + expect(find.byTooltip('Edit item'), findsNothing); + expect(find.byTooltip('Delete item'), findsOneWidget); + await fake.disconnect(); + }); + + testWidgets( + 'RedisKeyEditor cleans up orphaned delete sentinel on list load', + (tester) async { + final fake = RedisConnectionTestFake( + listItems: [ + 'normal_item', + '__QUERYA_DEL_123456789__', + 'another_item', + ], + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + keyType: 'list', + keyName: 'clean_jobs', + ); + + expect(find.text('normal_item'), findsOneWidget); + expect(find.text('another_item'), findsOneWidget); + expect(find.text('__QUERYA_DEL_123456789__'), findsNothing); + expect(fake.sentCommands.contains('LREM'), isTrue); + await fake.disconnect(); + }); + testWidgets( 'RedisKeyEditor Refresh proceeds without dialog when string is unedited', (tester) async {