From 3c6cd35a0f040cf2036f55777019bf5f7c7a5671 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Sat, 31 Jan 2026 08:29:54 -0800 Subject: [PATCH] feat: improve release selection UX with date display and truncation (#3493) --- .../lib/src/commands/patch/patch_command.dart | 9 +- .../lib/src/commands/preview_command.dart | 8 +- .../commands/releases/get_apks_command.dart | 9 +- .../lib/src/release_chooser.dart | 102 ++++++ .../commands/patch/patch_command_test.dart | 43 ++- .../src/commands/preview_command_test.dart | 42 ++- .../releases/get_apks_command_test.dart | 14 +- .../test/src/release_chooser_test.dart | 310 ++++++++++++++++++ 8 files changed, 496 insertions(+), 41 deletions(-) create mode 100644 packages/shorebird_cli/lib/src/release_chooser.dart create mode 100644 packages/shorebird_cli/test/src/release_chooser_test.dart diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart index 67fd6f49..c5a95309 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:collection/collection.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:meta/meta.dart'; import 'package:scoped_deps/scoped_deps.dart'; @@ -20,6 +19,7 @@ import 'package:shorebird_cli/src/metadata/metadata.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart'; import 'package:shorebird_cli/src/platform.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; +import 'package:shorebird_cli/src/release_chooser.dart'; import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/shorebird_command.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; @@ -486,10 +486,9 @@ Building patch with Flutter $flutterVersionString throw ProcessExit(ExitCode.usage.code); } - return logger.chooseOne( - 'Which release would you like to patch?', - choices: [...releasesForPlatform.sortedBy((r) => r.createdAt).reversed], - display: (r) => r.version, + return chooseRelease( + releases: releasesForPlatform, + action: 'patch', ); } diff --git a/packages/shorebird_cli/lib/src/commands/preview_command.dart b/packages/shorebird_cli/lib/src/commands/preview_command.dart index ae7ec2a0..d3db2365 100644 --- a/packages/shorebird_cli/lib/src/commands/preview_command.dart +++ b/packages/shorebird_cli/lib/src/commands/preview_command.dart @@ -19,6 +19,7 @@ import 'package:shorebird_cli/src/executables/executables.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/platform.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; +import 'package:shorebird_cli/src/release_chooser.dart'; import 'package:shorebird_cli/src/shorebird_command.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_process.dart'; @@ -305,10 +306,9 @@ This is only applicable when previewing Android releases.''', /// Prompts the user to choose a release version to preview. Future promptForReleaseVersion(List releases) async { - final release = logger.chooseOne( - 'Which release would you like to preview?', - choices: releases, - display: (release) => release.version, + final release = chooseRelease( + releases: releases, + action: 'preview', ); return release.version; } diff --git a/packages/shorebird_cli/lib/src/commands/releases/get_apks_command.dart b/packages/shorebird_cli/lib/src/commands/releases/get_apks_command.dart index 33ad03ff..1065ddae 100644 --- a/packages/shorebird_cli/lib/src/commands/releases/get_apks_command.dart +++ b/packages/shorebird_cli/lib/src/commands/releases/get_apks_command.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:archive/archive_io.dart'; -import 'package:collection/collection.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/artifact_manager.dart'; @@ -11,6 +10,7 @@ import 'package:shorebird_cli/src/config/config.dart'; import 'package:shorebird_cli/src/executables/bundletool.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/release_chooser.dart'; import 'package:shorebird_cli/src/shorebird_command.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; @@ -177,10 +177,9 @@ class GetApksCommand extends ShorebirdCommand { throw ProcessExit(ExitCode.usage.code); } - return logger.chooseOne( - 'Which release would you like to generate an apk for?', - choices: releases.sortedBy((r) => r.createdAt).reversed.toList(), - display: (r) => r.version, + return chooseRelease( + releases: releases, + action: 'generate an apk for', ); } diff --git a/packages/shorebird_cli/lib/src/release_chooser.dart b/packages/shorebird_cli/lib/src/release_chooser.dart new file mode 100644 index 00000000..6231811d --- /dev/null +++ b/packages/shorebird_cli/lib/src/release_chooser.dart @@ -0,0 +1,102 @@ +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// The maximum number of releases to display before offering to show all. +const maxDisplayedReleases = 10; + +const _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +/// Sentinel release used to represent the "Show all releases" option. +/// +/// We use a sentinel rather than `null` because `chooseOne` internally +/// performs a null check and does not support nullable types. +@visibleForTesting +final showAllReleaseSentinel = Release( + id: -1, + appId: '', + version: '', + flutterRevision: '', + flutterVersion: null, + displayName: null, + platformStatuses: const {}, + createdAt: DateTime(0), + updatedAt: DateTime(0), +); + +/// Formats a [DateTime] as "Mon DD" (e.g. "Jan 28"). +/// +/// Could be replaced with `DateFormat('MMM d').format(date)` from +/// `package:intl` if we ever add that dependency. +String formatReleaseDate(DateTime date) { + return '${_months[date.month - 1]} ${date.day}'; +} + +/// Formats a release for display in the chooser. +/// +/// The [showAllReleaseSentinel] is rendered as "Show all N releases...". +String formatReleaseDisplay(Release r, {required int totalCount}) { + if (identical(r, showAllReleaseSentinel)) { + return '\u2193 Show all $totalCount releases...'; + } + return '${r.version} (${formatReleaseDate(r.createdAt)})'; +} + +/// Prompts the user to choose a release from [releases]. +/// +/// Releases are sorted by [Release.createdAt] descending (newest first). +/// If there are more than [maxDisplayedReleases], only the most recent +/// are shown initially with an option to show all. +/// +/// The [action] parameter is used in the prompt message, e.g. "patch", +/// "preview", or "generate an apk for". +Release chooseRelease({ + required Iterable releases, + required String action, +}) { + final sorted = releases.sortedBy((r) => r.createdAt).reversed.toList(); + final prompt = 'Which release would you like to $action?'; + String display(Release r) => + formatReleaseDisplay(r, totalCount: sorted.length); + + if (sorted.length == 1) { + logger.info('Using release ${display(sorted.single)}'); + return sorted.single; + } + + // Only truncate when it actually saves lines (sentinel takes a line too). + if (sorted.length > maxDisplayedReleases + 1) { + final truncated = [ + ...sorted.take(maxDisplayedReleases), + showAllReleaseSentinel, // "Show all releases..." option. + ]; + + final choice = logger.chooseOne( + prompt, + choices: truncated, + display: display, + ); + + if (!identical(choice, showAllReleaseSentinel)) return choice; + } + + return logger.chooseOne( + prompt, + choices: sorted, + display: display, + ); +} diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart index 5109c440..08f5b5ec 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart @@ -1028,6 +1028,23 @@ void main() { group('when release version is not specified', () { setUp(() { when(() => argResults.wasParsed('release-version')).thenReturn(false); + // Need 2+ releases so chooseRelease prompts instead of auto-selecting. + final olderRelease = Release( + id: 99, + appId: appId, + version: '0.0.1', + flutterRevision: flutterRevision, + flutterVersion: flutterVersion, + displayName: '0.0.1', + platformStatuses: const {releasePlatform: ReleaseStatus.active}, + createdAt: DateTime(2022), + updatedAt: DateTime(2022), + ); + when( + () => codePushClientWrapper.getReleases( + appId: any(named: 'appId'), + ), + ).thenAnswer((_) async => [release, olderRelease]); }); test( @@ -1081,7 +1098,10 @@ void main() { final displayFunctionCapture = verificationResult.captured.flattened .whereType() .first; - expect(displayFunctionCapture(release), equals(release.version)); + expect( + displayFunctionCapture(release), + equals('${release.version} (Jan 1)'), + ); }, ); @@ -1144,18 +1164,15 @@ void main() { test('only lists and uses releases ' 'for the specified platform', () async { await expectLater(runWithOverrides(command.run), completes); - final captured = - verify( - () => logger.chooseOne( - any(), - choices: captureAny(named: 'choices'), - display: any(named: 'display'), - ), - ).captured.single - as List; - - expect(captured.length, equals(1)); - expect(captured.first.version, equals(releaseVersion)); + // Only one release matches the platform, so chooseRelease + // auto-selects it without prompting. + verifyNever( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ); verify( () => patcher.buildPatchArtifact(releaseVersion: releaseVersion), diff --git a/packages/shorebird_cli/test/src/commands/preview_command_test.dart b/packages/shorebird_cli/test/src/commands/preview_command_test.dart index 6cd50ee1..6de14cab 100644 --- a/packages/shorebird_cli/test/src/commands/preview_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/preview_command_test.dart @@ -255,6 +255,7 @@ void main() { when(() => app.displayName).thenReturn(appDisplayName); when(() => release.id).thenReturn(releaseId); when(() => release.version).thenReturn(releaseVersion); + when(() => release.createdAt).thenReturn(DateTime(2023)); when(() => release.platformStatuses).thenReturn({ ReleasePlatform.android: ReleaseStatus.active, ReleasePlatform.ios: ReleaseStatus.active, @@ -1153,6 +1154,7 @@ channel: ${track.channel} otherRelease = MockRelease(); when(() => otherRelease.appId).thenReturn(appId); when(() => otherRelease.version).thenReturn(releaseVersion); + when(() => otherRelease.createdAt).thenReturn(DateTime(2023)); when(() => otherRelease.displayName).thenReturn('2.0.0+1'); when(() => otherRelease.platformStatuses).thenReturn({ ReleasePlatform.macos: ReleaseStatus.active, @@ -1175,20 +1177,18 @@ channel: ${track.channel} ).thenAnswer((_) async => [release, otherRelease]); }); - test('only prompts for release versions that ' + test('only uses releases that ' 'support the current platform', () async { await runWithOverrides(command.run); - final releases = - verify( - () => logger.chooseOne( - any(), - choices: captureAny(named: 'choices'), - display: any(named: 'display'), - ), - ).captured.single - as List; - expect(releases.length, equals(1)); - expect(releases.first.version, equals(releaseVersion)); + // Only one release matches the platform, so chooseRelease + // auto-selects it without prompting. + verifyNever( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ); }); }); @@ -1309,6 +1309,22 @@ channel: ${track.channel} display: any(named: 'display'), ), ).thenReturn(release); + + // Need 2+ releases so chooseRelease prompts instead of + // auto-selecting. + final otherRelease = MockRelease(); + when(() => otherRelease.id).thenReturn(999); + when(() => otherRelease.version).thenReturn('0.0.1'); + when(() => otherRelease.createdAt).thenReturn(DateTime(2022)); + when(() => otherRelease.platformStatuses).thenReturn({ + ReleasePlatform.android: ReleaseStatus.active, + }); + when( + () => codePushClientWrapper.getReleases( + appId: any(named: 'appId'), + sideloadableOnly: any(named: 'sideloadableOnly'), + ), + ).thenAnswer((_) async => [release, otherRelease]); }); test('queries for releases', () async { @@ -1323,7 +1339,7 @@ channel: ${track.channel} ), ).captured.single as String Function(Release); - expect(captured(release), equals(releaseVersion)); + expect(captured(release), equals('$releaseVersion (Jan 1)')); verify( () => codePushClientWrapper.getReleases( appId: appId, diff --git a/packages/shorebird_cli/test/src/commands/releases/get_apks_command_test.dart b/packages/shorebird_cli/test/src/commands/releases/get_apks_command_test.dart index 636de2c9..06250659 100644 --- a/packages/shorebird_cli/test/src/commands/releases/get_apks_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/releases/get_apks_command_test.dart @@ -145,6 +145,7 @@ void main() { when(() => release.id).thenReturn(releaseId); when(() => release.version).thenReturn(releaseVersion); + when(() => release.createdAt).thenReturn(DateTime(2023)); when(() => releaseArtifact.url).thenReturn(releaseArtifactUrl); @@ -284,6 +285,17 @@ void main() { group('when release version is not specified', () { setUp(() { when(() => argResults.wasParsed('release-version')).thenReturn(false); + // Need 2+ releases so chooseRelease prompts instead of auto-selecting. + final otherRelease = MockRelease(); + when(() => otherRelease.id).thenReturn(999); + when(() => otherRelease.version).thenReturn('0.0.1'); + when(() => otherRelease.createdAt).thenReturn(DateTime(2022)); + when( + () => codePushClientWrapper.getReleases( + appId: any(named: 'appId'), + sideloadableOnly: any(named: 'sideloadableOnly'), + ), + ).thenAnswer((_) async => [release, otherRelease]); }); test('prompts for release', () async { @@ -299,7 +311,7 @@ void main() { ).captured.single as String Function(Release); - expect(capturedDisplay(release), equals(releaseVersion)); + expect(capturedDisplay(release), equals('$releaseVersion (Jan 1)')); }); }); diff --git a/packages/shorebird_cli/test/src/release_chooser_test.dart b/packages/shorebird_cli/test/src/release_chooser_test.dart new file mode 100644 index 00000000..85d96022 --- /dev/null +++ b/packages/shorebird_cli/test/src/release_chooser_test.dart @@ -0,0 +1,310 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; +import 'package:shorebird_cli/src/release_chooser.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; + +Release _release(String version, DateTime createdAt) { + return Release( + id: version.hashCode, + appId: 'app', + version: version, + flutterRevision: 'rev', + flutterVersion: null, + displayName: null, + platformStatuses: const {}, + createdAt: createdAt, + updatedAt: createdAt, + ); +} + +void main() { + group('chooseRelease', () { + late ShorebirdLogger logger; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: {loggerRef.overrideWith(() => logger)}, + ); + } + + setUp(() { + logger = MockShorebirdLogger(); + }); + + test('returns single release without prompting', () { + final release = _release('1.0.0', DateTime(2025, 3, 15)); + + final result = runWithOverrides( + () => chooseRelease( + releases: [release], + action: 'patch', + ), + ); + + expect(result, equals(release)); + verify( + () => logger.info('Using release 1.0.0 (Mar 15)'), + ).called(1); + verifyNever( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ); + }); + + test('sorts releases newest first', () { + final old = _release('1.0.0', DateTime(2025)); + final mid = _release('1.1.0', DateTime(2025, 6)); + final newest = _release('1.2.0', DateTime(2026)); + + when( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).thenReturn(newest); + + runWithOverrides( + () => chooseRelease( + releases: [mid, old, newest], + action: 'patch', + ), + ); + + final captured = + verify( + () => logger.chooseOne( + 'Which release would you like to patch?', + choices: captureAny(named: 'choices'), + display: any(named: 'display'), + ), + ).captured.first + as List; + + expect(captured.first.version, equals('1.2.0')); + expect(captured.last.version, equals('1.0.0')); + }); + + test('displays version with date', () { + final release = _release('1.2.3', DateTime(2025, 3, 15)); + final other = _release('1.2.2', DateTime(2025, 3)); + + when( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).thenReturn(release); + + runWithOverrides( + () => chooseRelease( + releases: [release, other], + action: 'preview', + ), + ); + + final verification = verify( + () => logger.chooseOne( + 'Which release would you like to preview?', + choices: any(named: 'choices'), + display: captureAny(named: 'display'), + ), + ); + final displayFn = verification.captured.first as String Function(Release); + expect(displayFn(release), equals('1.2.3 (Mar 15)')); + }); + + test('shows truncated list when more than 10 releases', () { + final releases = List.generate( + 15, + (i) => _release('1.0.$i', DateTime(2025, 1, i + 1)), + ); + + var callCount = 0; + when( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).thenAnswer((invocation) { + final choices = invocation.namedArguments[#choices] as List; + callCount++; + if (callCount == 1) { + // 10 releases + 1 "show all" sentinel = 11. + expect(choices, hasLength(11)); + expect( + identical(choices.last, showAllReleaseSentinel), + isTrue, + ); + // Return the sentinel to trigger "show all". + return choices.last; + } + // Second call: all 15 releases. + expect(choices, hasLength(15)); + return choices.first; + }); + + final result = runWithOverrides( + () => chooseRelease( + releases: releases, + action: 'patch', + ), + ); + + expect(result, isA()); + expect(identical(result, showAllReleaseSentinel), isFalse); + expect(callCount, equals(2)); + }); + + test('returns directly when user picks from truncated list', () { + final releases = List.generate( + 15, + (i) => _release('1.0.$i', DateTime(2025, 1, i + 1)), + ); + + when( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).thenAnswer((invocation) { + final choices = invocation.namedArguments[#choices] as List; + // Return a real release, not the sentinel. + return choices.first; + }); + + final result = runWithOverrides( + () => chooseRelease( + releases: releases, + action: 'patch', + ), + ); + + expect(result, isA()); + verify( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).called(1); + }); + + test('shows all 11 releases without truncation', () { + // 11 releases = maxDisplayedReleases + 1, so truncation would not + // save any lines (10 + sentinel = 11 lines vs 11 lines). + final releases = List.generate( + 11, + (i) => _release('1.0.$i', DateTime(2025, 1, i + 1)), + ); + + when( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).thenAnswer((invocation) { + final choices = invocation.namedArguments[#choices] as List; + expect(choices, hasLength(11)); + expect( + choices.every((r) => !identical(r, showAllReleaseSentinel)), + isTrue, + ); + return choices.first; + }); + + runWithOverrides( + () => chooseRelease( + releases: releases, + action: 'patch', + ), + ); + + verify( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).called(1); + }); + + test('skips truncation when 10 or fewer releases', () { + final releases = List.generate( + 5, + (i) => _release('1.0.$i', DateTime(2025, 1, i + 1)), + ); + + when( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).thenAnswer((invocation) { + final choices = invocation.namedArguments[#choices] as List; + expect(choices, hasLength(5)); + // No sentinel present. + expect( + choices.every((r) => !identical(r, showAllReleaseSentinel)), + isTrue, + ); + return choices.first; + }); + + runWithOverrides( + () => chooseRelease( + releases: releases, + action: 'patch', + ), + ); + + verify( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).called(1); + }); + + group('formatReleaseDisplay', () { + test('formats release with version and date', () { + final release = _release('2.0.1', DateTime(2025, 12, 25)); + expect( + formatReleaseDisplay(release, totalCount: 5), + equals('2.0.1 (Dec 25)'), + ); + }); + + test('formats show all sentinel', () { + expect( + formatReleaseDisplay(showAllReleaseSentinel, totalCount: 23), + equals('\u2193 Show all 23 releases...'), + ); + }); + }); + + group('formatReleaseDate', () { + test('formats date correctly', () { + expect(formatReleaseDate(DateTime(2025, 1, 5)), equals('Jan 5')); + expect( + formatReleaseDate(DateTime(2025, 12, 31)), + equals('Dec 31'), + ); + }); + }); + }); +}