From 310538460d2b24c998b0e28283ba65b0597e4a57 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sun, 10 May 2026 14:40:01 +0200 Subject: [PATCH] feat: improve Undo Log to support undoing any action from history --- .gitignore | 2 ++ lib/core/services/undo_service.dart | 16 ++++++++++++--- lib/ui/screens/undo_log_screen.dart | 23 +++++++--------------- test/unit/undo_service_test.dart | 30 +++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 6add3a5..26d3fcd 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,5 @@ snap/ *.log runner-data/ sharedinbox-runner/runner-data/ +.gemini/ +.rustup/ diff --git a/lib/core/services/undo_service.dart b/lib/core/services/undo_service.dart index 1ef8987..336bdaf 100644 --- a/lib/core/services/undo_service.dart +++ b/lib/core/services/undo_service.dart @@ -21,11 +21,21 @@ class UndoService extends StateNotifier> { state = []; } - Future undo() async { + Future undo({String? actionId}) async { if (state.isEmpty) return; - final action = state.last; - state = state.sublist(0, state.length - 1); + final UndoAction action; + if (actionId == null) { + action = state.last; + state = state.sublist(0, state.length - 1); + } else { + try { + action = state.firstWhere((a) => a.id == actionId); + state = state.where((a) => a.id != actionId).toList(); + } catch (e) { + return; // Action not found + } + } final repo = _ref.read(emailRepositoryProvider); diff --git a/lib/ui/screens/undo_log_screen.dart b/lib/ui/screens/undo_log_screen.dart index 471a4ec..37a6df1 100644 --- a/lib/ui/screens/undo_log_screen.dart +++ b/lib/ui/screens/undo_log_screen.dart @@ -56,22 +56,13 @@ class _UndoActionTile extends ConsumerWidget { subtitle: Text(_timeFmt.format(action.timestamp.toLocal())), trailing: TextButton( onPressed: () async { - // In a real log, "undoing" an old action might be complex - // if subsequent actions conflict. For now, we only support - // undoing the LATEST action via the global UndoService. - // To keep it simple, we check if this is the latest action. - final history = ref.read(undoServiceProvider); - final latest = history.isNotEmpty ? history.last : null; - if (latest?.id == action.id) { - await ref.read(undoServiceProvider.notifier).undo(); - } else { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Only the latest action can be undone.'), - ), - ); - } + await ref + .read(undoServiceProvider.notifier) + .undo(actionId: action.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Action undone.')), + ); } }, child: const Text('Undo'), diff --git a/test/unit/undo_service_test.dart b/test/unit/undo_service_test.dart index 753c764..2334a2c 100644 --- a/test/unit/undo_service_test.dart +++ b/test/unit/undo_service_test.dart @@ -88,6 +88,36 @@ void main() { verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1); }); + test('undo with actionId removes and undos specific action', () async { + final action1 = UndoAction( + id: '1', + accountId: 'acc1', + type: UndoType.move, + emailIds: ['e1'], + sourceMailboxPath: 'INBOX', + ); + final action2 = UndoAction( + id: '2', + accountId: 'acc1', + type: UndoType.delete, + emailIds: ['e2'], + sourceMailboxPath: 'INBOX', + ); + + when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); + when(mockEmailRepo.cancelPendingChange(any, any)) + .thenAnswer((_) async => false); + + final notifier = container.read(undoServiceProvider.notifier); + notifier.pushAction(action1); + notifier.pushAction(action2); + + await notifier.undo(actionId: '1'); + expect(container.read(undoServiceProvider), [action2]); + verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1); + verifyNever(mockEmailRepo.moveEmail('e2', 'INBOX')); + }); + test('undo performs cancelPendingChange optimization', () async { final action = UndoAction( id: '1',