diff --git a/apps/labfox/lib/features/merge_requests/data/mr_draft_notes_repository.dart b/apps/labfox/lib/features/merge_requests/data/mr_draft_notes_repository.dart index ab961a3e..18ec57f5 100644 --- a/apps/labfox/lib/features/merge_requests/data/mr_draft_notes_repository.dart +++ b/apps/labfox/lib/features/merge_requests/data/mr_draft_notes_repository.dart @@ -80,6 +80,16 @@ class MrDraftNotesRepository { ); } + /// Only regular drafts and complete original text anchors can be edited. + static bool canUpdate(MergeRequestDraftNote draft) { + try { + final position = _positionForUpdate(draft); + return position == null || validTextDiscussionPosition(position); + } on ArgumentError { + return false; + } + } + void _validateTarget(MergeRequestDraftNote draft, int mergeRequestId) { if (mergeRequestId < 1 || draft.id < 1 || @@ -92,7 +102,7 @@ class MrDraftNotesRepository { } } - DiffNotePosition? _positionForUpdate(MergeRequestDraftNote draft) { + static DiffNotePosition? _positionForUpdate(MergeRequestDraftNote draft) { final p = draft.position; final regular = p == null || 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 4425a825..fa530638 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 @@ -353,6 +353,113 @@ class MrDiscussionsController }) => _pendingReview((repository, detail, current, wait) async { final unsettled = _pendingWriteSettled; if (unsettled != null) await wait(unsettled); + final notes = await _readPendingNotes(repository, detail, current, wait); + _pendingNeedsInspection = false; + _pendingWriteSettled = null; + return List.unmodifiable(notes); + }, isCurrent: isCurrent); + + /// Selected body and original metadata must still match fresh private state. + Future updatePendingNote( + MergeRequestDraftNote draft, + String note, { + bool Function()? isCurrent, + }) { + if (note.trim().isEmpty || + !MrDraftNotesRepository.canUpdate(draft) || + pendingSaveNeedsInspection) { + return Future.value(null); + } + return _pendingReview((repository, detail, current, wait) async { + await _confirmPendingTarget(repository, detail, draft, current, wait); + if (!current()) throw const _PendingReviewCancelled(); + _pendingNeedsInspection = true; + final write = repository.update( + projectId: arg.projectId, + iid: arg.iid, + mergeRequestId: detail.id, + draft: draft, + note: note, + ); + _pendingWriteSettled = write.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + final saved = await wait(write); + if (!current()) throw const _PendingReviewCancelled(); + if (saved.id != draft.id || + saved.authorId != repository.authorId || + saved.mergeRequestId != detail.id || + saved.note != note) { + throw const GitLabServerException('Unconfirmed private review update.'); + } + _pendingNeedsInspection = false; + _pendingWriteSettled = null; + ref.read(mrDraftNotesRevisionProvider(arg).notifier).state++; + return saved; + }, isCurrent: isCurrent); + } + + /// Deletion is explicit and never treats a missing draft as success. + Future deletePendingNote( + MergeRequestDraftNote draft, { + bool Function()? isCurrent, + }) async { + if (pendingSaveNeedsInspection) return false; + return await _pendingReview((repository, detail, current, wait) async { + await _confirmPendingTarget(repository, detail, draft, current, wait); + if (!current()) throw const _PendingReviewCancelled(); + _pendingNeedsInspection = true; + final write = repository.delete( + projectId: arg.projectId, + iid: arg.iid, + mergeRequestId: detail.id, + draft: draft, + ); + _pendingWriteSettled = write.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + await wait(write); + if (!current()) throw const _PendingReviewCancelled(); + _pendingNeedsInspection = false; + _pendingWriteSettled = null; + ref.read(mrDraftNotesRevisionProvider(arg).notifier).state++; + return true; + }, isCurrent: isCurrent) ?? + false; + } + + Future _confirmPendingTarget( + MrDraftNotesRepository repository, + MergeRequest detail, + MergeRequestDraftNote selected, + bool Function() current, + _PendingReviewWait wait, + ) async { + if (selected.id < 1 || + selected.authorId != repository.authorId || + selected.mergeRequestId != detail.id) { + throw ArgumentError('A current owned private review target is required.'); + } + final notes = await _readPendingNotes(repository, detail, current, wait); + final matches = notes.where((draft) => draft.id == selected.id); + if (matches.length != 1 || matches.single != selected) { + throw const GitLabConflictException( + 'The selected private review note changed.', + ); + } + // Let session cancellation queued during comparison settle before dispatch. + await wait(Future.value()); + if (!current()) throw const _PendingReviewCancelled(); + } + + Future> _readPendingNotes( + MrDraftNotesRepository repository, + MergeRequest detail, + bool Function() current, + _PendingReviewWait wait, + ) async { final notes = []; final ids = {}; int? page = 1; @@ -375,10 +482,8 @@ class MrDiscussionsController notes.addAll(result.items); page = result.nextPage; } - _pendingNeedsInspection = false; - _pendingWriteSettled = null; return List.unmodifiable(notes); - }, isCurrent: isCurrent); + } /// Shares the existing discussion write/pagination reservation during fresh /// identity reads, private saves and complete read-only recovery. diff --git a/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_pending_review_composer.dart b/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_pending_review_composer.dart index 2c4ef9ad..567bb37a 100644 --- a/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_pending_review_composer.dart +++ b/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_pending_review_composer.dart @@ -7,6 +7,7 @@ import 'package:gitlab_models/gitlab_models.dart'; import '../../../../core/auth/auth_controller.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../comments/presentation/controllers/comments_controller.dart'; +import '../../data/mr_draft_notes_repository.dart'; import '../controllers/merge_requests_controllers.dart'; import '../controllers/mr_discussions_controller.dart'; import '../controllers/mr_draft_notes_provider.dart'; @@ -120,6 +121,32 @@ class _EntryState extends ConsumerState { } } +/// Shares private input and recovery guards with regular draft creation. +Future showMrPendingReviewMaintenanceDialog({ + required BuildContext context, + required MergeRequestRef resource, + required Account account, + required Object draftSession, + required Object detailSession, + required ValueNotifier viewActive, + required bool Function() isCurrent, + required MergeRequestDraftNote draft, + required bool deleting, +}) => showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _ComposeDialog( + resource: resource, + account: account, + draftSession: draftSession, + detailSession: detailSession, + viewActive: viewActive, + isCurrent: isCurrent, + draft: draft, + deleting: deleting, + ), +); + class _ComposeDialog extends ConsumerStatefulWidget { const _ComposeDialog({ required this.resource, @@ -128,6 +155,8 @@ class _ComposeDialog extends ConsumerStatefulWidget { required this.detailSession, required this.viewActive, required this.isCurrent, + this.draft, + this.deleting = false, }); final MergeRequestRef resource; final Account account; @@ -135,12 +164,16 @@ class _ComposeDialog extends ConsumerStatefulWidget { final Object detailSession; final ValueNotifier viewActive; final bool Function() isCurrent; + final MergeRequestDraftNote? draft; + final bool deleting; @override ConsumerState<_ComposeDialog> createState() => _ComposeState(); } class _ComposeState extends ConsumerState<_ComposeDialog> { final _text = TextEditingController(); + MergeRequestDraftNote? _target; + bool get _maintenance => widget.draft != null; Object? _commentsSession; bool _obsolete = false, _busy = false, _inspecting = false; bool _needsInspection = false, @@ -151,6 +184,8 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { @override void initState() { super.initState(); + _target = widget.draft; + if (_maintenance && !widget.deleting) _text.text = _target!.note; widget.viewActive.addListener(_observe); _text.addListener(_edited); } @@ -191,6 +226,7 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { _text.clear(); setState(() { _inspection = null; + _target = null; _acknowledged = false; }); }); @@ -201,7 +237,13 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { } Future _save() async { - if (_busy || !_current() || _text.text.trim().isEmpty) return; + if (_busy || + !_current() || + (!widget.deleting && _text.text.trim().isEmpty) || + (_maintenance && _target == null) || + (widget.deleting && !_acknowledged)) { + return; + } final controller = ref.read( mrDiscussionsControllerProvider(widget.resource).notifier, ); @@ -217,24 +259,36 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { _inspectionError = false; }); try { - final saved = await controller.savePendingNote( - _text.text, - isCurrent: _current, - ); + final saved = _maintenance + ? widget.deleting + ? (await controller.deletePendingNote( + _target!, + isCurrent: _current, + ) + ? true + : null) + : await controller.updatePendingNote( + _target!, + _text.text, + isCurrent: _current, + ) + : await controller.savePendingNote(_text.text, isCurrent: _current); if (!mounted || !_current()) return; if (saved != null) { Navigator.of(context).pop(true); } else { setState(() { _saveError = true; - _needsInspection = controller.pendingSaveNeedsInspection; + _needsInspection = + _maintenance || controller.pendingSaveNeedsInspection; }); } } catch (_) { if (_current()) { setState(() { _saveError = true; - _needsInspection = controller.pendingSaveNeedsInspection; + _needsInspection = + _maintenance || controller.pendingSaveNeedsInspection; }); } } finally { @@ -259,6 +313,10 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { setState(() { _inspection = notes; _inspectionError = notes == null; + if (_maintenance && notes != null) { + final matches = notes.where((note) => note.id == widget.draft!.id); + _target = matches.length == 1 ? matches.single : null; + } }); } catch (_) { if (_current()) setState(() => _inspectionError = true); @@ -336,7 +394,14 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { final canSave = ready && !_busy && - _text.text.trim().isNotEmpty && + (widget.deleting + ? _acknowledged + : _text.text.trim().isNotEmpty && + (!_maintenance || + (_target != null && + MrDraftNotesRepository.canUpdate(_target!) && + _text.text != _target!.note))) && + (!_maintenance || _target != null) && !gated && (!_needsInspection || (_inspection != null && _acknowledged)); return PopScope( @@ -344,7 +409,13 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { child: AlertDialog( scrollable: true, insetPadding: const EdgeInsets.all(LabFoxSpacing.md), - title: Text(l.mrPendingComposeTitle), + title: Text( + _maintenance + ? (widget.deleting + ? l.mrPendingDeleteTitle + : l.mrPendingEditTitle) + : l.mrPendingComposeTitle, + ), content: SizedBox( width: LabFoxBreakpoints.tablet, child: Column( @@ -353,18 +424,31 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { if (!current) Text(l.mrPendingComposeChanged) else ...[ - Text(l.mrPendingComposeHint), + Text( + _maintenance + ? (widget.deleting + ? l.mrPendingDeleteHint + : l.mrPendingEditHint) + : l.mrPendingComposeHint, + ), + if (_maintenance && _target != null) ...[ + const SizedBox(height: LabFoxSpacing.sm), + MrPendingReviewNote(draft: _target!), + ], + if (_maintenance && _target == null && _inspection != null) + Text(l.mrPendingTargetMissing), const SizedBox(height: LabFoxSpacing.md), - TextField( - key: const ValueKey('mr-pending-compose-input'), - controller: _text, - enabled: !_busy, - minLines: 3, - maxLines: 6, - decoration: InputDecoration( - labelText: l.mrPendingComposeLabel, + if (!widget.deleting) + TextField( + key: const ValueKey('mr-pending-compose-input'), + controller: _text, + enabled: !_busy, + minLines: 3, + maxLines: 6, + decoration: InputDecoration( + labelText: l.mrPendingComposeLabel, + ), ), - ), if (preparing && !_busy) ...[ const SizedBox(height: LabFoxSpacing.sm), LinearProgressIndicator( @@ -383,10 +467,31 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { ), ), ], - if (_saveError) Text(l.mrPendingComposeSaveError), + if (_saveError) + Text( + _maintenance + ? l.mrPendingMaintenanceError + : l.mrPendingComposeSaveError, + ), + if (widget.deleting && !_needsInspection) + CheckboxListTile( + key: const ValueKey('mr-pending-compose-acknowledge'), + contentPadding: EdgeInsets.zero, + value: _acknowledged, + controlAffinity: ListTileControlAffinity.leading, + title: Text(l.mrPendingDeleteConfirm), + onChanged: _busy + ? null + : (value) => + setState(() => _acknowledged = value ?? false), + ), if (_needsInspection) ...[ const SizedBox(height: LabFoxSpacing.md), - Text(l.mrPendingComposeUncertain), + Text( + _maintenance + ? l.mrPendingMaintenanceUncertain + : l.mrPendingComposeUncertain, + ), ], if (_inspectionError) Text(l.mrPendingComposeInspectError), if (_inspection case final notes?) ...[ @@ -395,7 +500,11 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { l.mrPendingComposeInspectionTitle, style: Theme.of(context).textTheme.titleSmall, ), - Text(l.mrPendingComposeInspectionHint), + Text( + _maintenance + ? l.mrPendingMaintenanceInspectionHint + : l.mrPendingComposeInspectionHint, + ), Text(l.mrPendingReviewCount(notes.length)), if (notes.isEmpty) Text(l.mrPendingReviewEmpty) @@ -415,28 +524,40 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { ), ), ), - CheckboxListTile( - key: const ValueKey('mr-pending-compose-acknowledge'), - contentPadding: EdgeInsets.zero, - value: _acknowledged, - controlAffinity: ListTileControlAffinity.leading, - title: Text(l.mrPendingComposeAcknowledge), - onChanged: _busy - ? null - : (value) => - setState(() => _acknowledged = value ?? false), - ), + if (!_maintenance || + (_target != null && + (widget.deleting || + MrDraftNotesRepository.canUpdate(_target!)))) + CheckboxListTile( + key: const ValueKey('mr-pending-compose-acknowledge'), + contentPadding: EdgeInsets.zero, + value: _acknowledged, + controlAffinity: ListTileControlAffinity.leading, + title: Text( + _maintenance + ? l.mrPendingMaintenanceAcknowledge + : l.mrPendingComposeAcknowledge, + ), + onChanged: _busy + ? null + : (value) => + setState(() => _acknowledged = value ?? false), + ), ], if (_busy) ...[ const SizedBox(height: LabFoxSpacing.md), LinearProgressIndicator( semanticsLabel: _inspecting ? l.mrPendingComposeInspecting + : widget.deleting + ? l.mrPendingDeleting : l.mrPendingComposeSaving, ), Text( _inspecting ? l.mrPendingComposeInspecting + : widget.deleting + ? l.mrPendingDeleting : l.mrPendingComposeSaving, ), ], @@ -464,7 +585,13 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { TextButton( key: const ValueKey('mr-pending-compose-save'), onPressed: canSave ? _save : null, - child: Text(l.mrPendingComposeSave), + child: Text( + _maintenance + ? (widget.deleting + ? l.mrPendingDelete + : l.mrPendingUpdate) + : l.mrPendingComposeSave, + ), ), ], ), diff --git a/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_pending_review_panel.dart b/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_pending_review_panel.dart index 6ebddf17..90fa74dc 100644 --- a/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_pending_review_panel.dart +++ b/apps/labfox/lib/features/merge_requests/presentation/widgets/mr_pending_review_panel.dart @@ -2,9 +2,11 @@ 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 '../../../../core/auth/auth_controller.dart'; import '../../../../l10n/app_localizations.dart'; +import '../../data/mr_draft_notes_repository.dart'; import '../controllers/merge_requests_controllers.dart'; import '../controllers/mr_draft_notes_provider.dart'; import '../controllers/mr_pending_review_controller.dart'; @@ -12,12 +14,91 @@ import 'mr_pending_review_composer.dart'; import 'mr_pending_review_note.dart'; /// Private saved notes and a regular composer using current MR detail. -class MrPendingReviewPanel extends ConsumerWidget { +class MrPendingReviewPanel extends ConsumerStatefulWidget { const MrPendingReviewPanel({required this.mergeRequest, super.key}); final MergeRequestRef mergeRequest; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _PanelState(); +} + +class _PanelState extends ConsumerState { + bool _opening = false; + ValueNotifier? _view; + MergeRequestRef get mergeRequest => widget.mergeRequest; + @override + void dispose() { + _view?.value = false; + super.dispose(); + } + + @override + void didUpdateWidget(MrPendingReviewPanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.mergeRequest != mergeRequest) _view?.value = false; + } + + Future _openDraft(MergeRequestDraftNote draft, bool deleting) async { + if (_opening) return; + final account = ref.read(currentAccountProvider); + final drafts = ref.read(mrDraftNotesRepositoryProvider).unwrapPrevious(); + final source = ref.read(mergeRequestsRepositoryProvider).unwrapPrevious(); + if (account == null || + drafts.valueOrNull == null || + source.valueOrNull == null) { + return; + } + final resource = mergeRequest; + final active = ValueNotifier(true); + _view = active; + setState(() => _opening = true); + try { + final saved = await showMrPendingReviewMaintenanceDialog( + context: context, + resource: resource, + account: account, + draftSession: drafts.value!, + detailSession: source.value!, + viewActive: active, + isCurrent: () => mounted && mergeRequest == resource, + draft: draft, + deleting: deleting, + ); + if (mounted && + active.value && + saved == true && + mergeRequest == resource && + ref.read(currentAccountProvider) == account && + identical( + ref + .read(mrDraftNotesRepositoryProvider) + .unwrapPrevious() + .valueOrNull, + drafts.value, + ) && + identical( + ref + .read(mergeRequestsRepositoryProvider) + .unwrapPrevious() + .valueOrNull, + source.value, + )) { + final l = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(deleting ? l.mrPendingDeleted : l.mrPendingUpdated), + ), + ); + } + } finally { + _view = null; + active.dispose(); + if (mounted) setState(() => _opening = false); + } + } + + @override + Widget build(BuildContext context) { final account = ref.watch(currentAccountProvider); if (account == null) return const SizedBox.shrink(); final l10n = AppLocalizations.of(context); @@ -61,6 +142,8 @@ class MrPendingReviewPanel extends ConsumerWidget { ref.watch(mrDraftNotesRepositoryProvider).valueOrNull, )), value: value, + onEdit: _opening ? null : (draft) => _openDraft(draft, false), + onDelete: _opening ? null : (draft) => _openDraft(draft, true), onRefresh: refresh, onLoadMore: () async { try { @@ -147,11 +230,14 @@ class _Loaded extends StatelessWidget { required this.value, required this.onRefresh, required this.onLoadMore, + required this.onEdit, + required this.onDelete, super.key, }); final MrPendingReviewDrafts value; final VoidCallback onRefresh; final VoidCallback onLoadMore; + final void Function(MergeRequestDraftNote)? onEdit, onDelete; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -187,10 +273,36 @@ class _Loaded extends StatelessWidget { itemCount: value.items.length, separatorBuilder: (_, _) => const Divider(height: LabFoxSpacing.xl), - itemBuilder: (context, index) => MrPendingReviewNote( - key: ValueKey(value.items[index].id), - draft: value.items[index], - ), + itemBuilder: (context, index) { + final draft = value.items[index]; + return Column( + key: ValueKey(draft.id), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MrPendingReviewNote(draft: draft), + Wrap( + spacing: LabFoxSpacing.sm, + children: [ + if (MrDraftNotesRepository.canUpdate(draft)) + TextButton( + key: ValueKey('mr-pending-edit-${draft.id}'), + onPressed: onEdit == null + ? null + : () => onEdit!(draft), + child: Text(l10n.mrPendingEdit), + ), + TextButton( + key: ValueKey('mr-pending-delete-${draft.id}'), + onPressed: onDelete == null + ? null + : () => onDelete!(draft), + child: Text(l10n.mrPendingDelete), + ), + ], + ), + ], + ); + }, ), ), if (!value.isComplete) ...[ diff --git a/apps/labfox/lib/l10n/app_en.arb b/apps/labfox/lib/l10n/app_en.arb index 94d56809..16ddf518 100644 --- a/apps/labfox/lib/l10n/app_en.arb +++ b/apps/labfox/lib/l10n/app_en.arb @@ -2907,5 +2907,69 @@ "mrPendingComposeChanged": "The review session changed. Close this dialog and start again.", "@mrPendingComposeChanged": { "description": "Private regular MR review composer changed." + }, + "mrPendingEdit": "Edit note", + "@mrPendingEdit": { + "description": "Private pending review note maintenance: Edit." + }, + "mrPendingDelete": "Delete note", + "@mrPendingDelete": { + "description": "Private pending review note maintenance: Delete." + }, + "mrPendingUpdate": "Save changes", + "@mrPendingUpdate": { + "description": "Private pending review note maintenance: Update." + }, + "mrPendingEditTitle": "Edit pending review note", + "@mrPendingEditTitle": { + "description": "Private pending review note maintenance: EditTitle." + }, + "mrPendingDeleteTitle": "Delete pending review note", + "@mrPendingDeleteTitle": { + "description": "Private pending review note maintenance: DeleteTitle." + }, + "mrPendingEditHint": "Your changes stay private. The original note location is preserved.", + "@mrPendingEditHint": { + "description": "Private pending review note maintenance: EditHint." + }, + "mrPendingDeleteHint": "This removes only this private saved note from your pending review.", + "@mrPendingDeleteHint": { + "description": "Private pending review note maintenance: DeleteHint." + }, + "mrPendingDeleteConfirm": "I want to delete the note shown above.", + "@mrPendingDeleteConfirm": { + "description": "Private pending review note maintenance: DeleteConfirm." + }, + "mrPendingUpdated": "Your private review note was updated.", + "@mrPendingUpdated": { + "description": "Private pending review note maintenance: Updated." + }, + "mrPendingDeleted": "Your private review note was deleted.", + "@mrPendingDeleted": { + "description": "Private pending review note maintenance: Deleted." + }, + "mrPendingMaintenanceError": "We couldn't confirm this change. Check the current pending review before trying again.", + "@mrPendingMaintenanceError": { + "description": "Private pending review note maintenance: MaintenanceError." + }, + "mrPendingMaintenanceUncertain": "A change may already have been applied. Check your saved notes before making another change.", + "@mrPendingMaintenanceUncertain": { + "description": "Private pending review note maintenance: MaintenanceUncertain." + }, + "mrPendingMaintenanceInspectionHint": "These are all currently readable saved notes. Check the selected note above before changing it again.", + "@mrPendingMaintenanceInspectionHint": { + "description": "Private pending review note maintenance: MaintenanceInspectionHint." + }, + "mrPendingMaintenanceAcknowledge": "I checked the current selected note and want to apply this change.", + "@mrPendingMaintenanceAcknowledge": { + "description": "Private pending review note maintenance: MaintenanceAcknowledge." + }, + "mrPendingTargetMissing": "The selected note is no longer available. Close this dialog and refresh your pending review.", + "@mrPendingTargetMissing": { + "description": "Private pending review note maintenance: TargetMissing." + }, + "mrPendingDeleting": "Deleting review note…", + "@mrPendingDeleting": { + "description": "Progress while deleting one private pending review note." } } diff --git a/apps/labfox/lib/l10n/app_hi.arb b/apps/labfox/lib/l10n/app_hi.arb index 6a320087..16790659 100644 --- a/apps/labfox/lib/l10n/app_hi.arb +++ b/apps/labfox/lib/l10n/app_hi.arb @@ -1398,5 +1398,21 @@ "mrPendingComposeInspectionTitle": "वर्तमान अप्रकाशित समीक्षा", "mrPendingComposeInspectionHint": "ये आपके वर्तमान सहेजे गए नोट हैं। समान पाठ से यह पता नहीं चलता कि किस अनुरोध ने नोट बनाया।", "mrPendingComposeAcknowledge": "मैंने ये नोट जाँच लिए हैं और एक और नोट सहेजना चाहता हूँ।", - "mrPendingComposeChanged": "समीक्षा सत्र बदल गया है। यह संवाद बंद करके फिर से शुरू करें।" + "mrPendingComposeChanged": "समीक्षा सत्र बदल गया है। यह संवाद बंद करके फिर से शुरू करें।", + "mrPendingEdit": "नोट संपादित करें", + "mrPendingDelete": "नोट हटाएं", + "mrPendingUpdate": "बदलाव सहेजें", + "mrPendingEditTitle": "लंबित समीक्षा नोट संपादित करें", + "mrPendingDeleteTitle": "लंबित समीक्षा नोट हटाएं", + "mrPendingEditHint": "आपके बदलाव निजी रहते हैं। नोट का मूल स्थान सुरक्षित रहता है।", + "mrPendingDeleteHint": "इससे आपकी लंबित समीक्षा से केवल यह निजी सहेजा गया नोट हटता है।", + "mrPendingDeleteConfirm": "मैं ऊपर दिखाया गया नोट हटाना चाहता/चाहती हूं।", + "mrPendingUpdated": "आपका निजी समीक्षा नोट अपडेट हो गया।", + "mrPendingDeleted": "आपका निजी समीक्षा नोट हटा दिया गया।", + "mrPendingMaintenanceError": "हम इस बदलाव की पुष्टि नहीं कर सके। दोबारा कोशिश करने से पहले मौजूदा लंबित समीक्षा जांचें।", + "mrPendingMaintenanceUncertain": "बदलाव पहले ही लागू हो सकता है। अगला बदलाव करने से पहले अपने सहेजे गए नोट जांचें।", + "mrPendingMaintenanceInspectionHint": "ये अभी पढ़े जा सकने वाले सभी सहेजे गए नोट हैं। दोबारा बदलने से पहले ऊपर चुना गया नोट जांचें।", + "mrPendingMaintenanceAcknowledge": "मैंने मौजूदा चुना हुआ नोट जांच लिया है और यह बदलाव लागू करना चाहता/चाहती हूं।", + "mrPendingTargetMissing": "चुना गया नोट अब उपलब्ध नहीं है। यह संवाद बंद करें और लंबित समीक्षा रीफ़्रेश करें।", + "mrPendingDeleting": "समीक्षा नोट हटाया जा रहा है…" } diff --git a/apps/labfox/lib/l10n/app_ja.arb b/apps/labfox/lib/l10n/app_ja.arb index 8864361a..22e344ef 100644 --- a/apps/labfox/lib/l10n/app_ja.arb +++ b/apps/labfox/lib/l10n/app_ja.arb @@ -1398,5 +1398,21 @@ "mrPendingComposeInspectionTitle": "現在の未公開レビュー", "mrPendingComposeInspectionHint": "現在保存されているノートです。同じ内容でも、どの保存リクエストで作成されたかは特定できません。", "mrPendingComposeAcknowledge": "これらのノートを確認し、別のノートを保存します。", - "mrPendingComposeChanged": "レビューのセッションが変更されました。この画面を閉じて、やり直してください。" + "mrPendingComposeChanged": "レビューのセッションが変更されました。この画面を閉じて、やり直してください。", + "mrPendingEdit": "ノートを編集", + "mrPendingDelete": "ノートを削除", + "mrPendingUpdate": "変更を保存", + "mrPendingEditTitle": "保留中のレビューノートを編集", + "mrPendingDeleteTitle": "保留中のレビューノートを削除", + "mrPendingEditHint": "変更は非公開のままです。元のノートの位置は保持されます。", + "mrPendingDeleteHint": "保留中のレビューから、この非公開の保存済みノートだけを削除します。", + "mrPendingDeleteConfirm": "上に表示されたノートを削除します。", + "mrPendingUpdated": "非公開のレビューノートを更新しました。", + "mrPendingDeleted": "非公開のレビューノートを削除しました。", + "mrPendingMaintenanceError": "この変更を確認できませんでした。再試行する前に、現在の保留中のレビューを確認してください。", + "mrPendingMaintenanceUncertain": "変更はすでに適用されている可能性があります。次の変更を行う前に、保存済みノートを確認してください。", + "mrPendingMaintenanceInspectionHint": "現在読み取れる保存済みノートの一覧です。再度変更する前に、上の選択したノートを確認してください。", + "mrPendingMaintenanceAcknowledge": "現在選択されているノートを確認し、この変更を適用します。", + "mrPendingTargetMissing": "選択したノートは利用できなくなりました。この画面を閉じて、保留中のレビューを更新してください。", + "mrPendingDeleting": "レビューノートを削除中…" } diff --git a/apps/labfox/lib/l10n/app_ko.arb b/apps/labfox/lib/l10n/app_ko.arb index 64c6f43e..d95da14e 100644 --- a/apps/labfox/lib/l10n/app_ko.arb +++ b/apps/labfox/lib/l10n/app_ko.arb @@ -1398,5 +1398,21 @@ "mrPendingComposeInspectionTitle": "현재 미게시 리뷰", "mrPendingComposeInspectionHint": "현재 저장된 노트입니다. 내용이 같아도 어느 저장 요청으로 생성되었는지는 알 수 없습니다.", "mrPendingComposeAcknowledge": "이 노트들을 확인했으며 다른 노트를 저장하겠습니다.", - "mrPendingComposeChanged": "리뷰 세션이 변경되었습니다. 이 창을 닫고 다시 시작하세요." + "mrPendingComposeChanged": "리뷰 세션이 변경되었습니다. 이 창을 닫고 다시 시작하세요.", + "mrPendingEdit": "메모 수정", + "mrPendingDelete": "메모 삭제", + "mrPendingUpdate": "변경 사항 저장", + "mrPendingEditTitle": "대기 중인 리뷰 메모 수정", + "mrPendingDeleteTitle": "대기 중인 리뷰 메모 삭제", + "mrPendingEditHint": "변경 사항은 비공개로 유지됩니다. 원래 메모 위치는 그대로 유지됩니다.", + "mrPendingDeleteHint": "대기 중인 리뷰에서 이 비공개 저장 메모만 삭제합니다.", + "mrPendingDeleteConfirm": "위에 표시된 메모를 삭제하겠습니다.", + "mrPendingUpdated": "비공개 리뷰 메모를 수정했습니다.", + "mrPendingDeleted": "비공개 리뷰 메모를 삭제했습니다.", + "mrPendingMaintenanceError": "변경 결과를 확인하지 못했습니다. 다시 시도하기 전에 현재 대기 중인 리뷰를 확인하세요.", + "mrPendingMaintenanceUncertain": "변경 사항이 이미 적용되었을 수 있습니다. 다른 변경 전에 저장된 메모를 확인하세요.", + "mrPendingMaintenanceInspectionHint": "현재 읽을 수 있는 저장 메모 전체입니다. 다시 변경하기 전에 위에서 선택한 메모를 확인하세요.", + "mrPendingMaintenanceAcknowledge": "현재 선택된 메모를 확인했으며 이 변경을 적용하겠습니다.", + "mrPendingTargetMissing": "선택한 메모를 더 이상 사용할 수 없습니다. 창을 닫고 대기 중인 리뷰를 새로고침하세요.", + "mrPendingDeleting": "리뷰 메모 삭제 중…" } diff --git a/apps/labfox/lib/l10n/app_localizations.dart b/apps/labfox/lib/l10n/app_localizations.dart index 322fc258..37af269c 100644 --- a/apps/labfox/lib/l10n/app_localizations.dart +++ b/apps/labfox/lib/l10n/app_localizations.dart @@ -8959,6 +8959,102 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'The review session changed. Close this dialog and start again.'** String get mrPendingComposeChanged; + + /// Private pending review note maintenance: Edit. + /// + /// In en, this message translates to: + /// **'Edit note'** + String get mrPendingEdit; + + /// Private pending review note maintenance: Delete. + /// + /// In en, this message translates to: + /// **'Delete note'** + String get mrPendingDelete; + + /// Private pending review note maintenance: Update. + /// + /// In en, this message translates to: + /// **'Save changes'** + String get mrPendingUpdate; + + /// Private pending review note maintenance: EditTitle. + /// + /// In en, this message translates to: + /// **'Edit pending review note'** + String get mrPendingEditTitle; + + /// Private pending review note maintenance: DeleteTitle. + /// + /// In en, this message translates to: + /// **'Delete pending review note'** + String get mrPendingDeleteTitle; + + /// Private pending review note maintenance: EditHint. + /// + /// In en, this message translates to: + /// **'Your changes stay private. The original note location is preserved.'** + String get mrPendingEditHint; + + /// Private pending review note maintenance: DeleteHint. + /// + /// In en, this message translates to: + /// **'This removes only this private saved note from your pending review.'** + String get mrPendingDeleteHint; + + /// Private pending review note maintenance: DeleteConfirm. + /// + /// In en, this message translates to: + /// **'I want to delete the note shown above.'** + String get mrPendingDeleteConfirm; + + /// Private pending review note maintenance: Updated. + /// + /// In en, this message translates to: + /// **'Your private review note was updated.'** + String get mrPendingUpdated; + + /// Private pending review note maintenance: Deleted. + /// + /// In en, this message translates to: + /// **'Your private review note was deleted.'** + String get mrPendingDeleted; + + /// Private pending review note maintenance: MaintenanceError. + /// + /// In en, this message translates to: + /// **'We couldn\'t confirm this change. Check the current pending review before trying again.'** + String get mrPendingMaintenanceError; + + /// Private pending review note maintenance: MaintenanceUncertain. + /// + /// In en, this message translates to: + /// **'A change may already have been applied. Check your saved notes before making another change.'** + String get mrPendingMaintenanceUncertain; + + /// Private pending review note maintenance: MaintenanceInspectionHint. + /// + /// In en, this message translates to: + /// **'These are all currently readable saved notes. Check the selected note above before changing it again.'** + String get mrPendingMaintenanceInspectionHint; + + /// Private pending review note maintenance: MaintenanceAcknowledge. + /// + /// In en, this message translates to: + /// **'I checked the current selected note and want to apply this change.'** + String get mrPendingMaintenanceAcknowledge; + + /// Private pending review note maintenance: TargetMissing. + /// + /// In en, this message translates to: + /// **'The selected note is no longer available. Close this dialog and refresh your pending review.'** + String get mrPendingTargetMissing; + + /// Progress while deleting one private pending review note. + /// + /// In en, this message translates to: + /// **'Deleting review note…'** + String get mrPendingDeleting; } class _AppLocalizationsDelegate diff --git a/apps/labfox/lib/l10n/app_localizations_en.dart b/apps/labfox/lib/l10n/app_localizations_en.dart index be760b39..f10535b3 100644 --- a/apps/labfox/lib/l10n/app_localizations_en.dart +++ b/apps/labfox/lib/l10n/app_localizations_en.dart @@ -5109,4 +5109,59 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mrPendingComposeChanged => 'The review session changed. Close this dialog and start again.'; + + @override + String get mrPendingEdit => 'Edit note'; + + @override + String get mrPendingDelete => 'Delete note'; + + @override + String get mrPendingUpdate => 'Save changes'; + + @override + String get mrPendingEditTitle => 'Edit pending review note'; + + @override + String get mrPendingDeleteTitle => 'Delete pending review note'; + + @override + String get mrPendingEditHint => + 'Your changes stay private. The original note location is preserved.'; + + @override + String get mrPendingDeleteHint => + 'This removes only this private saved note from your pending review.'; + + @override + String get mrPendingDeleteConfirm => 'I want to delete the note shown above.'; + + @override + String get mrPendingUpdated => 'Your private review note was updated.'; + + @override + String get mrPendingDeleted => 'Your private review note was deleted.'; + + @override + String get mrPendingMaintenanceError => + 'We couldn\'t confirm this change. Check the current pending review before trying again.'; + + @override + String get mrPendingMaintenanceUncertain => + 'A change may already have been applied. Check your saved notes before making another change.'; + + @override + String get mrPendingMaintenanceInspectionHint => + 'These are all currently readable saved notes. Check the selected note above before changing it again.'; + + @override + String get mrPendingMaintenanceAcknowledge => + 'I checked the current selected note and want to apply this change.'; + + @override + String get mrPendingTargetMissing => + 'The selected note is no longer available. Close this dialog and refresh your pending review.'; + + @override + String get mrPendingDeleting => 'Deleting review note…'; } diff --git a/apps/labfox/lib/l10n/app_localizations_hi.dart b/apps/labfox/lib/l10n/app_localizations_hi.dart index 65a52f90..6e1ebe48 100644 --- a/apps/labfox/lib/l10n/app_localizations_hi.dart +++ b/apps/labfox/lib/l10n/app_localizations_hi.dart @@ -5105,4 +5105,60 @@ class AppLocalizationsHi extends AppLocalizations { @override String get mrPendingComposeChanged => 'समीक्षा सत्र बदल गया है। यह संवाद बंद करके फिर से शुरू करें।'; + + @override + String get mrPendingEdit => 'नोट संपादित करें'; + + @override + String get mrPendingDelete => 'नोट हटाएं'; + + @override + String get mrPendingUpdate => 'बदलाव सहेजें'; + + @override + String get mrPendingEditTitle => 'लंबित समीक्षा नोट संपादित करें'; + + @override + String get mrPendingDeleteTitle => 'लंबित समीक्षा नोट हटाएं'; + + @override + String get mrPendingEditHint => + 'आपके बदलाव निजी रहते हैं। नोट का मूल स्थान सुरक्षित रहता है।'; + + @override + String get mrPendingDeleteHint => + 'इससे आपकी लंबित समीक्षा से केवल यह निजी सहेजा गया नोट हटता है।'; + + @override + String get mrPendingDeleteConfirm => + 'मैं ऊपर दिखाया गया नोट हटाना चाहता/चाहती हूं।'; + + @override + String get mrPendingUpdated => 'आपका निजी समीक्षा नोट अपडेट हो गया।'; + + @override + String get mrPendingDeleted => 'आपका निजी समीक्षा नोट हटा दिया गया।'; + + @override + String get mrPendingMaintenanceError => + 'हम इस बदलाव की पुष्टि नहीं कर सके। दोबारा कोशिश करने से पहले मौजूदा लंबित समीक्षा जांचें।'; + + @override + String get mrPendingMaintenanceUncertain => + 'बदलाव पहले ही लागू हो सकता है। अगला बदलाव करने से पहले अपने सहेजे गए नोट जांचें।'; + + @override + String get mrPendingMaintenanceInspectionHint => + 'ये अभी पढ़े जा सकने वाले सभी सहेजे गए नोट हैं। दोबारा बदलने से पहले ऊपर चुना गया नोट जांचें।'; + + @override + String get mrPendingMaintenanceAcknowledge => + 'मैंने मौजूदा चुना हुआ नोट जांच लिया है और यह बदलाव लागू करना चाहता/चाहती हूं।'; + + @override + String get mrPendingTargetMissing => + 'चुना गया नोट अब उपलब्ध नहीं है। यह संवाद बंद करें और लंबित समीक्षा रीफ़्रेश करें।'; + + @override + String get mrPendingDeleting => 'समीक्षा नोट हटाया जा रहा है…'; } diff --git a/apps/labfox/lib/l10n/app_localizations_ja.dart b/apps/labfox/lib/l10n/app_localizations_ja.dart index fcf75cb4..8487def1 100644 --- a/apps/labfox/lib/l10n/app_localizations_ja.dart +++ b/apps/labfox/lib/l10n/app_localizations_ja.dart @@ -4930,4 +4930,56 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mrPendingComposeChanged => 'レビューのセッションが変更されました。この画面を閉じて、やり直してください。'; + + @override + String get mrPendingEdit => 'ノートを編集'; + + @override + String get mrPendingDelete => 'ノートを削除'; + + @override + String get mrPendingUpdate => '変更を保存'; + + @override + String get mrPendingEditTitle => '保留中のレビューノートを編集'; + + @override + String get mrPendingDeleteTitle => '保留中のレビューノートを削除'; + + @override + String get mrPendingEditHint => '変更は非公開のままです。元のノートの位置は保持されます。'; + + @override + String get mrPendingDeleteHint => '保留中のレビューから、この非公開の保存済みノートだけを削除します。'; + + @override + String get mrPendingDeleteConfirm => '上に表示されたノートを削除します。'; + + @override + String get mrPendingUpdated => '非公開のレビューノートを更新しました。'; + + @override + String get mrPendingDeleted => '非公開のレビューノートを削除しました。'; + + @override + String get mrPendingMaintenanceError => + 'この変更を確認できませんでした。再試行する前に、現在の保留中のレビューを確認してください。'; + + @override + String get mrPendingMaintenanceUncertain => + '変更はすでに適用されている可能性があります。次の変更を行う前に、保存済みノートを確認してください。'; + + @override + String get mrPendingMaintenanceInspectionHint => + '現在読み取れる保存済みノートの一覧です。再度変更する前に、上の選択したノートを確認してください。'; + + @override + String get mrPendingMaintenanceAcknowledge => '現在選択されているノートを確認し、この変更を適用します。'; + + @override + String get mrPendingTargetMissing => + '選択したノートは利用できなくなりました。この画面を閉じて、保留中のレビューを更新してください。'; + + @override + String get mrPendingDeleting => 'レビューノートを削除中…'; } diff --git a/apps/labfox/lib/l10n/app_localizations_ko.dart b/apps/labfox/lib/l10n/app_localizations_ko.dart index f708e060..2fa4a395 100644 --- a/apps/labfox/lib/l10n/app_localizations_ko.dart +++ b/apps/labfox/lib/l10n/app_localizations_ko.dart @@ -4935,4 +4935,57 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mrPendingComposeChanged => '리뷰 세션이 변경되었습니다. 이 창을 닫고 다시 시작하세요.'; + + @override + String get mrPendingEdit => '메모 수정'; + + @override + String get mrPendingDelete => '메모 삭제'; + + @override + String get mrPendingUpdate => '변경 사항 저장'; + + @override + String get mrPendingEditTitle => '대기 중인 리뷰 메모 수정'; + + @override + String get mrPendingDeleteTitle => '대기 중인 리뷰 메모 삭제'; + + @override + String get mrPendingEditHint => '변경 사항은 비공개로 유지됩니다. 원래 메모 위치는 그대로 유지됩니다.'; + + @override + String get mrPendingDeleteHint => '대기 중인 리뷰에서 이 비공개 저장 메모만 삭제합니다.'; + + @override + String get mrPendingDeleteConfirm => '위에 표시된 메모를 삭제하겠습니다.'; + + @override + String get mrPendingUpdated => '비공개 리뷰 메모를 수정했습니다.'; + + @override + String get mrPendingDeleted => '비공개 리뷰 메모를 삭제했습니다.'; + + @override + String get mrPendingMaintenanceError => + '변경 결과를 확인하지 못했습니다. 다시 시도하기 전에 현재 대기 중인 리뷰를 확인하세요.'; + + @override + String get mrPendingMaintenanceUncertain => + '변경 사항이 이미 적용되었을 수 있습니다. 다른 변경 전에 저장된 메모를 확인하세요.'; + + @override + String get mrPendingMaintenanceInspectionHint => + '현재 읽을 수 있는 저장 메모 전체입니다. 다시 변경하기 전에 위에서 선택한 메모를 확인하세요.'; + + @override + String get mrPendingMaintenanceAcknowledge => + '현재 선택된 메모를 확인했으며 이 변경을 적용하겠습니다.'; + + @override + String get mrPendingTargetMissing => + '선택한 메모를 더 이상 사용할 수 없습니다. 창을 닫고 대기 중인 리뷰를 새로고침하세요.'; + + @override + String get mrPendingDeleting => '리뷰 메모 삭제 중…'; } diff --git a/apps/labfox/lib/l10n/app_localizations_zh.dart b/apps/labfox/lib/l10n/app_localizations_zh.dart index 47684004..a77b5a04 100644 --- a/apps/labfox/lib/l10n/app_localizations_zh.dart +++ b/apps/labfox/lib/l10n/app_localizations_zh.dart @@ -4777,4 +4777,53 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mrPendingComposeChanged => '审阅会话已更改。请关闭此对话框并重新开始。'; + + @override + String get mrPendingEdit => '编辑笔记'; + + @override + String get mrPendingDelete => '删除笔记'; + + @override + String get mrPendingUpdate => '保存更改'; + + @override + String get mrPendingEditTitle => '编辑待发布的审查笔记'; + + @override + String get mrPendingDeleteTitle => '删除待发布的审查笔记'; + + @override + String get mrPendingEditHint => '更改保持私密,笔记的原始位置会保留。'; + + @override + String get mrPendingDeleteHint => '这只会从待发布的审查中移除此私密笔记。'; + + @override + String get mrPendingDeleteConfirm => '我确认删除上方显示的笔记。'; + + @override + String get mrPendingUpdated => '已更新私密审查笔记。'; + + @override + String get mrPendingDeleted => '已删除私密审查笔记。'; + + @override + String get mrPendingMaintenanceError => '无法确认此更改。请检查当前待发布的审查后再试。'; + + @override + String get mrPendingMaintenanceUncertain => '更改可能已经生效。再次更改前,请检查已保存的笔记。'; + + @override + String get mrPendingMaintenanceInspectionHint => + '以下是当前可读取的全部已保存笔记。再次更改前,请检查上方选中的笔记。'; + + @override + String get mrPendingMaintenanceAcknowledge => '我已检查当前选中的笔记,并确认应用此更改。'; + + @override + String get mrPendingTargetMissing => '选中的笔记已不可用。请关闭此对话框并刷新待发布的审查。'; + + @override + String get mrPendingDeleting => '正在删除审查笔记…'; } diff --git a/apps/labfox/lib/l10n/app_zh.arb b/apps/labfox/lib/l10n/app_zh.arb index 06c87ab1..39bb63cd 100644 --- a/apps/labfox/lib/l10n/app_zh.arb +++ b/apps/labfox/lib/l10n/app_zh.arb @@ -1398,5 +1398,21 @@ "mrPendingComposeInspectionTitle": "当前待发布审阅", "mrPendingComposeInspectionHint": "这些是当前已保存的备注。内容相同并不能确定备注由哪次保存请求创建。", "mrPendingComposeAcknowledge": "我已检查这些备注,并希望再保存一条备注。", - "mrPendingComposeChanged": "审阅会话已更改。请关闭此对话框并重新开始。" + "mrPendingComposeChanged": "审阅会话已更改。请关闭此对话框并重新开始。", + "mrPendingEdit": "编辑笔记", + "mrPendingDelete": "删除笔记", + "mrPendingUpdate": "保存更改", + "mrPendingEditTitle": "编辑待发布的审查笔记", + "mrPendingDeleteTitle": "删除待发布的审查笔记", + "mrPendingEditHint": "更改保持私密,笔记的原始位置会保留。", + "mrPendingDeleteHint": "这只会从待发布的审查中移除此私密笔记。", + "mrPendingDeleteConfirm": "我确认删除上方显示的笔记。", + "mrPendingUpdated": "已更新私密审查笔记。", + "mrPendingDeleted": "已删除私密审查笔记。", + "mrPendingMaintenanceError": "无法确认此更改。请检查当前待发布的审查后再试。", + "mrPendingMaintenanceUncertain": "更改可能已经生效。再次更改前,请检查已保存的笔记。", + "mrPendingMaintenanceInspectionHint": "以下是当前可读取的全部已保存笔记。再次更改前,请检查上方选中的笔记。", + "mrPendingMaintenanceAcknowledge": "我已检查当前选中的笔记,并确认应用此更改。", + "mrPendingTargetMissing": "选中的笔记已不可用。请关闭此对话框并刷新待发布的审查。", + "mrPendingDeleting": "正在删除审查笔记…" } diff --git a/apps/labfox/test/mr_pending_review_maintenance_test.dart b/apps/labfox/test/mr_pending_review_maintenance_test.dart new file mode 100644 index 00000000..02a65c1d --- /dev/null +++ b/apps/labfox/test/mr_pending_review_maintenance_test.dart @@ -0,0 +1,662 @@ +import 'dart:async'; +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/core/auth/auth_controller.dart'; +import 'package:labfox/core/auth/gitlab_client_provider.dart'; +import 'package:labfox/features/comments/data/comments_repository.dart'; +import 'package:labfox/features/comments/presentation/controllers/comments_controller.dart'; +import 'package:labfox/features/merge_requests/data/merge_requests_repository.dart'; +import 'package:labfox/features/merge_requests/data/mr_draft_notes_repository.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_draft_notes_provider.dart'; + +const resource = MergeRequestRef(projectId: 8, iid: 142); +const account = Account( + instanceUrl: 'https://gitlab.example.com', + user: User(id: 23, username: 'reviewer', name: 'Reviewer'), +); +const markdown = ' **Private**\n\nKeep this exact text. '; +MergeRequest mr({int id = 1100, int iid = 142, int? project = 8}) => + MergeRequest( + id: id, + iid: iid, + projectId: project, + title: 'Review', + state: 'opened', + sourceBranch: 'feature', + targetBranch: 'dev', + ); +MergeRequestDraftNote draft( + int id, { + int global = 1100, + int author = 23, + String body = markdown, +}) => MergeRequestDraftNote( + id: id, + authorId: author, + mergeRequestId: global, + note: body, +); +Paginated page( + List items, { + int? next, +}) => Paginated(items: items, nextPage: next); +final accountState = StateProvider((ref) => account); +final draftState = StateProvider>( + (ref) async => null, +); +final sourceState = StateProvider>( + (ref) async => null, +); +final clientState = StateProvider>((ref) async => null); +final commentsState = StateProvider>( + (ref) async => null, +); +GitLabClient client() => GitLabClient( + baseUrl: 'https://gitlab.example.com', + token: 'glpat-xxxxxxxxxxxx', +); + +class Drafts extends MrDraftNotesRepository { + Drafts(this.client, {int author = 23}) : super(client, authorId: author); + final GitLabClient client; + Object result = draft(7); + final writes = <(int, int, int, String, DiffNotePosition?)>[]; + final reads = <(int, int, int, int)>[]; + final pages = { + 1: page([draft(7)]), + }; + final updates = <(int, int, int, MergeRequestDraftNote, String)>[]; + final deletes = <(int, int, int, MergeRequestDraftNote)>[]; + Object updated = draft(7, body: ' Updated **private**\n '); + Object? deleted; + @override + Future update({ + required int projectId, + required int iid, + required int mergeRequestId, + required MergeRequestDraftNote draft, + required String note, + }) async { + updates.add((projectId, iid, mergeRequestId, draft, note)); + final value = updated; + if (value is Future) return value; + if (value is MergeRequestDraftNote) return value; + throw value; + } + + @override + Future delete({ + required int projectId, + required int iid, + required int mergeRequestId, + required MergeRequestDraftNote draft, + }) async { + deletes.add((projectId, iid, mergeRequestId, draft)); + final value = deleted; + if (value is Future) return value; + if (value != null) throw value; + } + + @override + Future create({ + required int projectId, + required int iid, + required int mergeRequestId, + required String note, + DiffNotePosition? position, + }) async { + writes.add((projectId, iid, mergeRequestId, note, position)); + final value = result; + if (value is Future) return value; + if (value is MergeRequestDraftNote) return value; + throw value; + } + + @override + Future> list({ + required int projectId, + required int iid, + int page = 1, + int perPage = 20, + }) async { + reads.add((projectId, iid, page, perPage)); + final value = pages[page]!; + if (value is Future>) return value; + if (value is Paginated) return value; + throw value; + } +} + +class Details extends MergeRequestsRepository { + Details(super.client); + Object result = mr(); + final reads = <(int, int)>[]; + @override + Future get({required int projectId, required int iid}) async { + reads.add((projectId, iid)); + final value = result; + if (value is Future) return value; + if (value is MergeRequest) return value; + throw value; + } +} + +class Comments extends CommentsRepository { + Comments(super.client); + Object pageResult = const Paginated(items: [], nextPage: 2); + Future? pendingPost; + final posts = []; + final pages = []; + @override + Future> discussions({ + required int projectId, + required int iid, + int page = 1, + }) async { + pages.add(page); + if (pageResult is Future>) { + return pageResult as Future>; + } + if (pageResult is Paginated) { + return pageResult as Paginated; + } + throw pageResult; + } + + @override + Future post({ + required NoteableType type, + required int projectId, + required int iid, + required String body, + }) async { + posts.add(body); + return pendingPost ?? const Note(id: 9, body: 'Public'); + } +} + +class Events implements Analytics { + final names = []; + @override + Future track(String name, [Map? properties]) async { + names.add(name); + } +} + +class Fixture { + Fixture() { + c = ProviderContainer( + overrides: [ + analyticsProvider.overrideWithValue(events), + currentAccountProvider.overrideWith((ref) => ref.watch(accountState)), + draftState.overrideWith((ref) async => drafts), + sourceState.overrideWith((ref) async => details), + commentsState.overrideWith((ref) async => comments), + clientState.overrideWith((ref) async => api), + mrDraftNotesRepositoryProvider.overrideWith( + (ref) => ref.watch(draftState), + ), + mergeRequestsRepositoryProvider.overrideWith( + (ref) => ref.watch(sourceState), + ), + commentsRepositoryProvider.overrideWith( + (ref) => ref.watch(commentsState), + ), + gitLabClientProvider.overrideWith((ref) => ref.watch(clientState)), + ], + ); + addTearDown(c.dispose); + addTearDown(api.close); + } + final events = Events(); + final api = client(); + late final drafts = Drafts(api); + late final details = Details(api); + late final comments = Comments(api); + late final ProviderContainer c; + MrDiscussionsController get controller => + c.read(mrDiscussionsControllerProvider(resource).notifier); + Future ready() async => + c.read(mrDiscussionsControllerProvider(resource).future); + Future save({bool Function()? current}) => + controller.savePendingNote(markdown, isCurrent: current); + Future?> inspect({bool Function()? current}) => + controller.inspectPendingNotes(isCurrent: current); + Future fail() async { + drafts.result = const GitLabServerException('Uncertain save.'); + await expectLater(save(), throwsA(isA())); + expect(controller.pendingSaveNeedsInspection, true); + } +} + +const edited = ' Updated **private**\n '; +Future mutate( + Fixture f, + bool deleting, { + MergeRequestDraftNote? target, + bool Function()? current, +}) => deleting + ? f.controller.deletePendingNote(target ?? draft(7), isCurrent: current) + : f.controller.updatePendingNote( + target ?? draft(7), + edited, + isCurrent: current, + ); + +// Simulates an external account event queued at the target-comparison boundary. +class ComparedDraft implements MergeRequestDraftNote { + ComparedDraft(this.original, this.onCompared); + final MergeRequestDraftNote original; + final void Function() onCompared; + @override + int get id => original.id; + @override + int get authorId => original.authorId; + @override + int get mergeRequestId => original.mergeRequestId; + @override + bool operator ==(Object other) { + scheduleMicrotask(onCompared); + return original == other; + } + + @override + int get hashCode => original.hashCode; + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +void main() { + for (final deleting in [false, true]) { + final verb = deleting ? 'delete' : 'update'; + test( + '$verb reloads all pages and confirms exact captured target before one write', + () async { + final f = Fixture(); + await f.ready(); + f.drafts.pages[1] = page([], next: 3); + f.drafts.pages[3] = page([draft(7)]); + final result = await mutate(f, deleting); + expect(result, deleting ? true : draft(7, body: edited)); + expect(f.drafts.reads, [(8, 142, 1, 20), (8, 142, 3, 20)]); + expect(f.details.reads, [(8, 142)]); + expect( + f.drafts.updates, + deleting ? isEmpty : [(8, 142, 1100, draft(7), edited)], + ); + expect( + f.drafts.deletes, + deleting ? [(8, 142, 1100, draft(7))] : isEmpty, + ); + expect(f.drafts.writes, isEmpty); + expect(f.comments.posts, isEmpty); + expect(f.events.names, isEmpty); + expect(f.controller.pendingSaveNeedsInspection, false); + }, + ); + for (final replacement in [ + [], + [draft(7, body: 'Changed elsewhere')], + [draft(7).copyWith(resolveDiscussion: true)], + [draft(7).copyWith(discussionId: 'new-thread')], + [draft(7).copyWith(commitId: 'different')], + [draft(7).copyWith(lineCode: 'opaque')], + ]) { + test( + '$verb refuses missing or stale target ${replacement.toString()}', + () async { + final f = Fixture(); + await f.ready(); + f.drafts.pages[1] = page(replacement); + await expectLater( + mutate(f, deleting), + throwsA(isA()), + ); + expect(f.drafts.updates, isEmpty); + expect(f.drafts.deletes, isEmpty); + expect(f.controller.pendingSaveNeedsInspection, false); + }, + ); + } + for (final invalid in [ + page([draft(7), draft(7)]), + page([draft(7, author: 99)]), + page([draft(7, global: 142)]), + page([draft(0)]), + page([draft(7)], next: 1), + ]) { + test('$verb rejects malformed private preflight $invalid', () async { + final f = Fixture(); + await f.ready(); + f.drafts.pages[1] = invalid; + await expectLater( + mutate(f, deleting), + throwsA(isA()), + ); + expect(f.drafts.updates, isEmpty); + expect(f.drafts.deletes, isEmpty); + }); + } + for (final target in [ + draft(0), + draft(7, author: 99), + draft(7, global: 142), + ]) { + test('$verb cannot address wrong selected identity $target', () async { + final f = Fixture(); + await f.ready(); + await expectLater( + mutate(f, deleting, target: target), + throwsA(isA()), + ); + expect(f.drafts.updates, isEmpty); + expect(f.drafts.deletes, isEmpty); + }); + } + for (final error in [ + const GitLabAuthException('Private'), + const GitLabForbiddenException('Private'), + const GitLabNotFoundException('Private'), + const GitLabConflictException('Private'), + const GitLabRateLimitException('Private'), + const GitLabServerException('Private'), + const GitLabConnectionException('Private'), + ]) { + test( + '$verb failure gates all private writes until complete inspection ${error.runtimeType}', + () async { + final f = Fixture(); + await f.ready(); + if (deleting) { + f.drafts.deleted = error; + } else { + f.drafts.updated = error; + } + await expectLater(mutate(f, deleting), throwsA(same(error))); + expect(f.controller.pendingSaveNeedsInspection, true); + expect(await f.save(), isNull); + expect(await mutate(f, false), isNull); + expect(await mutate(f, true), false); + expect(f.drafts.updates.length + f.drafts.deletes.length, 1); + expect(await f.inspect(), [draft(7)]); + expect(f.controller.pendingSaveNeedsInspection, false); + f.drafts.deleted = null; + f.drafts.updated = draft(7, body: edited); + expect( + await mutate(f, deleting), + deleting ? true : draft(7, body: edited), + ); + }, + ); + } + test( + '$verb shares the public discussion reservation and waits before dispatch', + () async { + final f = Fixture(); + await f.ready(); + final p = Completer>(); + f.drafts.pages[1] = p.future; + final operation = mutate(f, deleting); + await f.c.pump(); + expect(await f.controller.post('Public'), false); + expect(await f.save(), isNull); + expect(await mutate(f, false), isNull); + expect(await mutate(f, true), false); + p.complete(page([draft(7)])); + expect(await operation, deleting ? true : draft(7, body: edited)); + }, + ); + for (final phase in ['detail', 'private', 'write']) { + for (final changed in [ + 'account', + 'draft', + 'client', + 'comments', + 'view', + 'controller', + ]) { + test('$verb suppresses $changed replacement during $phase', () async { + final f = Fixture(); + await f.ready(); + bool active = true; + final detail = Completer(), + notes = Completer>(), + update = Completer(), + remove = Completer(); + if (phase == 'detail') f.details.result = detail.future; + if (phase == 'private') f.drafts.pages[1] = notes.future; + if (phase == 'write') { + if (deleting) { + f.drafts.deleted = remove.future; + } else { + f.drafts.updated = update.future; + } + } + final op = mutate(f, deleting, current: () => active); + await f.c.pump(); + switch (changed) { + case 'account': + f.c.read(accountState.notifier).state = account.copyWith( + user: const User(id: 99, username: 'other', name: 'Other'), + ); + case 'draft': + f.c.read(draftState.notifier).state = Future.value(Drafts(f.api)); + case 'client': + f.c.read(clientState.notifier).state = Future.value(client()); + case 'comments': + f.c.read(commentsState.notifier).state = Future.value( + Comments(f.api), + ); + case 'view': + active = false; + case 'controller': + f.c.invalidate(mrDiscussionsControllerProvider(resource)); + } + await f.c.pump(); + if (phase == 'detail') detail.complete(mr()); + if (phase == 'private') notes.complete(page([draft(7)])); + if (phase == 'write') { + if (deleting) { + remove.complete(); + } else { + update.complete(draft(7, body: edited)); + } + } + expect(await op, deleting ? false : isNull); + expect( + f.drafts.updates.length + f.drafts.deletes.length, + phase == 'write' ? 1 : 0, + ); + expect(f.comments.posts, isEmpty); + }); + } + } + test( + '$verb cancelled write settles before recovery reads and does not clear the gate', + () async { + final f = Fixture(); + await f.ready(); + bool active = true; + final update = Completer(), + remove = Completer(); + if (deleting) { + f.drafts.deleted = remove.future; + } else { + f.drafts.updated = update.future; + } + final op = mutate(f, deleting, current: () => active); + await f.c.pump(); + active = false; + f.c.invalidate(mrDiscussionsControllerProvider(resource)); + await f.ready(); + expect(await op, deleting ? false : isNull); + expect(f.controller.pendingSaveNeedsInspection, true); + final readCount = f.drafts.reads.length; + final inspection = f.inspect(); + await f.c.pump(); + expect(f.drafts.reads.length, readCount); + expect(await f.save(), isNull); + if (deleting) { + remove.complete(); + } else { + update.complete(draft(7, body: edited)); + } + expect(await inspection, [draft(7)]); + }, + ); + } + for (final body in ['', ' \n ']) { + test('blank edit cannot dispatch', () async { + final f = Fixture(); + await f.ready(); + expect(await f.controller.updatePendingNote(draft(7), body), isNull); + expect(f.drafts.reads, isEmpty); + }); + } + for (final p in [ + const DiffNotePosition(positionType: 'image'), + const DiffNotePosition(positionType: 'file'), + const DiffNotePosition(positionType: 'future'), + const DiffNotePosition(positionType: 'text', newLine: 1), + ]) { + test('unsupported original position permits delete only $p', () async { + final f = Fixture(); + await f.ready(); + final d = draft(7).copyWith(position: p); + f.drafts.pages[1] = page([d]); + expect(await f.controller.updatePendingNote(d, edited), isNull); + expect(f.drafts.reads, isEmpty); + expect(await f.controller.deletePendingNote(d), true); + expect(f.drafts.deletes.single.$4, d); + }); + } + for (final bad in [ + draft(0, body: edited), + draft(8, body: edited), + draft(7, body: 'Wrong'), + draft(7, author: 99, body: edited), + draft(7, global: 142, body: edited), + ]) { + test('unconfirmed update keeps inspection gate $bad', () async { + final f = Fixture(); + await f.ready(); + f.drafts.updated = bad; + await expectLater( + mutate(f, false), + throwsA(isA()), + ); + expect(f.controller.pendingSaveNeedsInspection, true); + }); + } + for (final deleting in [false, true]) { + test( + 'maintenance ${deleting ? 'delete' : 'update'} refreshes every private reader variant for only its MR', + () async { + final f = Fixture(); + await f.ready(); + const other = MergeRequestRef(projectId: 8, iid: 143); + final keep = f.c.listen( + mrDraftNotesRevisionProvider(resource), + (_, _) {}, + ); + final otherKeep = f.c.listen( + mrDraftNotesRevisionProvider(other), + (_, _) {}, + ); + addTearDown(keep.close); + addTearDown(otherKeep.close); + expect( + await mutate(f, deleting), + deleting ? true : draft(7, body: edited), + ); + expect(f.c.read(mrDraftNotesRevisionProvider(resource)), 1); + expect(f.c.read(mrDraftNotesRevisionProvider(other)), 0); + expect(f.comments.pages, [1]); + }, + ); + test( + 'maintenance ${deleting ? 'delete' : 'update'} later preflight page failure cannot write', + () async { + final f = Fixture(); + await f.ready(); + f.drafts.pages[1] = page([draft(7)], next: 3); + f.drafts.pages[3] = const GitLabServerException('Unread page'); + await expectLater( + mutate(f, deleting), + throwsA(isA()), + ); + expect(f.drafts.updates, isEmpty); + expect(f.drafts.deletes, isEmpty); + expect(f.controller.pendingSaveNeedsInspection, false); + }, + ); + } + for (final position in [ + null, + const DiffNotePosition(), + const DiffNotePosition(positionType: 'text'), + const DiffNotePosition( + positionType: 'text', + baseSha: 'base', + startSha: 'start', + headSha: 'head', + oldPath: 'old.dart', + newPath: 'new.dart', + newLine: 9, + ), + ]) { + test('update retains supported original metadata $position', () async { + final f = Fixture(); + await f.ready(); + final d = draft(7).copyWith( + position: position, + discussionId: 'thread', + commitId: 'commit', + resolveDiscussion: true, + ); + f.drafts.pages[1] = page([d]); + f.drafts.updated = d.copyWith(note: edited); + expect(MrDraftNotesRepository.canUpdate(d), true); + expect( + await f.controller.updatePendingNote(d, edited), + d.copyWith(note: edited), + ); + expect(f.drafts.updates.single.$4, d); + }); + } + test( + 'opaque line-code-only draft cannot be edited but can be explicitly deleted', + () async { + final f = Fixture(); + await f.ready(); + final d = draft(7).copyWith(lineCode: 'opaque'); + f.drafts.pages[1] = page([d]); + expect(MrDraftNotesRepository.canUpdate(d), false); + expect(await f.controller.updatePendingNote(d, edited), isNull); + expect(f.drafts.reads, isEmpty); + expect(await f.controller.deletePendingNote(d), true); + }, + ); + for (final deleting in [false, true]) { + test( + 'account change queued after target comparison cannot dispatch ${deleting ? 'delete' : 'update'}', + () async { + final f = Fixture(); + await f.ready(); + f.drafts.pages[1] = page([ + ComparedDraft(draft(7), () { + f.c.read(accountState.notifier).state = null; + }), + ]); + expect(await mutate(f, deleting), deleting ? false : isNull); + expect(f.drafts.updates, isEmpty); + expect(f.drafts.deletes, isEmpty); + }, + ); + } +} diff --git a/apps/labfox/test/mr_pending_review_maintenance_widget_test.dart b/apps/labfox/test/mr_pending_review_maintenance_widget_test.dart new file mode 100644 index 00000000..38dff943 --- /dev/null +++ b/apps/labfox/test/mr_pending_review_maintenance_widget_test.dart @@ -0,0 +1,794 @@ +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/core/auth/auth_controller.dart'; +import 'package:labfox/core/auth/gitlab_client_provider.dart'; +import 'package:labfox/features/comments/data/comments_repository.dart'; +import 'package:labfox/features/comments/presentation/controllers/comments_controller.dart'; +import 'package:labfox/features/merge_requests/data/merge_requests_repository.dart'; +import 'package:labfox/features/merge_requests/data/mr_draft_notes_repository.dart'; +import 'package:labfox/features/merge_requests/presentation/controllers/merge_requests_controllers.dart'; +import 'package:labfox/features/merge_requests/presentation/controllers/mr_actions_controller.dart'; +import 'package:labfox/features/merge_requests/presentation/controllers/mr_draft_notes_provider.dart'; +import 'package:labfox/features/merge_requests/presentation/merge_request_detail_screen.dart'; +import 'package:labfox/features/merge_requests/presentation/widgets/mr_pending_review_panel.dart'; +import 'package:labfox/l10n/app_localizations.dart'; + +const resource = MergeRequestRef(projectId: 8, iid: 142); +const account = Account( + instanceUrl: 'https://gitlab.example.com', + user: User(id: 23, username: 'reviewer', name: 'Reviewer'), +); +const body = + ' **Private note**\n\n![image](https://example.com/private.png)\n '; +MergeRequest mr({int id = 1100, int iid = 142, int? project = 8}) => + MergeRequest( + id: id, + iid: iid, + projectId: project, + title: 'Review resource', + state: 'opened', + sourceBranch: 'feature', + targetBranch: 'dev', + ); +MergeRequestDraftNote draft( + int id, { + String note = body, + int author = 23, + int global = 1100, +}) => MergeRequestDraftNote( + id: id, + authorId: author, + mergeRequestId: global, + note: note, +); +Paginated page( + List items, { + int? next, +}) => Paginated(items: items, nextPage: next); +final accountState = StateProvider((ref) => account); +final draftState = StateProvider>( + (ref) async => null, +); +final detailState = StateProvider>( + (ref) async => null, +); +final clientState = StateProvider( + (ref) => throw UnimplementedError(), +); +final commentsState = StateProvider>( + (ref) async => null, +); +GitLabClient client() => GitLabClient( + baseUrl: 'https://gitlab.example.com', + token: 'glpat-xxxxxxxxxxxx', +); + +class Drafts extends MrDraftNotesRepository { + Drafts(this.api) : super(api, authorId: 23); + final GitLabClient api; + Object result = draft(7); + final writes = <(int, int, int, String, DiffNotePosition?)>[]; + final reads = []; + final pages = { + 1: page([draft(7)]), + }; + final updates = <(MergeRequestDraftNote, String)>[]; + final deletes = []; + Object? updateResult, deleteResult; + @override + Future update({ + required int projectId, + required int iid, + required int mergeRequestId, + required MergeRequestDraftNote draft, + required String note, + }) async { + updates.add((draft, note)); + final result = updateResult; + if (result is Future) return result; + if (result != null) throw result; + final updated = draft.copyWith(note: note); + pages[1] = page([updated]); + return updated; + } + + @override + Future delete({ + required int projectId, + required int iid, + required int mergeRequestId, + required MergeRequestDraftNote draft, + }) async { + deletes.add(draft); + final result = deleteResult; + if (result is Future) return result; + if (result != null) throw result; + pages[1] = page([]); + } + + @override + Future create({ + required int projectId, + required int iid, + required int mergeRequestId, + required String note, + DiffNotePosition? position, + }) async { + writes.add((projectId, iid, mergeRequestId, note, position)); + final value = result; + if (value is Future) return value; + if (value is MergeRequestDraftNote) { + pages[1] = page([value]); + return value; + } + throw value; + } + + @override + Future> list({ + required int projectId, + required int iid, + int page = 1, + int perPage = 20, + }) async { + expect(projectId, 8); + expect(iid, 142); + reads.add(page); + final value = pages[page]!; + if (value is Future>) return value; + if (value is Paginated) return value; + throw value; + } +} + +class Details extends MergeRequestsRepository { + Details(super.client); + Object value = mr(); + int reads = 0; + @override + Future get({required int projectId, required int iid}) async { + reads++; + final v = value; + if (v is Future) return v; + if (v is MergeRequest) return v; + throw v; + } +} + +class Comments extends CommentsRepository { + Comments(super.client); + Object value = const Paginated(items: []); + final posts = []; + @override + Future> discussions({ + required int projectId, + required int iid, + int page = 1, + }) async { + final v = value; + if (v is Future>) return v; + if (v is Paginated) return v; + throw v; + } + + @override + Future post({ + required NoteableType type, + required int projectId, + required int iid, + required String body, + }) async { + posts.add(body); + return const Note(id: 9, body: 'Public'); + } +} + +class Events implements Analytics { + final names = []; + @override + Future track(String name, [Map? properties]) async { + names.add(name); + } +} + +class Actions extends MrActionsController { + @override + Future build(MergeRequestRef arg) async {} +} + +class Fixture { + Fixture() { + c = ProviderContainer( + overrides: [ + currentAccountProvider.overrideWith((ref) => ref.watch(accountState)), + clientState.overrideWith((ref) => api), + draftState.overrideWith((ref) async => drafts), + detailState.overrideWith((ref) async => details), + commentsState.overrideWith((ref) async => comments), + mrDraftNotesRepositoryProvider.overrideWith((ref) { + ref.watch(clientState); + return ref.watch(draftState); + }), + mergeRequestsRepositoryProvider.overrideWith((ref) { + ref.watch(clientState); + return ref.watch(detailState); + }), + commentsRepositoryProvider.overrideWith((ref) { + ref.watch(clientState); + return ref.watch(commentsState); + }), + gitLabClientProvider.overrideWith( + (ref) async => ref.watch(clientState), + ), + mrApprovalsProvider.overrideWith((ref, arg) async => null), + mrActionsControllerProvider.overrideWith(Actions.new), + analyticsProvider.overrideWithValue(events), + ], + ); + addTearDown(c.dispose); + addTearDown(api.close); + } + final api = client(); + final events = Events(); + late final drafts = Drafts(api); + late final details = Details(api); + late final comments = Comments(api); + late final ProviderContainer c; +} + +final captureKey = GlobalKey(); +Future pump( + WidgetTester t, + Fixture f, { + double width = 390, + double height = 1000, + bool dark = false, + double scale = 1, + double keyboard = 0, + Locale locale = const Locale('en'), + bool settle = true, + bool integrated = false, + MergeRequestRef arg = resource, + ThemeData? theme, +}) async { + t.view.physicalSize = Size(width, height); + t.view.devicePixelRatio = 1; + addTearDown(t.view.resetPhysicalSize); + addTearDown(t.view.resetDevicePixelRatio); + await t.pumpWidget( + UncontrolledProviderScope( + container: f.c, + child: RepaintBoundary( + key: captureKey, + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: theme ?? (dark ? LabFoxTheme.dark : LabFoxTheme.light), + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(scale), + viewInsets: EdgeInsets.only(bottom: keyboard), + ), + child: child!, + ), + home: integrated + ? MergeRequestDetailScreen(projectId: arg.projectId, iid: arg.iid) + : Scaffold( + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: MrPendingReviewPanel(mergeRequest: arg), + ), + ), + ), + ), + ), + ), + ); + if (settle) { + await t.pumpAndSettle(); + } else { + await t.pump(); + await t.pump(const Duration(milliseconds: 100)); + } + return AppLocalizations.of(t.element(find.byType(Scaffold).first)); +} + +const openKey = ValueKey('mr-pending-compose-open'); +const inputKey = ValueKey('mr-pending-compose-input'); +const saveKey = ValueKey('mr-pending-compose-save'); +const cancelKey = ValueKey('mr-pending-compose-cancel'); +const inspectKey = ValueKey('mr-pending-compose-inspect'); +const ackKey = ValueKey('mr-pending-compose-acknowledge'); +Future open(WidgetTester t) async { + await t.ensureVisible(find.byKey(openKey)); + await t.tap(find.byKey(openKey)); + await t.pumpAndSettle(); +} + +Future tap(WidgetTester t, Key key, {bool settle = true}) async { + await t.ensureVisible(find.byKey(key)); + await t.tap(find.byKey(key)); + if (settle) { + await t.pumpAndSettle(); + } else { + await t.pump(); + await t.pump(const Duration(milliseconds: 100)); + } +} + +Future enter(WidgetTester t, [String text = body]) async { + await t.enterText(find.byKey(inputKey), text); + await t.pump(); +} + +bool enabled(WidgetTester t, Key key) => + t.widget(find.byKey(key)).onPressed != null; +Future fail(WidgetTester t, Fixture f) async { + f.drafts.result = const GitLabServerException('Never expose this payload'); + await enter(t); + await tap(t, saveKey); + expect(f.drafts.writes.length, 1); + expect(enabled(t, saveKey), false); +} + +Finder inDialog(Finder finder) => + find.descendant(of: find.byType(AlertDialog), matching: finder); +void hidden() => expect(find.byKey(inputKey), findsNothing); + +const edited = ' **Edited private**\nKeep exact whitespace. '; +Key action(bool deleting) => + ValueKey(deleting ? 'mr-pending-delete-7' : 'mr-pending-edit-7'); +Future openAction(WidgetTester t, bool deleting) => + tap(t, action(deleting)); +void main() { + testWidgets('edits captured Markdown once and refreshes saved private rows', ( + t, + ) async { + final f = Fixture(); + await pump(t, f); + await openAction(t, false); + expect(t.widget(find.byKey(inputKey)).controller!.text, body); + expect(enabled(t, saveKey), false); + await enter(t, edited); + expect(enabled(t, saveKey), true); + await tap(t, saveKey); + expect(f.drafts.updates, [(draft(7), edited)]); + expect(find.byType(AlertDialog), findsNothing); + expect(find.text(edited), findsOneWidget); + expect(f.drafts.writes, isEmpty); + expect(f.comments.posts, isEmpty); + expect(f.events.names, isEmpty); + }); + testWidgets( + 'deletion displays original note and requires explicit confirmation', + (t) async { + final f = Fixture(); + await pump(t, f); + await openAction(t, true); + expect(inDialog(find.text(body)), findsOneWidget); + expect(find.byKey(inputKey), findsNothing); + expect(enabled(t, saveKey), false); + expect(f.drafts.deletes, isEmpty); + await tap(t, ackKey); + expect(enabled(t, saveKey), true); + await tap(t, saveKey); + expect(f.drafts.deletes, [draft(7)]); + expect(find.byType(AlertDialog), findsNothing); + expect(find.text(body), findsNothing); + expect(f.drafts.writes, isEmpty); + expect(f.comments.posts, isEmpty); + }, + ); + for (final deleting in [false, true]) { + testWidgets('cancel ${deleting ? 'delete' : 'edit'} never writes', ( + t, + ) async { + final f = Fixture(); + await pump(t, f); + await openAction(t, deleting); + await tap(t, cancelKey); + expect(f.drafts.updates, isEmpty); + expect(f.drafts.deletes, isEmpty); + expect(f.drafts.writes, isEmpty); + }); + testWidgets( + 'uncertain ${deleting ? 'delete' : 'edit'} preserves input and requires visible inspection plus acknowledgement', + (t) async { + final f = Fixture(); + await pump(t, f); + await openAction(t, deleting); + if (deleting) { + await tap(t, ackKey); + f.drafts.deleteResult = const GitLabServerException( + 'Do not expose payload', + ); + } else { + await enter(t, edited); + f.drafts.updateResult = const GitLabServerException( + 'Do not expose payload', + ); + } + await tap(t, saveKey); + expect(enabled(t, saveKey), false); + expect(find.text('Do not expose payload'), findsNothing); + final fresh = draft(7, note: 'Changed on server'); + f.drafts.pages[1] = page([fresh]); + await tap(t, inspectKey); + expect(inDialog(find.text(fresh.note)), findsWidgets); + expect(enabled(t, saveKey), false); + await tap(t, ackKey); + expect(enabled(t, saveKey), true); + f.drafts.deleteResult = null; + f.drafts.updateResult = null; + await tap(t, saveKey); + expect(f.drafts.updates.length + f.drafts.deletes.length, 2); + if (deleting) { + expect(f.drafts.deletes.last, fresh); + } else { + expect(f.drafts.updates.last, (fresh, edited)); + } + expect(f.drafts.writes, isEmpty); + expect(f.comments.posts, isEmpty); + }, + ); + testWidgets( + 'inspection with missing target cannot repeat ${deleting ? 'delete' : 'edit'}', + (t) async { + final f = Fixture(); + await pump(t, f); + await openAction(t, deleting); + if (deleting) { + await tap(t, ackKey); + } else { + await enter(t, edited); + } + f.drafts.pages[1] = page([]); + await tap(t, saveKey); + expect(f.drafts.updates, isEmpty); + expect(f.drafts.deletes, isEmpty); + await tap(t, inspectKey); + expect(enabled(t, saveKey), false); + expect(find.byKey(ackKey), findsNothing); + await tap(t, cancelKey); + }, + ); + for (final phase in ['preflight', 'write']) { + for (final changed in [ + 'account', + 'draft', + 'source', + 'comments', + 'origin', + ]) { + testWidgets( + '${deleting ? 'delete' : 'edit'} hides private state after $changed during $phase', + (t) async { + final f = Fixture(); + await pump(t, f); + await openAction(t, deleting); + if (deleting) { + await tap(t, ackKey); + } else { + await enter(t, edited); + } + final details = Completer(), + update = Completer(), + remove = Completer(); + if (phase == 'preflight') { + f.details.value = details.future; + } else if (deleting) { + f.drafts.deleteResult = remove.future; + } else { + f.drafts.updateResult = update.future; + } + await tap(t, saveKey, settle: false); + switch (changed) { + case 'account': + f.c.read(accountState.notifier).state = account.copyWith( + user: const User(id: 99, username: 'other', name: 'Other'), + ); + case 'draft': + f.c.read(draftState.notifier).state = Future.value( + Drafts(f.api), + ); + case 'source': + f.c.read(detailState.notifier).state = Future.value( + Details(f.api), + ); + case 'comments': + f.c.read(commentsState.notifier).state = Future.value( + Comments(f.api), + ); + case 'origin': + await t.pumpWidget( + UncontrolledProviderScope( + container: f.c, + child: const MaterialApp( + localizationsDelegates: + AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: Text('Other view')), + ), + ), + ); + } + await t.pump(); + if (phase == 'preflight') { + details.complete(mr()); + } else if (deleting) { + remove.complete(); + } else { + update.complete(draft(7, note: edited)); + } + await t.pumpAndSettle(); + expect(find.byKey(inputKey), findsNothing); + expect(inDialog(find.text(body)), findsNothing); + expect(inDialog(find.text(edited)), findsNothing); + expect( + f.drafts.updates.length + f.drafts.deletes.length, + phase == 'write' ? 1 : 0, + ); + expect(t.takeException(), isNull); + }, + ); + } + } + } + for (final p in [ + const DiffNotePosition(positionType: 'image'), + const DiffNotePosition(positionType: 'file'), + const DiffNotePosition(positionType: 'text', newLine: 1), + ]) { + testWidgets('unsupported anchor has deletion but no edit action $p', ( + t, + ) async { + final f = Fixture(); + f.drafts.pages[1] = page([draft(7).copyWith(position: p)]); + await pump(t, f); + expect(find.byKey(action(false)), findsNothing); + expect(find.byKey(action(true)), findsOneWidget); + }); + } + for (final locale in ['en', 'ko', 'ja', 'hi', 'zh']) { + for (final width in [390.0, 1200.0]) { + for (final deleting in [false, true]) { + testWidgets( + '$locale ${width.toInt()} ${deleting ? 'delete' : 'edit'} supports large text and keyboard', + (t) async { + final f = Fixture(); + await pump( + t, + f, + width: width, + height: 900, + scale: 1.7, + keyboard: deleting ? 0 : 270, + locale: Locale(locale), + ); + await openAction(t, deleting); + if (deleting) { + await tap(t, ackKey); + } else { + await enter(t, edited); + } + await tap(t, saveKey); + expect(f.drafts.updates.length + f.drafts.deletes.length, 1); + expect(t.takeException(), isNull); + }, + ); + } + } + } + testWidgets( + 'changed original anchor is confirmed again after inspection before an edit', + (t) async { + final f = Fixture(); + await pump(t, f); + await openAction(t, false); + await enter(t, edited); + final d = draft(7).copyWith( + position: const DiffNotePosition( + positionType: 'text', + baseSha: 'base', + startSha: 'start', + headSha: 'head', + oldPath: 'old.dart', + newPath: 'new.dart', + newLine: 9, + ), + ); + f.drafts.pages[1] = page([d]); + await tap(t, saveKey); + expect(f.drafts.updates, isEmpty); + await tap(t, inspectKey); + expect(enabled(t, saveKey), false); + await tap(t, ackKey); + await enter(t, '${edited}Again'); + expect(enabled(t, saveKey), false); + await tap(t, ackKey); + await tap(t, saveKey); + expect(f.drafts.updates.single, (d, '${edited}Again')); + }, + ); + testWidgets( + 'failed complete inspection exposes no partial list and cannot retry', + (t) async { + final f = Fixture(); + await pump(t, f); + await openAction(t, false); + await enter(t, edited); + f.drafts.updateResult = const GitLabConnectionException( + 'Private payload', + ); + await tap(t, saveKey); + f.drafts.pages[1] = page([draft(7, note: 'Partial result')], next: 2); + f.drafts.pages[2] = const GitLabServerException('Unread page'); + await tap(t, inspectKey); + expect(inDialog(find.text('Partial result')), findsNothing); + expect(find.byKey(ackKey), findsNothing); + expect(enabled(t, saveKey), false); + expect(f.drafts.updates.length, 1); + }, + ); + testWidgets( + 'reopening after uncertain write still requires complete inspection', + (t) async { + final f = Fixture(); + await pump(t, f); + await openAction(t, false); + await enter(t, edited); + f.drafts.updateResult = const GitLabConnectionException( + 'Private payload', + ); + await tap(t, saveKey); + await tap(t, cancelKey); + await openAction(t, false); + await enter(t, edited); + expect(enabled(t, saveKey), false); + await tap(t, inspectKey); + await tap(t, ackKey); + f.drafts.updateResult = null; + await tap(t, saveKey); + expect(f.drafts.updates.length, 2); + expect(f.drafts.writes, isEmpty); + }, + ); + if (Platform.environment['LABFOX_PENDING_MAINTENANCE_CAPTURE'] + case final String directory) { + for (final width in [390.0, 800.0, 1200.0]) { + for (final dark in [false, true]) { + for (final recovering in [false, true]) { + testWidgets('synthetic maintenance capture $width $dark $recovering', ( + t, + ) async { + final sdk = Platform.environment['FLUTTER_ROOT']!; + await t.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(), + ), + ), + ); + await loader.load(); + } + }); + final base = dark ? LabFoxTheme.dark : LabFoxTheme.light; + ButtonStyle font(ButtonStyle style) => style.copyWith( + textStyle: WidgetStatePropertyAll( + style.textStyle!.resolve({})!.copyWith(fontFamily: 'Roboto'), + ), + ); + final theme = base.copyWith( + textTheme: base.textTheme.apply(fontFamily: 'Roboto'), + outlinedButtonTheme: OutlinedButtonThemeData( + style: font(base.outlinedButtonTheme.style!), + ), + textButtonTheme: const TextButtonThemeData( + style: ButtonStyle( + textStyle: WidgetStatePropertyAll( + TextStyle(fontFamily: 'Roboto'), + ), + ), + ), + inputDecorationTheme: base.inputDecorationTheme.copyWith( + labelStyle: const TextStyle(fontFamily: 'Roboto'), + ), + ); + final f = Fixture(); + await pump( + t, + f, + width: width, + height: width < 600 ? 1000 : 1100, + dark: dark, + theme: theme, + ); + await openAction(t, recovering); + if (!recovering) { + await enter( + t, + 'Please cover empty intermediate pages.\n\nThe original Markdown stays private.', + ); + } + if (recovering) { + await tap(t, ackKey); + f.drafts.deleteResult = const GitLabServerException( + 'Synthetic save failure', + ); + await tap(t, saveKey); + f.drafts.pages[1] = page([ + draft( + 7, + note: 'Could the parser preserve the original whitespace?', + ), + ]); + await tap(t, inspectKey); + await t.ensureVisible(find.byKey(ackKey)); + await t.pumpAndSettle(); + } + // Capture with no focused-field cursor so repeated frames are stable. + FocusManager.instance.primaryFocus?.unfocus(); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + final boundary = t.renderObject( + find.byKey(captureKey), + ); + final image = (await t.runAsync(boundary.toImage))!; + final data = await t.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.png), + ); + await t.runAsync(() async { + final file = File( + '$directory/${recovering ? 'delete-inspection' : 'edit'}-${width.toInt()}-${dark ? 'dark' : 'light'}.png', + ); + await file.parent.create(recursive: true); + await file.writeAsBytes(data!.buffer.asUint8List()); + }); + image.dispose(); + }); + } + } + } + } + testWidgets( + 'delete shows specific progress and prevents duplicate confirmation', + (t) async { + final f = Fixture(); + final l = await pump(t, f); + await openAction(t, true); + await tap(t, ackKey); + final pending = Completer(); + f.drafts.deleteResult = pending.future; + await tap(t, saveKey, settle: false); + expect(find.text(l.mrPendingDeleting), findsOneWidget); + expect(enabled(t, saveKey), false); + expect(enabled(t, cancelKey), false); + pending.complete(); + await t.pumpAndSettle(); + expect(f.drafts.deletes.length, 1); + }, + ); +} diff --git a/docs/images/mr-pending-maintenance/delete-inspection-1200-dark.png b/docs/images/mr-pending-maintenance/delete-inspection-1200-dark.png new file mode 100644 index 00000000..1c24c9c8 Binary files /dev/null and b/docs/images/mr-pending-maintenance/delete-inspection-1200-dark.png differ diff --git a/docs/images/mr-pending-maintenance/delete-inspection-1200-light.png b/docs/images/mr-pending-maintenance/delete-inspection-1200-light.png new file mode 100644 index 00000000..7c13e1c0 Binary files /dev/null and b/docs/images/mr-pending-maintenance/delete-inspection-1200-light.png differ diff --git a/docs/images/mr-pending-maintenance/delete-inspection-390-dark.png b/docs/images/mr-pending-maintenance/delete-inspection-390-dark.png new file mode 100644 index 00000000..a1e2239d Binary files /dev/null and b/docs/images/mr-pending-maintenance/delete-inspection-390-dark.png differ diff --git a/docs/images/mr-pending-maintenance/delete-inspection-390-light.png b/docs/images/mr-pending-maintenance/delete-inspection-390-light.png new file mode 100644 index 00000000..2699494b Binary files /dev/null and b/docs/images/mr-pending-maintenance/delete-inspection-390-light.png differ diff --git a/docs/images/mr-pending-maintenance/delete-inspection-800-dark.png b/docs/images/mr-pending-maintenance/delete-inspection-800-dark.png new file mode 100644 index 00000000..183ef1d7 Binary files /dev/null and b/docs/images/mr-pending-maintenance/delete-inspection-800-dark.png differ diff --git a/docs/images/mr-pending-maintenance/delete-inspection-800-light.png b/docs/images/mr-pending-maintenance/delete-inspection-800-light.png new file mode 100644 index 00000000..99afbf6b Binary files /dev/null and b/docs/images/mr-pending-maintenance/delete-inspection-800-light.png differ diff --git a/docs/images/mr-pending-maintenance/edit-1200-dark.png b/docs/images/mr-pending-maintenance/edit-1200-dark.png new file mode 100644 index 00000000..96e7417c Binary files /dev/null and b/docs/images/mr-pending-maintenance/edit-1200-dark.png differ diff --git a/docs/images/mr-pending-maintenance/edit-1200-light.png b/docs/images/mr-pending-maintenance/edit-1200-light.png new file mode 100644 index 00000000..6e9dc7fe Binary files /dev/null and b/docs/images/mr-pending-maintenance/edit-1200-light.png differ diff --git a/docs/images/mr-pending-maintenance/edit-390-dark.png b/docs/images/mr-pending-maintenance/edit-390-dark.png new file mode 100644 index 00000000..9ecf7f29 Binary files /dev/null and b/docs/images/mr-pending-maintenance/edit-390-dark.png differ diff --git a/docs/images/mr-pending-maintenance/edit-390-light.png b/docs/images/mr-pending-maintenance/edit-390-light.png new file mode 100644 index 00000000..8366b780 Binary files /dev/null and b/docs/images/mr-pending-maintenance/edit-390-light.png differ diff --git a/docs/images/mr-pending-maintenance/edit-800-dark.png b/docs/images/mr-pending-maintenance/edit-800-dark.png new file mode 100644 index 00000000..6502953e Binary files /dev/null and b/docs/images/mr-pending-maintenance/edit-800-dark.png differ diff --git a/docs/images/mr-pending-maintenance/edit-800-light.png b/docs/images/mr-pending-maintenance/edit-800-light.png new file mode 100644 index 00000000..f83b0c88 Binary files /dev/null and b/docs/images/mr-pending-maintenance/edit-800-light.png differ diff --git a/docs/mobile-web-parity.md b/docs/mobile-web-parity.md index de21ff66..b63ff6f1 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. 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). [#594](https://github.com/Theorvane/labfox/issues/594) / [PR #595](https://github.com/Theorvane/labfox/pull/595) adds exact original multiline context with inclusive side-aware per-line markers, full filename-hash/raw-counter endpoint identity, optional coordinate support, complete contiguous hunk checks, duplicate/reversed/gapped-span rejection and retained draft/session behavior. Five locales, three widths, both themes and compact keyboard/large-text layouts are covered; see [original context](mr-discussion-context.md). [#596](https://github.com/Theorvane/labfox/issues/596) / [PR #597](https://github.com/Theorvane/labfox/pull/597) adds explicit original multiline selection and positioned creation, indexed complete contiguous range eligibility, exact endpoint request/response confirmation, inclusive accessible markers, retained drafts and complete-range recovery inspection. Repository identity is checked before dispatch and after completion to isolate replaced-session outcomes. Five locales, three widths, both themes and compact keyboard/large-text layouts are covered; see [multiline creation](mr-multiline-discussions.md). [#598](https://github.com/Theorvane/labfox/issues/598) / [PR #599](https://github.com/Theorvane/labfox/pull/599) adds the read-only pending-review draft foundation: generated private note/global MR/author identities, exact Markdown and nullable original position/reply/resolution metadata; strict one-page draft_notes reads with immutable server order, header pagination, sanitized malformed/status failures and normal read-only OAuth refresh. Account-bound repositories reject other-author pages; auto-disposed page consumers prevent stale pre-dispatch reads and ignore late outcomes. Presentation removes retained private rows immediately during account/client/instance changes, sign-out, loading and failed refresh. Draft mutations, aggregation and pending-review UI remain separate; no live GitLab/device validation is claimed. [#600](https://github.com/Theorvane/labfox/issues/600) / [PR #601](https://github.com/Theorvane/labfox/pull/601) adds the unpublished regular/original text-range draft creation API prerequisite: exact Markdown and recursive null omission, strict HTTP 201 and original-position/body confirmation, no redirect/OAuth replay/automatic retry/public-comment fallback, and sanitized typed/status-preserving failures. Account-bound repositories require and confirm captured author plus explicit global MR identity separately from route IID. Shared text/range validation preserves published discussion behavior. Replies, commit/image/file drafts, editing/deletion, publication and the pending-review controller/UI remain follow-up work; uncertain writes require authoritative inspection before an explicit retry, and no live GitLab/device validation is claimed. [#602](https://github.com/Theorvane/labfox/issues/602) / [PR #603](https://github.com/Theorvane/labfox/pull/603) adds read-only pending-review pagination with an authoritative global MR identity separate from route IID, immutable server order, explicit cursors through empty pages, optional totals and duplicate-load prevention. Mismatched author/MR identities, duplicate drafts and backward cursors reject the full result. Account/repository generations cancel obsolete dispatch and isolate initial/pagination success/errors. Presentation immediately removes retained private rows during account/client/instance/sign-out, refresh and failed reads; explicit retry restarts page one. Offset traversal is not an atomic snapshot and cannot authorize publication. The controller exposes no UI or mutation; see [private pagination](mr-pending-review-reader.md). [#604](https://github.com/Theorvane/labfox/issues/604) / [PR #605](https://github.com/Theorvane/labfox/pull/605) adds a read-only private pending-review panel on MR detail with exact selectable saved Markdown, known original metadata, localized loading/empty/partial/error states and explicit paging/refresh/retry. Guarded queries await fresh MR detail and verify route/global identity before dispatch, closing account/instance/client replacement requests ahead of detail; old private rows disappear during detail refresh and failed reads. Five locales, three widths, both themes, large text, long content and resize/theme preservation are covered with six synthetic captures. The panel sends no mutation or embedded image request and never infers/re-anchors original positions; see [private review panel](mr-pending-review-panel.md). Creation orchestration, uncertain-write recovery and publication remain follow-ups; no live GitLab/device validation is claimed. [#606](https://github.com/Theorvane/labfox/issues/606) / [PR #607](https://github.com/Theorvane/LabFox/pull/607) adds regular private review save orchestration with fresh account-bound detail/global identity, shared discussion write/pagination reservations, exact Markdown and one confirmed create before route-scoped private-reader refresh. Uncertain writes block replay across ordinary refreshes and same-account client/repository replacement; explicit complete read-only inspection follows empty-page cursors, validates ownership/identity/uniqueness/order and waits for any dispatched write to settle. Old-session/view results and errors cannot expose private rows or dispatch further pages. The controller has no composer UI, positioned/reply save orchestration, editing/deletion or publication; see [guarded saves](mr-pending-review-save.md). Recovery is in-memory and offset inspection cannot promise exactly-once creation after a client timeout; no live-instance/device validation is claimed. [#608](https://github.com/Theorvane/LabFox/issues/608) / [PR #609](https://github.com/Theorvane/LabFox/pull/609) adds a localized regular private-note composer on MR detail with exact Markdown, guarded one-save orchestration, retained failed input, complete visible inspection and separate acknowledgement before manual retry. Account/client/repository/resource replacement discards private input and snapshots; obsolete results cannot toast or expose data. Public drafts stay intact, and same-account reopen retains uncertain-write gating. Five locales, three widths, both themes and keyboard/large-text recovery are covered with twelve synthetic captures; see [private composer](mr-pending-review-composer.md). Positioned/reply/commit saves, editing/deletion and publication remain separate; no exactly-once or live-instance/device validation is claimed. [#610](https://github.com/Theorvane/LabFox/issues/610) / [PR #611](https://github.com/Theorvane/LabFox/pull/611) adds private draft update/delete API and account-bound repository foundations: exact Markdown, explicit original text-position preservation, strict 200 target/body/position confirmation and 204 deletion acknowledgement, positive project/IID/draft identities, captured author/global-MR checks and unchanged non-body metadata. Status-first plain-wire decoding preserves typed failures even for malformed JSON; redirects, authentication replay, fallback writes and automatic retry are disabled. Original line-code-only and unsupported update anchors fail before dispatch; owned opaque/image/file drafts can be deleted by identity. See [maintenance contract](mr-draft-note-maintenance.md). Fresh target/controller reservations, localized edit/delete confirmation/recovery and publication remain follow-ups; no live-instance/device validation is claimed. | +| 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). [#594](https://github.com/Theorvane/labfox/issues/594) / [PR #595](https://github.com/Theorvane/labfox/pull/595) adds exact original multiline context with inclusive side-aware per-line markers, full filename-hash/raw-counter endpoint identity, optional coordinate support, complete contiguous hunk checks, duplicate/reversed/gapped-span rejection and retained draft/session behavior. Five locales, three widths, both themes and compact keyboard/large-text layouts are covered; see [original context](mr-discussion-context.md). [#596](https://github.com/Theorvane/labfox/issues/596) / [PR #597](https://github.com/Theorvane/labfox/pull/597) adds explicit original multiline selection and positioned creation, indexed complete contiguous range eligibility, exact endpoint request/response confirmation, inclusive accessible markers, retained drafts and complete-range recovery inspection. Repository identity is checked before dispatch and after completion to isolate replaced-session outcomes. Five locales, three widths, both themes and compact keyboard/large-text layouts are covered; see [multiline creation](mr-multiline-discussions.md). [#598](https://github.com/Theorvane/labfox/issues/598) / [PR #599](https://github.com/Theorvane/labfox/pull/599) adds the read-only pending-review draft foundation: generated private note/global MR/author identities, exact Markdown and nullable original position/reply/resolution metadata; strict one-page draft_notes reads with immutable server order, header pagination, sanitized malformed/status failures and normal read-only OAuth refresh. Account-bound repositories reject other-author pages; auto-disposed page consumers prevent stale pre-dispatch reads and ignore late outcomes. Presentation removes retained private rows immediately during account/client/instance changes, sign-out, loading and failed refresh. Draft mutations, aggregation and pending-review UI remain separate; no live GitLab/device validation is claimed. [#600](https://github.com/Theorvane/labfox/issues/600) / [PR #601](https://github.com/Theorvane/labfox/pull/601) adds the unpublished regular/original text-range draft creation API prerequisite: exact Markdown and recursive null omission, strict HTTP 201 and original-position/body confirmation, no redirect/OAuth replay/automatic retry/public-comment fallback, and sanitized typed/status-preserving failures. Account-bound repositories require and confirm captured author plus explicit global MR identity separately from route IID. Shared text/range validation preserves published discussion behavior. Replies, commit/image/file drafts, editing/deletion, publication and the pending-review controller/UI remain follow-up work; uncertain writes require authoritative inspection before an explicit retry, and no live GitLab/device validation is claimed. [#602](https://github.com/Theorvane/labfox/issues/602) / [PR #603](https://github.com/Theorvane/labfox/pull/603) adds read-only pending-review pagination with an authoritative global MR identity separate from route IID, immutable server order, explicit cursors through empty pages, optional totals and duplicate-load prevention. Mismatched author/MR identities, duplicate drafts and backward cursors reject the full result. Account/repository generations cancel obsolete dispatch and isolate initial/pagination success/errors. Presentation immediately removes retained private rows during account/client/instance/sign-out, refresh and failed reads; explicit retry restarts page one. Offset traversal is not an atomic snapshot and cannot authorize publication. The controller exposes no UI or mutation; see [private pagination](mr-pending-review-reader.md). [#604](https://github.com/Theorvane/labfox/issues/604) / [PR #605](https://github.com/Theorvane/labfox/pull/605) adds a read-only private pending-review panel on MR detail with exact selectable saved Markdown, known original metadata, localized loading/empty/partial/error states and explicit paging/refresh/retry. Guarded queries await fresh MR detail and verify route/global identity before dispatch, closing account/instance/client replacement requests ahead of detail; old private rows disappear during detail refresh and failed reads. Five locales, three widths, both themes, large text, long content and resize/theme preservation are covered with six synthetic captures. The panel sends no mutation or embedded image request and never infers/re-anchors original positions; see [private review panel](mr-pending-review-panel.md). Creation orchestration, uncertain-write recovery and publication remain follow-ups; no live GitLab/device validation is claimed. [#606](https://github.com/Theorvane/labfox/issues/606) / [PR #607](https://github.com/Theorvane/LabFox/pull/607) adds regular private review save orchestration with fresh account-bound detail/global identity, shared discussion write/pagination reservations, exact Markdown and one confirmed create before route-scoped private-reader refresh. Uncertain writes block replay across ordinary refreshes and same-account client/repository replacement; explicit complete read-only inspection follows empty-page cursors, validates ownership/identity/uniqueness/order and waits for any dispatched write to settle. Old-session/view results and errors cannot expose private rows or dispatch further pages. The controller has no composer UI, positioned/reply save orchestration, editing/deletion or publication; see [guarded saves](mr-pending-review-save.md). Recovery is in-memory and offset inspection cannot promise exactly-once creation after a client timeout; no live-instance/device validation is claimed. [#608](https://github.com/Theorvane/LabFox/issues/608) / [PR #609](https://github.com/Theorvane/LabFox/pull/609) adds a localized regular private-note composer on MR detail with exact Markdown, guarded one-save orchestration, retained failed input, complete visible inspection and separate acknowledgement before manual retry. Account/client/repository/resource replacement discards private input and snapshots; obsolete results cannot toast or expose data. Public drafts stay intact, and same-account reopen retains uncertain-write gating. Five locales, three widths, both themes and keyboard/large-text recovery are covered with twelve synthetic captures; see [private composer](mr-pending-review-composer.md). Positioned/reply/commit saves, editing/deletion and publication remain separate; no exactly-once or live-instance/device validation is claimed. [#610](https://github.com/Theorvane/LabFox/issues/610) / [PR #611](https://github.com/Theorvane/LabFox/pull/611) adds private draft update/delete API and account-bound repository foundations: exact Markdown, explicit original text-position preservation, strict 200 target/body/position confirmation and 204 deletion acknowledgement, positive project/IID/draft identities, captured author/global-MR checks and unchanged non-body metadata. Status-first plain-wire decoding preserves typed failures even for malformed JSON; redirects, authentication replay, fallback writes and automatic retry are disabled. Original line-code-only and unsupported update anchors fail before dispatch; owned opaque/image/file drafts can be deleted by identity. See [maintenance contract](mr-draft-note-maintenance.md). Fresh target/controller reservations, localized edit/delete confirmation/recovery and publication remain follow-ups; no live-instance/device validation is claimed. [#612](https://github.com/Theorvane/LabFox/issues/612) connects private draft editing and confirmed deletion to the existing pending review panel. Shared discussion reservations, fresh detail/global identity and complete private-page comparison refuse stale or missing selected targets before one write. Uncertain results gate every private write; complete visible inspection and acknowledgement adopt a current target only for explicit retry, with obsolete session/view input and outcomes discarded. Five locales, keyboard/large text and twelve synthetic captures cover three widths and both themes; see [maintenance UI](mr-pending-review-maintenance.md). Publication and positioned/reply/commit creation remain separate; no atomic snapshot, conditional-write, durable recovery or live-instance/device guarantee is claimed. | | 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-draft-note-maintenance.md b/docs/mr-draft-note-maintenance.md index 4857a6dd..666c7065 100644 --- a/docs/mr-draft-note-maintenance.md +++ b/docs/mr-draft-note-maintenance.md @@ -88,3 +88,7 @@ No model, generated file, dependency, license, localization, visible layout, cache or analytics changes are introduced. Tests use synthetic identities and dummy credentials; no live GitLab, physical-device or store validation is claimed. Publication and editing/deletion UI remain separate. MW-07 stays in progress. + +The subsequent [maintenance UI](mr-pending-review-maintenance.md) connects +guarded private editing and confirmed deletion, with fresh selected-target +comparison and visible inspection before an explicit retry. diff --git a/docs/mr-pending-review-composer.md b/docs/mr-pending-review-composer.md index 6ea780b5..af832b61 100644 --- a/docs/mr-pending-review-composer.md +++ b/docs/mr-pending-review-composer.md @@ -105,3 +105,7 @@ remain separate slices. MW-07 stays in progress. The subsequent [private draft maintenance foundation](mr-draft-note-maintenance.md) adds guarded update/delete API and repository methods; editing/deletion controller and UI orchestration remain separate. + +The subsequent [maintenance UI](mr-pending-review-maintenance.md) connects +guarded private editing and confirmed deletion, with fresh selected-target +comparison and visible inspection before an explicit retry. diff --git a/docs/mr-pending-review-maintenance.md b/docs/mr-pending-review-maintenance.md new file mode 100644 index 00000000..edf1fd31 --- /dev/null +++ b/docs/mr-pending-review-maintenance.md @@ -0,0 +1,102 @@ +# Editing and deleting private review notes + +Issue [#612](https://github.com/Theorvane/LabFox/issues/612) connects the +[pending review panel](mr-pending-review-panel.md) to the +[update/delete foundations](mr-draft-note-maintenance.md). The regular composer +shares its private input, preparation, session and recovery guards with the new +maintenance dialog. Publication and positioned/reply/commit creation remain +separate; MW-07 is still in progress. + +## Selection and execution + +Owned saved notes offer deletion. Editing is offered for regular notes and +complete original text anchors, including supported multiline ranges. Image, +file, incomplete and opaque line-code-only anchors have no edit action. The +repository uses the API's existing pure text-position validator, exported for +this classification, so presentation does not recreate its range rules. + +Editing starts with exact saved Markdown and displays the original note and +known location metadata. Blank and unchanged input cannot be submitted. +Deletion shows the selected note and requires an explicit checkbox before the +Delete note button is enabled. Both dialogs are scrollable and use the same +width-responsive feature code on every platform. All new messages have English +source and translations for Korean, Japanese, Hindi and Chinese. + +`updatePendingNote` and `deletePendingNote` reserve the existing discussion +controller before asynchronous work. This shares the create/reply/resolution/ +suggestion and discussion-pagination reservation; approval/merge commands still +have their separate controller. Fresh account-bound MR detail supplies the +positive global MR ID separately from project/IID. Ownership, selected draft ID +and global identity are checked before reading private state. + +The controller traverses every private page, including empty intermediate pages, +and validates positive IDs, captured author/global identity, uniqueness and +advancing cursors. A missing draft or any change to its captured body, original +position or modeled metadata stops the command before dispatch. A later page +failure cannot authorize a write from an earlier partial result. It never +silently adopts a concurrently changed target or treats its disappearance as a +successful deletion. + +A current exact match permits one repository update or delete. Updates retain +original anchors and exact edited Markdown; deletion addresses the captured +identity without reconstructing its position. The existing strict API responses, +status-first errors and disabled redirects/authentication replay remain intact. +Only a confirmed current-view outcome refreshes every private reader query for +this MR and shows localized success. No replacement create, public fallback, +publication, automatic retry, analytics or disk persistence is introduced. + +## Uncertain results and current views + +The shared in-memory inspection requirement is set before dispatch and remains +on failure, malformed acknowledgement or an obsolete completion. It blocks +subsequent private creation, update and deletion. Ordinary controller refresh or +same-account repository/client replacement cannot clear it. Complete explicit +inspection waits for the actual dispatched request to settle before reading. + +A failed maintenance dialog retains edited input, displays a generic localized +error, and requires read-only inspection before another attempt. The complete +validated list is displayed together with the latest selected note and metadata. +A separate acknowledgement is required before applying the user's current +change to that freshly inspected target. Editing the input clears that +acknowledgement. Another execution still performs a fresh preflight, so a later +concurrent change is refused again. Missing targets cannot be retried; an +unsupported newly observed anchor cannot be edited. Inspection itself never +writes and never exposes a partial list after a later page fails. + +The panel owns the originating view lifetime outside rows that its own detail +refresh replaces. Account/instance, draft/detail/comments repository or route +replacement permanently discards local private input, target and inspection +state. Late data/errors cannot toast, clear another view's input or send a write +through the replacement session. Existing regular-composer behavior shares these +guards and remains covered by its regression tests. + +Already dispatched writes cannot be undone. Offset pages and a settled client +request are not an atomic server snapshot; a server may finish processing after +a timeout. Fresh comparison is not a conditional server update and cannot +eliminate a race after the last read. No durable recovery or exactly-once claim +is made. There is no live GitLab, physical-device or store validation. + +## Verification and synthetic captures + +Tests were written before the new methods and controls and initially failed for +missing implementation. Two later failing regressions reproduced an account +change queued after fresh-target comparison; yielding to queued session +cancellation and checking the current session immediately before dispatch +prevents either write at this boundary. Controller and widget regressions cover exact bodies, +original metadata, stale/missing targets, all-page preflight, typed failures, +shared reservations, uncertain-write settlement, cross-command gating, +account/repository/view replacement, explicit inspection/acknowledgement, +unsupported edit anchors, deletion confirmation and route-scoped refreshes. +Five locales are exercised with large text and keyboard in narrow/wide dialogs. +The existing reader, repository, save, panel and composer suites remain required. + +The captures use synthetic notes and dummy credentials; embedded Markdown image +URLs remain selectable text, without image fetching or rendering. All captures +are generated by an optional test harness at 390, 800 and 1200 logical pixels. + +| State | Narrow | Tablet | Wide | +|---|---|---|---| +| Edit, light | [390](images/mr-pending-maintenance/edit-390-light.png) | [800](images/mr-pending-maintenance/edit-800-light.png) | [1200](images/mr-pending-maintenance/edit-1200-light.png) | +| Edit, dark | [390](images/mr-pending-maintenance/edit-390-dark.png) | [800](images/mr-pending-maintenance/edit-800-dark.png) | [1200](images/mr-pending-maintenance/edit-1200-dark.png) | +| Delete inspection, light | [390](images/mr-pending-maintenance/delete-inspection-390-light.png) | [800](images/mr-pending-maintenance/delete-inspection-800-light.png) | [1200](images/mr-pending-maintenance/delete-inspection-1200-light.png) | +| Delete inspection, dark | [390](images/mr-pending-maintenance/delete-inspection-390-dark.png) | [800](images/mr-pending-maintenance/delete-inspection-800-dark.png) | [1200](images/mr-pending-maintenance/delete-inspection-1200-dark.png) | diff --git a/docs/mr-pending-review-panel.md b/docs/mr-pending-review-panel.md index df220a9b..7951ca57 100644 --- a/docs/mr-pending-review-panel.md +++ b/docs/mr-pending-review-panel.md @@ -93,3 +93,7 @@ They show the isolated read-only panel with synthetic bodies and file paths. | Mobile, 390 | ![Mobile light](images/mr-pending-review/pending-390-light.png) | ![Mobile dark](images/mr-pending-review/pending-390-dark.png) | | Tablet, 800 | ![Tablet light](images/mr-pending-review/pending-800-light.png) | ![Tablet dark](images/mr-pending-review/pending-800-dark.png) | | Desktop, 1200 | ![Desktop light](images/mr-pending-review/pending-1200-light.png) | ![Desktop dark](images/mr-pending-review/pending-1200-dark.png) | + +The subsequent [maintenance UI](mr-pending-review-maintenance.md) connects +guarded private editing and confirmed deletion, with fresh selected-target +comparison and visible inspection before an explicit retry. diff --git a/docs/mr-pending-review-save.md b/docs/mr-pending-review-save.md index a622ca6c..3ff9e381 100644 --- a/docs/mr-pending-review-save.md +++ b/docs/mr-pending-review-save.md @@ -91,3 +91,7 @@ visible layout, remote asset, telemetry or disk cache. Tests use synthetic users no live GitLab, physical-device or store validation is claimed. Existing widget and review-controller suites remain part of regression verification. MW-07 stays in progress until the remaining review flows are completed and validated. + +The subsequent [maintenance UI](mr-pending-review-maintenance.md) connects +guarded private editing and confirmed deletion, with fresh selected-target +comparison and visible inspection before an explicit retry. diff --git a/packages/gitlab_api/lib/gitlab_api.dart b/packages/gitlab_api/lib/gitlab_api.dart index 2db96823..bf50a12b 100644 --- a/packages/gitlab_api/lib/gitlab_api.dart +++ b/packages/gitlab_api/lib/gitlab_api.dart @@ -24,6 +24,8 @@ export 'src/labels/project_labels_api.dart'; export 'src/members/group_members_api.dart'; export 'src/members/project_members_api.dart'; export 'src/merge_requests/merge_requests_api.dart'; +export 'src/merge_requests/positioned_discussion.dart' + show validTextDiscussionPosition; export 'src/milestones/group_milestones_api.dart'; export 'src/milestones/milestones_api.dart'; export 'src/notes/notes_api.dart';