feat: improve release selection UX with date display and truncation (#3493)

This commit is contained in:
Eric Seidel
2026-01-31 08:29:54 -08:00
committed by GitHub
parent 5430ed2eb0
commit 3c6cd35a0f
8 changed files with 496 additions and 41 deletions
@@ -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<Release>(
'Which release would you like to patch?',
choices: [...releasesForPlatform.sortedBy((r) => r.createdAt).reversed],
display: (r) => r.version,
return chooseRelease(
releases: releasesForPlatform,
action: 'patch',
);
}
@@ -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<String> promptForReleaseVersion(List<Release> 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;
}
@@ -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<Release>(
'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',
);
}
@@ -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<Release> 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<Release>(
prompt,
choices: truncated,
display: display,
);
if (!identical(choice, showAllReleaseSentinel)) return choice;
}
return logger.chooseOne<Release>(
prompt,
choices: sorted,
display: display,
);
}
@@ -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<String Function(Release)>()
.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<Release>(
any(),
choices: captureAny(named: 'choices'),
display: any(named: 'display'),
),
).captured.single
as List<Release>;
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<Release>(
any(),
choices: any(named: 'choices'),
display: any(named: 'display'),
),
);
verify(
() => patcher.buildPatchArtifact(releaseVersion: releaseVersion),
@@ -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<Release>(
any(),
choices: captureAny(named: 'choices'),
display: any(named: 'display'),
),
).captured.single
as List<Release>;
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<Release>(
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,
@@ -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)'));
});
});
@@ -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>(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<Release>(
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<Release>(
any(),
choices: any(named: 'choices'),
display: any(named: 'display'),
),
).thenReturn(newest);
runWithOverrides(
() => chooseRelease(
releases: [mid, old, newest],
action: 'patch',
),
);
final captured =
verify(
() => logger.chooseOne<Release>(
'Which release would you like to patch?',
choices: captureAny(named: 'choices'),
display: any(named: 'display'),
),
).captured.first
as List<Release>;
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<Release>(
any(),
choices: any(named: 'choices'),
display: any(named: 'display'),
),
).thenReturn(release);
runWithOverrides(
() => chooseRelease(
releases: [release, other],
action: 'preview',
),
);
final verification = verify(
() => logger.chooseOne<Release>(
'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<Release>(
any(),
choices: any(named: 'choices'),
display: any(named: 'display'),
),
).thenAnswer((invocation) {
final choices = invocation.namedArguments[#choices] as List<Release>;
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<Release>());
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<Release>(
any(),
choices: any(named: 'choices'),
display: any(named: 'display'),
),
).thenAnswer((invocation) {
final choices = invocation.namedArguments[#choices] as List<Release>;
// Return a real release, not the sentinel.
return choices.first;
});
final result = runWithOverrides(
() => chooseRelease(
releases: releases,
action: 'patch',
),
);
expect(result, isA<Release>());
verify(
() => logger.chooseOne<Release>(
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<Release>(
any(),
choices: any(named: 'choices'),
display: any(named: 'display'),
),
).thenAnswer((invocation) {
final choices = invocation.namedArguments[#choices] as List<Release>;
expect(choices, hasLength(11));
expect(
choices.every((r) => !identical(r, showAllReleaseSentinel)),
isTrue,
);
return choices.first;
});
runWithOverrides(
() => chooseRelease(
releases: releases,
action: 'patch',
),
);
verify(
() => logger.chooseOne<Release>(
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<Release>(
any(),
choices: any(named: 'choices'),
display: any(named: 'display'),
),
).thenAnswer((invocation) {
final choices = invocation.namedArguments[#choices] as List<Release>;
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<Release>(
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'),
);
});
});
});
}