diff --git a/apps/labfox/lib/features/comments/data/comments_repository.dart b/apps/labfox/lib/features/comments/data/comments_repository.dart index c3467aaf..900d43a6 100644 --- a/apps/labfox/lib/features/comments/data/comments_repository.dart +++ b/apps/labfox/lib/features/comments/data/comments_repository.dart @@ -35,6 +35,14 @@ class CommentsRepository { String? commitMessage, }) => _client.suggestions.apply(suggestionId, commitMessage: commitMessage); + Future> applySuggestions({ + required List suggestionIds, + String? commitMessage, + }) => _client.suggestions.applyBatch( + suggestionIds, + commitMessage: commitMessage, + ); + Future replyToDiscussion({ required int projectId, required int iid, diff --git a/apps/labfox/lib/features/comments/data/suggestion_application.dart b/apps/labfox/lib/features/comments/data/suggestion_application.dart index e260faa9..1b971d0b 100644 --- a/apps/labfox/lib/features/comments/data/suggestion_application.dart +++ b/apps/labfox/lib/features/comments/data/suggestion_application.dart @@ -1,5 +1,11 @@ import 'package:gitlab_models/gitlab_models.dart'; +typedef SuggestionTarget = ({ + String discussionId, + Note note, + Suggestion suggestion, +}); + /// Patch eligibility is not a permission check: GitLab enforces write access. bool canApplySuggestion(Note note, Suggestion suggestion) => !note.isSystem && @@ -53,3 +59,37 @@ bool confirmsAppliedSuggestion(Suggestion returned, Suggestion expected) => (returned.fromContent == null || returned.fromContent == expected.fromContent) && (returned.toContent == null || returned.toContent == expected.toContent); + +List applicableSuggestionTargets(List groups) { + final counts = {}; + for (final group in groups) { + for (final note in group.notes) { + for (final suggestion in note.suggestions ?? []) { + counts.update(suggestion.id, (v) => v + 1, ifAbsent: () => 1); + } + } + } + return List.unmodifiable([ + for (final group in groups) + for (final note in group.notes) + for (final suggestion in note.suggestions ?? []) + if (counts[suggestion.id] == 1 && + canApplySuggestion(note, suggestion)) + (discussionId: group.id, note: note, suggestion: suggestion), + ]); +} + +bool containsApplicableSuggestionTargets( + List groups, + List targets, +) => + targets.length >= 2 && + targets.map((t) => t.suggestion.id).toSet().length == targets.length && + targets.every( + (t) => containsApplicableSuggestion( + groups, + discussionId: t.discussionId, + note: t.note, + suggestion: t.suggestion, + ), + ); diff --git a/apps/labfox/lib/features/merge_requests/presentation/controllers/mr_discussions_controller.dart b/apps/labfox/lib/features/merge_requests/presentation/controllers/mr_discussions_controller.dart index 9f1f8db1..d0a04493 100644 --- a/apps/labfox/lib/features/merge_requests/presentation/controllers/mr_discussions_controller.dart +++ b/apps/labfox/lib/features/merge_requests/presentation/controllers/mr_discussions_controller.dart @@ -214,12 +214,96 @@ class MrDiscussionsController } } + bool _currentSuggestions(List targets) => + !state.isLoading && + !state.hasError && + state.hasValue && + containsApplicableSuggestionTargets(state.value!.items, targets); + + /// Confirms all selected patches before one batch; never applies a subset. + Future applySuggestions( + List selection, { + String? commitMessage, + bool Function()? isCurrent, + }) async { + final targets = List.unmodifiable(selection); + if (_posting || + _loadingMore || + !_currentSuggestions(targets) || + isCurrent?.call() == false) { + return false; + } + final generation = _generation; + _posting = true; + bool current() => generation == _generation && isCurrent?.call() != false; + try { + final repo = await _repository(); + if (!current()) return false; + if (repo == null) { + throw const GitLabAuthException('No authenticated account.'); + } + if (!_currentSuggestions(targets)) return false; + final fresh = await _readDiscussions( + repo, + targets.map((t) => t.discussionId).toSet().toList(), + current, + ); + if (!current() || fresh == null) return false; + if (!_currentSuggestions(targets) || + !containsApplicableSuggestionTargets(fresh, targets)) { + throw const GitLabConflictException( + 'Suggestions changed. Reload before applying.', + ); + } + final returned = await repo.applySuggestions( + suggestionIds: targets.map((t) => t.suggestion.id).toList(), + commitMessage: commitMessage, + ); + if (!current()) return false; + final byId = {for (final s in returned) s.id: s}; + if (returned.length != targets.length || + byId.length != targets.length || + !targets.every( + (t) => + byId[t.suggestion.id] != null && + confirmsAppliedSuggestion(byId[t.suggestion.id]!, t.suggestion), + )) { + throw const GitLabServerException( + 'Unconfirmed suggestion batch application.', + ); + } + ref.invalidate(mergeRequestControllerProvider(arg)); + ref.invalidate(mrReviewSnapshotControllerProvider(arg)); + ref.invalidateSelf(); + return true; + } catch (_) { + if (!current()) return false; + rethrow; + } finally { + if (generation == _generation) _posting = false; + } + } + /// Explicit read-only recovery never retries an application automatically. Future inspectDiscussion( String discussionId, { bool Function()? isCurrent, + }) async => + (await inspectDiscussions([discussionId], isCurrent: isCurrent))?.single; + + /// Stages every selected thread before replacing any loaded cache entry. + Future?> inspectDiscussions( + List discussionIds, { + bool Function()? isCurrent, }) async { - if (_posting || _loadingMore || isCurrent?.call() == false) return null; + final ids = List.unmodifiable(discussionIds); + if (_posting || + _loadingMore || + isCurrent?.call() == false || + ids.isEmpty || + ids.toSet().length != ids.length) { + return null; + } final generation = _generation; _posting = true; bool current() => generation == _generation && isCurrent?.call() != false; @@ -229,21 +313,15 @@ class MrDiscussionsController if (repo == null) { throw const GitLabAuthException('No authenticated account.'); } - final fresh = await repo.discussion( - projectId: arg.projectId, - iid: arg.iid, - discussionId: discussionId, - ); - if (!current()) return null; - if (fresh.id != discussionId) { - throw const GitLabServerException('Invalid discussion response.'); - } + final fresh = await _readDiscussions(repo, ids, current); + if (!current() || fresh == null) return null; final page = state.valueOrNull; if (page != null && !state.isLoading && !state.hasError) { + final byId = {for (final g in fresh) g.id: g}; state = AsyncData( Paginated( items: List.unmodifiable( - page.items.map((g) => g.id == discussionId ? fresh : g), + page.items.map((g) => byId[g.id] ?? g), ), nextPage: page.nextPage, total: page.total, @@ -260,6 +338,28 @@ class MrDiscussionsController } } + Future?> _readDiscussions( + CommentsRepository repo, + List ids, + bool Function() isCurrent, + ) async { + final result = []; + for (final id in ids) { + if (!isCurrent()) return null; + final fresh = await repo.discussion( + projectId: arg.projectId, + iid: arg.iid, + discussionId: id, + ); + if (!isCurrent()) return null; + if (fresh.id != id) { + throw const GitLabServerException('Invalid discussion response.'); + } + result.add(fresh); + } + return List.unmodifiable(result); + } + Future _post( String body, { String? discussionId, diff --git a/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_discussion_thread.dart b/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_discussion_thread.dart index 046cc5dd..de64661a 100644 --- a/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_discussion_thread.dart +++ b/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_discussion_thread.dart @@ -14,6 +14,7 @@ import '../controllers/merge_requests_controllers.dart'; import '../controllers/mr_discussions_controller.dart'; import 'mr_discussion_position_context.dart'; import 'mr_suggestion_apply_dialog.dart'; +import 'mr_suggestion_batch_apply_dialog.dart'; import 'mr_suggestion_preview.dart'; /// Grouped MR replies with explicit pagination and the shared note composer. @@ -202,6 +203,49 @@ class _ConversationState extends ConsumerState<_Conversation> { } } + Future _applyBatch(List targets) async { + if (_applicationId != null || + _topPosting || + _replyId != null || + _resolutionId != null || + widget.discussions.isLoading || + widget.discussions.hasError) { + return; + } + final session = ref.read(commentsRepositoryProvider); + final view = ValueNotifier(true); + _applicationView = view; + setState(() => _applicationId = 0); + try { + final applied = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => MrSuggestionBatchApplyDialog( + resource: widget.arg, + targets: targets, + session: session, + isCurrent: () => mounted, + viewActive: view, + ), + ); + if (mounted && + applied == true && + ref.read(commentsRepositoryProvider) == session) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).mrSuggestionsApplySuccess, + ), + ), + ); + } + } finally { + _applicationView = null; + view.dispose(); + if (mounted) setState(() => _applicationId = null); + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -248,12 +292,29 @@ class _ConversationState extends ConsumerState<_Conversation> { } } } + final targets = applicableSuggestionTargets(page.items); final groups = page.items .where((group) => group.notes.any((note) => !note.isSystem)) .toList(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (targets.length >= 2) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + key: const ValueKey('mr-suggestions-batch-open'), + onPressed: + canPost && + _applicationId == null && + !_topPosting && + _replyId == null && + _resolutionId == null + ? () => _applyBatch(targets) + : null, + child: Text(l10n.mrSuggestionsBatchButton), + ), + ), if (groups.isEmpty) Text( page.hasMore ? l10n.mrDiscussionsPartial : l10n.commentsEmpty, diff --git a/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_suggestion_batch_apply_dialog.dart b/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_suggestion_batch_apply_dialog.dart new file mode 100644 index 00000000..e9e9ac07 --- /dev/null +++ b/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_suggestion_batch_apply_dialog.dart @@ -0,0 +1,375 @@ +import 'dart:async'; + +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gitlab_api/gitlab_api.dart'; +import 'package:gitlab_models/gitlab_models.dart'; +import 'package:intl/intl.dart'; + +import '../../../../l10n/app_localizations.dart'; +import '../../../comments/data/comments_repository.dart'; +import '../../../comments/data/suggestion_application.dart'; +import '../../../comments/presentation/controllers/comments_controller.dart'; +import '../controllers/merge_requests_controllers.dart'; +import '../controllers/mr_discussions_controller.dart'; +import 'mr_suggestion_preview.dart'; + +/// Selects an explicit set, reviews every patch, and recovers through reads only. +class MrSuggestionBatchApplyDialog extends ConsumerStatefulWidget { + const MrSuggestionBatchApplyDialog({ + required this.resource, + required this.targets, + required this.session, + required this.isCurrent, + required this.viewActive, + super.key, + }); + final MergeRequestRef resource; + final List targets; + final AsyncValue session; + final bool Function() isCurrent; + final ValueNotifier viewActive; + @override + ConsumerState createState() => _BatchState(); +} + +enum _Failure { uncertain, forbidden, changed } + +class _BatchState extends ConsumerState { + final _message = TextEditingController(); + final _selected = {}; + final _unavailable = {}; + late List _targets; + bool _reviewing = false; + bool _busy = false; + bool _reloading = false; + bool _needsReload = false; + bool _reloadFailed = false; + _Failure? _failure; + + @override + void initState() { + super.initState(); + _targets = List.unmodifiable(widget.targets); + widget.viewActive.addListener(_viewChanged); + } + + void _viewChanged() { + scheduleMicrotask(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + widget.viewActive.removeListener(_viewChanged); + _message.dispose(); + super.dispose(); + } + + bool _current() => + mounted && + widget.viewActive.value && + widget.isCurrent() && + ref.read(commentsRepositoryProvider) == widget.session; + + List get _selection => List.unmodifiable( + _targets.where((target) => _selected.contains(target.suggestion.id)), + ); + + Future _apply() async { + if (_busy || _needsReload || !_current()) return; + setState(() => _busy = true); + try { + final applied = await ref + .read(mrDiscussionsControllerProvider(widget.resource).notifier) + .applySuggestions( + _selection, + commitMessage: _message.text.isEmpty ? null : _message.text, + isCurrent: _current, + ); + if (!mounted || !_current()) return; + if (applied) { + Navigator.of(context).pop(true); + } else { + setState(() { + _needsReload = true; + _failure = _Failure.uncertain; + }); + } + } catch (error) { + if (!_current()) return; + setState(() { + _needsReload = true; + _failure = switch (error) { + GitLabAuthException() || + GitLabForbiddenException() => _Failure.forbidden, + GitLabConflictException() => _Failure.changed, + _ => _Failure.uncertain, + }; + }); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _reload() async { + if (_busy || !_current()) return; + final selection = _selection; + setState(() { + _busy = true; + _reloading = true; + _reloadFailed = false; + }); + try { + final fresh = await ref + .read(mrDiscussionsControllerProvider(widget.resource).notifier) + .inspectDiscussions( + selection.map((t) => t.discussionId).toSet().toList(), + isCurrent: _current, + ); + if (!_current()) return; + if (fresh == null) { + setState(() => _reloadFailed = true); + return; + } + final replacements = {}; + final counts = {}; + for (final group in fresh) { + for (final note in group.notes) { + for (final suggestion in note.suggestions ?? []) { + counts.update(suggestion.id, (v) => v + 1, ifAbsent: () => 1); + if (selection.any( + (t) => + t.discussionId == group.id && + t.note.id == note.id && + t.suggestion.id == suggestion.id, + )) { + replacements[suggestion.id] = ( + discussionId: group.id, + note: note, + suggestion: suggestion, + ); + } + } + } + } + setState(() { + for (final target in selection) { + final id = target.suggestion.id; + if (counts[id] != 1 || !replacements.containsKey(id)) { + _unavailable.add(id); + } else { + _unavailable.remove(id); + } + } + _targets = List.unmodifiable( + _targets.map( + (target) => + _selected.contains(target.suggestion.id) && + !_unavailable.contains(target.suggestion.id) + ? replacements[target.suggestion.id]! + : target, + ), + ); + _needsReload = false; + _failure = null; + }); + } catch (_) { + if (_current()) setState(() => _reloadFailed = true); + } finally { + if (mounted) { + setState(() { + _busy = false; + _reloading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + ref.watch(commentsRepositoryProvider); + final page = ref.watch(mrDiscussionsControllerProvider(widget.resource)); + final current = _current(); + final selection = _selection; + final groups = page.value?.items ?? []; + final healthy = page.hasValue && !page.isLoading && !page.hasError; + final eligible = + current && + healthy && + !_selected.any(_unavailable.contains) && + containsApplicableSuggestionTargets(groups, selection); + final error = switch (_failure) { + _Failure.uncertain => l10n.mrSuggestionsApplyError, + _Failure.forbidden => l10n.mrSuggestionsApplyForbidden, + _Failure.changed => l10n.mrSuggestionsApplyChanged, + null => null, + }; + // With a keyboard and large text, actions must scroll with the content. + final compact = + MediaQuery.sizeOf(context).height - + MediaQuery.viewInsetsOf(context).bottom < + 500; + final actions = [ + TextButton( + key: const ValueKey('mr-suggestions-batch-cancel'), + onPressed: _busy ? null : () => Navigator.of(context).pop(false), + child: Text(l10n.cancel), + ), + if (_reviewing) + TextButton( + key: const ValueKey('mr-suggestions-batch-back'), + onPressed: current && !_busy && !_needsReload + ? () => setState(() => _reviewing = false) + : null, + child: Text(l10n.mrSuggestionsBackButton), + ), + if (current && _needsReload) + TextButton( + key: const ValueKey('mr-suggestions-batch-reload'), + onPressed: _busy ? null : _reload, + child: Text(l10n.mrSuggestionsReloadButton), + ), + if (_reviewing) + TextButton( + key: const ValueKey('mr-suggestions-batch-confirm'), + onPressed: eligible && !_busy && !_needsReload ? _apply : null, + child: Text(l10n.mrSuggestionsApplyButton), + ) + else + TextButton( + key: const ValueKey('mr-suggestions-batch-review'), + onPressed: eligible && !_busy + ? () => setState(() => _reviewing = true) + : null, + child: Text(l10n.mrSuggestionsReviewButton), + ), + ]; + return PopScope( + canPop: !_busy, + child: AlertDialog( + scrollable: true, + title: Text( + _reviewing + ? l10n.mrSuggestionsReviewTitle( + NumberFormat.decimalPattern( + l10n.localeName, + ).format(_selected.length), + ) + : l10n.mrSuggestionsSelectTitle, + ), + content: SizedBox( + width: LabFoxBreakpoints.tablet, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!current) + Text(l10n.mrSuggestionSessionChanged) + else ...[ + Text( + _reviewing + ? l10n.mrSuggestionsApplyImpact + : l10n.mrSuggestionsSelectHint, + ), + for (final target in _reviewing ? selection : _targets) ...[ + if (!_reviewing) + CheckboxListTile( + key: ValueKey( + 'mr-suggestions-select-${target.suggestion.id}', + ), + contentPadding: EdgeInsets.zero, + title: Text( + l10n.mrSuggestionTitle( + NumberFormat.decimalPattern( + l10n.localeName, + ).format(target.suggestion.id), + ), + ), + value: _selected.contains(target.suggestion.id), + onChanged: + !_busy && + healthy && + (_selected.contains(target.suggestion.id) || + (!_unavailable.contains( + target.suggestion.id, + ) && + containsApplicableSuggestion( + groups, + discussionId: target.discussionId, + note: target.note, + suggestion: target.suggestion, + ))) + ? (value) => setState(() { + if (value == true) { + _selected.add(target.suggestion.id); + } else { + _selected.remove(target.suggestion.id); + } + }) + : null, + ), + if (_unavailable.contains(target.suggestion.id)) + Text(l10n.mrSuggestionUnavailable) + else ...[ + if (target.note.position?.newPath != null) + Text(target.note.position!.newPath!), + MrSuggestionPreview( + key: ValueKey(( + target.note.id, + target.suggestion, + _reviewing, + )), + noteId: target.note.id, + suggestion: target.suggestion, + initiallyExpanded: _reviewing, + ), + ], + ], + if (_reviewing) ...[ + if (!eligible && !_busy && !_needsReload) + Text(l10n.mrSuggestionUnavailable), + TextField( + key: const ValueKey('mr-suggestions-batch-message'), + controller: _message, + enabled: !_busy, + minLines: 1, + maxLines: 4, + decoration: InputDecoration( + labelText: l10n.mrSuggestionCommitMessageLabel, + helperText: l10n.mrSuggestionCommitMessageHint, + helperMaxLines: 4, + ), + ), + if (error != null) Text(error), + if (_reloadFailed) Text(l10n.mrSuggestionsReloadError), + ], + ], + if (compact) + Padding( + padding: const EdgeInsets.only(top: LabFoxSpacing.md), + child: OverflowBar( + alignment: MainAxisAlignment.end, + overflowAlignment: OverflowBarAlignment.end, + children: actions, + ), + ), + if (_busy) ...[ + const SizedBox(height: LabFoxSpacing.md), + const LinearProgressIndicator(), + Text( + _reloading + ? l10n.mrSuggestionsReloadProgress + : l10n.mrSuggestionsApplyProgress, + ), + ], + ], + ), + ), + actions: compact ? null : actions, + ), + ); + } +} diff --git a/apps/labfox/lib/l10n/app_en.arb b/apps/labfox/lib/l10n/app_en.arb index 4ab5109b..64db4cb5 100644 --- a/apps/labfox/lib/l10n/app_en.arb +++ b/apps/labfox/lib/l10n/app_en.arb @@ -2663,5 +2663,29 @@ "type": "String" } } + }, + "mrSuggestionsBatchButton": "Apply multiple suggestions", + "mrSuggestionsSelectTitle": "Select suggestions", + "mrSuggestionsSelectHint": "Select at least two suggestions from the loaded discussions.", + "mrSuggestionsReviewButton": "Review selected", + "mrSuggestionsReviewTitle": "Apply {count} suggestions?", + "mrSuggestionsApplyButton": "Apply selected", + "mrSuggestionsApplyImpact": "These changes will be sent together to the merge request source branch. Review every replacement before applying.", + "mrSuggestionsApplyProgress": "Checking and applying suggestions…", + "mrSuggestionsApplySuccess": "Selected suggestions applied.", + "mrSuggestionsApplyError": "The batch could not be confirmed. Reload every selected discussion before trying again.", + "mrSuggestionsApplyChanged": "One or more suggestions changed. Reload and review every selected patch.", + "mrSuggestionsReloadButton": "Reload selected discussions", + "mrSuggestionsReloadProgress": "Reloading selected discussions…", + "mrSuggestionsReloadError": "Not all discussions could be reloaded. Reload again before applying.", + "mrSuggestionsApplyForbidden": "GitLab denied this operation. Check your access and reload every selected discussion before trying again.", + "mrSuggestionsBackButton": "Back to selection", + "@mrSuggestionsReviewTitle": { + "description": "Confirmation title for the selected suggestion batch.", + "placeholders": { + "count": { + "type": "String" + } + } } } diff --git a/apps/labfox/lib/l10n/app_hi.arb b/apps/labfox/lib/l10n/app_hi.arb index 0141a02b..f35ac9b6 100644 --- a/apps/labfox/lib/l10n/app_hi.arb +++ b/apps/labfox/lib/l10n/app_hi.arb @@ -1333,5 +1333,21 @@ "mrSuggestionReloadProgress": "चर्चा दोबारा लोड हो रही है…", "mrSuggestionReloadError": "चर्चा दोबारा लोड नहीं हो सकी। दोबारा लोड करने की कोशिश करें।", "mrSuggestionUnavailable": "यह सुझाव लागू करने के लिए उपलब्ध नहीं है।", - "mrSuggestionSessionChanged": "खाता या मर्ज अनुरोध बदल गया है। यह संवाद बंद करके सुझाव दोबारा खोलें।" + "mrSuggestionSessionChanged": "खाता या मर्ज अनुरोध बदल गया है। यह संवाद बंद करके सुझाव दोबारा खोलें।", + "mrSuggestionsBatchButton": "कई सुझाव लागू करें", + "mrSuggestionsSelectTitle": "सुझाव चुनें", + "mrSuggestionsSelectHint": "लोड की गई चर्चाओं से कम से कम दो सुझाव चुनें।", + "mrSuggestionsReviewButton": "चुने हुए सुझावों की समीक्षा करें", + "mrSuggestionsReviewTitle": "{count} सुझाव लागू करें?", + "mrSuggestionsApplyButton": "चुने हुए सुझाव लागू करें", + "mrSuggestionsApplyImpact": "ये बदलाव मर्ज अनुरोध की स्रोत शाखा में एक साथ भेजे जाएंगे। लागू करने से पहले हर प्रतिस्थापन की समीक्षा करें।", + "mrSuggestionsApplyProgress": "सुझावों की जाँच और लागू करना जारी है…", + "mrSuggestionsApplySuccess": "चुने हुए सुझाव लागू किए गए।", + "mrSuggestionsApplyError": "बैच की पुष्टि नहीं हो सकी। फिर कोशिश करने से पहले सभी चुनी हुई चर्चाएँ फिर से लोड करें।", + "mrSuggestionsApplyChanged": "एक या अधिक सुझाव बदल गए हैं। हर चुने हुए पैच को फिर से लोड करके समीक्षा करें।", + "mrSuggestionsReloadButton": "चुनी हुई चर्चाएँ फिर से लोड करें", + "mrSuggestionsReloadProgress": "चुनी हुई चर्चाएँ लोड हो रही हैं…", + "mrSuggestionsReloadError": "सभी चर्चाएँ लोड नहीं हो सकीं। लागू करने से पहले फिर से लोड करें।", + "mrSuggestionsApplyForbidden": "GitLab ने इस कार्रवाई को अस्वीकार किया। अपनी पहुँच जाँचें और फिर कोशिश करने से पहले सभी चुनी हुई चर्चाएँ फिर से लोड करें।", + "mrSuggestionsBackButton": "चयन पर वापस जाएँ" } diff --git a/apps/labfox/lib/l10n/app_ja.arb b/apps/labfox/lib/l10n/app_ja.arb index 7389c1dc..064d2b7f 100644 --- a/apps/labfox/lib/l10n/app_ja.arb +++ b/apps/labfox/lib/l10n/app_ja.arb @@ -1333,5 +1333,21 @@ "mrSuggestionReloadProgress": "ディスカッションを再読み込み中…", "mrSuggestionReloadError": "ディスカッションを再読み込みできませんでした。もう一度再読み込みしてください。", "mrSuggestionUnavailable": "この提案は適用できません。", - "mrSuggestionSessionChanged": "アカウントまたはマージリクエストが変更されました。このダイアログを閉じて提案を開き直してください。" + "mrSuggestionSessionChanged": "アカウントまたはマージリクエストが変更されました。このダイアログを閉じて提案を開き直してください。", + "mrSuggestionsBatchButton": "複数の提案を適用", + "mrSuggestionsSelectTitle": "提案を選択", + "mrSuggestionsSelectHint": "読み込まれたディスカッションから提案を2つ以上選択してください。", + "mrSuggestionsReviewButton": "選択した提案を確認", + "mrSuggestionsReviewTitle": "{count}件の提案を適用しますか?", + "mrSuggestionsApplyButton": "選択した提案を適用", + "mrSuggestionsApplyImpact": "これらの変更はマージリクエストのソースブランチにまとめて送信されます。適用前にすべての置換内容を確認してください。", + "mrSuggestionsApplyProgress": "提案を確認して適用中…", + "mrSuggestionsApplySuccess": "選択した提案を適用しました。", + "mrSuggestionsApplyError": "一括適用を確認できませんでした。再試行する前に選択したすべてのディスカッションを再読み込みしてください。", + "mrSuggestionsApplyChanged": "一部の提案が変更されました。選択したすべてのパッチを再読み込みして確認してください。", + "mrSuggestionsReloadButton": "選択したディスカッションを再読み込み", + "mrSuggestionsReloadProgress": "選択したディスカッションを読み込み中…", + "mrSuggestionsReloadError": "一部のディスカッションを読み込めませんでした。適用前に再読み込みしてください。", + "mrSuggestionsApplyForbidden": "GitLabがこの操作を拒否しました。アクセス権を確認し、選択したすべてのディスカッションを再読み込みしてから再試行してください。", + "mrSuggestionsBackButton": "選択に戻る" } diff --git a/apps/labfox/lib/l10n/app_ko.arb b/apps/labfox/lib/l10n/app_ko.arb index 01d30d95..f5c853a4 100644 --- a/apps/labfox/lib/l10n/app_ko.arb +++ b/apps/labfox/lib/l10n/app_ko.arb @@ -1333,5 +1333,21 @@ "mrSuggestionReloadProgress": "토론을 새로고침하는 중…", "mrSuggestionReloadError": "토론을 새로고침하지 못했습니다. 다시 새로고침하세요.", "mrSuggestionUnavailable": "이 제안은 적용할 수 없습니다.", - "mrSuggestionSessionChanged": "계정 또는 병합 요청이 변경되었습니다. 이 창을 닫고 제안을 다시 여세요." + "mrSuggestionSessionChanged": "계정 또는 병합 요청이 변경되었습니다. 이 창을 닫고 제안을 다시 여세요.", + "mrSuggestionsBatchButton": "여러 제안 적용", + "mrSuggestionsSelectTitle": "제안 선택", + "mrSuggestionsSelectHint": "불러온 토론에서 제안을 두 개 이상 선택하세요.", + "mrSuggestionsReviewButton": "선택한 제안 검토", + "mrSuggestionsReviewTitle": "제안 {count}개를 적용할까요?", + "mrSuggestionsApplyButton": "선택한 제안 적용", + "mrSuggestionsApplyImpact": "이 변경 사항은 머지 리퀘스트의 소스 브랜치에 함께 전송됩니다. 적용하기 전에 모든 대체 내용을 검토하세요.", + "mrSuggestionsApplyProgress": "제안 확인 및 적용 중…", + "mrSuggestionsApplySuccess": "선택한 제안을 적용했습니다.", + "mrSuggestionsApplyError": "일괄 적용을 확인하지 못했습니다. 다시 시도하기 전에 선택한 모든 토론을 새로 불러오세요.", + "mrSuggestionsApplyChanged": "하나 이상의 제안이 변경되었습니다. 선택한 모든 패치를 새로 불러와 검토하세요.", + "mrSuggestionsReloadButton": "선택한 토론 새로 불러오기", + "mrSuggestionsReloadProgress": "선택한 토론 불러오는 중…", + "mrSuggestionsReloadError": "일부 토론을 불러오지 못했습니다. 적용하기 전에 다시 불러오세요.", + "mrSuggestionsApplyForbidden": "GitLab에서 이 작업을 거부했습니다. 접근 권한을 확인하고 선택한 모든 토론을 새로 불러온 후 다시 시도하세요.", + "mrSuggestionsBackButton": "선택으로 돌아가기" } diff --git a/apps/labfox/lib/l10n/app_localizations.dart b/apps/labfox/lib/l10n/app_localizations.dart index 19f84443..ceeabd7b 100644 --- a/apps/labfox/lib/l10n/app_localizations.dart +++ b/apps/labfox/lib/l10n/app_localizations.dart @@ -8569,6 +8569,102 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'The account or merge request changed. Close this dialog and reopen the suggestion.'** String get mrSuggestionSessionChanged; + + /// No description provided for @mrSuggestionsBatchButton. + /// + /// In en, this message translates to: + /// **'Apply multiple suggestions'** + String get mrSuggestionsBatchButton; + + /// No description provided for @mrSuggestionsSelectTitle. + /// + /// In en, this message translates to: + /// **'Select suggestions'** + String get mrSuggestionsSelectTitle; + + /// No description provided for @mrSuggestionsSelectHint. + /// + /// In en, this message translates to: + /// **'Select at least two suggestions from the loaded discussions.'** + String get mrSuggestionsSelectHint; + + /// No description provided for @mrSuggestionsReviewButton. + /// + /// In en, this message translates to: + /// **'Review selected'** + String get mrSuggestionsReviewButton; + + /// Confirmation title for the selected suggestion batch. + /// + /// In en, this message translates to: + /// **'Apply {count} suggestions?'** + String mrSuggestionsReviewTitle(String count); + + /// No description provided for @mrSuggestionsApplyButton. + /// + /// In en, this message translates to: + /// **'Apply selected'** + String get mrSuggestionsApplyButton; + + /// No description provided for @mrSuggestionsApplyImpact. + /// + /// In en, this message translates to: + /// **'These changes will be sent together to the merge request source branch. Review every replacement before applying.'** + String get mrSuggestionsApplyImpact; + + /// No description provided for @mrSuggestionsApplyProgress. + /// + /// In en, this message translates to: + /// **'Checking and applying suggestions…'** + String get mrSuggestionsApplyProgress; + + /// No description provided for @mrSuggestionsApplySuccess. + /// + /// In en, this message translates to: + /// **'Selected suggestions applied.'** + String get mrSuggestionsApplySuccess; + + /// No description provided for @mrSuggestionsApplyError. + /// + /// In en, this message translates to: + /// **'The batch could not be confirmed. Reload every selected discussion before trying again.'** + String get mrSuggestionsApplyError; + + /// No description provided for @mrSuggestionsApplyChanged. + /// + /// In en, this message translates to: + /// **'One or more suggestions changed. Reload and review every selected patch.'** + String get mrSuggestionsApplyChanged; + + /// No description provided for @mrSuggestionsReloadButton. + /// + /// In en, this message translates to: + /// **'Reload selected discussions'** + String get mrSuggestionsReloadButton; + + /// No description provided for @mrSuggestionsReloadProgress. + /// + /// In en, this message translates to: + /// **'Reloading selected discussions…'** + String get mrSuggestionsReloadProgress; + + /// No description provided for @mrSuggestionsReloadError. + /// + /// In en, this message translates to: + /// **'Not all discussions could be reloaded. Reload again before applying.'** + String get mrSuggestionsReloadError; + + /// No description provided for @mrSuggestionsApplyForbidden. + /// + /// In en, this message translates to: + /// **'GitLab denied this operation. Check your access and reload every selected discussion before trying again.'** + String get mrSuggestionsApplyForbidden; + + /// No description provided for @mrSuggestionsBackButton. + /// + /// In en, this message translates to: + /// **'Back to selection'** + String get mrSuggestionsBackButton; } class _AppLocalizationsDelegate diff --git a/apps/labfox/lib/l10n/app_localizations_en.dart b/apps/labfox/lib/l10n/app_localizations_en.dart index 260120c5..9c68bf3a 100644 --- a/apps/labfox/lib/l10n/app_localizations_en.dart +++ b/apps/labfox/lib/l10n/app_localizations_en.dart @@ -4865,4 +4865,60 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mrSuggestionSessionChanged => 'The account or merge request changed. Close this dialog and reopen the suggestion.'; + + @override + String get mrSuggestionsBatchButton => 'Apply multiple suggestions'; + + @override + String get mrSuggestionsSelectTitle => 'Select suggestions'; + + @override + String get mrSuggestionsSelectHint => + 'Select at least two suggestions from the loaded discussions.'; + + @override + String get mrSuggestionsReviewButton => 'Review selected'; + + @override + String mrSuggestionsReviewTitle(String count) { + return 'Apply $count suggestions?'; + } + + @override + String get mrSuggestionsApplyButton => 'Apply selected'; + + @override + String get mrSuggestionsApplyImpact => + 'These changes will be sent together to the merge request source branch. Review every replacement before applying.'; + + @override + String get mrSuggestionsApplyProgress => 'Checking and applying suggestions…'; + + @override + String get mrSuggestionsApplySuccess => 'Selected suggestions applied.'; + + @override + String get mrSuggestionsApplyError => + 'The batch could not be confirmed. Reload every selected discussion before trying again.'; + + @override + String get mrSuggestionsApplyChanged => + 'One or more suggestions changed. Reload and review every selected patch.'; + + @override + String get mrSuggestionsReloadButton => 'Reload selected discussions'; + + @override + String get mrSuggestionsReloadProgress => 'Reloading selected discussions…'; + + @override + String get mrSuggestionsReloadError => + 'Not all discussions could be reloaded. Reload again before applying.'; + + @override + String get mrSuggestionsApplyForbidden => + 'GitLab denied this operation. Check your access and reload every selected discussion before trying again.'; + + @override + String get mrSuggestionsBackButton => 'Back to selection'; } diff --git a/apps/labfox/lib/l10n/app_localizations_hi.dart b/apps/labfox/lib/l10n/app_localizations_hi.dart index 0a9b181b..4164d5f1 100644 --- a/apps/labfox/lib/l10n/app_localizations_hi.dart +++ b/apps/labfox/lib/l10n/app_localizations_hi.dart @@ -4867,4 +4867,61 @@ class AppLocalizationsHi extends AppLocalizations { @override String get mrSuggestionSessionChanged => 'खाता या मर्ज अनुरोध बदल गया है। यह संवाद बंद करके सुझाव दोबारा खोलें।'; + + @override + String get mrSuggestionsBatchButton => 'कई सुझाव लागू करें'; + + @override + String get mrSuggestionsSelectTitle => 'सुझाव चुनें'; + + @override + String get mrSuggestionsSelectHint => + 'लोड की गई चर्चाओं से कम से कम दो सुझाव चुनें।'; + + @override + String get mrSuggestionsReviewButton => 'चुने हुए सुझावों की समीक्षा करें'; + + @override + String mrSuggestionsReviewTitle(String count) { + return '$count सुझाव लागू करें?'; + } + + @override + String get mrSuggestionsApplyButton => 'चुने हुए सुझाव लागू करें'; + + @override + String get mrSuggestionsApplyImpact => + 'ये बदलाव मर्ज अनुरोध की स्रोत शाखा में एक साथ भेजे जाएंगे। लागू करने से पहले हर प्रतिस्थापन की समीक्षा करें।'; + + @override + String get mrSuggestionsApplyProgress => + 'सुझावों की जाँच और लागू करना जारी है…'; + + @override + String get mrSuggestionsApplySuccess => 'चुने हुए सुझाव लागू किए गए।'; + + @override + String get mrSuggestionsApplyError => + 'बैच की पुष्टि नहीं हो सकी। फिर कोशिश करने से पहले सभी चुनी हुई चर्चाएँ फिर से लोड करें।'; + + @override + String get mrSuggestionsApplyChanged => + 'एक या अधिक सुझाव बदल गए हैं। हर चुने हुए पैच को फिर से लोड करके समीक्षा करें।'; + + @override + String get mrSuggestionsReloadButton => 'चुनी हुई चर्चाएँ फिर से लोड करें'; + + @override + String get mrSuggestionsReloadProgress => 'चुनी हुई चर्चाएँ लोड हो रही हैं…'; + + @override + String get mrSuggestionsReloadError => + 'सभी चर्चाएँ लोड नहीं हो सकीं। लागू करने से पहले फिर से लोड करें।'; + + @override + String get mrSuggestionsApplyForbidden => + 'GitLab ने इस कार्रवाई को अस्वीकार किया। अपनी पहुँच जाँचें और फिर कोशिश करने से पहले सभी चुनी हुई चर्चाएँ फिर से लोड करें।'; + + @override + String get mrSuggestionsBackButton => 'चयन पर वापस जाएँ'; } diff --git a/apps/labfox/lib/l10n/app_localizations_ja.dart b/apps/labfox/lib/l10n/app_localizations_ja.dart index 66563575..2b0ca30c 100644 --- a/apps/labfox/lib/l10n/app_localizations_ja.dart +++ b/apps/labfox/lib/l10n/app_localizations_ja.dart @@ -4702,4 +4702,59 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mrSuggestionSessionChanged => 'アカウントまたはマージリクエストが変更されました。このダイアログを閉じて提案を開き直してください。'; + + @override + String get mrSuggestionsBatchButton => '複数の提案を適用'; + + @override + String get mrSuggestionsSelectTitle => '提案を選択'; + + @override + String get mrSuggestionsSelectHint => '読み込まれたディスカッションから提案を2つ以上選択してください。'; + + @override + String get mrSuggestionsReviewButton => '選択した提案を確認'; + + @override + String mrSuggestionsReviewTitle(String count) { + return '$count件の提案を適用しますか?'; + } + + @override + String get mrSuggestionsApplyButton => '選択した提案を適用'; + + @override + String get mrSuggestionsApplyImpact => + 'これらの変更はマージリクエストのソースブランチにまとめて送信されます。適用前にすべての置換内容を確認してください。'; + + @override + String get mrSuggestionsApplyProgress => '提案を確認して適用中…'; + + @override + String get mrSuggestionsApplySuccess => '選択した提案を適用しました。'; + + @override + String get mrSuggestionsApplyError => + '一括適用を確認できませんでした。再試行する前に選択したすべてのディスカッションを再読み込みしてください。'; + + @override + String get mrSuggestionsApplyChanged => + '一部の提案が変更されました。選択したすべてのパッチを再読み込みして確認してください。'; + + @override + String get mrSuggestionsReloadButton => '選択したディスカッションを再読み込み'; + + @override + String get mrSuggestionsReloadProgress => '選択したディスカッションを読み込み中…'; + + @override + String get mrSuggestionsReloadError => + '一部のディスカッションを読み込めませんでした。適用前に再読み込みしてください。'; + + @override + String get mrSuggestionsApplyForbidden => + 'GitLabがこの操作を拒否しました。アクセス権を確認し、選択したすべてのディスカッションを再読み込みしてから再試行してください。'; + + @override + String get mrSuggestionsBackButton => '選択に戻る'; } diff --git a/apps/labfox/lib/l10n/app_localizations_ko.dart b/apps/labfox/lib/l10n/app_localizations_ko.dart index cecacaef..f8973087 100644 --- a/apps/labfox/lib/l10n/app_localizations_ko.dart +++ b/apps/labfox/lib/l10n/app_localizations_ko.dart @@ -4708,4 +4708,58 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mrSuggestionSessionChanged => '계정 또는 병합 요청이 변경되었습니다. 이 창을 닫고 제안을 다시 여세요.'; + + @override + String get mrSuggestionsBatchButton => '여러 제안 적용'; + + @override + String get mrSuggestionsSelectTitle => '제안 선택'; + + @override + String get mrSuggestionsSelectHint => '불러온 토론에서 제안을 두 개 이상 선택하세요.'; + + @override + String get mrSuggestionsReviewButton => '선택한 제안 검토'; + + @override + String mrSuggestionsReviewTitle(String count) { + return '제안 $count개를 적용할까요?'; + } + + @override + String get mrSuggestionsApplyButton => '선택한 제안 적용'; + + @override + String get mrSuggestionsApplyImpact => + '이 변경 사항은 머지 리퀘스트의 소스 브랜치에 함께 전송됩니다. 적용하기 전에 모든 대체 내용을 검토하세요.'; + + @override + String get mrSuggestionsApplyProgress => '제안 확인 및 적용 중…'; + + @override + String get mrSuggestionsApplySuccess => '선택한 제안을 적용했습니다.'; + + @override + String get mrSuggestionsApplyError => + '일괄 적용을 확인하지 못했습니다. 다시 시도하기 전에 선택한 모든 토론을 새로 불러오세요.'; + + @override + String get mrSuggestionsApplyChanged => + '하나 이상의 제안이 변경되었습니다. 선택한 모든 패치를 새로 불러와 검토하세요.'; + + @override + String get mrSuggestionsReloadButton => '선택한 토론 새로 불러오기'; + + @override + String get mrSuggestionsReloadProgress => '선택한 토론 불러오는 중…'; + + @override + String get mrSuggestionsReloadError => '일부 토론을 불러오지 못했습니다. 적용하기 전에 다시 불러오세요.'; + + @override + String get mrSuggestionsApplyForbidden => + 'GitLab에서 이 작업을 거부했습니다. 접근 권한을 확인하고 선택한 모든 토론을 새로 불러온 후 다시 시도하세요.'; + + @override + String get mrSuggestionsBackButton => '선택으로 돌아가기'; } diff --git a/apps/labfox/lib/l10n/app_localizations_zh.dart b/apps/labfox/lib/l10n/app_localizations_zh.dart index ae7e399d..4c3f80c5 100644 --- a/apps/labfox/lib/l10n/app_localizations_zh.dart +++ b/apps/labfox/lib/l10n/app_localizations_zh.dart @@ -4558,4 +4558,55 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mrSuggestionSessionChanged => '账号或合并请求已更改。请关闭此对话框并重新打开建议。'; + + @override + String get mrSuggestionsBatchButton => '应用多个建议'; + + @override + String get mrSuggestionsSelectTitle => '选择建议'; + + @override + String get mrSuggestionsSelectHint => '从已加载的讨论中选择至少两个建议。'; + + @override + String get mrSuggestionsReviewButton => '检查所选建议'; + + @override + String mrSuggestionsReviewTitle(String count) { + return '应用 $count 个建议?'; + } + + @override + String get mrSuggestionsApplyButton => '应用所选建议'; + + @override + String get mrSuggestionsApplyImpact => '这些更改将一起发送到合并请求的源分支。应用前请检查每项替换内容。'; + + @override + String get mrSuggestionsApplyProgress => '正在检查并应用建议…'; + + @override + String get mrSuggestionsApplySuccess => '已应用所选建议。'; + + @override + String get mrSuggestionsApplyError => '无法确认批量应用结果。重试前请重新加载所有选中的讨论。'; + + @override + String get mrSuggestionsApplyChanged => '一个或多个建议已更改。请重新加载并检查每个选中的补丁。'; + + @override + String get mrSuggestionsReloadButton => '重新加载所选讨论'; + + @override + String get mrSuggestionsReloadProgress => '正在重新加载所选讨论…'; + + @override + String get mrSuggestionsReloadError => '未能加载所有讨论。应用前请重新加载。'; + + @override + String get mrSuggestionsApplyForbidden => + 'GitLab 拒绝了此操作。请检查访问权限,并在重试前重新加载所有选中的讨论。'; + + @override + String get mrSuggestionsBackButton => '返回选择'; } diff --git a/apps/labfox/lib/l10n/app_zh.arb b/apps/labfox/lib/l10n/app_zh.arb index 43e3f1c8..da5ca186 100644 --- a/apps/labfox/lib/l10n/app_zh.arb +++ b/apps/labfox/lib/l10n/app_zh.arb @@ -1333,5 +1333,21 @@ "mrSuggestionReloadProgress": "正在重新加载讨论…", "mrSuggestionReloadError": "无法重新加载讨论。请再次尝试重新加载。", "mrSuggestionUnavailable": "此建议无法应用。", - "mrSuggestionSessionChanged": "账号或合并请求已更改。请关闭此对话框并重新打开建议。" + "mrSuggestionSessionChanged": "账号或合并请求已更改。请关闭此对话框并重新打开建议。", + "mrSuggestionsBatchButton": "应用多个建议", + "mrSuggestionsSelectTitle": "选择建议", + "mrSuggestionsSelectHint": "从已加载的讨论中选择至少两个建议。", + "mrSuggestionsReviewButton": "检查所选建议", + "mrSuggestionsReviewTitle": "应用 {count} 个建议?", + "mrSuggestionsApplyButton": "应用所选建议", + "mrSuggestionsApplyImpact": "这些更改将一起发送到合并请求的源分支。应用前请检查每项替换内容。", + "mrSuggestionsApplyProgress": "正在检查并应用建议…", + "mrSuggestionsApplySuccess": "已应用所选建议。", + "mrSuggestionsApplyError": "无法确认批量应用结果。重试前请重新加载所有选中的讨论。", + "mrSuggestionsApplyChanged": "一个或多个建议已更改。请重新加载并检查每个选中的补丁。", + "mrSuggestionsReloadButton": "重新加载所选讨论", + "mrSuggestionsReloadProgress": "正在重新加载所选讨论…", + "mrSuggestionsReloadError": "未能加载所有讨论。应用前请重新加载。", + "mrSuggestionsApplyForbidden": "GitLab 拒绝了此操作。请检查访问权限,并在重试前重新加载所有选中的讨论。", + "mrSuggestionsBackButton": "返回选择" } diff --git a/apps/labfox/test/mr_suggestion_batch_application_test.dart b/apps/labfox/test/mr_suggestion_batch_application_test.dart new file mode 100644 index 00000000..aebb3656 --- /dev/null +++ b/apps/labfox/test/mr_suggestion_batch_application_test.dart @@ -0,0 +1,1295 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gitlab_api/gitlab_api.dart'; +import 'package:gitlab_models/gitlab_models.dart'; +import 'package:labfox/core/analytics/analytics.dart'; +import 'package:labfox/features/comments/data/comments_repository.dart'; +import 'package:labfox/features/comments/data/suggestion_application.dart'; +import 'package:labfox/features/comments/presentation/controllers/comments_controller.dart'; +import 'package:labfox/features/merge_requests/presentation/controllers/merge_requests_controllers.dart'; +import 'package:labfox/features/merge_requests/presentation/controllers/mr_discussions_controller.dart'; +import 'package:labfox/features/merge_requests/presentation/controllers/mr_review_snapshot_controller.dart'; +import 'package:labfox/features/merge_requests/presentation/widgets/mr_discussion_thread.dart'; +import 'package:labfox/l10n/app_localizations.dart'; + +const _key = MergeRequestRef(projectId: 8, iid: 142); + +class _Repository extends CommentsRepository { + _Repository() + : this._( + GitLabClient( + baseUrl: 'https://gitlab.example.com', + token: 'glpat-xxxxxxxxxxxx', + ), + ); + _Repository._(this.client) : super(client); + final GitLabClient client; + final pages = {1: const Paginated(items: [])}; + final reads = <(int, int, int)>[]; + final posts = []; + final inspectionResults = {}; + Object? batchResult; + final batches = <(List, String?)>[]; + Object? inspectionResult; + Object? applicationResult; + final inspections = <(int, int, String)>[]; + final applications = <(int, String?)>[]; + @override + Future discussion({ + required int projectId, + required int iid, + required String discussionId, + }) async { + inspections.add((projectId, iid, discussionId)); + final result = + inspectionResults[discussionId] ?? + inspectionResult ?? + (pages[1] as Paginated).items.firstWhere( + (g) => g.id == discussionId, + ); + if (result is Future) return result; + if (result is Discussion) return result; + throw result; + } + + @override + Future applySuggestion({ + required int suggestionId, + String? commitMessage, + }) async { + applications.add((suggestionId, commitMessage)); + final result = + applicationResult ?? + Suggestion.fromJson(_suggestion()).copyWith(applied: true); + if (result is Future) return result; + if (result is Suggestion) return result; + throw result; + } + + @override + Future> applySuggestions({ + required List suggestionIds, + String? commitMessage, + }) async { + batches.add((List.of(suggestionIds), commitMessage)); + final result = batchResult; + if (result is Future>) return result; + if (result is List) return result; + if (result != null) throw result; + final all = (pages[1] as Paginated).items + .expand((g) => g.notes) + .expand((n) => n.suggestions ?? []); + return [ + for (final id in suggestionIds) + all.firstWhere((s) => s.id == id).copyWith(applied: true), + ]; + } + + Future? postResult; + @override + Future> discussions({ + required int projectId, + required int iid, + int page = 1, + }) async { + reads.add((projectId, iid, page)); + final result = pages[page]!; + if (result is Future>) return result; + if (result is Paginated) return result; + throw result; + } + + @override + Future post({ + required NoteableType type, + required int projectId, + required int iid, + required String body, + }) async { + expect(type, NoteableType.mergeRequest); + posts.add(body); + return postResult ?? const Note(id: 999, body: 'Posted'); + } + + @override + Future> list({ + required NoteableType type, + required int projectId, + required int iid, + }) async => throw StateError('MRs must read grouped discussions'); +} + +class _Analytics implements Analytics { + final events = []; + @override + Future track(String name, [Map? properties]) async => + events.add(name); +} + +class _Detail extends MergeRequestController { + _Detail(this.reads); + final void Function() reads; + @override + Future build(MergeRequestRef arg) async { + reads(); + return const MergeRequest( + id: 99, + iid: 142, + title: 'Review', + state: 'opened', + sourceBranch: 'feature', + targetBranch: 'dev', + ); + } +} + +class _Snapshot extends MrReviewSnapshotController { + _Snapshot(this.reads); + final void Function() reads; + @override + Future build(MergeRequestRef arg) async { + reads(); + return MrReviewSnapshot(); + } +} + +final _session = StateProvider>( + (ref) async => null, +); +ProviderContainer _container(_Repository repo, _Analytics analytics) { + final container = ProviderContainer( + overrides: [ + _session.overrideWith((ref) async => repo), + commentsRepositoryProvider.overrideWith((ref) => ref.watch(_session)), + analyticsProvider.overrideWithValue(analytics), + ], + ); + addTearDown(container.dispose); + addTearDown(repo.client.close); + return container; +} + +final _captureKey = GlobalKey(); +Future _pump( + WidgetTester tester, + _Repository repo, { + double width = 390, + bool dark = false, + double height = 1400, + double textScale = 1, + Locale locale = const Locale('en'), + bool settle = true, + ProviderContainer? container, + ThemeData? theme, + int iid = 142, +}) async { + tester.view.physicalSize = Size(width, height); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final scope = container ?? _container(repo, _Analytics()); + await tester.pumpWidget( + UncontrolledProviderScope( + container: scope, + child: RepaintBoundary( + key: _captureKey, + child: MaterialApp( + debugShowCheckedModeBanner: false, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + theme: theme ?? (dark ? LabFoxTheme.dark : LabFoxTheme.light), + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: MrDiscussionThread(projectId: 8, iid: iid), + ), + ), + ), + ), + ), + ), + ); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + return AppLocalizations.of(tester.element(find.byType(MrDiscussionThread))); +} + +Discussion _suggestions( + List>? suggestions, { + bool system = false, + int noteId = 301, +}) => Discussion( + id: 'suggestion-thread', + individualNote: false, + notes: [ + Note.fromJson({ + 'id': noteId, + 'body': 'Review this patch', + 'type': 'DiffNote', + 'resolvable': true, + 'resolved': false, + 'system': system, + 'suggestions': suggestions, + }), + ], +); +Map _suggestion({ + int id = 7, + Object? original = ' old();\n', + Object? replacement = ' new();\n', + bool? applied = false, + bool? applicable = true, +}) => { + 'id': id, + 'from_line': 10, + 'to_line': 12, + 'from_content': original, + 'to_content': replacement, + 'applied': applied, + 'applicable': applicable, +}; + +Discussion _group( + int id, { + String? discussionId, + int? noteId, + String? content, + int? line, +}) => _suggestions([ + { + ..._suggestion(id: id, replacement: content ?? 'new $id'), + 'from_line': line ?? id * 10, + 'to_line': (line ?? id * 10) + 2, + }, +], noteId: noteId ?? id * 100).copyWith(id: discussionId ?? 'thread-$id'); +List _targets(List groups) => [ + for (final g in groups) + for (final n in g.notes) + for (final s in n.suggestions ?? []) + (discussionId: g.id, note: n, suggestion: s), +]; +Future _controller(ProviderContainer c) async { + await c.read(mrDiscussionsControllerProvider(_key).future); + return c.read(mrDiscussionsControllerProvider(_key).notifier); +} + +Future _openBatch(WidgetTester tester, {bool review = true}) async { + final open = find.byKey(const ValueKey('mr-suggestions-batch-open')); + expect(open, findsOneWidget); + await tester.ensureVisible(open); + await tester.tap(open); + await tester.pumpAndSettle(); + if (!review) return; + for (final id in [7, 8]) { + final check = find.byKey(ValueKey('mr-suggestions-select-$id')); + await tester.ensureVisible(check); + await tester.tap(check); + await tester.pumpAndSettle(); + } + await tester.tap(find.byKey(const ValueKey('mr-suggestions-batch-review'))); + await tester.pumpAndSettle(); +} + +void main() { + for (final confirmed in [false, true]) { + test( + 'refresh effects occur only after a fully confirmed batch $confirmed', + () async { + final repo = _Repository() + ..pages[1] = Paginated(items: [_group(7), _group(8)]); + if (!confirmed) repo.batchResult = []; + var details = 0, snapshots = 0; + final c = ProviderContainer( + overrides: [ + _session.overrideWith((ref) async => repo), + commentsRepositoryProvider.overrideWith( + (ref) => ref.watch(_session), + ), + analyticsProvider.overrideWithValue(_Analytics()), + mergeRequestControllerProvider.overrideWith( + () => _Detail(() => details++), + ), + mrReviewSnapshotControllerProvider.overrideWith( + () => _Snapshot(() => snapshots++), + ), + ], + ); + addTearDown(c.dispose); + addTearDown(repo.client.close); + final d = c.listen(mergeRequestControllerProvider(_key), (_, _) {}); + final v = c.listen(mrReviewSnapshotControllerProvider(_key), (_, _) {}); + addTearDown(d.close); + addTearDown(v.close); + await c.read(mergeRequestControllerProvider(_key).future); + await c.read(mrReviewSnapshotControllerProvider(_key).future); + final notifier = await _controller(c); + final result = notifier.applySuggestions( + _targets((repo.pages[1] as Paginated).items), + ); + if (confirmed) { + expect(await result, true); + } else { + await expectLater(result, throwsA(isA())); + } + await c.read(mergeRequestControllerProvider(_key).future); + await c.read(mrReviewSnapshotControllerProvider(_key).future); + expect(details, confirmed ? 2 : 1); + expect(snapshots, confirmed ? 2 : 1); + }, + ); + } + test('selection is snapshotted before awaiting the repository', () async { + final groups = [_group(7), _group(8)]; + final repo = _Repository()..pages[1] = Paginated(items: groups); + final c = _container(repo, _Analytics()); + final notifier = await _controller(c); + final selection = _targets(groups).toList(); + final result = notifier.applySuggestions(selection); + selection.clear(); + expect(await result, true); + expect(repo.batches.single.$1, [7, 8]); + }); + for (final endAccount in [false, true]) { + test( + 'ended recovery never replaces part of the old cache account=$endAccount', + () async { + final groups = [_group(7), _group(8)]; + final pending = Completer(); + final repo = _Repository() + ..pages[1] = Paginated(items: groups) + ..inspectionResults['thread-7'] = pending.future; + final c = _container(repo, _Analytics()); + final notifier = await _controller(c); + var current = true; + final result = notifier.inspectDiscussions([ + 'thread-7', + 'thread-8', + ], isCurrent: () => current); + await Future.delayed(Duration.zero); + if (endAccount) { + final next = _Repository(); + addTearDown(next.client.close); + c.read(_session.notifier).state = Future.value(next); + await c.read(mrDiscussionsControllerProvider(_key).future); + } else { + current = false; + } + pending.complete(_group(7, content: 'Unseen new patch')); + expect(await result, isNull); + expect(repo.inspections.length, 1); + expect(repo.batches, isEmpty); + if (!endAccount) { + expect( + c.read(mrDiscussionsControllerProvider(_key)).requireValue.items, + groups, + ); + } + }, + ); + } + testWidgets( + 'missing selected suggestion stays explicit after complete recovery', + (tester) async { + final repo = _Repository() + ..pages[1] = Paginated( + items: [ + _group(7, content: 'Removed old patch'), + _group(8), + ], + ) + ..batchResult = const GitLabServerException('Unconfirmed'); + await _pump(tester, repo); + await _openBatch(tester); + await tester.tap( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ); + await tester.pumpAndSettle(); + repo.inspectionResults['thread-7'] = _group(7).copyWith(notes: []); + await tester.tap( + find.byKey(const ValueKey('mr-suggestions-batch-reload')), + ); + await tester.pumpAndSettle(); + expect(find.text('Removed old patch'), findsNothing); + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ) + .onPressed, + isNull, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-back')), + ) + .onPressed, + isNotNull, + ); + expect(repo.batches.length, 1); + }, + ); + for (final duplicate in [false, true]) { + testWidgets( + 'batch action excludes unknown and ambiguous suggestions duplicate=$duplicate', + (tester) async { + final first = _group(7); + final second = duplicate + ? _group(7, discussionId: 'other', noteId: 800) + : _group(8).copyWith( + notes: [ + first.notes.single.copyWith( + id: 800, + suggestions: [const Suggestion(id: 8)], + ), + ], + ); + final repo = _Repository() + ..pages[1] = Paginated(items: [first, second]); + await _pump(tester, repo); + expect( + find.byKey(const ValueKey('mr-suggestions-batch-open')), + findsNothing, + ); + }, + ); + } + + testWidgets('batch selection and patch review never write until confirmed', ( + tester, + ) async { + final repo = _Repository() + ..pages[1] = Paginated(items: [_group(7), _group(8), _group(9)]); + await _pump(tester, repo); + await _openBatch(tester, review: false); + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-review')), + ) + .onPressed, + isNull, + ); + await tester.tap(find.byKey(const ValueKey('mr-suggestions-select-7'))); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-review')), + ) + .onPressed, + isNull, + ); + await tester.ensureVisible( + find.byKey(const ValueKey('mr-suggestions-select-8')), + ); + await tester.tap(find.byKey(const ValueKey('mr-suggestions-select-8'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('mr-suggestions-batch-review'))); + await tester.pumpAndSettle(); + expect(repo.inspections, isEmpty); + expect(repo.batches, isEmpty); + await tester.tap(find.byKey(const ValueKey('mr-suggestions-batch-cancel'))); + await tester.pumpAndSettle(); + expect(repo.batches, isEmpty); + }); + testWidgets( + 'selected subset sends exactly one batch and preserves message and root draft', + (tester) async { + final repo = _Repository() + ..pages[1] = Paginated(items: [_group(7), _group(8), _group(9)]); + await _pump(tester, repo); + final root = find.byType(TextField).first; + await tester.enterText(root, 'Root draft'); + await _openBatch(tester); + final input = find.byKey(const ValueKey('mr-suggestions-batch-message')); + await tester.ensureVisible(input); + await tester.enterText(input, ' Exact\nMessage '); + await tester.tap( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ); + await tester.pumpAndSettle(); + expect(repo.batches.map((b) => b.$1), [ + [7, 8], + ]); + expect(repo.batches.single.$2, ' Exact\nMessage '); + expect(repo.inspections.map((r) => r.$3), ['thread-7', 'thread-8']); + expect(repo.applications, isEmpty); + expect(find.byType(AlertDialog), findsNothing); + expect( + tester.widget(find.byType(TextField).first).controller!.text, + 'Root draft', + ); + }, + ); + for (final error in [ + const GitLabServerException('Uncertain'), + const GitLabForbiddenException('Denied'), + const GitLabConflictException('Changed'), + ]) { + testWidgets( + 'batch failure preserves draft and requires every selected thread reload $error', + (tester) async { + final repo = _Repository() + ..pages[1] = Paginated(items: [_group(7), _group(8)]) + ..batchResult = error; + await _pump(tester, repo); + await _openBatch(tester); + final input = find.byKey( + const ValueKey('mr-suggestions-batch-message'), + ); + await tester.ensureVisible(input); + await tester.enterText(input, 'Keep draft'); + await tester.tap( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ) + .onPressed, + isNull, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-back')), + ) + .onPressed, + isNull, + ); + expect(tester.widget(input).controller!.text, 'Keep draft'); + repo.inspectionResults['thread-7'] = _group(7).copyWith( + notes: [ + _group(7).notes.single.copyWith( + suggestions: [ + _group( + 7, + ).notes.single.suggestions!.single.copyWith(applied: true), + ], + ), + ], + ); + await tester.tap( + find.byKey(const ValueKey('mr-suggestions-batch-reload')), + ); + await tester.pumpAndSettle(); + expect(repo.inspections.length, 4); + expect(repo.batches.length, 1); + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ) + .onPressed, + isNull, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-back')), + ) + .onPressed, + isNotNull, + ); + }, + ); + } + testWidgets( + 'incomplete recovery cannot unlock confirmation or selection changes', + (tester) async { + final repo = _Repository() + ..pages[1] = Paginated(items: [_group(7), _group(8)]) + ..batchResult = const GitLabServerException('Uncertain'); + await _pump(tester, repo); + await _openBatch(tester); + await tester.tap( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ); + await tester.pumpAndSettle(); + repo.inspectionResults['thread-7'] = _group(7, content: 'Reloaded 7'); + repo.inspectionResults['thread-8'] = const GitLabServerException( + 'Read failed', + ); + await tester.tap( + find.byKey(const ValueKey('mr-suggestions-batch-reload')), + ); + await tester.pumpAndSettle(); + expect(repo.batches.length, 1); + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ) + .onPressed, + isNull, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-back')), + ) + .onPressed, + isNull, + ); + repo.inspectionResults['thread-8'] = _group(8, content: 'Reloaded 8'); + await tester.tap( + find.byKey(const ValueKey('mr-suggestions-batch-reload')), + ); + await tester.pumpAndSettle(); + expect(find.text('Reloaded 7'), findsWidgets); + expect(find.text('Reloaded 8'), findsWidgets); + expect(repo.batches.length, 1); + repo.batchResult = [ + _group( + 7, + content: 'Reloaded 7', + ).notes.single.suggestions!.single.copyWith(applied: true), + _group( + 8, + content: 'Reloaded 8', + ).notes.single.suggestions!.single.copyWith(applied: true), + ]; + await tester.tap( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ); + await tester.pumpAndSettle(); + expect(repo.batches.length, 2); + }, + ); + testWidgets( + 'pending batch preflight blocks input back cancel and duplicate writes', + (tester) async { + final pending = Completer(); + final repo = _Repository() + ..pages[1] = Paginated(items: [_group(7), _group(8)]) + ..inspectionResults['thread-7'] = pending.future; + await _pump(tester, repo); + await _openBatch(tester); + await tester.tap( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ); + await tester.pump(); + for (final key in ['confirm', 'back', 'cancel']) { + expect( + tester + .widget( + find.byKey(ValueKey('mr-suggestions-batch-$key')), + ) + .onPressed, + isNull, + ); + } + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-message')), + ) + .enabled, + false, + ); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byType(AlertDialog), findsOneWidget); + pending.complete(_group(7)); + await tester.pumpAndSettle(); + expect(repo.batches.length, 1); + }, + ); + testWidgets( + 'resource replacement hides selected old code and disables batch', + (tester) async { + final repo = _Repository() + ..pages[1] = Paginated( + items: [ + _group(7, content: 'Private old 7'), + _group(8, content: 'Private old 8'), + ], + ); + final c = _container(repo, _Analytics()); + await _pump(tester, repo, container: c); + await _openBatch(tester); + await _pump(tester, repo, container: c, iid: 143); + expect(find.text('Private old 7'), findsNothing); + expect(find.text('Private old 8'), findsNothing); + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ) + .onPressed, + isNull, + ); + expect(repo.batches, isEmpty); + }, + ); + testWidgets( + 'account switch during first read hides patches and cancels following reads', + (tester) async { + final pending = Completer(); + final repo = _Repository() + ..pages[1] = Paginated( + items: [ + _group(7, content: 'Private old'), + _group(8), + ], + ) + ..inspectionResults['thread-7'] = pending.future; + final c = _container(repo, _Analytics()); + await _pump(tester, repo, container: c); + await _openBatch(tester); + await tester.tap( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ); + await tester.pump(); + final next = _Repository(); + addTearDown(next.client.close); + c.read(_session.notifier).state = Future.value(next); + await tester.pump(); + expect(find.text('Private old'), findsNothing); + pending.complete(_group(7)); + await tester.pumpAndSettle(); + expect(repo.inspections.length, 1); + expect(repo.batches, isEmpty); + expect(next.batches, isEmpty); + }, + ); + testWidgets('resize and theme change preserve selection and commit draft', ( + tester, + ) async { + final repo = _Repository() + ..pages[1] = Paginated(items: [_group(7), _group(8)]); + final c = _container(repo, _Analytics()); + await _pump(tester, repo, container: c); + await _openBatch(tester); + final input = find.byKey(const ValueKey('mr-suggestions-batch-message')); + await tester.ensureVisible(input); + await tester.enterText(input, 'Keep across resize'); + await _pump(tester, repo, container: c, width: 1200, dark: true); + expect( + tester.widget(input).controller!.text, + 'Keep across resize', + ); + expect(repo.batches, isEmpty); + }); + for (final locale in AppLocalizations.supportedLocales) { + for (final width in [390.0, 800.0, 1200.0]) { + for (final dark in [false, true]) { + testWidgets( + 'batch selection and confirmation layout ${locale.languageCode} $width $dark', + (tester) async { + final repo = _Repository() + ..pages[1] = Paginated(items: [_group(7), _group(8)]); + await _pump( + tester, + repo, + locale: locale, + width: width, + dark: dark, + height: 900, + ); + await _openBatch(tester); + expect(tester.takeException(), isNull); + expect(find.byType(AlertDialog), findsOneWidget); + expect(repo.batches, isEmpty); + }, + ); + } + } + } + testWidgets( + 'batch confirmation fits keyboard and doubled text at compact width', + (tester) async { + final repo = _Repository() + ..pages[1] = Paginated(items: [_group(7), _group(8)]); + await _pump(tester, repo, width: 320, height: 600, textScale: 2); + await _openBatch(tester); + tester.view.viewInsets = const FakeViewPadding(bottom: 280); + addTearDown(tester.view.resetViewInsets); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect( + tester + .widget( + find.byKey(const ValueKey('mr-suggestions-batch-confirm')), + ) + .onPressed, + isNotNull, + ); + }, + ); + + test( + 'multi-thread preflight confirms all before exactly one batch write', + () async { + final groups = [_group(7), _group(8)]; + final repo = _Repository()..pages[1] = Paginated(items: groups); + final analytics = _Analytics(); + final c = _container(repo, analytics); + expect( + await (await _controller(c)).applySuggestions( + _targets(groups), + commitMessage: ' Exact\nDraft ', + ), + true, + ); + expect(repo.inspections, [(8, 142, 'thread-7'), (8, 142, 'thread-8')]); + expect(repo.batches.map((b) => b.$1), [ + [7, 8], + ]); + expect(repo.batches.single.$2, ' Exact\nDraft '); + expect(repo.applications, isEmpty); + expect(analytics.events, isEmpty); + await c.read(mrDiscussionsControllerProvider(_key).future); + expect(repo.reads.length, 2); + }, + ); + test('same-thread suggestions inspect the thread once', () async { + final a = _group(7); + final b = _group(8, discussionId: a.id); + final group = a.copyWith(notes: [...a.notes, ...b.notes]); + final repo = _Repository()..pages[1] = Paginated(items: [group]); + final c = _container(repo, _Analytics()); + expect( + await (await _controller(c)).applySuggestions(_targets([group])), + true, + ); + expect(repo.inspections.length, 1); + expect(repo.batches.map((b) => b.$1), [ + [7, 8], + ]); + expect(repo.batches.single.$2, isNull); + }); + for (final size in [0, 1, 3]) { + test( + 'empty single or duplicate selections reject before reads size=$size', + () async { + final groups = [_group(7), _group(8)]; + final repo = _Repository()..pages[1] = Paginated(items: groups); + final c = _container(repo, _Analytics()); + final targets = _targets(groups); + final selected = size == 3 + ? [...targets, targets.first] + : targets.take(size).toList(); + expect(await (await _controller(c)).applySuggestions(selected), false); + expect(repo.inspections, isEmpty); + expect(repo.batches, isEmpty); + }, + ); + } + for (final changed in [ + (Suggestion s) => s.copyWith(applied: true), + (Suggestion s) => s.copyWith(applied: null), + (Suggestion s) => s.copyWith(applicable: false), + (Suggestion s) => s.copyWith(fromContent: null), + (Suggestion s) => s.copyWith(toContent: 'Changed'), + (Suggestion s) => s.copyWith(fromLine: 99), + ]) { + test( + 'changed selected patch prevents the whole batch ${changed(Suggestion.fromJson(_suggestion()))}', + () async { + final groups = [_group(7), _group(8)]; + final last = groups.last; + final repo = _Repository() + ..pages[1] = Paginated(items: groups) + ..inspectionResults[last.id] = last.copyWith( + notes: [ + last.notes.single.copyWith( + suggestions: [changed(last.notes.single.suggestions!.single)], + ), + ], + ); + final c = _container(repo, _Analytics()); + await expectLater( + (await _controller(c)).applySuggestions(_targets(groups)), + throwsA(isA()), + ); + expect(repo.batches, isEmpty); + expect(repo.applications, isEmpty); + }, + ); + } + test('preflight read failure never writes a subset', () async { + final groups = [_group(7), _group(8)]; + final repo = _Repository() + ..pages[1] = Paginated(items: groups) + ..inspectionResults['thread-8'] = const GitLabServerException( + 'Read failed', + ); + final c = _container(repo, _Analytics()); + await expectLater( + (await _controller(c)).applySuggestions(_targets(groups)), + throwsA(isA()), + ); + expect(repo.batches, isEmpty); + }); + test( + 'response-wide duplicate suggestion across fresh threads prevents write', + () async { + final groups = [_group(7), _group(8)]; + final repo = _Repository() + ..pages[1] = Paginated(items: groups) + ..inspectionResults['thread-8'] = groups.last.copyWith( + notes: [ + ...groups.last.notes, + groups.first.notes.single.copyWith(id: 999), + ], + ); + final c = _container(repo, _Analytics()); + await expectLater( + (await _controller(c)).applySuggestions(_targets(groups)), + throwsA(isA()), + ); + expect(repo.batches, isEmpty); + }, + ); + for (final bad in >[ + [], + [const Suggestion(id: 7, applied: true)], + [ + const Suggestion(id: 7, applied: true), + const Suggestion(id: 7, applied: true), + ], + [ + const Suggestion(id: 7, applied: true), + const Suggestion(id: 9, applied: true), + ], + [ + const Suggestion(id: 7, applied: true), + const Suggestion(id: 8, applied: false), + ], + [ + const Suggestion(id: 7, applied: true), + const Suggestion(id: 8, applied: true, toContent: 'Changed'), + ], + ]) { + test( + 'unconfirmed response retains state and emits no success effects $bad', + () async { + final groups = [_group(7), _group(8)]; + final repo = _Repository() + ..pages[1] = Paginated(items: groups) + ..batchResult = bad; + final analytics = _Analytics(); + final c = _container(repo, analytics); + await expectLater( + (await _controller(c)).applySuggestions(_targets(groups)), + throwsA(isA()), + ); + expect(repo.batches.length, 1); + expect(repo.reads.length, 1); + expect(analytics.events, isEmpty); + }, + ); + } + test('reordered sparse applied response is accepted', () async { + final groups = [_group(7), _group(8)]; + final repo = _Repository() + ..pages[1] = Paginated(items: groups) + ..batchResult = [ + const Suggestion(id: 8, applied: true), + const Suggestion(id: 7, applied: true), + ]; + final c = _container(repo, _Analytics()); + expect( + await (await _controller(c)).applySuggestions(_targets(groups)), + true, + ); + }); + test('reservation spans every preflight and batch completion', () async { + final groups = [_group(7), _group(8)]; + final pending = Completer(); + final repo = _Repository() + ..pages[1] = Paginated(items: groups, nextPage: 2) + ..inspectionResults['thread-7'] = pending.future; + final c = _container(repo, _Analytics()); + final ctrl = await _controller(c); + final first = ctrl.applySuggestions(_targets(groups)); + await Future.delayed(Duration.zero); + expect(await ctrl.applySuggestions(_targets(groups)), false); + expect( + await ctrl.applySuggestion( + discussionId: groups.first.id, + note: groups.first.notes.single, + suggestion: groups.first.notes.single.suggestions!.single, + ), + false, + ); + expect(await ctrl.post('Other'), false); + expect( + await ctrl.reply(discussionId: groups.first.id, body: 'Other'), + false, + ); + expect( + await ctrl.setResolved(discussionId: groups.first.id, resolved: true), + false, + ); + expect(await ctrl.inspectDiscussions(['thread-7', 'thread-8']), isNull); + await ctrl.loadMore(); + expect(repo.reads.length, 1); + pending.complete(groups.first); + expect(await first, true); + expect(repo.batches.length, 1); + }); + test( + 'pending pagination blocks batch and multi-discussion inspection', + () async { + final groups = [_group(7), _group(8)]; + final pending = Completer>(); + final repo = _Repository() + ..pages[1] = Paginated(items: groups, nextPage: 2) + ..pages[2] = pending.future; + final c = _container(repo, _Analytics()); + final ctrl = await _controller(c); + final load = ctrl.loadMore(); + await Future.delayed(Duration.zero); + expect(await ctrl.applySuggestions(_targets(groups)), false); + expect(await ctrl.inspectDiscussions(['thread-7', 'thread-8']), isNull); + expect(repo.inspections, isEmpty); + pending.complete(const Paginated(items: [])); + await load; + }, + ); + for (final failed in [false, true]) { + test( + 'account change stops follow-up preflight and write failed=$failed', + () async { + final groups = [_group(7), _group(8)]; + final pending = Completer(); + final repo = _Repository() + ..pages[1] = Paginated(items: groups) + ..inspectionResults['thread-7'] = pending.future; + final c = _container(repo, _Analytics()); + final future = (await _controller( + c, + )).applySuggestions(_targets(groups)); + await Future.delayed(Duration.zero); + final next = _Repository(); + addTearDown(next.client.close); + c.read(_session.notifier).state = Future.value(next); + await Future.delayed(Duration.zero); + if (failed) { + pending.completeError(const GitLabServerException('Late')); + } else { + pending.complete(groups.first); + } + expect(await future, false); + expect(repo.inspections.length, 1); + expect(repo.batches, isEmpty); + expect(next.batches, isEmpty); + }, + ); + test( + 'account change isolates late batch completion failed=$failed', + () async { + final groups = [_group(7), _group(8)]; + final pending = Completer>(); + final repo = _Repository() + ..pages[1] = Paginated(items: groups) + ..batchResult = pending.future; + final c = _container(repo, _Analytics()); + final future = (await _controller( + c, + )).applySuggestions(_targets(groups)); + await Future.delayed(Duration.zero); + final next = _Repository(); + addTearDown(next.client.close); + c.read(_session.notifier).state = Future.value(next); + await c.read(mrDiscussionsControllerProvider(_key).future); + if (failed) { + pending.completeError(const GitLabServerException('Late')); + } else { + pending.complete([ + const Suggestion(id: 7, applied: true), + const Suggestion(id: 8, applied: true), + ]); + } + expect(await future, false); + expect(next.reads.length, 1); + expect(next.batches, isEmpty); + }, + ); + } + test( + 'originating view ends after first preflight and cancels remaining reads', + () async { + var active = true; + final groups = [_group(7), _group(8)]; + final pending = Completer(); + final repo = _Repository() + ..pages[1] = Paginated(items: groups) + ..inspectionResults['thread-7'] = pending.future; + final c = _container(repo, _Analytics()); + final future = (await _controller( + c, + )).applySuggestions(_targets(groups), isCurrent: () => active); + await Future.delayed(Duration.zero); + active = false; + pending.complete(groups.first); + expect(await future, false); + expect(repo.inspections.length, 1); + expect(repo.batches, isEmpty); + }, + ); + test( + 'complete recovery replaces all groups atomically and preserves pagination', + () async { + final groups = [_group(7), _group(8)]; + final fresh = [ + _group(7, content: 'Fresh 7'), + _group(8, content: 'Fresh 8'), + ]; + final repo = _Repository() + ..pages[1] = Paginated( + items: groups, + nextPage: 4, + total: 12, + totalPages: 6, + ) + ..inspectionResults['thread-7'] = fresh.first + ..inspectionResults['thread-8'] = fresh.last; + final c = _container(repo, _Analytics()); + final result = await (await _controller( + c, + )).inspectDiscussions(['thread-7', 'thread-8']); + expect(result, fresh); + final state = c.read(mrDiscussionsControllerProvider(_key)).value!; + expect(state.items, fresh); + expect(state.nextPage, 4); + expect(state.total, 12); + expect(state.totalPages, 6); + expect(repo.batches, isEmpty); + }, + ); + test( + 'failed recovery preserves every cached group without partial refresh', + () async { + final groups = [_group(7), _group(8)]; + final repo = _Repository() + ..pages[1] = Paginated(items: groups) + ..inspectionResults['thread-7'] = _group(7, content: 'Fresh 7') + ..inspectionResults['thread-8'] = const GitLabServerException('Failed'); + final c = _container(repo, _Analytics()); + await expectLater( + (await _controller(c)).inspectDiscussions(['thread-7', 'thread-8']), + throwsA(isA()), + ); + expect( + c.read(mrDiscussionsControllerProvider(_key)).value!.items, + groups, + ); + expect(repo.batches, isEmpty); + }, + ); + if (Platform.environment['LABFOX_BATCH_UI_CAPTURE'] + case final String directory) { + for (final width in [390.0, 1200.0]) { + for (final dark in [false, true]) { + testWidgets('synthetic batch confirmation capture $width $dark', ( + tester, + ) async { + final sdk = Platform.environment['FLUTTER_ROOT']!; + await tester.runAsync(() async { + for (final (family, name) in [ + ('Roboto', 'Roboto-Regular.ttf'), + ('monospace', 'Roboto-Regular.ttf'), + ('MaterialIcons', 'MaterialIcons-Regular.otf'), + ]) { + final loader = FontLoader(family) + ..addFont( + Future.value( + ByteData.sublistView( + File( + '$sdk/bin/cache/artifacts/material_fonts/$name', + ).readAsBytesSync(), + ), + ), + ); + if (family == 'Roboto') { + for (final weight in ['Medium', 'Bold']) { + loader.addFont( + Future.value( + ByteData.sublistView( + File( + '$sdk/bin/cache/artifacts/material_fonts/Roboto-$weight.ttf', + ).readAsBytesSync(), + ), + ), + ); + } + } + await loader.load(); + } + }); + final baseTheme = dark ? LabFoxTheme.dark : LabFoxTheme.light; + final buttonStyle = baseTheme.filledButtonTheme.style!; + final captureTheme = baseTheme.copyWith( + filledButtonTheme: FilledButtonThemeData( + style: buttonStyle.copyWith( + textStyle: WidgetStatePropertyAll( + buttonStyle.textStyle! + .resolve({})! + .copyWith(fontFamily: 'Roboto'), + ), + ), + ), + ); + final repo = _Repository() + ..pages[1] = Paginated( + items: [ + _group(7, content: ' return reviewedValue;\n'), + _group(8, content: ' notifyReviewers();\n'), + ], + ); + await _pump( + tester, + repo, + width: width, + height: width < 600 ? 844 : 900, + dark: dark, + theme: captureTheme, + ); + await _openBatch(tester); + expect(tester.takeException(), isNull); + final boundary = tester.renderObject( + find.byKey(_captureKey), + ); + final image = (await tester.runAsync(boundary.toImage))!; + final data = await tester.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.png), + ); + await tester.runAsync(() async { + final file = File( + '$directory/batch-${width.toInt()}-${dark ? 'dark' : 'light'}.png', + ); + await file.parent.create(recursive: true); + await file.writeAsBytes(data!.buffer.asUint8List()); + }); + image.dispose(); + }); + } + } + } +} diff --git a/apps/labfox/test/mr_suggestion_batch_repository_test.dart b/apps/labfox/test/mr_suggestion_batch_repository_test.dart new file mode 100644 index 00000000..ea729318 --- /dev/null +++ b/apps/labfox/test/mr_suggestion_batch_repository_test.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gitlab_api/gitlab_api.dart'; +import 'package:labfox/features/comments/data/comments_repository.dart'; + +class _Adapter implements HttpClientAdapter { + final requests = []; + @override + Future fetch( + RequestOptions options, + Stream? stream, + Future? cancel, + ) async { + requests.add(options); + final body = options.method == 'PUT' + ? [ + {'id': 8, 'applied': true}, + {'id': 7, 'applied': true}, + ] + : {'id': 'thread /#', 'individual_note': false, 'notes': []}; + return ResponseBody.fromString( + jsonEncode(body), + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +void main() { + for (final message in [null, '', ' Exact\nMessage ']) { + test( + 'repository forwards authenticated read and exact batch application message $message', + () async { + final adapter = _Adapter(); + final client = GitLabClient( + baseUrl: 'https://gitlab.example.com/subpath', + token: 'glpat-xxxxxxxxxxxx', + dio: Dio()..httpClientAdapter = adapter, + ); + addTearDown(client.close); + final repo = CommentsRepository(client); + final group = await repo.discussion( + projectId: 8, + iid: 142, + discussionId: 'thread /#', + ); + expect(group.id, 'thread /#'); + final applied = await repo.applySuggestions( + suggestionIds: [7, 8], + commitMessage: message, + ); + expect(applied.map((s) => s.id), [8, 7]); + expect(applied.every((s) => s.applied == true), true); + expect(adapter.requests.map((r) => r.method), ['GET', 'PUT']); + expect( + adapter.requests.first.uri.toString(), + 'https://gitlab.example.com/subpath/api/v4/projects/8/merge_requests/142/discussions/thread%20%2F%23', + ); + final write = adapter.requests.last; + expect( + write.uri.toString(), + 'https://gitlab.example.com/subpath/api/v4/suggestions/batch_apply', + ); + expect(write.data, { + 'ids': [7, 8], + 'commit_message': ?message, + }); + expect(write.followRedirects, false); + expect(write.extra['labfox_no_auth_retry'], true); + expect(write.headers['PRIVATE-TOKEN'], 'glpat-xxxxxxxxxxxx'); + }, + ); + } +} diff --git a/docs/images/mr-suggestion-batch/batch-1200-dark.png b/docs/images/mr-suggestion-batch/batch-1200-dark.png new file mode 100644 index 00000000..7ea02aa2 Binary files /dev/null and b/docs/images/mr-suggestion-batch/batch-1200-dark.png differ diff --git a/docs/images/mr-suggestion-batch/batch-1200-light.png b/docs/images/mr-suggestion-batch/batch-1200-light.png new file mode 100644 index 00000000..c7f320b3 Binary files /dev/null and b/docs/images/mr-suggestion-batch/batch-1200-light.png differ diff --git a/docs/images/mr-suggestion-batch/batch-390-dark.png b/docs/images/mr-suggestion-batch/batch-390-dark.png new file mode 100644 index 00000000..15d352fb Binary files /dev/null and b/docs/images/mr-suggestion-batch/batch-390-dark.png differ diff --git a/docs/images/mr-suggestion-batch/batch-390-light.png b/docs/images/mr-suggestion-batch/batch-390-light.png new file mode 100644 index 00000000..79cf03b0 Binary files /dev/null and b/docs/images/mr-suggestion-batch/batch-390-light.png differ diff --git a/docs/mobile-web-parity.md b/docs/mobile-web-parity.md index 56447ecb..48975b8e 100644 --- a/docs/mobile-web-parity.md +++ b/docs/mobile-web-parity.md @@ -57,7 +57,7 @@ issues and PRs. Keep it open until its full acceptance boundary is verified. | MW-04 | P1 | Member invitations, role/expiry changes, and removal for groups/projects. | Queued | Issue needed | | MW-05 | P0 | Issue/work-item editing, metadata, types, and validated state transitions. | In progress | [#329](https://github.com/Theorvane/labfox/issues/329), [PR #330](https://github.com/Theorvane/labfox/pull/330) shipped title and description editing; [#339](https://github.com/Theorvane/labfox/issues/339), [PR #340](https://github.com/Theorvane/labfox/pull/340) shipped due-date editing; [#341](https://github.com/Theorvane/labfox/issues/341), [PR #342](https://github.com/Theorvane/labfox/pull/342) shipped issue-label editing; [#343](https://github.com/Theorvane/labfox/issues/343), [PR #344](https://github.com/Theorvane/labfox/pull/344) shipped assignee editing; [#345](https://github.com/Theorvane/labfox/issues/345), [PR #346](https://github.com/Theorvane/labfox/pull/346) shipped milestone editing; [#347](https://github.com/Theorvane/labfox/issues/347), [PR #348](https://github.com/Theorvane/labfox/pull/348) shipped confidentiality; [#349](https://github.com/Theorvane/labfox/issues/349), [PR #350](https://github.com/Theorvane/labfox/pull/350) shipped discussion locking. Type-conversion compatibility and remaining work-item actions need separate issues. | | MW-06 | P1 | Boards and iterations: discover mobile-web behavior, then list/detail/mutations. | Queued | Issue needed | -| MW-07 | P0 | Advanced MR review: audit inline discussions/suggestions and finish missing review/approval flows. | In progress | [#552](https://github.com/Theorvane/labfox/issues/552), [PR #553](https://github.com/Theorvane/labfox/pull/553) prevent overlapping approval, merge, and other MR commands per project/IID, including while the repository resolves; failures release the reservation for explicit retry. [#554](https://github.com/Theorvane/labfox/issues/554), [PR #555](https://github.com/Theorvane/labfox/pull/555) add repository-session observation, pre-dispatch cancellation and late-result/error isolation, success-only current-session refresh/analytics/inbox effects, cancelled to-do outcomes without current-account toasts, and generation-owned duplicate reservations. Already dispatched server writes cannot be undone. [#556](https://github.com/Theorvane/labfox/issues/556), [PR #557](https://github.com/Theorvane/labfox/pull/557) add localized approval-read loading/error states, disabled approval toggles until successful reads, read-only retry, retained-data refresh protection, reactive new-session reads with stale-result isolation, and current-state toggle selection while preserving unavailable endpoint compatibility and independent merge controls. [#558](https://github.com/Theorvane/labfox/issues/558), [PR #559](https://github.com/Theorvane/labfox/pull/559) derives approval ownership from the authenticated account user ID and documented `approved_by` membership, ignores conflicting legacy current-user flags, and refreshes membership on account replacement. Regression coverage checks real toggle dispatch on mobile/desktop in both themes. [#560](https://github.com/Theorvane/labfox/issues/560), [PR #561](https://github.com/Theorvane/labfox/pull/561) validates successful approval responses before model parsing: explicit approver lists, complete user wrappers, positive integral user IDs, and nonnegative integral counts when present. Malformed reads become sanitized domain errors; status mapping precedes payload parsing. End-to-end widget tests verify blocked approval toggles and read-only recovery in both widths/themes, while unavailable endpoint compatibility remains intact. [#562](https://github.com/Theorvane/labfox/issues/562), [PR #563](https://github.com/Theorvane/labfox/pull/563) adds an account-bound, one-page MR discussion reader with generated grouped-note models and nullable resolution metadata. It preserves next-page headers without assuming totals or fetching ahead, rejects incomplete groups and nonadvancing cursors, and keeps HTTP failures distinct from sanitized parsing errors. Building on the API/models foundation, [#564](https://github.com/Theorvane/labfox/issues/564), [PR #565](https://github.com/Theorvane/labfox/pull/565) connects grouped MR replies to the detail-screen comments flow and aggregates resolution across all resolvable notes without claiming unknown states are resolved. The shared top-level composer refreshes discussion page one after posting; explicit load-more and read/page retries preserve groups and cursors. Account-bound reads/posts ignore late results, drafts reset when the session or resource changes, and resizing preserves the current draft. Five locales and narrow/compact/wide light/dark layouts are covered. [#566](https://github.com/Theorvane/labfox/issues/566), [PR #567](https://github.com/Theorvane/labfox/pull/567) adds the MR discussion reply API foundation: encoded thread/project paths, explicit IID routing, exact nonempty Markdown bodies, validated generated note responses, sanitized typed errors and no automatic authentication replay for the write. [#568](https://github.com/Theorvane/labfox/issues/568), [PR #569](https://github.com/Theorvane/labfox/pull/569) connects a localized inline reply composer to the selected discussion. One active reply preserves the top-level draft; cancel and write reservations prevent accidental overlaps. Replies preserve Markdown, retain failed drafts for explicit retry, refresh authoritative discussion page one and ignore old-session completions. Account/MR replacement resets the selection, resizing and pagination preserve current drafts, and a removed target restores top-level submission. Five locales and three widths in both themes are covered. [#570](https://github.com/Theorvane/labfox/issues/570), [PR #571](https://github.com/Theorvane/labfox/pull/571) adds known-state Resolve/Reopen controls and a strict account-bound mutation: encoded identity, explicit resolution, no redirects/authentication replay, and updated thread/state confirmation. Resolution shares comment/reply write reservations, preserves the top-level draft, retains localized permission/failure feedback for explicit retry, and refreshes discussions and MR detail only on current-session success. Unknown/nonresolvable states offer no toggle, and an active reply draft prevents resolution. [#572](https://github.com/Theorvane/labfox/issues/572), [PR #573](https://github.com/Theorvane/labfox/pull/573) preserves generated diff-note position metadata: original version SHAs, renamed paths, old/new text lines, multiline range endpoints and image/file types. Discussion reads, replies and resolution responses reject malformed or fractional coordinates before parsing; absent legacy fields remain unknown. [#574](https://github.com/Theorvane/labfox/issues/574), [PR #575](https://github.com/Theorvane/labfox/pull/575) adds paginated diff-version reads and an explicitly selected original unified-diff snapshot. Generated version/file models retain SHA triplets, renamed paths, raw text and nullable omission flags; absent files differ from empty snapshots. Identity/payload validation, status-first typed failures and no eager pagination or current-diff fallback are covered. [#576](https://github.com/Theorvane/labfox/issues/576) / [PR #577](https://github.com/Theorvane/labfox/pull/577) adds an expandable original-diff context beside positioned notes. It matches complete SHA triplets, both file paths and exact old/new single-line text coordinates; explicit older-page reads and read-only retries preserve drafts. The matched original hunk/line has an accessible non-color marker. Unsupported, omitted, truncated or ambiguous contexts remain unavailable without a current-diff fallback; account/resource replacement resets panels and stale reads cannot dispatch follow-up snapshots or affect the new session. Five locales, three widths and both themes are covered. Multiline context, positioned thread creation and suggestion application remain separate slices. [#578](https://github.com/Theorvane/labfox/issues/578), [PR #580](https://github.com/Theorvane/labfox/pull/580) adds the single-line positioned discussion creation API foundation: exact original SHA/path/line anchors, Markdown preservation, pre-dispatch argument validation, no redirects or authentication replay, created-thread confirmation and sanitized typed failures. The line selector/composer, multiline creation and suggestions remain separate. [#579](https://github.com/Theorvane/labfox/issues/579), [PR #581](https://github.com/Theorvane/labfox/pull/581) connects the latest authoritative diff-version snapshot to localized single-line selection and discussion creation. Exact original coordinates, indexed eligibility, shared write reservations, retained drafts, session/resource isolation and visible authoritative discussion inspection before uncertain-write retry are covered. The existing MR Changes route restores from project/IID; commit diffs stay read-only. Multiline context/creation and suggestion application remain separate. [#582](https://github.com/Theorvane/labfox/issues/582) / [PR #583](https://github.com/Theorvane/labfox/pull/583) adds typed immutable suggestion metadata to MR discussion responses, preserving original/replacement code, inclusive ranges, applied status and both documented applicability spellings. Missing states remain unknown; positive integral identities/coordinates, range order, response-wide uniqueness, content/flag types and alias agreement are validated before generated parsing. Suggestion presentation/application and multiline context/creation remain separate. [#584](https://github.com/Theorvane/labfox/issues/584) / [PR #585](https://github.com/Theorvane/labfox/pull/585) adds read-only suggestion previews beside user notes with exact selectable original/replacement code, nullable application/applicability states, localized ranges and empty/unavailable content, bounded code panes, retained drafts across resize/theme, and account/resource/metadata isolation. Suggestion application and batching remain separate. [#586](https://github.com/Theorvane/labfox/issues/586) / [PR #587](https://github.com/Theorvane/labfox/pull/587) adds the single-suggestion application API foundation with a positive global identity, exact optional commit messages, strict HTTP 200 and matching applied=true confirmation, shared metadata validation, typed sanitized failures, and no redirect/authentication replay. Application UI/session reservations and batch application remain separate. [#588](https://github.com/Theorvane/labfox/issues/588) / [PR #589](https://github.com/Theorvane/labfox/pull/589) adds explicit single-suggestion confirmation with literal code and optional commit messages, a fresh exact-discussion preflight, shared reservations including pagination, current-view/account isolation, confirmed-success detail/discussion/review-snapshot refreshes, and draft-preserving read-only recovery before uncertain-write retry. Five locales, three widths, both themes, keyboard insets and large text are covered. Batch application and multiline context/creation remain separate. [#590](https://github.com/Theorvane/labfox/issues/590) / [PR #591](https://github.com/Theorvane/labfox/pull/591) adds the batch suggestion API foundation: one account-bound request with immutable positive unique global IDs, exact optional messages, strict complete matching applied-state confirmation, preserved server order/nullable metadata, status-first sanitized failures, and no redirects, authentication replay or single-write fallback. Batch selection/confirmation and fresh-state recovery UI, plus multiline context/creation, remain separate. | +| MW-07 | P0 | Advanced MR review: audit inline discussions/suggestions and finish missing review/approval flows. | In progress | [#552](https://github.com/Theorvane/labfox/issues/552), [PR #553](https://github.com/Theorvane/labfox/pull/553) prevent overlapping approval, merge, and other MR commands per project/IID, including while the repository resolves; failures release the reservation for explicit retry. [#554](https://github.com/Theorvane/labfox/issues/554), [PR #555](https://github.com/Theorvane/labfox/pull/555) add repository-session observation, pre-dispatch cancellation and late-result/error isolation, success-only current-session refresh/analytics/inbox effects, cancelled to-do outcomes without current-account toasts, and generation-owned duplicate reservations. Already dispatched server writes cannot be undone. [#556](https://github.com/Theorvane/labfox/issues/556), [PR #557](https://github.com/Theorvane/labfox/pull/557) add localized approval-read loading/error states, disabled approval toggles until successful reads, read-only retry, retained-data refresh protection, reactive new-session reads with stale-result isolation, and current-state toggle selection while preserving unavailable endpoint compatibility and independent merge controls. [#558](https://github.com/Theorvane/labfox/issues/558), [PR #559](https://github.com/Theorvane/labfox/pull/559) derives approval ownership from the authenticated account user ID and documented `approved_by` membership, ignores conflicting legacy current-user flags, and refreshes membership on account replacement. Regression coverage checks real toggle dispatch on mobile/desktop in both themes. [#560](https://github.com/Theorvane/labfox/issues/560), [PR #561](https://github.com/Theorvane/labfox/pull/561) validates successful approval responses before model parsing: explicit approver lists, complete user wrappers, positive integral user IDs, and nonnegative integral counts when present. Malformed reads become sanitized domain errors; status mapping precedes payload parsing. End-to-end widget tests verify blocked approval toggles and read-only recovery in both widths/themes, while unavailable endpoint compatibility remains intact. [#562](https://github.com/Theorvane/labfox/issues/562), [PR #563](https://github.com/Theorvane/labfox/pull/563) adds an account-bound, one-page MR discussion reader with generated grouped-note models and nullable resolution metadata. It preserves next-page headers without assuming totals or fetching ahead, rejects incomplete groups and nonadvancing cursors, and keeps HTTP failures distinct from sanitized parsing errors. Building on the API/models foundation, [#564](https://github.com/Theorvane/labfox/issues/564), [PR #565](https://github.com/Theorvane/labfox/pull/565) connects grouped MR replies to the detail-screen comments flow and aggregates resolution across all resolvable notes without claiming unknown states are resolved. The shared top-level composer refreshes discussion page one after posting; explicit load-more and read/page retries preserve groups and cursors. Account-bound reads/posts ignore late results, drafts reset when the session or resource changes, and resizing preserves the current draft. Five locales and narrow/compact/wide light/dark layouts are covered. [#566](https://github.com/Theorvane/labfox/issues/566), [PR #567](https://github.com/Theorvane/labfox/pull/567) adds the MR discussion reply API foundation: encoded thread/project paths, explicit IID routing, exact nonempty Markdown bodies, validated generated note responses, sanitized typed errors and no automatic authentication replay for the write. [#568](https://github.com/Theorvane/labfox/issues/568), [PR #569](https://github.com/Theorvane/labfox/pull/569) connects a localized inline reply composer to the selected discussion. One active reply preserves the top-level draft; cancel and write reservations prevent accidental overlaps. Replies preserve Markdown, retain failed drafts for explicit retry, refresh authoritative discussion page one and ignore old-session completions. Account/MR replacement resets the selection, resizing and pagination preserve current drafts, and a removed target restores top-level submission. Five locales and three widths in both themes are covered. [#570](https://github.com/Theorvane/labfox/issues/570), [PR #571](https://github.com/Theorvane/labfox/pull/571) adds known-state Resolve/Reopen controls and a strict account-bound mutation: encoded identity, explicit resolution, no redirects/authentication replay, and updated thread/state confirmation. Resolution shares comment/reply write reservations, preserves the top-level draft, retains localized permission/failure feedback for explicit retry, and refreshes discussions and MR detail only on current-session success. Unknown/nonresolvable states offer no toggle, and an active reply draft prevents resolution. [#572](https://github.com/Theorvane/labfox/issues/572), [PR #573](https://github.com/Theorvane/labfox/pull/573) preserves generated diff-note position metadata: original version SHAs, renamed paths, old/new text lines, multiline range endpoints and image/file types. Discussion reads, replies and resolution responses reject malformed or fractional coordinates before parsing; absent legacy fields remain unknown. [#574](https://github.com/Theorvane/labfox/issues/574), [PR #575](https://github.com/Theorvane/labfox/pull/575) adds paginated diff-version reads and an explicitly selected original unified-diff snapshot. Generated version/file models retain SHA triplets, renamed paths, raw text and nullable omission flags; absent files differ from empty snapshots. Identity/payload validation, status-first typed failures and no eager pagination or current-diff fallback are covered. [#576](https://github.com/Theorvane/labfox/issues/576) / [PR #577](https://github.com/Theorvane/labfox/pull/577) adds an expandable original-diff context beside positioned notes. It matches complete SHA triplets, both file paths and exact old/new single-line text coordinates; explicit older-page reads and read-only retries preserve drafts. The matched original hunk/line has an accessible non-color marker. Unsupported, omitted, truncated or ambiguous contexts remain unavailable without a current-diff fallback; account/resource replacement resets panels and stale reads cannot dispatch follow-up snapshots or affect the new session. Five locales, three widths and both themes are covered. Multiline context, positioned thread creation and suggestion application remain separate slices. [#578](https://github.com/Theorvane/labfox/issues/578), [PR #580](https://github.com/Theorvane/labfox/pull/580) adds the single-line positioned discussion creation API foundation: exact original SHA/path/line anchors, Markdown preservation, pre-dispatch argument validation, no redirects or authentication replay, created-thread confirmation and sanitized typed failures. The line selector/composer, multiline creation and suggestions remain separate. [#579](https://github.com/Theorvane/labfox/issues/579), [PR #581](https://github.com/Theorvane/labfox/pull/581) connects the latest authoritative diff-version snapshot to localized single-line selection and discussion creation. Exact original coordinates, indexed eligibility, shared write reservations, retained drafts, session/resource isolation and visible authoritative discussion inspection before uncertain-write retry are covered. The existing MR Changes route restores from project/IID; commit diffs stay read-only. Multiline context/creation and suggestion application remain separate. [#582](https://github.com/Theorvane/labfox/issues/582) / [PR #583](https://github.com/Theorvane/labfox/pull/583) adds typed immutable suggestion metadata to MR discussion responses, preserving original/replacement code, inclusive ranges, applied status and both documented applicability spellings. Missing states remain unknown; positive integral identities/coordinates, range order, response-wide uniqueness, content/flag types and alias agreement are validated before generated parsing. Suggestion presentation/application and multiline context/creation remain separate. [#584](https://github.com/Theorvane/labfox/issues/584) / [PR #585](https://github.com/Theorvane/labfox/pull/585) adds read-only suggestion previews beside user notes with exact selectable original/replacement code, nullable application/applicability states, localized ranges and empty/unavailable content, bounded code panes, retained drafts across resize/theme, and account/resource/metadata isolation. Suggestion application and batching remain separate. [#586](https://github.com/Theorvane/labfox/issues/586) / [PR #587](https://github.com/Theorvane/labfox/pull/587) adds the single-suggestion application API foundation with a positive global identity, exact optional commit messages, strict HTTP 200 and matching applied=true confirmation, shared metadata validation, typed sanitized failures, and no redirect/authentication replay. Application UI/session reservations and batch application remain separate. [#588](https://github.com/Theorvane/labfox/issues/588) / [PR #589](https://github.com/Theorvane/labfox/pull/589) adds explicit single-suggestion confirmation with literal code and optional commit messages, a fresh exact-discussion preflight, shared reservations including pagination, current-view/account isolation, confirmed-success detail/discussion/review-snapshot refreshes, and draft-preserving read-only recovery before uncertain-write retry. Five locales, three widths, both themes, keyboard insets and large text are covered. Batch application and multiline context/creation remain separate. [#590](https://github.com/Theorvane/labfox/issues/590) / [PR #591](https://github.com/Theorvane/labfox/pull/591) adds the batch suggestion API foundation: one account-bound request with immutable positive unique global IDs, exact optional messages, strict complete matching applied-state confirmation, preserved server order/nullable metadata, status-first sanitized failures, and no redirects, authentication replay or single-write fallback. Building on that API, [#592](https://github.com/Theorvane/labfox/issues/592) / [PR #593](https://github.com/Theorvane/labfox/pull/593) adds explicit loaded-target selection and multi-patch confirmation, deduplicated all-thread preflight before one batch, immutable selected identities, shared reservations, current-account/view isolation, complete staged read-only recovery, exact retained drafts and success-only refreshes. Five locales, three widths, both themes and compact keyboard/large-text layouts are covered; see [the batch UI](mr-suggestion-batch-ui.md). Multiline context/creation remains separate. | | MW-08 | P1 | CI/CD configuration: pipeline editor, variables, triggers, schedule editing, and execution history. | In progress | [#423](https://github.com/Theorvane/labfox/issues/423), [PR #424](https://github.com/Theorvane/labfox/pull/424) cover timing editing; [#425](https://github.com/Theorvane/labfox/issues/425), [PR #426](https://github.com/Theorvane/labfox/pull/426) cover deletion; [#427](https://github.com/Theorvane/labfox/issues/427), [PR #428](https://github.com/Theorvane/labfox/pull/428) cover creation; [#429](https://github.com/Theorvane/labfox/issues/429), [PR #430](https://github.com/Theorvane/labfox/pull/430) cover ownership transfer; [#431](https://github.com/Theorvane/labfox/issues/431), [PR #432](https://github.com/Theorvane/labfox/pull/432) cover ref/active editing. These slices await review or maintainer merge. [#433](https://github.com/Theorvane/labfox/issues/433), [PR #434](https://github.com/Theorvane/labfox/pull/434) cover schedule-specific execution history in review: newest-first header pagination, retained rows/cursor on failed next pages, retry, run-now/refresh reload with inline errors, stale-request protection, and identifier-only pipeline navigation. History filters, variables/inputs, pipeline configuration editing, and triggers remain separate slices. | | MW-09 | P1 | Wiki creation/editing/deletion and history with conflict handling. | In progress | [#351](https://github.com/Theorvane/labfox/issues/351), [PR #352](https://github.com/Theorvane/labfox/pull/352) shipped Markdown page creation; [#353](https://github.com/Theorvane/labfox/issues/353), [PR #354](https://github.com/Theorvane/labfox/pull/354) shipped editing with best-effort stale-draft detection; [#373](https://github.com/Theorvane/labfox/issues/373), [PR #374](https://github.com/Theorvane/labfox/pull/374) shipped deletion with confirmation and best-effort stale-page detection; [#375](https://github.com/Theorvane/labfox/issues/375), [PR #376](https://github.com/Theorvane/labfox/pull/376) shipped Markdown template selection. [#377](https://github.com/Theorvane/labfox/issues/377), [PR #378](https://github.com/Theorvane/labfox/pull/378) cover template listing and creation in review. Page history and atomic conflict protection remain. The documented wiki REST API can retrieve a page by version SHA but does not list page history. | | MW-10 | P1 | Snippet creation/editing/deletion, files, and visibility. | In progress | [#355](https://github.com/Theorvane/labfox/issues/355), [PR #356](https://github.com/Theorvane/labfox/pull/356) shipped single-file project snippet creation; [#357](https://github.com/Theorvane/labfox/issues/357), [PR #358](https://github.com/Theorvane/labfox/pull/358) shipped deletion with confirmation; [#359](https://github.com/Theorvane/labfox/issues/359), [PR #360](https://github.com/Theorvane/labfox/pull/360) shipped title and description editing; [#361](https://github.com/Theorvane/labfox/issues/361), [PR #362](https://github.com/Theorvane/labfox/pull/362) shipped single-file content editing; [#363](https://github.com/Theorvane/labfox/issues/363), [PR #364](https://github.com/Theorvane/labfox/pull/364) shipped private/public visibility editing; [#365](https://github.com/Theorvane/labfox/issues/365), [PR #366](https://github.com/Theorvane/labfox/pull/366) shipped multi-file content editing; [#367](https://github.com/Theorvane/labfox/issues/367), [PR #368](https://github.com/Theorvane/labfox/pull/368) shipped adding a file; [#369](https://github.com/Theorvane/labfox/issues/369), [PR #370](https://github.com/Theorvane/labfox/pull/370) shipped deleting a file; [#371](https://github.com/Theorvane/labfox/issues/371), [PR #372](https://github.com/Theorvane/labfox/pull/372) shipped moving and renaming a file. Broader visibility management remains. | diff --git a/docs/mr-suggestion-application-api.md b/docs/mr-suggestion-application-api.md index de3e0b2c..11982cd9 100644 --- a/docs/mr-suggestion-application-api.md +++ b/docs/mr-suggestion-application-api.md @@ -10,6 +10,6 @@ Only HTTP 200 with a strictly validated matching suggestion ID and `applied: tru The shared suggestion validator retains the existing MR read/reply/resolution/positioned-creation boundaries: positive integral IDs and supplied coordinates, ordered complete ranges, string content, boolean status, consistent legacy/documented applicability aliases, and response-wide identity uniqueness. It runs before generated numeric parsing so fractional values cannot be truncated. The apply response uses the same validator and generated DTO; malformed or unconfirmed results become `Invalid suggestion application response.` without source text, IDs or raw parser details. -An error or unconfirmed response can follow an accepted repository write. A caller must reload authoritative discussions and inspect the current suggestion state before offering an explicitly confirmed manual retry. The API itself does not own UI confirmation or completion effects. The app now implements single-suggestion confirmation, shared discussion reservations, account/resource isolation and read-only recovery as documented in [the application UI](mr-suggestion-application-ui.md). The [batch API](mr-suggestion-batch-api.md) is a separate foundation; batch selection and confirmation UI remain follow-up work. No live repository change or physical-device validation is claimed. +An error or unconfirmed response can follow an accepted repository write. A caller must reload authoritative discussions and inspect the current suggestion state before offering an explicitly confirmed manual retry. The API itself does not own UI confirmation or completion effects. The app now implements single-suggestion confirmation, shared discussion reservations, account/resource isolation and read-only recovery as documented in [the application UI](mr-suggestion-application-ui.md). The [batch API](mr-suggestion-batch-api.md) powers [batch selection, confirmation and complete read-only recovery](mr-suggestion-batch-ui.md). No live repository change or physical-device validation is claimed. Tests use documented synthetic payloads and dummy credentials. Reference: [Suggest Changes API](https://docs.gitlab.com/api/suggestions/). diff --git a/docs/mr-suggestion-application-ui.md b/docs/mr-suggestion-application-ui.md index a98f262d..cc90a841 100644 --- a/docs/mr-suggestion-application-ui.md +++ b/docs/mr-suggestion-application-ui.md @@ -23,4 +23,4 @@ These are synthetic Flutter widget-test captures with dummy metadata and SDK fon | Mobile, 390 × 844 | [Capture](images/mr-suggestion-apply/apply-390-light.png) | [Capture](images/mr-suggestion-apply/apply-390-dark.png) | | Desktop, 1200 × 900 | [Capture](images/mr-suggestion-apply/apply-1200-light.png) | [Capture](images/mr-suggestion-apply/apply-1200-dark.png) | -The [batch application API](mr-suggestion-batch-api.md) is available as a separate foundation. Batch selection/confirmation UI and multiline discussion context/creation remain separate parity slices. +The [batch application API](mr-suggestion-batch-api.md) now powers [batch selection, confirmation and complete read-only recovery](mr-suggestion-batch-ui.md). Multiline discussion context/creation remains a separate parity slice. diff --git a/docs/mr-suggestion-batch-api.md b/docs/mr-suggestion-batch-api.md index 79097a23..27ec166e 100644 --- a/docs/mr-suggestion-batch-api.md +++ b/docs/mr-suggestion-batch-api.md @@ -10,7 +10,7 @@ The write disables redirects and automatic OAuth refresh/replay. Status-first ma This confirms returned suggestion states, not a commit count, branch SHA, permission claim or client-side atomicity guarantee. An error or incomplete response can follow an accepted repository write. Callers must freshly inspect every selected discussion and require renewed confirmation before an explicit manual retry. There is no automatic application retry, single-write fallback, rollback or inferred success for part of a batch. -This slice provides the API only. The existing app still offers single-suggestion confirmation/application. Batch selection, MR grouping, multi-patch confirmation, shared UI reservations and complete fresh-state recovery belong to a separate UI slice. No model, dependency or generated file changes, live GitLab writes or physical-device validation are included. +The original API slice changed no models, dependencies or generated files. The app now provides [batch selection, multi-patch confirmation and complete read-only recovery](mr-suggestion-batch-ui.md), alongside single-suggestion application. No live GitLab writes or physical-device validation are claimed. Test-first coverage includes missing-method failure and 130 failing behavior contracts against a minimal unsafe implementation, followed by all 149 batch contracts and 254 existing single-application/metadata cases passing together. Tests cover exact routing/authentication/messages, invalid inputs, reordered and sparse confirmed payloads, every member's strict metadata boundary, incomplete identities, caller mutation, immutable results, typed failures and no OAuth replay. diff --git a/docs/mr-suggestion-batch-ui.md b/docs/mr-suggestion-batch-ui.md new file mode 100644 index 00000000..a47eb122 --- /dev/null +++ b/docs/mr-suggestion-batch-ui.md @@ -0,0 +1,26 @@ +# Selecting and applying multiple code suggestions + +An MR discussion page offers **Apply multiple suggestions** when at least two loaded suggestions are eligible and have unique global identities. The selection screen starts empty. Select at least two targets, inspect their previews, and choose **Review selected**. The confirmation shows every selected original/replacement patch, range, and server state before **Apply selected**. Opening, selecting, navigating back, or cancelling sends no write. An optional commit message retains its exact whitespace; an empty field uses GitLab's default. The existing project/IID discussion route owns this flow. + +Selection uses loaded discussions only, without eager pagination or selecting hidden pages. Unknown, applied, system, non-text, malformed, and ambiguous suggestions are unavailable. Patch applicability does not imply permission: GitLab decides authorization. The controller snapshots the selected set and holds the shared comment/reply/resolution/application reservation. Pending pagination also blocks batch application and recovery. + +Before the single batch write, every selected discussion is fetched once through the authenticated single-discussion endpoint. Each selected note identity, position, patch content, inclusive range and flags must still match the review. A failed read, changed patch or ambiguous identity prevents the entire dispatch. There is no single-write fallback or automatic subset application. The real repository forwards the selection to the [batch API](mr-suggestion-batch-api.md), preserving the optional message. Only complete, unique, matching `applied=true` confirmation succeeds; returned known patch metadata must agree. Sparse and reordered confirmation is supported. Success refreshes discussions, MR detail and the authoritative review snapshot in the originating session, without comment analytics. + +Failure or unconfirmed completion retains the message and requires explicit **Reload selected discussions**. Recovery reads every selected discussion before replacing any loaded cache entry, preserving pagination. A failed intermediate read leaves the cache unchanged and keeps confirmation and selection changes disabled. A complete read displays changed patches for renewed confirmation. Missing or ambiguous suggestions lose their old code preview and remain explicitly selected but unavailable until the user returns to selection and deselects them. Already applied, unknown or inapplicable targets block another application. Recovery itself never writes or infers success for a subset. + +Pending reads/writes disable input, cancellation, back navigation and duplicate confirmation. Account or MR replacement hides the old source and cancels undispatched follow-up reads and writes. Late errors, success feedback and refreshes cannot affect the replacement view. A request already accepted by GitLab cannot be undone by view replacement. These are returned-state checks, not a branch SHA guard, rollback or a client guarantee that a server batch is atomic. Closing and reopening starts a new flow; it does not persist an uncertain-operation journal. + +All five locales use the same responsive dialog. Short windows with keyboard insets scroll the actions together with the content, including at doubled text size. Existing single suggestion application, comment drafts and original-diff context remain available. + +## Verification and synthetic captures + +Tests were written before the controller methods and batch entry action, and failed for those missing behaviors. A compact keyboard/large-text test then reproduced an action overflow before its layout fix. Controller/widget coverage includes exact selected subsets and drafts, all preflights before one batch, same-thread read deduplication, changed and duplicate patches, sparse/reordered or incomplete confirmation, reservations and pagination, account/view cancellation, success-only refreshes, complete staged read-only recovery and explicit missing-target presentation. Real repository tests cover subpath/PAT routing, null/empty/exact messages, returned server order and write replay protections. + +Layout tests cover five locales at 390, 800 and 1200 logical pixels in both themes and a 320 × 600 window with keyboard insets and doubled text. These captures use dummy fixtures and SDK fonts in Flutter widget tests. No live GitLab write, physical-device or store validation is claimed. Regenerate with `LABFOX_BATCH_UI_CAPTURE=` and `FLUTTER_ROOT=`, running `mr_suggestion_batch_application_test.dart` with the name filter `synthetic batch confirmation capture`. Mobile content scrolls to reveal the remaining patch and commit message. + +| Layout | Light | Dark | +|---|---|---| +| Mobile, 390 × 844 | [Capture](images/mr-suggestion-batch/batch-390-light.png) | [Capture](images/mr-suggestion-batch/batch-390-dark.png) | +| Desktop, 1200 × 900 | [Capture](images/mr-suggestion-batch/batch-1200-light.png) | [Capture](images/mr-suggestion-batch/batch-1200-dark.png) | + +References: [GitLab batch suggestion application](https://docs.gitlab.com/api/suggestions/#apply-multiple-suggestions), [MR discussion inspection](https://docs.gitlab.com/api/discussions/#retrieve-a-merge-request-discussion-item). Multiline original context and positioned thread creation remain separate parity slices.