diff --git a/apps/labfox/lib/app/router.dart b/apps/labfox/lib/app/router.dart index c601686df..c611fd2cc 100644 --- a/apps/labfox/lib/app/router.dart +++ b/apps/labfox/lib/app/router.dart @@ -37,6 +37,8 @@ import '../features/members/presentation/project_members_screen.dart'; import '../features/merge_requests/presentation/merge_request_detail_screen.dart'; import '../features/merge_requests/presentation/merge_requests_screen.dart'; import '../features/merge_requests/presentation/mr_changes_screen.dart'; +import '../features/merge_requests/presentation/mr_commits_screen.dart'; +import '../features/merge_requests/presentation/mr_original_changes_screen.dart'; import '../features/merge_requests/presentation/my_merge_requests_screen.dart'; import '../features/merge_requests/presentation/new_merge_request_screen.dart'; import '../features/milestones/presentation/milestone_detail_screen.dart'; @@ -197,6 +199,10 @@ abstract final class Routes { static String job(int id, int jobId) => '/projects/$id/jobs/$jobId'; static String commitChanges(int id, String sha) => '/projects/$id/commit/$sha/changes'; + static String mergeRequestCommits(int id, int iid) => + '/projects/$id/merge_requests/$iid/commits'; + static String mergeRequestCommitChanges(int id, int iid, String sha) => + '/projects/$id/merge_requests/$iid/commits/$sha'; static String mergeRequestChanges(int id, int iid) => '/projects/$id/merge_requests/$iid/changes'; } @@ -771,6 +777,23 @@ final routerProvider = Provider((ref) { return MrChangesScreen(projectId: id, iid: iid); }, ), + GoRoute( + path: 'commits', + builder: (context, state) => MrCommitsScreen( + projectId: int.parse(state.pathParameters['id']!), + iid: int.parse(state.pathParameters['iid']!), + ), + routes: [ + GoRoute( + path: ':sha', + builder: (context, state) => MrOriginalChangesScreen( + projectId: int.parse(state.pathParameters['id']!), + iid: int.parse(state.pathParameters['iid']!), + commitId: state.pathParameters['sha']!, + ), + ), + ], + ), ], ), ], diff --git a/apps/labfox/lib/core/analytics/analytics.dart b/apps/labfox/lib/core/analytics/analytics.dart index d1b3367c6..c111217d1 100644 --- a/apps/labfox/lib/core/analytics/analytics.dart +++ b/apps/labfox/lib/core/analytics/analytics.dart @@ -73,11 +73,19 @@ class Analytics { /// Collapses numeric path segments (`/projects/42` → `/projects/:id`) so a /// screen-view route never carries an identifying or high-cardinality value. +/// Commit route parameters are hidden even when an invalid deep link is visited. String sanitizeRoute(String path) { - return path - .split('/') - .map((segment) => int.tryParse(segment) == null ? segment : ':id') - .join('/'); + final segments = path.split('/'); + return [ + for (var i = 0; i < segments.length; i++) + if (i > 0 && + (segments[i - 1] == 'commit' || segments[i - 1] == 'commits')) + ':sha' + else if (int.tryParse(segments[i]) != null) + ':id' + else + segments[i], + ].join('/'); } final analyticsProvider = Provider((ref) { diff --git a/apps/labfox/lib/features/merge_requests/presentation/controllers/mr_commits_controller.dart b/apps/labfox/lib/features/merge_requests/presentation/controllers/mr_commits_controller.dart new file mode 100644 index 000000000..d45184a1d --- /dev/null +++ b/apps/labfox/lib/features/merge_requests/presentation/controllers/mr_commits_controller.dart @@ -0,0 +1,102 @@ +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 '../../data/merge_requests_repository.dart'; +import 'merge_requests_controllers.dart'; + +/// Establishes the authoritative global MR identity from an identifier-only route. +class MrCommitOriginController + extends AutoDisposeFamilyAsyncNotifier { + @override + Future build(MergeRequestRef arg) async { + if (arg.projectId < 1 || arg.iid < 1) { + throw ArgumentError('Invalid MR route.'); + } + var active = true; + ref.onDispose(() => active = false); + final account = ref.watch(currentAccountProvider); + if (account == null) return null; + final source = ref.watch(mergeRequestsRepositoryProvider.future); + bool current() => + active && + ref.read(currentAccountProvider) == account && + identical(ref.read(mergeRequestsRepositoryProvider.future), source); + try { + final repo = await source; + if (!current()) return null; + if (repo == null || account.user.id < 1) { + throw const GitLabAuthException( + 'No authenticated commit review session.', + ); + } + final detail = await repo.get(projectId: arg.projectId, iid: arg.iid); + if (!current()) return null; + if (detail.id < 1 || + detail.iid != arg.iid || + detail.targetProjectId != arg.projectId || + (detail.projectId != null && detail.projectId != arg.projectId)) { + throw const GitLabServerException('Invalid commit review identity.'); + } + return detail; + } catch (_) { + if (!current()) return null; + rethrow; + } + } +} + +final mrCommitOriginControllerProvider = AsyncNotifierProvider.autoDispose + .family( + MrCommitOriginController.new, + ); + +/// Browses every advertised membership page without exposing partial stale lists. +class MrCommitsController + extends AutoDisposeFamilyAsyncNotifier?, MergeRequestRef> { + @override + Future?> build(MergeRequestRef arg) async { + var active = true; + ref.onDispose(() => active = false); + final account = ref.watch(currentAccountProvider); + if (account == null) return null; + final origin = ref.watch(mrCommitOriginControllerProvider(arg).future); + final source = ref.watch(mergeRequestsRepositoryProvider.future); + bool current() => + active && + ref.read(currentAccountProvider) == account && + identical(ref.read(mergeRequestsRepositoryProvider.future), source) && + identical( + ref.read(mrCommitOriginControllerProvider(arg).future), + origin, + ); + try { + // Observe both started futures immediately, even if one fails first. + final values = await Future.wait([ + origin, + source, + ], eagerError: true); + if (!current() || values[0] == null) return null; + final repo = values[1] as MergeRequestsRepository?; + if (repo == null) { + throw const GitLabAuthException('No authenticated commit browser.'); + } + final commits = await repo.commits( + projectId: arg.projectId, + iid: arg.iid, + isCurrent: current, + ); + if (!current() || commits == null) return null; + return List.unmodifiable(commits); + } catch (_) { + if (!current()) return null; + rethrow; + } + } +} + +final mrCommitsControllerProvider = AsyncNotifierProvider.autoDispose + .family?, MergeRequestRef>( + MrCommitsController.new, + ); diff --git a/apps/labfox/lib/features/merge_requests/presentation/merge_request_detail_screen.dart b/apps/labfox/lib/features/merge_requests/presentation/merge_request_detail_screen.dart index 21bba61f5..535f73cfd 100644 --- a/apps/labfox/lib/features/merge_requests/presentation/merge_request_detail_screen.dart +++ b/apps/labfox/lib/features/merge_requests/presentation/merge_request_detail_screen.dart @@ -77,6 +77,13 @@ class MergeRequestDetailScreen extends ConsumerWidget { icon: const Icon(LabFoxIcons.diff, size: 18), label: Text(l10n.mrViewChanges), ), + const SizedBox(height: LabFoxSpacing.sm), + OutlinedButton.icon( + onPressed: () => + context.push(Routes.mergeRequestCommits(projectId, iid)), + icon: const Icon(LabFoxIcons.commit), + label: Text(l10n.commitsTitle), + ), if (data.labels.isNotEmpty) ...[ const SizedBox(height: LabFoxSpacing.md), Wrap( diff --git a/apps/labfox/lib/features/merge_requests/presentation/mr_commits_screen.dart b/apps/labfox/lib/features/merge_requests/presentation/mr_commits_screen.dart new file mode 100644 index 000000000..837b74e54 --- /dev/null +++ b/apps/labfox/lib/features/merge_requests/presentation/mr_commits_screen.dart @@ -0,0 +1,108 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../app/router.dart'; +import '../../../l10n/app_localizations.dart'; +import 'controllers/merge_requests_controllers.dart'; +import 'controllers/mr_commits_controller.dart'; + +/// Lists complete MR commit membership and navigates by identifiers only. +class MrCommitsScreen extends ConsumerWidget { + const MrCommitsScreen({ + required this.projectId, + required this.iid, + super.key, + }); + final int projectId, iid; + @override + Widget build(BuildContext context, WidgetRef ref) { + final l = AppLocalizations.of(context); + final resource = MergeRequestRef(projectId: projectId, iid: iid); + final provider = mrCommitsControllerProvider(resource); + return Scaffold( + appBar: AppBar(title: Text(l.commitsTitle)), + body: ref + .watch(provider) + .when( + skipLoadingOnRefresh: false, + skipLoadingOnReload: false, + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l.commitsError), + TextButton( + onPressed: () { + ref.invalidate( + mrCommitOriginControllerProvider(resource), + ); + ref.invalidate(provider); + }, + child: Text(l.retry), + ), + ], + ), + ), + data: (commits) { + if (commits == null) { + return Center(child: Text(l.mrReviewUnavailable)); + } + if (commits.isEmpty) return Center(child: Text(l.mrCommitsEmpty)); + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: LabFoxBreakpoints.desktop, + ), + child: RefreshIndicator( + onRefresh: () async { + ref.invalidate( + mrCommitOriginControllerProvider(resource), + ); + ref.invalidate(provider); + try { + await ref.read(provider.future); + } catch (_) { + /* The provider displays a sanitized read error. */ + } + }, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: commits.length, + itemBuilder: (_, i) { + final c = commits[i]; + final canonical = RegExp( + r'^[0-9a-f]{40}$', + ).hasMatch(c.id); + return ListTile( + title: Text( + c.title.isEmpty ? c.id : c.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + canonical ? c.id.substring(0, 8) : c.id, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: canonical + ? () => context.push( + Routes.mergeRequestCommitChanges( + projectId, + iid, + c.id, + ), + ) + : null, + ); + }, + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/apps/labfox/lib/features/merge_requests/presentation/mr_original_changes_screen.dart b/apps/labfox/lib/features/merge_requests/presentation/mr_original_changes_screen.dart new file mode 100644 index 000000000..92b5d9307 --- /dev/null +++ b/apps/labfox/lib/features/merge_requests/presentation/mr_original_changes_screen.dart @@ -0,0 +1,479 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gitlab_models/gitlab_models.dart'; +import 'package:intl/intl.dart'; +import '../../../core/auth/auth_controller.dart'; +import '../../../core/auth/gitlab_client_provider.dart'; +import '../../../l10n/app_localizations.dart'; +import 'controllers/merge_requests_controllers.dart'; +import 'controllers/mr_commits_controller.dart'; +import 'controllers/mr_draft_notes_provider.dart'; +import 'controllers/mr_original_review_controller.dart'; +import 'widgets/mr_pending_review_composer.dart'; + +/// Restores an original review from project/IID/full SHA, never route objects. +class MrOriginalChangesScreen extends ConsumerWidget { + const MrOriginalChangesScreen({ + required this.projectId, + required this.iid, + required this.commitId, + super.key, + }); + final int projectId, iid; + final String commitId; + @override + Widget build(BuildContext context, WidgetRef ref) { + final l = AppLocalizations.of(context); + if (projectId < 1 || + iid < 1 || + !RegExp(r'^[0-9a-f]{40}$').hasMatch(commitId)) { + return Scaffold( + appBar: AppBar(title: Text(l.mrOriginalReviewTitle)), + body: Center(child: Text(l.mrReviewUnavailable)), + ); + } + final resource = MergeRequestRef(projectId: projectId, iid: iid); + final provider = mrCommitOriginControllerProvider(resource); + final account = ref.watch(currentAccountProvider); + final source = ref.watch(mergeRequestsRepositoryProvider); + return ref + .watch(provider) + .when( + skipLoadingOnRefresh: false, + skipLoadingOnReload: false, + loading: () => Scaffold( + appBar: AppBar(title: Text(l.mrOriginalReviewTitle)), + body: const Center(child: CircularProgressIndicator()), + ), + error: (_, _) => Scaffold( + appBar: AppBar(title: Text(l.mrOriginalReviewTitle)), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l.changesError), + TextButton( + onPressed: () => ref.invalidate(provider), + child: Text(l.retry), + ), + ], + ), + ), + ), + data: (mr) => mr == null + ? Scaffold( + appBar: AppBar(title: Text(l.mrOriginalReviewTitle)), + body: Center(child: Text(l.mrReviewUnavailable)), + ) + : _OriginalContent( + key: ValueKey((resource, mr.id, commitId, account, source)), + original: ( + projectId: projectId, + iid: iid, + mergeRequestId: mr.id, + commitId: commitId, + ), + ), + ); + } +} + +class _OriginalContent extends ConsumerStatefulWidget { + const _OriginalContent({required this.original, super.key}); + final MrOriginalReviewRef original; + @override + ConsumerState<_OriginalContent> createState() => _OriginalState(); +} + +class _OriginalState extends ConsumerState<_OriginalContent> { + MrOriginalReviewSelection? _selection; + DiffNotePosition? _start; + bool _choosingEnd = false, _opening = false; + ValueNotifier? _view; + MergeRequestRef get resource => MergeRequestRef( + projectId: widget.original.projectId, + iid: widget.original.iid, + ); + @override + void dispose() { + _view?.value = false; + super.dispose(); + } + + void _clear() { + setState(() { + _selection = null; + _start = null; + _choosingEnd = false; + }); + } + + Future _open() async { + if (_opening || _choosingEnd) return; + final selected = _selection; + if (selected == null) return; + final displayed = ref.read( + mrOriginalReviewControllerProvider(widget.original), + ); + if (displayed.isLoading || + displayed.hasError || + !displayed.hasValue || + !selected.matches(displayed.value!)) { + return; + } + final account = ref.read(currentAccountProvider); + final drafts = ref + .read(mrDraftNotesRepositoryProvider) + .unwrapPrevious() + .valueOrNull; + final details = ref + .read(mergeRequestsRepositoryProvider) + .unwrapPrevious() + .valueOrNull; + final client = ref.read(gitLabClientProvider).unwrapPrevious().valueOrNull; + if (account == null || + drafts == null || + details == null || + client == null) { + return; + } + final active = ValueNotifier(true); + _view = active; + setState(() => _opening = true); + bool current() => + mounted && + active.value && + ref.read(currentAccountProvider) == account && + identical( + ref.read(mrDraftNotesRepositoryProvider).unwrapPrevious().valueOrNull, + drafts, + ) && + identical( + ref + .read(mergeRequestsRepositoryProvider) + .unwrapPrevious() + .valueOrNull, + details, + ) && + identical( + ref.read(gitLabClientProvider).unwrapPrevious().valueOrNull, + client, + ); + try { + final saved = await showMrPendingOriginalReviewDialog( + context: context, + resource: resource, + original: widget.original, + selection: selected, + account: account, + draftSession: drafts, + detailSession: details, + clientSession: client, + viewActive: active, + isCurrent: current, + ); + if (!mounted || !current()) return; + if (saved == true) { + _clear(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).mrPendingComposeSaved), + ), + ); + } + } finally { + _view = null; + active.dispose(); + if (mounted) setState(() => _opening = false); + } + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final provider = mrOriginalReviewControllerProvider(widget.original); + final drafts = ref.watch(mrDraftNotesRepositoryProvider).unwrapPrevious(); + final client = ref.watch(gitLabClientProvider).unwrapPrevious(); + final privateReady = + !drafts.isLoading && + !drafts.hasError && + drafts.valueOrNull != null && + !client.isLoading && + !client.hasError && + client.valueOrNull != null; + return Scaffold( + appBar: AppBar( + title: Text(l.mrOriginalReviewTitle), + actions: [ + IconButton( + tooltip: l.retry, + onPressed: _opening + ? null + : () { + _clear(); + ref.invalidate(provider); + }, + icon: const Icon(LabFoxIcons.refresh), + ), + ], + ), + body: ref + .watch(provider) + .when( + skipLoadingOnRefresh: false, + skipLoadingOnReload: false, + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l.changesError), + TextButton( + onPressed: _opening ? null : () => ref.invalidate(provider), + child: Text(l.retry), + ), + ], + ), + ), + data: (s) { + final contextData = s.context; + if (contextData == null) { + return Center(child: Text(l.mrReviewUnavailable)); + } + if (contextData.files.isEmpty) { + return Center(child: Text(l.changesEmpty)); + } + final valid = _selection != null && _selection!.matches(s); + final marked = valid + ? s.linesForPosition(_selection!.position) + : []; + final starts = _start == null + ? [] + : s.linesForPosition(_start!); + final start = starts.length == 1 ? starts.single : null; + return LayoutBuilder( + builder: (context, constraints) { + final files = ListView.builder( + itemCount: contextData.files.length, + itemBuilder: (_, index) { + final literal = contextData.files[index]; + final eligible = s.files + .where((f) => identical(f.file, literal)) + .toList(); + final file = eligible.length == 1 + ? eligible.single + : null; + final rangeMode = + _choosingEnd && + start != null && + file != null && + file.hunks.any( + (h) => h.lines.any((row) => identical(row, start)), + ); + return Card( + margin: const EdgeInsets.all(LabFoxSpacing.sm), + child: Padding( + padding: const EdgeInsets.all(LabFoxSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + literal.newPath, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (literal.oldPath != literal.newPath) + Text( + l.mrPendingReviewOldPath(literal.oldPath), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: LabFoxSpacing.sm), + if (file == null) + Text(l.mrOriginalReviewDiffUnavailable) + else + DiffHunksViewer( + hunks: file.hunks, + highlightedLines: marked, + highlightedLineLabel: + l.mrDiffSelectedLineLabel, + lineActionLabel: rangeMode + ? l.mrDiffRangeEndButton + : l.mrPendingComposeOpen, + canSelectLine: (line) => rangeMode + ? s.rangePositionFor(file, start, line) != + null + : !_choosingEnd && + _selection == null && + s.positionFor(file, line) != null, + onLineSelected: _opening + ? null + : (line) { + final p = rangeMode + ? s.rangePositionFor( + file, + start, + line, + ) + : s.positionFor(file, line); + if (p == null) return; + final selection = + MrOriginalReviewSelection.capture( + s, + p, + ); + if (selection == null) return; + setState(() { + _selection = selection; + if (!rangeMode) _start = p; + _choosingEnd = false; + }); + }, + ), + ], + ), + ), + ); + }, + ); + final composer = _selection == null + ? null + : SafeArea( + top: false, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(LabFoxSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _selection!.position.newPath!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (_selection!.position.lineRange + case final range?) + Text( + l.mrDiffSelectedRangeLabel( + '${range.start!.type == 'old' ? '-' : '+'}${NumberFormat.decimalPattern(l.localeName).format(range.start!.type == 'old' ? range.start!.oldLine : range.start!.newLine)}', + '${range.end!.type == 'old' ? '-' : '+'}${NumberFormat.decimalPattern(l.localeName).format(range.end!.type == 'old' ? range.end!.oldLine : range.end!.newLine)}', + ), + ) + else ...[ + if (_selection!.position.oldLine + case final line?) + Text( + l.mrPendingReviewOldLine( + NumberFormat.decimalPattern( + l.localeName, + ).format(line), + ), + ), + if (_selection!.position.newLine + case final line?) + Text( + l.mrPendingReviewNewLine( + NumberFormat.decimalPattern( + l.localeName, + ).format(line), + ), + ), + ], + if (!valid) Text(l.mrReviewUnavailable), + if (_choosingEnd) + Text(l.mrDiffRangeChooseEnd), + Wrap( + spacing: LabFoxSpacing.sm, + children: [ + TextButton( + key: const ValueKey( + 'mr-original-select-range', + ), + onPressed: + _opening || + _choosingEnd || + !valid || + start == null + ? null + : () => setState( + () => _choosingEnd = true, + ), + child: Text(l.mrDiffSelectRangeButton), + ), + TextButton( + onPressed: _opening ? null : _clear, + child: Text(l.cancel), + ), + ], + ), + FilledButton( + key: const ValueKey( + 'mr-original-private-open', + ), + onPressed: + _opening || + _choosingEnd || + !valid || + !privateReady + ? null + : _open, + child: Text(l.mrPendingComposeOpen), + ), + ], + ), + ), + ), + ); + final wide = constraints.maxWidth >= LabFoxBreakpoints.tablet; + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(LabFoxSpacing.sm), + child: Column( + children: [ + Text( + contextData.commit.title.isEmpty + ? widget.original.commitId + : contextData.commit.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + SelectableText(widget.original.commitId), + if (!s.canSelect) Text(l.mrOriginalReviewReadOnly), + ], + ), + ), + Expanded( + child: wide && composer != null + ? Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: files), + const VerticalDivider( + width: LabFoxSpacing.md, + ), + SizedBox( + width: constraints.maxWidth * 0.4, + child: composer, + ), + ], + ) + : Column( + children: [ + Expanded(flex: 2, child: files), + if (composer != null) + Flexible(child: composer), + ], + ), + ), + ], + ); + }, + ); + }, + ), + ); + } +} 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 196da5dc2..47db9ee3c 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 @@ -16,6 +16,7 @@ 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'; +import '../controllers/mr_original_review_controller.dart'; import '../controllers/mr_review_snapshot_controller.dart'; import 'mr_pending_review_note.dart'; @@ -181,6 +182,34 @@ Future showMrPendingReviewPositionDialog({ ), ); +/// Reuses private-note input and inspection for a captured original commit. +Future showMrPendingOriginalReviewDialog({ + required BuildContext context, + required MergeRequestRef resource, + required MrOriginalReviewRef original, + required MrOriginalReviewSelection selection, + required Account account, + required Object draftSession, + required Object detailSession, + required Object clientSession, + required ValueNotifier viewActive, + required bool Function() isCurrent, +}) => showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _ComposeDialog( + resource: resource, + original: original, + originalSelection: selection, + account: account, + draftSession: draftSession, + detailSession: detailSession, + clientSession: clientSession, + viewActive: viewActive, + isCurrent: isCurrent, + ), +); + /// Keeps private reply input bound to the original public discussion and client. Future showMrPendingReviewReplyDialog({ required BuildContext context, @@ -217,6 +246,8 @@ class _ComposeDialog extends ConsumerStatefulWidget { required this.isCurrent, this.discussion, this.selection, + this.original, + this.originalSelection, this.diffSession, this.clientSession, this.draft, @@ -230,6 +261,8 @@ class _ComposeDialog extends ConsumerStatefulWidget { final bool Function() isCurrent; final Discussion? discussion; final MrReviewSelection? selection; + final MrOriginalReviewRef? original; + final MrOriginalReviewSelection? originalSelection; final Object? diffSession, clientSession; final MergeRequestDraftNote? draft; final bool deleting; @@ -268,6 +301,19 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { } bool _selectionCurrent() { + if (widget.originalSelection case final original?) { + final snapshot = ref.read( + mrOriginalReviewControllerProvider(widget.original!), + ); + return identical( + ref.read(gitLabClientProvider).unwrapPrevious().valueOrNull, + widget.clientSession, + ) && + (snapshot.isLoading || + snapshot.hasError || + !snapshot.hasValue || + original.matches(snapshot.value!)); + } final selection = widget.selection; if (selection == null) { return !_reply || @@ -379,6 +425,13 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { _text.text, isCurrent: _current, ) + : widget.originalSelection != null + ? await controller.savePendingCommitNote( + _text.text, + original: widget.original!, + position: widget.originalSelection!.position, + isCurrent: _current, + ) : await controller.savePendingNote( _text.text, position: widget.selection?.position, @@ -463,6 +516,9 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { if (widget.selection != null) { ref.invalidate(mrReviewSnapshotControllerProvider(widget.resource)); } + if (widget.original != null) { + ref.invalidate(mrOriginalReviewControllerProvider(widget.original!)); + } ref.invalidate(mrDiscussionsControllerProvider(widget.resource)); ref.invalidate(mergeRequestControllerProvider(widget.resource)); } @@ -478,6 +534,19 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { : ref .watch(mrReviewSnapshotControllerProvider(widget.resource)) .unwrapPrevious(); + final originalReview = widget.original == null + ? null + : ref + .watch(mrOriginalReviewControllerProvider(widget.original!)) + .unwrapPrevious(); + if (widget.original != null) { + ref.watch(gitLabClientProvider); + ref.listen(gitLabClientProvider, (_, _) => _observe()); + ref.listen( + mrOriginalReviewControllerProvider(widget.original!), + (_, _) => _observe(), + ); + } if (_reply) { ref.watch(gitLabClientProvider); ref.listen(gitLabClientProvider, (_, _) => _observe()); @@ -526,7 +595,8 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { (comments.isLoading || discussions!.isLoading || detail!.isLoading || - review?.isLoading == true); + review?.isLoading == true || + originalReview?.isLoading == true); final ready = current && !preparing && @@ -535,6 +605,11 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { !review.hasError && review.hasValue && widget.selection!.matches(review.value!))) && + (widget.originalSelection == null || + (originalReview != null && + !originalReview.hasError && + originalReview.hasValue && + widget.originalSelection!.matches(originalReview.value!))) && !comments.hasError && comments.valueOrNull != null && !discussions!.hasError && @@ -572,7 +647,7 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { : l.mrPendingEditTitle) : _reply ? l.mrPendingReplyTitle - : widget.selection != null + : widget.selection != null || widget.originalSelection != null ? l.mrPendingInlineTitle : l.mrPendingComposeTitle, ), @@ -591,8 +666,11 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { : l.mrPendingEditHint) : _reply ? l.mrPendingReplyHint - : widget.selection != null - ? l.mrPendingInlineHint + : widget.selection != null || + widget.originalSelection != null + ? (widget.originalSelection != null + ? l.mrOriginalReviewPrivateHint + : l.mrPendingInlineHint) : l.mrPendingComposeHint, ), if (_reply) ...[ @@ -679,6 +757,42 @@ class _ComposeState extends ConsumerState<_ComposeDialog> { ), ], ], + if (widget.originalSelection case final original?) ...[ + const SizedBox(height: LabFoxSpacing.sm), + Text(l.mrOriginalReviewTitle), + SelectableText(widget.original!.commitId), + SelectableText( + l.mrPendingReviewOldPath(original.position.oldPath!), + ), + SelectableText( + l.mrPendingReviewNewPath(original.position.newPath!), + ), + if (original.position.lineRange case final range?) + Text( + l.mrDiscussionContextRangeLabel( + _rangeLabel(range.start!, l.localeName), + _rangeLabel(range.end!, l.localeName), + ), + ) + else ...[ + if (original.position.oldLine case final line?) + Text( + l.mrPendingReviewOldLine( + NumberFormat.decimalPattern( + l.localeName, + ).format(line), + ), + ), + if (original.position.newLine case final line?) + Text( + l.mrPendingReviewNewLine( + NumberFormat.decimalPattern( + l.localeName, + ).format(line), + ), + ), + ], + ], if (controller?.pendingPublicationNeedsInspection ?? false) ...[ const SizedBox(height: LabFoxSpacing.sm), Text(l.mrPendingPublishRecoveryRequired), diff --git a/apps/labfox/lib/l10n/app_en.arb b/apps/labfox/lib/l10n/app_en.arb index c7f998ea0..239715abb 100644 --- a/apps/labfox/lib/l10n/app_en.arb +++ b/apps/labfox/lib/l10n/app_en.arb @@ -3018,5 +3018,10 @@ "mrPendingReplyTargetTitle": "Discussion context", "mrPendingReplyTargetUnavailable": "This discussion is missing or no longer accepts a private reply.", "mrPendingReplyInspectionHint": "Check the current discussion and all pending notes for a reply that may already have saved. Saving again may create a duplicate.", - "mrPendingReplyAcknowledge": "I checked the discussion and pending notes and want to save this reply." + "mrPendingReplyAcknowledge": "I checked the discussion and pending notes and want to save this reply.", + "mrCommitsEmpty": "No commits in this merge request.", + "mrOriginalReviewTitle": "Original commit changes", + "mrOriginalReviewReadOnly": "Inline review requires a validated text diff with one reported parent.", + "mrOriginalReviewDiffUnavailable": "This original file diff is unavailable for inline review.", + "mrOriginalReviewPrivateHint": "This note stays private until you publish your review. The selected commit's original diff is checked again before saving." } diff --git a/apps/labfox/lib/l10n/app_hi.arb b/apps/labfox/lib/l10n/app_hi.arb index 53d7caa76..b135209a8 100644 --- a/apps/labfox/lib/l10n/app_hi.arb +++ b/apps/labfox/lib/l10n/app_hi.arb @@ -1461,5 +1461,10 @@ "mrPendingReplyTargetTitle": "चर्चा का संदर्भ", "mrPendingReplyTargetUnavailable": "यह चर्चा मौजूद नहीं है या अब निजी जवाब स्वीकार नहीं करती।", "mrPendingReplyInspectionHint": "वर्तमान चर्चा और सभी लंबित नोट जाँचें कि जवाब पहले ही सहेजा तो नहीं गया। दोबारा सहेजने से डुप्लिकेट बन सकता है।", - "mrPendingReplyAcknowledge": "मैंने चर्चा और लंबित नोट जाँचे हैं और यह जवाब सहेजना चाहता हूँ।" + "mrPendingReplyAcknowledge": "मैंने चर्चा और लंबित नोट जाँचे हैं और यह जवाब सहेजना चाहता हूँ।", + "mrCommitsEmpty": "इस मर्ज रिक्वेस्ट में कोई कमिट नहीं है।", + "mrOriginalReviewTitle": "मूल कमिट के बदलाव", + "mrOriginalReviewReadOnly": "इनलाइन समीक्षा के लिए एक रिपोर्ट किए गए पैरेंट वाला सत्यापित टेक्स्ट डिफ़ आवश्यक है।", + "mrOriginalReviewDiffUnavailable": "इस फ़ाइल का मूल डिफ़ इनलाइन समीक्षा के लिए उपलब्ध नहीं है।", + "mrOriginalReviewPrivateHint": "यह नोट आपकी समीक्षा प्रकाशित होने तक निजी रहता है। सहेजने से पहले चुने गए कमिट का मूल डिफ़ दोबारा जाँचा जाता है।" } diff --git a/apps/labfox/lib/l10n/app_ja.arb b/apps/labfox/lib/l10n/app_ja.arb index 699f4caa6..53c581b21 100644 --- a/apps/labfox/lib/l10n/app_ja.arb +++ b/apps/labfox/lib/l10n/app_ja.arb @@ -1461,5 +1461,10 @@ "mrPendingReplyTargetTitle": "ディスカッションの内容", "mrPendingReplyTargetUnavailable": "このディスカッションは見つからないか、非公開の返信を受け付けなくなりました。", "mrPendingReplyInspectionHint": "返信がすでに保存されていないか、現在のディスカッションとすべての下書きを確認してください。再保存すると重複する場合があります。", - "mrPendingReplyAcknowledge": "ディスカッションと下書きを確認し、この返信を保存します。" + "mrPendingReplyAcknowledge": "ディスカッションと下書きを確認し、この返信を保存します。", + "mrCommitsEmpty": "このマージリクエストにはコミットがありません。", + "mrOriginalReviewTitle": "元のコミットの変更", + "mrOriginalReviewReadOnly": "インラインレビューには、親が1つと報告された検証済みのテキスト差分が必要です。", + "mrOriginalReviewDiffUnavailable": "このファイルの元の差分はインラインレビューに使用できません。", + "mrOriginalReviewPrivateHint": "このノートはレビューを公開するまで非公開です。保存前に、選択したコミットの元の差分を再確認します。" } diff --git a/apps/labfox/lib/l10n/app_ko.arb b/apps/labfox/lib/l10n/app_ko.arb index c5bdbf3d1..f0af9df1f 100644 --- a/apps/labfox/lib/l10n/app_ko.arb +++ b/apps/labfox/lib/l10n/app_ko.arb @@ -1461,5 +1461,10 @@ "mrPendingReplyTargetTitle": "토론 내용", "mrPendingReplyTargetUnavailable": "이 토론이 없거나 더 이상 비공개 답글을 받을 수 없습니다.", "mrPendingReplyInspectionHint": "이미 저장된 답글이 있는지 현재 토론과 모든 초안을 확인하세요. 다시 저장하면 중복될 수 있습니다.", - "mrPendingReplyAcknowledge": "토론과 초안을 확인했으며 이 답글을 저장하겠습니다." + "mrPendingReplyAcknowledge": "토론과 초안을 확인했으며 이 답글을 저장하겠습니다.", + "mrCommitsEmpty": "이 머지 리퀘스트에 커밋이 없습니다.", + "mrOriginalReviewTitle": "원본 커밋 변경 사항", + "mrOriginalReviewReadOnly": "인라인 리뷰에는 부모가 하나로 보고된 검증된 텍스트 diff가 필요합니다.", + "mrOriginalReviewDiffUnavailable": "이 파일의 원본 diff는 인라인 리뷰에 사용할 수 없습니다.", + "mrOriginalReviewPrivateHint": "이 노트는 리뷰를 게시하기 전까지 비공개로 유지됩니다. 저장하기 전에 선택한 커밋의 원본 diff를 다시 확인합니다." } diff --git a/apps/labfox/lib/l10n/app_localizations.dart b/apps/labfox/lib/l10n/app_localizations.dart index 556dde3d9..5954e23da 100644 --- a/apps/labfox/lib/l10n/app_localizations.dart +++ b/apps/labfox/lib/l10n/app_localizations.dart @@ -9337,6 +9337,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'I checked the discussion and pending notes and want to save this reply.'** String get mrPendingReplyAcknowledge; + + /// No description provided for @mrCommitsEmpty. + /// + /// In en, this message translates to: + /// **'No commits in this merge request.'** + String get mrCommitsEmpty; + + /// No description provided for @mrOriginalReviewTitle. + /// + /// In en, this message translates to: + /// **'Original commit changes'** + String get mrOriginalReviewTitle; + + /// No description provided for @mrOriginalReviewReadOnly. + /// + /// In en, this message translates to: + /// **'Inline review requires a validated text diff with one reported parent.'** + String get mrOriginalReviewReadOnly; + + /// No description provided for @mrOriginalReviewDiffUnavailable. + /// + /// In en, this message translates to: + /// **'This original file diff is unavailable for inline review.'** + String get mrOriginalReviewDiffUnavailable; + + /// No description provided for @mrOriginalReviewPrivateHint. + /// + /// In en, this message translates to: + /// **'This note stays private until you publish your review. The selected commit\'s original diff is checked again before saving.'** + String get mrOriginalReviewPrivateHint; } class _AppLocalizationsDelegate diff --git a/apps/labfox/lib/l10n/app_localizations_en.dart b/apps/labfox/lib/l10n/app_localizations_en.dart index 7bdd8b9f7..8f2f25783 100644 --- a/apps/labfox/lib/l10n/app_localizations_en.dart +++ b/apps/labfox/lib/l10n/app_localizations_en.dart @@ -5324,4 +5324,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mrPendingReplyAcknowledge => 'I checked the discussion and pending notes and want to save this reply.'; + + @override + String get mrCommitsEmpty => 'No commits in this merge request.'; + + @override + String get mrOriginalReviewTitle => 'Original commit changes'; + + @override + String get mrOriginalReviewReadOnly => + 'Inline review requires a validated text diff with one reported parent.'; + + @override + String get mrOriginalReviewDiffUnavailable => + 'This original file diff is unavailable for inline review.'; + + @override + String get mrOriginalReviewPrivateHint => + 'This note stays private until you publish your review. The selected commit\'s original diff is checked again before saving.'; } diff --git a/apps/labfox/lib/l10n/app_localizations_hi.dart b/apps/labfox/lib/l10n/app_localizations_hi.dart index dcdc8bac3..75e22ae79 100644 --- a/apps/labfox/lib/l10n/app_localizations_hi.dart +++ b/apps/labfox/lib/l10n/app_localizations_hi.dart @@ -5321,4 +5321,22 @@ class AppLocalizationsHi extends AppLocalizations { @override String get mrPendingReplyAcknowledge => 'मैंने चर्चा और लंबित नोट जाँचे हैं और यह जवाब सहेजना चाहता हूँ।'; + + @override + String get mrCommitsEmpty => 'इस मर्ज रिक्वेस्ट में कोई कमिट नहीं है।'; + + @override + String get mrOriginalReviewTitle => 'मूल कमिट के बदलाव'; + + @override + String get mrOriginalReviewReadOnly => + 'इनलाइन समीक्षा के लिए एक रिपोर्ट किए गए पैरेंट वाला सत्यापित टेक्स्ट डिफ़ आवश्यक है।'; + + @override + String get mrOriginalReviewDiffUnavailable => + 'इस फ़ाइल का मूल डिफ़ इनलाइन समीक्षा के लिए उपलब्ध नहीं है।'; + + @override + String get mrOriginalReviewPrivateHint => + 'यह नोट आपकी समीक्षा प्रकाशित होने तक निजी रहता है। सहेजने से पहले चुने गए कमिट का मूल डिफ़ दोबारा जाँचा जाता है।'; } diff --git a/apps/labfox/lib/l10n/app_localizations_ja.dart b/apps/labfox/lib/l10n/app_localizations_ja.dart index 8e8adfb0c..953526c0a 100644 --- a/apps/labfox/lib/l10n/app_localizations_ja.dart +++ b/apps/labfox/lib/l10n/app_localizations_ja.dart @@ -5135,4 +5135,22 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mrPendingReplyAcknowledge => 'ディスカッションと下書きを確認し、この返信を保存します。'; + + @override + String get mrCommitsEmpty => 'このマージリクエストにはコミットがありません。'; + + @override + String get mrOriginalReviewTitle => '元のコミットの変更'; + + @override + String get mrOriginalReviewReadOnly => + 'インラインレビューには、親が1つと報告された検証済みのテキスト差分が必要です。'; + + @override + String get mrOriginalReviewDiffUnavailable => + 'このファイルの元の差分はインラインレビューに使用できません。'; + + @override + String get mrOriginalReviewPrivateHint => + 'このノートはレビューを公開するまで非公開です。保存前に、選択したコミットの元の差分を再確認します。'; } diff --git a/apps/labfox/lib/l10n/app_localizations_ko.dart b/apps/labfox/lib/l10n/app_localizations_ko.dart index ed936455d..d951f078b 100644 --- a/apps/labfox/lib/l10n/app_localizations_ko.dart +++ b/apps/labfox/lib/l10n/app_localizations_ko.dart @@ -5142,4 +5142,22 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mrPendingReplyAcknowledge => '토론과 초안을 확인했으며 이 답글을 저장하겠습니다.'; + + @override + String get mrCommitsEmpty => '이 머지 리퀘스트에 커밋이 없습니다.'; + + @override + String get mrOriginalReviewTitle => '원본 커밋 변경 사항'; + + @override + String get mrOriginalReviewReadOnly => + '인라인 리뷰에는 부모가 하나로 보고된 검증된 텍스트 diff가 필요합니다.'; + + @override + String get mrOriginalReviewDiffUnavailable => + '이 파일의 원본 diff는 인라인 리뷰에 사용할 수 없습니다.'; + + @override + String get mrOriginalReviewPrivateHint => + '이 노트는 리뷰를 게시하기 전까지 비공개로 유지됩니다. 저장하기 전에 선택한 커밋의 원본 diff를 다시 확인합니다.'; } diff --git a/apps/labfox/lib/l10n/app_localizations_zh.dart b/apps/labfox/lib/l10n/app_localizations_zh.dart index c6d65c1fb..9c99bdb16 100644 --- a/apps/labfox/lib/l10n/app_localizations_zh.dart +++ b/apps/labfox/lib/l10n/app_localizations_zh.dart @@ -4973,4 +4973,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mrPendingReplyAcknowledge => '我已检查讨论和待发布笔记,并希望保存此回复。'; + + @override + String get mrCommitsEmpty => '此合并请求中没有提交。'; + + @override + String get mrOriginalReviewTitle => '原始提交更改'; + + @override + String get mrOriginalReviewReadOnly => '行内审查需要已验证的文本差异,且报告的父提交只有一个。'; + + @override + String get mrOriginalReviewDiffUnavailable => '此文件的原始差异无法用于行内审查。'; + + @override + String get mrOriginalReviewPrivateHint => '此笔记在您发布审查前保持私密。保存前会再次检查所选提交的原始差异。'; } diff --git a/apps/labfox/lib/l10n/app_zh.arb b/apps/labfox/lib/l10n/app_zh.arb index 35b9fd715..b99d6e00d 100644 --- a/apps/labfox/lib/l10n/app_zh.arb +++ b/apps/labfox/lib/l10n/app_zh.arb @@ -1461,5 +1461,10 @@ "mrPendingReplyTargetTitle": "讨论内容", "mrPendingReplyTargetUnavailable": "此讨论已不存在或不再接受私密回复。", "mrPendingReplyInspectionHint": "请检查当前讨论和所有待发布笔记,确认回复是否已保存。再次保存可能产生重复内容。", - "mrPendingReplyAcknowledge": "我已检查讨论和待发布笔记,并希望保存此回复。" + "mrPendingReplyAcknowledge": "我已检查讨论和待发布笔记,并希望保存此回复。", + "mrCommitsEmpty": "此合并请求中没有提交。", + "mrOriginalReviewTitle": "原始提交更改", + "mrOriginalReviewReadOnly": "行内审查需要已验证的文本差异,且报告的父提交只有一个。", + "mrOriginalReviewDiffUnavailable": "此文件的原始差异无法用于行内审查。", + "mrOriginalReviewPrivateHint": "此笔记在您发布审查前保持私密。保存前会再次检查所选提交的原始差异。" } diff --git a/apps/labfox/test/mr_commits_controller_test.dart b/apps/labfox/test/mr_commits_controller_test.dart new file mode 100644 index 000000000..e11055ad5 --- /dev/null +++ b/apps/labfox/test/mr_commits_controller_test.dart @@ -0,0 +1,210 @@ +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/auth/auth_controller.dart'; +import 'package:labfox/features/merge_requests/data/merge_requests_repository.dart'; +import 'package:labfox/features/merge_requests/presentation/controllers/merge_requests_controllers.dart'; +import 'package:labfox/features/merge_requests/presentation/controllers/mr_commits_controller.dart'; + +import '../../../packages/gitlab_api/test/mr_commits_api_test.dart' as api; +import 'mr_original_review_snapshot_test.dart' as o; +import 'mr_pending_review_save_test.dart' as b; + +class Repo extends b.Details { + Repo(this.client) : super(client) { + result = o.mr; + } + final GitLabClient client; + Object members = const [Commit(id: o.head, title: 'Selected')]; + int lists = 0; + @override + Future?> commits({ + required int projectId, + required int iid, + required bool Function() isCurrent, + }) async { + expect((projectId, iid), (8, 142)); + lists++; + final v = members; + if (v is Future?>) return v; + if (v is List) return v; + throw v; + } +} + +ProviderContainer container(Repo repo, {bool listen = true}) { + final c = ProviderContainer( + overrides: [ + currentAccountProvider.overrideWith((r) => r.watch(b.accountState)), + b.sourceState.overrideWith((r) async => repo), + mergeRequestsRepositoryProvider.overrideWith( + (r) => r.watch(b.sourceState), + ), + ], + ); + addTearDown(c.dispose); + addTearDown(repo.client.close); + if (listen) { + final sub = c.listen(mrCommitsControllerProvider(b.resource), (_, _) {}); + addTearDown(sub.close); + } + return c; +} + +void main() { + test( + 'captured commit browser traverses advertised pages including empty pages', + () async { + final paths = []; + final client = api.client((request) { + expect(request.method, 'GET'); + paths.add(request.path); + if (!request.path.endsWith('/commits')) { + return api.response(o.mr.toJson()); + } + final page = request.queryParameters['page']; + expect(request.queryParameters['per_page'], 100); + return api.response( + page == 1 + ? [api.commit()] + : page == 3 + ? [] + : [api.commit(id: o.other)], + headers: page == 4 + ? {} + : { + 'x-next-page': ['${page == 1 ? 3 : 4}'], + }, + ); + }); + addTearDown(client.close); + final c = ProviderContainer( + overrides: [ + currentAccountProvider.overrideWithValue(b.account), + mergeRequestsRepositoryProvider.overrideWith( + (r) async => MergeRequestsRepository(client), + ), + ], + ); + addTearDown(c.dispose); + final sub = c.listen(mrCommitsControllerProvider(b.resource), (_, _) {}); + addTearDown(sub.close); + final commits = await c.read( + mrCommitsControllerProvider(b.resource).future, + ); + expect(commits!.map((c) => c.id), [o.head, o.other]); + expect(paths.length, 4); + }, + ); + test('repository replacement discards pending old membership', () async { + final repo = Repo(b.client()); + final lateMembers = Completer?>(); + repo.members = lateMembers.future; + final c = container(repo); + final read = c.read(mrCommitsControllerProvider(b.resource).future); + await pumpEventQueue(); + final fresh = Repo(b.client()) + ..members = const [Commit(id: o.other, title: 'Fresh')]; + addTearDown(fresh.client.close); + c.read(b.sourceState.notifier).state = Future.value(fresh); + await pumpEventQueue(); + lateMembers.complete([const Commit(id: o.head, title: 'Stale')]); + await read; + await pumpEventQueue(); + expect( + c.read(mrCommitsControllerProvider(b.resource)).value!.single.id, + o.other, + ); + }); + + test( + 'origin separates route IID from global ID and returns immutable complete members', + () async { + final repo = Repo(b.client()); + final c = container(repo); + final members = await c.read( + mrCommitsControllerProvider(b.resource).future, + ); + expect(members!.single.id, o.head); + expect(repo.reads, [(8, 142)]); + expect(repo.lists, 1); + expect(() => members.clear(), throwsUnsupportedError); + expect( + (await c.read(mrCommitOriginControllerProvider(b.resource).future))!.id, + 1100, + ); + }, + ); + for (final detail in [ + o.mr.copyWith(id: 0), + o.mr.copyWith(iid: 143), + o.mr.copyWith(targetProjectId: null), + o.mr.copyWith(targetProjectId: 9), + o.mr.copyWith(projectId: 9), + ]) { + test('invalid origin prevents commit list: $detail', () async { + final repo = Repo(b.client())..result = detail; + final c = container(repo); + await expectLater( + c.read(mrCommitsControllerProvider(b.resource).future), + throwsA(isA()), + ); + expect(repo.lists, 0); + }); + } + test('empty complete list stays distinct from an obsolete origin', () async { + final repo = Repo(b.client())..members = []; + final c = container(repo); + expect( + await c.read(mrCommitsControllerProvider(b.resource).future), + isEmpty, + ); + }); + test('current list failure remains typed', () async { + final repo = Repo(b.client()) + ..members = const GitLabForbiddenException('Forbidden'); + final c = container(repo); + await expectLater( + c.read(mrCommitsControllerProvider(b.resource).future), + throwsA(isA()), + ); + }); + for (final phase in ['origin', 'members']) { + test('account replacement discards obsolete $phase outcomes', () async { + final repo = Repo(b.client()); + final c = container(repo); + final detail = Completer(); + final members = Completer?>(); + if (phase == 'origin') { + repo.result = detail.future; + } else { + repo.members = members.future; + } + final read = c.read(mrCommitsControllerProvider(b.resource).future); + await pumpEventQueue(); + c.read(b.accountState.notifier).state = null; + if (phase == 'origin') { + detail.complete(o.mr); + } else { + members.complete([const Commit(id: o.head, title: 'Late')]); + } + expect(await read, null); + if (phase == 'origin') expect(repo.lists, 0); + }); + } + test('invalid route starts no read', () async { + final repo = Repo(b.client()); + final c = container(repo, listen: false); + const invalid = MergeRequestRef(projectId: 0, iid: 142); + final s = c.listen(mrCommitOriginControllerProvider(invalid), (_, _) {}); + addTearDown(s.close); + await expectLater( + c.read(mrCommitOriginControllerProvider(invalid).future), + throwsArgumentError, + ); + expect(repo.reads, isEmpty); + }); +} diff --git a/apps/labfox/test/mr_original_review_ui_test.dart b/apps/labfox/test/mr_original_review_ui_test.dart new file mode 100644 index 000000000..0604cf20b --- /dev/null +++ b/apps/labfox/test/mr_original_review_ui_test.dart @@ -0,0 +1,540 @@ +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:go_router/go_router.dart'; +import 'package:labfox/app/router.dart'; +import 'package:labfox/core/analytics/analytics.dart'; +import 'package:labfox/features/merge_requests/presentation/controllers/merge_requests_controllers.dart'; +import 'package:labfox/features/merge_requests/presentation/controllers/mr_commits_controller.dart'; +import 'package:labfox/features/merge_requests/presentation/controllers/mr_original_review_controller.dart'; +import 'package:labfox/features/merge_requests/presentation/mr_commits_screen.dart'; +import 'package:labfox/features/merge_requests/presentation/mr_original_changes_screen.dart'; +import 'package:labfox/l10n/app_localizations.dart'; + +import 'mr_original_review_snapshot_test.dart' as o; +import 'mr_pending_original_review_save_test.dart' as b; +import 'mr_pending_review_save_test.dart' as pending; + +Future pump( + WidgetTester t, + b.Fixture f, { + double width = 390, + bool dark = false, + String locale = 'en', + bool settle = true, + double scale = 1, + double keyboard = 0, + ThemeData? theme, +}) async { + t.view.physicalSize = Size(width, 1000); + t.view.devicePixelRatio = 1; + addTearDown(t.view.resetPhysicalSize); + addTearDown(t.view.resetDevicePixelRatio); + await t.pumpWidget( + UncontrolledProviderScope( + container: f.c, + child: RepaintBoundary( + key: const ValueKey('capture'), + child: MaterialApp( + theme: theme ?? (dark ? LabFoxTheme.dark : LabFoxTheme.light), + debugShowCheckedModeBanner: false, + locale: 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: const MrOriginalChangesScreen( + projectId: 8, + iid: 142, + commitId: o.head, + ), + ), + ), + ), + ); + if (settle) { + await t.pumpAndSettle(); + } else { + await t.pump(); + } + return AppLocalizations.of(t.element(find.byType(MrOriginalChangesScreen))); +} + +Future tap(WidgetTester t, String key, {bool settle = true}) async { + await t.pump(); + final f = find.byKey(ValueKey(key)); + await t.ensureVisible(f); + await t.tap(f); + if (settle) { + await t.pumpAndSettle(); + } else { + await t.pump(); + await t.pump(const Duration(milliseconds: 100)); + } +} + +Future select( + WidgetTester t, + AppLocalizations l, { + int index = 2, + bool end = false, +}) async { + final f = find + .byTooltip(end ? l.mrDiffRangeEndButton : l.mrPendingComposeOpen) + .at(index); + await t.ensureVisible(f); + await t.tap(f); + await t.pumpAndSettle(); +} + +class FakeList extends MrCommitsController { + FakeList(this.readResult); + final Object Function() readResult; + @override + Future?> build(MergeRequestRef arg) async { + final v = readResult(); + if (v is List) return v; + if (v is Future?>) return v; + throw v; + } +} + +void main() { + testWidgets( + 'single-line private selection survives a window resize and saves exact Markdown', + (t) async { + final f = b.Fixture(); + final l = await pump(t, f, width: 390); + await select(t, l); + t.view.physicalSize = const Size(1200, 1000); + await t.pumpAndSettle(); + final s = f.c.read(mrOriginalReviewControllerProvider(o.arg)).value!; + final file = s.files.single; + f.position = s.positionFor(file, file.hunks.single.lines.last)!; + f.mockDrafts.position = f.position; + await tap(t, 'mr-original-private-open'); + await t.enterText( + find.byKey(const ValueKey('mr-pending-compose-input')), + pending.markdown, + ); + await tap(t, 'mr-pending-compose-save'); + expect(f.mockDrafts.commits.single.$6.lineRange, null); + expect(f.mockDrafts.commits.single.$5, pending.markdown); + expect(t.takeException(), null); + }, + ); + testWidgets( + 'large text and keyboard keep original private input and controls reachable', + (t) async { + final f = b.Fixture(); + final l = await pump(t, f, width: 390, scale: 2, keyboard: 300); + await select(t, l); + await tap(t, 'mr-original-private-open'); + await t.enterText( + find.byKey(const ValueKey('mr-pending-compose-input')), + 'Private review', + ); + await t.ensureVisible( + find.byKey(const ValueKey('mr-pending-compose-save')), + ); + await t.pumpAndSettle(); + expect(t.takeException(), null); + expect( + find.byKey(const ValueKey('mr-pending-compose-input')), + findsOneWidget, + ); + }, + ); + + test( + 'commit routes remove canonical and invalid commit values from analytics', + () { + expect( + sanitizeRoute('/projects/8/merge_requests/142/commits/${o.head}'), + '/projects/:id/merge_requests/:id/commits/:sha', + ); + expect( + sanitizeRoute('/projects/8/commit/private-value/changes'), + '/projects/:id/commit/:sha/changes', + ); + }, + ); + testWidgets('MR commit list navigates by route identifiers only', (t) async { + final router = GoRouter( + initialLocation: '/list', + routes: [ + GoRoute( + path: '/list', + builder: (_, _) => const MrCommitsScreen(projectId: 8, iid: 142), + ), + GoRoute( + path: '/projects/:project/merge_requests/:iid/commits/:sha', + builder: (_, s) => Text(s.uri.path), + ), + ], + ); + addTearDown(router.dispose); + await t.pumpWidget( + ProviderScope( + overrides: [ + mrCommitsControllerProvider.overrideWith( + () => FakeList( + () => const [ + Commit(id: o.head, title: 'Original selected commit'), + ], + ), + ), + ], + child: MaterialApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ), + ); + await t.pumpAndSettle(); + await t.tap(find.text('Original selected commit')); + await t.pumpAndSettle(); + expect( + find.text(Routes.mergeRequestCommitChanges(8, 142, o.head)), + findsOneWidget, + ); + expect(t.takeException(), null); + }); + testWidgets( + 'commit list loading, current error, retry and empty state are distinct', + (t) async { + final pendingList = Completer?>(); + Object result = pendingList.future; + final c = ProviderContainer( + overrides: [ + mrCommitsControllerProvider.overrideWith( + () => FakeList(() => result), + ), + ], + ); + addTearDown(c.dispose); + await t.pumpWidget( + UncontrolledProviderScope( + container: c, + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MrCommitsScreen(projectId: 8, iid: 142), + ), + ), + ); + await t.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + pendingList.completeError(const GitLabServerException('Hidden details')); + await t.pumpAndSettle(); + final l = AppLocalizations.of(t.element(find.byType(MrCommitsScreen))); + expect(find.text(l.commitsError), findsOneWidget); + expect(find.textContaining('Hidden details'), findsNothing); + result = []; + await t.tap(find.text(l.retry)); + await t.pumpAndSettle(); + expect(find.byType(ListTile), findsNothing); + expect(find.text(l.commitsError), findsNothing); + expect(t.takeException(), null); + }, + ); + + test( + 'identifier-only original routes and analytics never expose a commit value', + () { + expect( + Routes.mergeRequestCommits(8, 142), + '/projects/8/merge_requests/142/commits', + ); + expect( + Routes.mergeRequestCommitChanges(8, 142, o.head), + '/projects/8/merge_requests/142/commits/${o.head}', + ); + expect( + sanitizeRoute('/projects/8/merge_requests/142/commits/${o.head}'), + '/projects/:id/merge_requests/:id/commits/:sha', + ); + expect( + sanitizeRoute('/projects/8/merge_requests/142/commits/private-value'), + '/projects/:id/merge_requests/:id/commits/:sha', + ); + }, + ); + for (final locale in ['en', 'ko', 'ja', 'hi', 'zh']) { + for (final width in [390.0, 800.0, 1200.0]) { + for (final dark in [false, true]) { + testWidgets('original private range $locale $width $dark', (t) async { + final f = b.Fixture(); + final l = await pump(t, f, width: width, dark: dark, locale: locale); + expect(find.textContaining('same'), findsOneWidget); + expect(find.textContaining('new'), findsOneWidget); + await select(t, l, index: 0); + await tap(t, 'mr-original-select-range'); + await select(t, l, index: 1, end: true); + final s = f.c.read(mrOriginalReviewControllerProvider(o.arg)).value!; + final file = s.files.single; + f.position = s.rangePositionFor( + file, + file.hunks.single.lines.first, + file.hunks.single.lines.last, + )!; + f.mockDrafts.position = f.position; + await tap(t, 'mr-original-private-open'); + expect(find.text(l.mrPendingInlineTitle), findsOneWidget); + await t.enterText( + find.byKey(const ValueKey('mr-pending-compose-input')), + pending.markdown, + ); + await tap(t, 'mr-pending-compose-save'); + expect(f.mockDrafts.commits.single.$6, f.position); + expect(f.mockDrafts.commits.single.$5, pending.markdown); + expect(f.mockDrafts.writes, isEmpty); + expect(f.comments.posts, isEmpty); + expect(find.text(l.mrPendingComposeSaved), findsOneWidget); + expect(t.takeException(), null); + }); + } + } + } + for (final parents in ?>[ + null, + [], + [o.parent, o.other], + ]) { + testWidgets( + 'unsupported original parents remain readable without selection $parents', + (t) async { + final f = b.Fixture(); + f.details.contextResult = o.context(parents: parents); + final l = await pump(t, f); + expect(find.textContaining('same'), findsOneWidget); + expect(find.byTooltip(l.mrPendingComposeOpen), findsNothing); + expect( + find.byKey(const ValueKey('mr-original-private-open')), + findsNothing, + ); + expect(t.takeException(), null); + }, + ); + } + testWidgets( + 'omitted or malformed files are explicit and never use permissive rows', + (t) async { + final f = b.Fixture(); + f.details.contextResult = o.context( + files: [ + o.literal.copyWith(diff: '@@ -1 +1 @@\n? untrusted'), + o.literal.copyWith( + oldPath: 'b.dart', + newPath: 'b.dart', + isTooLarge: true, + diff: '', + ), + ], + ); + await pump(t, f); + expect(find.text('a.dart'), findsOneWidget); + expect(find.text('b.dart'), findsOneWidget); + expect(find.textContaining('untrusted'), findsNothing); + expect(t.takeException(), null); + }, + ); + testWidgets('current original error has read-only retry', (t) async { + final f = b.Fixture(); + f.details.contextResult = const GitLabServerException('private details'); + final l = await pump(t, f); + expect(find.text(l.changesError), findsOneWidget); + expect(find.textContaining('private details'), findsNothing); + f.details.contextResult = o.context(); + await t.tap(find.text(l.retry)); + await t.pumpAndSettle(); + expect(find.textContaining('same'), findsOneWidget); + expect(f.mockDrafts.commits, isEmpty); + }); + testWidgets('account replacement discards private dialog input', (t) async { + final f = b.Fixture(); + final l = await pump(t, f); + await select(t, l); + await tap(t, 'mr-original-private-open'); + await t.enterText( + find.byKey(const ValueKey('mr-pending-compose-input')), + 'Secret private body', + ); + f.c.read(pending.accountState.notifier).state = null; + await t.pumpAndSettle(); + expect(find.text('Secret private body'), findsNothing); + expect( + find.byKey(const ValueKey('mr-pending-compose-input')), + findsNothing, + ); + expect(f.mockDrafts.commits, isEmpty); + }); + testWidgets( + 'uncertain original save exposes existing inspection with no replay', + (t) async { + final f = b.Fixture(); + final l = await pump(t, f); + await select(t, l); + await tap(t, 'mr-original-private-open'); + await t.enterText( + find.byKey(const ValueKey('mr-pending-compose-input')), + pending.markdown, + ); + f.mockDrafts.outcome = const GitLabServerException('Uncertain'); + await tap(t, 'mr-pending-compose-save'); + expect(f.mockDrafts.commits.length, 1); + expect( + find.byKey(const ValueKey('mr-pending-compose-inspect')), + findsOneWidget, + ); + await tap(t, 'mr-pending-compose-inspect'); + expect(f.mockDrafts.reads, isNotEmpty); + expect(f.mockDrafts.commits.length, 1); + expect(f.comments.posts, isEmpty); + }, + ); + testWidgets( + 'changed complete literal fingerprint prevents a save of old selected text', + (t) async { + final f = b.Fixture(); + final l = await pump(t, f); + await select(t, l); + await tap(t, 'mr-original-private-open'); + await t.enterText( + find.byKey(const ValueKey('mr-pending-compose-input')), + pending.markdown, + ); + f.details.contextResult = o.context( + files: [ + o.literal.copyWith(diff: o.raw.replaceFirst('same', 'changed')), + ], + ); + await tap(t, 'mr-pending-compose-save'); + expect(f.mockDrafts.commits, isEmpty); + expect( + find.byKey(const ValueKey('mr-pending-compose-input')), + findsNothing, + ); + }, + ); + testWidgets('invalid commit deep link starts no identity or original read', ( + t, + ) async { + final f = b.Fixture(); + await t.pump(); + f.details.contexts = 0; + await t.pumpWidget( + UncontrolledProviderScope( + container: f.c, + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MrOriginalChangesScreen( + projectId: 8, + iid: 142, + commitId: 'HEAD', + ), + ), + ), + ); + await t.pumpAndSettle(); + expect(f.details.reads, isEmpty); + expect(f.details.contexts, 0); + }); + if (Platform.environment['LABFOX_ORIGINAL_REVIEW_CAPTURE'] + case final String directory) { + for (final width in [390.0, 1200.0]) { + for (final dark in [false, true]) { + for (final dialog in [false, true]) { + testWidgets('synthetic original review capture $width $dark $dialog', ( + t, + ) async { + await t.runAsync(() async { + final sdk = Platform.environment['FLUTTER_ROOT']!; + 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.view( + File( + '$sdk/bin/cache/artifacts/material_fonts/$name', + ).readAsBytesSync().buffer, + ), + ), + ); + 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'), + filledButtonTheme: FilledButtonThemeData( + style: font(base.filledButtonTheme.style!), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: font(base.outlinedButtonTheme.style!), + ), + textButtonTheme: const TextButtonThemeData( + style: ButtonStyle( + textStyle: WidgetStatePropertyAll( + TextStyle(fontFamily: 'Roboto'), + ), + ), + ), + ); + final f = b.Fixture(); + final l = await pump(t, f, width: width, dark: dark, theme: theme); + await select(t, l, index: 0); + await tap(t, 'mr-original-select-range'); + await select(t, l, index: 1, end: true); + if (dialog) { + await tap(t, 'mr-original-private-open'); + await t.enterText( + find.byKey(const ValueKey('mr-pending-compose-input')), + 'Please add a regression test for this original commit.', + ); + await t.pumpAndSettle(); + } + expect(t.takeException(), null); + final boundary = t.renderObject( + find.byKey(const ValueKey('capture')), + ); + await t.runAsync(() async { + final img = await boundary.toImage(pixelRatio: 1); + final png = await img.toByteData(format: ui.ImageByteFormat.png); + Directory(directory).createSync(recursive: true); + File( + '$directory/original-${width.toInt()}-${dark ? 'dark' : 'light'}-${dialog ? 'compose' : 'selection'}.png', + ).writeAsBytesSync(png!.buffer.asUint8List()); + img.dispose(); + }); + }); + } + } + } + } +} diff --git a/docs/mobile-web-parity.md b/docs/mobile-web-parity.md index 568d3600e..d8fb8c717 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. [#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. [#614](https://github.com/Theorvane/LabFox/issues/614) adds whole-review publication: complete visible private-note confirmation and consent, a fresh full-set comparison before one strict-204 account-scoped bulk POST, and uncertainty that blocks private mutations until complete private/public inspection. Manual retries require new consent; session/view changes discard old confirmation and outcomes. Five locales and three widths in both themes are covered; see [publication flow](mr-pending-review-publication.md). Single-note publication, summary/reviewer-state controls and positioned/reply/commit creation remain separate. No automatic approval/merge, atomic server snapshot, durable recovery or live-instance/device guarantee is claimed. [#616](https://github.com/Theorvane/labfox/issues/616) adds private text line/range draft creation from the MR changes screen, fresh authoritative diff selection checks, immutable selected text, session/snapshot/origin isolation, preserved public input and explicit uncertain-save inspection. See [private inline review](mr-pending-review-inline.md). [#618](https://github.com/Theorvane/LabFox/issues/618) adds selected saved-note publication through one strict-204 PUT, fresh exact target comparison, explicit consent and shared two-sided recovery. Unrelated saved notes remain private; a missing selected ID cannot publish another note. See [single-note publication](mr-pending-review-single-publication.md). [#620](https://github.com/Theorvane/LabFox/issues/620) adds whole-review submission with an exact public summary, explicit reviewed/request-changes outcomes, complete current-reviewer reads, fresh reviewer preflight comparison and postwrite outcome verification. Partial or obsolete outcomes require private/public/reviewer inspection after actual write settlement and renewed consent, including selected-note recovery entry. Five locales, three widths, both themes and keyboard/large text are covered; see [review submission](mr-review-submission.md). Formal approval remains separate; additional draft creation types are follow-ups. No atomic, durable recovery or live-instance/device guarantee is claimed. [#622](https://github.com/Theorvane/LabFox/issues/622) adds private reply drafts on existing public discussion groups, exact target/body and non-resolution confirmation, a fresh selected-thread preflight, shared command reservations, and actual-settlement recovery that stages every private page together with the original public target. Missing targets stay missing; scoped recovery cannot clear publication/reviewer uncertainty. The localized shared composer preserves unsent public comments/replies and survives its own detail refresh or target removal. Five locales, keyboard/large text and twelve synthetic captures cover three widths and both themes; see [private replies](mr-private-review-replies.md). Commit/image/file draft creation remains separate; no atomic, durable recovery or live-instance/device guarantee is claimed. [#624](https://github.com/Theorvane/LabFox/issues/624) adds the private commit-text draft API/repository foundation: exact commit/body/original text position, matching head SHA, strict private acknowledgement and captured author/global MR validation, with no redirect/auth replay/retry. No user action is exposed; guarded commit selection, membership/freshness, shared reservations and recovery remain follow-ups. See [commit-text draft API](mr-commit-text-draft-api.md). Image/file creation and MW-07 remain in progress. [#626](https://github.com/Theorvane/LabFox/issues/626) adds MR-scoped commit reads and complete advertised-page traversal, preserving unknown/root/ordered parent metadata. Strict SHA-1 identities, offset and same-resource Link cursor checks, cross-page duplicate rejection, immutable results and origin currency guards prevent partial or obsolete membership exposure. Read-only OAuth refresh remains account-bound. See [MR commit membership reads](mr-commit-membership-reads.md). Original commit diff selection and guarded private-save/recovery UI remain follow-ups; no atomic snapshot or live-instance guarantee is claimed. [#628](https://github.com/Theorvane/LabFox/issues/628) adds fixed-SHA literal commit diff pages and guarded advertised-page traversal, retaining raw paths/text and unknown omission flags. Shared offset validation preserves MR membership and confirms unified format plus exact route echoes. Diff file limits can stop pagination before full coverage; no original coordinate, membership or private-write preflight is inferred. See [original commit diff reads](original-commit-diff-reads.md). Authoritative fork/parent context and guarded literal selection/save/recovery UI remain follow-ups. [#630](https://github.com/Theorvane/LabFox/issues/630) preserves separate nullable fork project identities and adds a fresh captured MR detail context read. Strict integer and route/global identity checks reject malformed or mismatched responses; unknown target context is never inferred, while unknown source metadata stays unknown. Currency guards discard obsolete results and typed failures; read-only authentication remains captured. See [MR commit review context](mr-commit-review-context.md). Original parent/reference validation and guarded literal selection/save/recovery UI remain follow-ups; no atomic snapshot, original-coordinate or write-eligibility guarantee is claimed. [#632](https://github.com/Theorvane/LabFox/issues/632) adds strict fixed-head commit detail and guarded captured-account reads. Exact SHA and ordered nullable parent checks preserve unknown/root/merge metadata without synthesizing references; status-first failures, disabled redirects and obsolete-result isolation retain honest context. General branch/tag reads remain compatible. See [fixed commit review reads](fixed-commit-review-reads.md). Original reference/coordinate validation and guarded private-save/recovery UI remain follow-ups; no membership, atomic snapshot or write-eligibility guarantee is inferred. Captured original commit aggregation follows in #634 ([contract](mr-original-commit-context.md)); guarded literal coordinates and private-save UI remain in progress. Strict literal original diff validation and endpoint matching follow in #636 ([contract](literal-original-diff.md)); original refs and guarded private-save UI remain in progress. Captured single-parent original review snapshots and full-file selection fingerprints follow in #638 ([contract](original-review-snapshots.md)); guarded private-save UI remains in progress. [#640](https://github.com/Theorvane/labfox/issues/640) connects original selections to the shared discussion write reservation: fresh complete-fingerprint reconfirmation, captured-client parent availability, one private commit draft write, immediate account/provider/reload cancellation, strict acknowledgements and settled all-page read-only inspection without replay. Original-commit review UI remains a subsequent slice. | +| 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. [#614](https://github.com/Theorvane/LabFox/issues/614) adds whole-review publication: complete visible private-note confirmation and consent, a fresh full-set comparison before one strict-204 account-scoped bulk POST, and uncertainty that blocks private mutations until complete private/public inspection. Manual retries require new consent; session/view changes discard old confirmation and outcomes. Five locales and three widths in both themes are covered; see [publication flow](mr-pending-review-publication.md). Single-note publication, summary/reviewer-state controls and positioned/reply/commit creation remain separate. No automatic approval/merge, atomic server snapshot, durable recovery or live-instance/device guarantee is claimed. [#616](https://github.com/Theorvane/labfox/issues/616) adds private text line/range draft creation from the MR changes screen, fresh authoritative diff selection checks, immutable selected text, session/snapshot/origin isolation, preserved public input and explicit uncertain-save inspection. See [private inline review](mr-pending-review-inline.md). [#618](https://github.com/Theorvane/LabFox/issues/618) adds selected saved-note publication through one strict-204 PUT, fresh exact target comparison, explicit consent and shared two-sided recovery. Unrelated saved notes remain private; a missing selected ID cannot publish another note. See [single-note publication](mr-pending-review-single-publication.md). [#620](https://github.com/Theorvane/LabFox/issues/620) adds whole-review submission with an exact public summary, explicit reviewed/request-changes outcomes, complete current-reviewer reads, fresh reviewer preflight comparison and postwrite outcome verification. Partial or obsolete outcomes require private/public/reviewer inspection after actual write settlement and renewed consent, including selected-note recovery entry. Five locales, three widths, both themes and keyboard/large text are covered; see [review submission](mr-review-submission.md). Formal approval remains separate; additional draft creation types are follow-ups. No atomic, durable recovery or live-instance/device guarantee is claimed. [#622](https://github.com/Theorvane/LabFox/issues/622) adds private reply drafts on existing public discussion groups, exact target/body and non-resolution confirmation, a fresh selected-thread preflight, shared command reservations, and actual-settlement recovery that stages every private page together with the original public target. Missing targets stay missing; scoped recovery cannot clear publication/reviewer uncertainty. The localized shared composer preserves unsent public comments/replies and survives its own detail refresh or target removal. Five locales, keyboard/large text and twelve synthetic captures cover three widths and both themes; see [private replies](mr-private-review-replies.md). Commit/image/file draft creation remains separate; no atomic, durable recovery or live-instance/device guarantee is claimed. [#624](https://github.com/Theorvane/LabFox/issues/624) adds the private commit-text draft API/repository foundation: exact commit/body/original text position, matching head SHA, strict private acknowledgement and captured author/global MR validation, with no redirect/auth replay/retry. No user action is exposed; guarded commit selection, membership/freshness, shared reservations and recovery remain follow-ups. See [commit-text draft API](mr-commit-text-draft-api.md). Image/file creation and MW-07 remain in progress. [#626](https://github.com/Theorvane/LabFox/issues/626) adds MR-scoped commit reads and complete advertised-page traversal, preserving unknown/root/ordered parent metadata. Strict SHA-1 identities, offset and same-resource Link cursor checks, cross-page duplicate rejection, immutable results and origin currency guards prevent partial or obsolete membership exposure. Read-only OAuth refresh remains account-bound. See [MR commit membership reads](mr-commit-membership-reads.md). Original commit diff selection and guarded private-save/recovery UI remain follow-ups; no atomic snapshot or live-instance guarantee is claimed. [#628](https://github.com/Theorvane/LabFox/issues/628) adds fixed-SHA literal commit diff pages and guarded advertised-page traversal, retaining raw paths/text and unknown omission flags. Shared offset validation preserves MR membership and confirms unified format plus exact route echoes. Diff file limits can stop pagination before full coverage; no original coordinate, membership or private-write preflight is inferred. See [original commit diff reads](original-commit-diff-reads.md). Authoritative fork/parent context and guarded literal selection/save/recovery UI remain follow-ups. [#630](https://github.com/Theorvane/LabFox/issues/630) preserves separate nullable fork project identities and adds a fresh captured MR detail context read. Strict integer and route/global identity checks reject malformed or mismatched responses; unknown target context is never inferred, while unknown source metadata stays unknown. Currency guards discard obsolete results and typed failures; read-only authentication remains captured. See [MR commit review context](mr-commit-review-context.md). Original parent/reference validation and guarded literal selection/save/recovery UI remain follow-ups; no atomic snapshot, original-coordinate or write-eligibility guarantee is claimed. [#632](https://github.com/Theorvane/LabFox/issues/632) adds strict fixed-head commit detail and guarded captured-account reads. Exact SHA and ordered nullable parent checks preserve unknown/root/merge metadata without synthesizing references; status-first failures, disabled redirects and obsolete-result isolation retain honest context. General branch/tag reads remain compatible. See [fixed commit review reads](fixed-commit-review-reads.md). Original reference/coordinate validation and guarded private-save/recovery UI remain follow-ups; no membership, atomic snapshot or write-eligibility guarantee is inferred. Captured original commit aggregation follows in #634 ([contract](mr-original-commit-context.md)); guarded literal coordinates and private-save UI remain in progress. Strict literal original diff validation and endpoint matching follow in #636 ([contract](literal-original-diff.md)); original refs and guarded private-save UI remain in progress. Captured single-parent original review snapshots and full-file selection fingerprints follow in #638 ([contract](original-review-snapshots.md)); guarded private-save UI remains in progress. [#640](https://github.com/Theorvane/labfox/issues/640) connects original selections to the shared discussion write reservation: fresh complete-fingerprint reconfirmation, captured-client parent availability, one private commit draft write, immediate account/provider/reload cancellation, strict acknowledgements and settled all-page read-only inspection without replay. Original-commit review UI remains a subsequent slice. [#642](https://github.com/Theorvane/labfox/issues/642) exposes MR commit browsing and identifier-only original review routes, captured literal-row rendering, single-line/range private save and existing inspection recovery. Unsupported/omitted originals remain honest read-only states; five-locale responsive light/dark and lifecycle tests cover the UI, and commit route values are sanitized from analytics. Live-instance role/version validation remains. | | 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/original-review-snapshots.md b/docs/original-review-snapshots.md index 58afac526..8c0df4509 100644 --- a/docs/original-review-snapshots.md +++ b/docs/original-review-snapshots.md @@ -22,4 +22,14 @@ The [Draft Notes API](https://docs.gitlab.com/api/draft_notes/) documents commit The acknowledgement must retain the author, global MR ID, exact Markdown, selected commit, canonical position and unpublished/non-resolution metadata. An uncertain dispatched write blocks another save until explicit read-only inspection waits for the actual request to settle and traverses all advertised private-note pages. Cancellation cannot undo an already dispatched server write. There is no automatic replay, public fallback, publication or analytics emission. -These sequential reads are not an atomic server snapshot or a live-version compatibility guarantee. Root, unknown-parent and multiple-parent commits remain unselectable. The original-commit list, literal-row renderer and localized private-save UI remain the next connection; this controller command does not expose a user action yet. +## Original review screens + +MR detail links to the commit browser at `/projects/:projectId/merge_requests/:iid/commits`. The browser resolves authoritative global MR identity, checks the reported target/IID and reads all advertised commit membership pages through one captured repository. Loading, current typed errors, read-only retry, empty state and obsolete origin are distinct. No partial stale list is exposed. Selecting a canonical full SHA opens the identifier-only child route; original review resolves its MR identity independently for deep links. + +`DiffHunksViewer` shares the existing diff row renderer while accepting the already validated immutable original hunks. Highlighting, selection and range endpoints retain the actual captured row objects without permissive reparsing. Every literal file is listed; omitted, malformed or ambiguous diffs receive an unavailable message. Valid root, unknown-parent and multiple-parent diffs remain readable with selection disabled. Narrow screens stack the file list and selected-position controls; tablet/wide screens show them side by side. Resizing preserves selection. + +The selected original position opens the existing private-note dialog with exact commit/path/line or range metadata and Markdown input. It calls only `savePendingCommitNote`, retaining its fresh selection/parent checks and settled read-only inspection. Account or origin replacement/disposal discards obsolete private input and outcomes. Five locales, three widths, both themes, large text/keyboard, resize, single-line/range save, unavailable diffs, cancellation and uncertain-save inspection are covered by synthetic widget tests. Synthetic screen captures are local validation, not live-instance evidence. + +Commit route values, including invalid deep links, are replaced with `:sha` before route analytics; numeric project/IID values remain `:id`. No real commit identifier is recorded by this route flow. + +These sequential reads are not an atomic server snapshot or a live-version compatibility guarantee. Single explicitly reported canonical parent only; no public fallback, automatic replay or automatic publication. Live GitLab role/version validation remains separate. diff --git a/packages/design_system/lib/src/components/diff_viewer.dart b/packages/design_system/lib/src/components/diff_viewer.dart index 26c35a133..69da4398d 100644 --- a/packages/design_system/lib/src/components/diff_viewer.dart +++ b/packages/design_system/lib/src/components/diff_viewer.dart @@ -63,35 +63,14 @@ class DiffViewer extends StatelessWidget { ); } - final marked = Set.identity()..addAll(highlightedLines); - if (highlightedLine != null) marked.add(highlightedLine!); - final matching = file.hunks.where( - (hunk) => hunk.lines.any(marked.contains), - ); - final hunks = focusOnHighlightedLine && matching.isNotEmpty - ? matching - : file.hunks; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final hunk in hunks) ...[ - _HunkHeader(header: hunk.header), - for (final line in hunk.lines) - _DiffLineRow( - line: line, - onSelected: - onLineSelected != null && - lineActionLabel != null && - (canSelectLine?.call(line) ?? true) - ? () => onLineSelected!(line) - : null, - actionLabel: lineActionLabel, - showHighlightGutter: marked.isNotEmpty, - highlighted: marked.contains(line), - highlightedLabel: highlightedLineLabel, - ), - ], - ], + return DiffHunksViewer( + hunks: file.hunks, + highlightedLines: [?highlightedLine, ...highlightedLines], + highlightedLineLabel: highlightedLineLabel, + focusOnHighlightedLine: focusOnHighlightedLine, + onLineSelected: onLineSelected, + canSelectLine: canSelectLine, + lineActionLabel: lineActionLabel, ); } } @@ -233,3 +212,53 @@ class _DiffLineRow extends StatelessWidget { color: Theme.of(context).colorScheme.onSurface, ); } + +/// Renders already validated diff hunks without reparsing their rows. +class DiffHunksViewer extends StatelessWidget { + const DiffHunksViewer({ + required this.hunks, + this.highlightedLines = const [], + this.highlightedLineLabel, + this.focusOnHighlightedLine = false, + this.onLineSelected, + this.canSelectLine, + this.lineActionLabel, + super.key, + }); + final List hunks; + final List highlightedLines; + final String? highlightedLineLabel, lineActionLabel; + final bool focusOnHighlightedLine; + final ValueChanged? onLineSelected; + final bool Function(DiffLine)? canSelectLine; + @override + Widget build(BuildContext context) { + final marked = Set.identity()..addAll(highlightedLines); + final matching = hunks.where((hunk) => hunk.lines.any(marked.contains)); + final displayed = focusOnHighlightedLine && matching.isNotEmpty + ? matching + : hunks; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final hunk in displayed) ...[ + _HunkHeader(header: hunk.header), + for (final line in hunk.lines) + _DiffLineRow( + line: line, + onSelected: + onLineSelected != null && + lineActionLabel != null && + (canSelectLine?.call(line) ?? true) + ? () => onLineSelected!(line) + : null, + actionLabel: lineActionLabel, + showHighlightGutter: marked.isNotEmpty, + highlighted: marked.contains(line), + highlightedLabel: highlightedLineLabel, + ), + ], + ], + ); + } +} diff --git a/packages/design_system/test/diff_hunks_viewer_test.dart b/packages/design_system/test/diff_hunks_viewer_test.dart new file mode 100644 index 000000000..0481fea8c --- /dev/null +++ b/packages/design_system/test/diff_hunks_viewer_test.dart @@ -0,0 +1,84 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gitlab_models/gitlab_models.dart'; + +List hunks() => FileDiff( + oldPath: 'a.dart', + newPath: 'a.dart', + isNew: false, + isDeleted: false, + isRenamed: false, + diff: '@@ -1,2 +1,2 @@\n same\n-old\n+new\n@@ -10 +10 @@\n distant\n', +).hunks; +void main() { + for (final width in [320.0, 800.0, 1280.0]) { + for (final dark in [false, true]) { + testWidgets('captured rows retain identity $width $dark', (t) async { + t.view.physicalSize = Size(width, 800); + t.view.devicePixelRatio = 1; + addTearDown(t.view.resetPhysicalSize); + addTearDown(t.view.resetDevicePixelRatio); + final h = hunks(); + final row = h.first.lines.last; + DiffLine? selected; + await t.pumpWidget( + MaterialApp( + theme: dark ? LabFoxTheme.dark : LabFoxTheme.light, + home: Scaffold( + body: SingleChildScrollView( + child: DiffHunksViewer( + hunks: h, + highlightedLines: [row], + highlightedLineLabel: 'Selected', + lineActionLabel: 'Select', + canSelectLine: (l) => identical(l, row), + onLineSelected: (l) => selected = l, + ), + ), + ), + ), + ); + expect(find.textContaining('same'), findsOneWidget); + expect(find.textContaining('distant'), findsOneWidget); + await t.tap(find.byTooltip('Select')); + expect(identical(selected, row), true); + expect(t.takeException(), null); + }); + } + } + testWidgets('focus uses captured identities and ignores foreign equal rows', ( + t, + ) async { + final h = hunks(); + final row = h.first.lines.last; + final foreign = hunks().last.lines.single; + await t.pumpWidget( + MaterialApp( + home: Scaffold( + body: DiffHunksViewer( + hunks: h, + highlightedLines: [row, row, foreign], + highlightedLineLabel: 'Selected', + focusOnHighlightedLine: true, + ), + ), + ), + ); + expect(find.textContaining('new'), findsOneWidget); + expect(find.textContaining('distant'), findsNothing); + expect(find.textContaining('@@ -10'), findsNothing); + }); + testWidgets('no selection callback means no action even with a label', ( + t, + ) async { + await t.pumpWidget( + MaterialApp( + home: Scaffold( + body: DiffHunksViewer(hunks: hunks(), lineActionLabel: 'Select'), + ), + ), + ); + expect(find.byTooltip('Select'), findsNothing); + }); +}