Merge branch 'feat/issue-7-undo-log'

This commit is contained in:
Thomas SharedInbox
2026-05-10 14:45:11 +02:00
4 changed files with 52 additions and 19 deletions
+2
View File
@@ -91,3 +91,5 @@ snap/
*.log
runner-data/
sharedinbox-runner/runner-data/
.gemini/
.rustup/
+13 -3
View File
@@ -21,11 +21,21 @@ class UndoService extends StateNotifier<List<UndoAction>> {
state = [];
}
Future<void> undo() async {
Future<void> 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);
+7 -16
View File
@@ -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'),
+30
View File
@@ -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',