blob: a920415465bf8242f874790abdb80e723e346392 [file]
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
// Adapted from package:pub lib/src/progress.dart to align progress updates
// visually with pub. It would be better to let pub stream updates to dartdev
// and have dartdev display progress in a consistent way for all kinds of
// progress.
// TODO(https://dartbug.com/61539): Standardize.
import 'dart:async';
import 'dart:io';
import 'package:cli_util/cli_logging.dart' as cli_logging;
import 'package:clock/clock.dart';
import 'package:pub/pub.dart';
import 'core.dart';
/// The shared progress grace period instance for dartdev.
final progressGracePeriod = ProgressGracePeriod();
/// Resets the shared grace period timer.
void resetProgressGracePeriod() {
progressGracePeriod.reset();
}
/// Destination for progress and build status updates.
enum ProgressOutput {
/// Write progress and status updates to [stdout].
stdout,
/// Write progress and status updates to [stderr].
stderr,
/// Suppress progress and status updates.
none,
}
/// Runs [callback] while displaying a live-updating progress indicator.
///
/// The [message] is shown to the user, followed by "..." and a timer.
/// The progress indicator is only animated if output is going to a terminal
/// that supports ANSI escape codes.
///
/// If [transient] is `true`, the progress indicator is erased from the terminal
/// when [callback] completes (and omitted entirely when ANSI escape codes are
/// not supported). By default, transient progress is only shown after the
/// shared [progressGracePeriod] (500ms since program start or last
/// non-progress output), unless overridden by [delay].
///
/// If [transient] is `false`, the progress indicator is stopped and the final
/// time is shown on completion. If omitted, [transient] defaults to the active
/// [DartdevLogger.transientProgress] in the current [Zone].
///
/// [output] controls whether progress updates are written to [stdout],
/// [stderr], or suppressed ([ProgressOutput.none]). If omitted, [output]
/// defaults to the active [DartdevLogger.output] in the current [Zone] (and is
/// always [ProgressOutput.none] if the zone logger is configured with
/// [ProgressOutput.none]).
Future<T> progress<T>(
String message,
FutureOr<T> Function() callback, {
bool? transient,
ProgressOutput? output,
Duration? delay,
}) {
final currentOutput = dartdevLogger.output;
final effectiveOutput = currentOutput == ProgressOutput.none
? ProgressOutput.none
: (output ?? currentOutput);
if (effectiveOutput == ProgressOutput.none) {
return Future.sync(callback);
}
stopActiveProgress();
final effectiveTransient = transient ?? dartdevLogger.transientProgress;
final effectiveDelay =
delay ??
(effectiveTransient ? progressGracePeriod.remainingDelay : Duration.zero);
final progress = _Progress(
message,
effectiveOutput == ProgressOutput.stderr,
transient: effectiveTransient,
delay: effectiveDelay,
);
return Future.sync(
callback,
).whenComplete(
effectiveTransient ? progress._stopAndClear : progress._stop,
);
}
/// Starts a live-updating progress indicator and returns its
/// [cli_logging.Progress] handle.
///
/// Prefer [progress] when wrapping a callback.
cli_logging.Progress startProgress(
String message, {
bool? transient,
ProgressOutput? output,
}) {
final currentOutput = dartdevLogger.output;
final effectiveOutput = currentOutput == ProgressOutput.none
? ProgressOutput.none
: (output ?? currentOutput);
if (effectiveOutput != ProgressOutput.none) {
stopActiveProgress();
}
final effectiveTransient = transient ?? dartdevLogger.transientProgress;
final effectiveDelay = effectiveTransient
? progressGracePeriod.remainingDelay
: Duration.zero;
return _Progress(
message,
effectiveOutput == ProgressOutput.stderr,
transient: effectiveTransient,
delay: effectiveDelay,
suppressed: effectiveOutput == ProgressOutput.none,
);
}
_Progress? _activeProgress;
/// Stops animating any currently active progress indicator.
void stopActiveProgress() {
if (_activeProgress != null) {
_activeProgress!._stopAnimating();
_activeProgress = null;
}
}
/// A live-updating progress indicator for long-running log entries.
final class _Progress implements cli_logging.Progress {
/// Whether progress updates should be printed to [stderr] instead of [stdout].
final bool _progressUpdatesOnStderr;
/// The timer used to write "..." during a progress log.
Timer? _timer;
/// The [Stopwatch] used to track how long a progress log has been running.
///
/// Backed by the current [clock], so tests can control elapsed time.
final _stopwatch = clock.stopwatch();
/// The message displayed for this progress indicator.
final String _message;
@override
String get message => _message;
@override
Duration get elapsed => _stopwatch.elapsed;
/// Gets the current progress time as a parenthesized, formatted string.
String get _time => '(${_niceDuration(_stopwatch.elapsed)})';
/// The length of the most recently-printed [_time] string.
var _timeLength = 0;
/// Whether the initial start message has been printed.
var _hasStarted = false;
/// The output sink for progress updates.
IOSink get _sink => _progressUpdatesOnStderr ? stderr : stdout;
/// Whether this progress indicator is transient (erased upon completion).
final bool _transient;
/// Whether all output of this progress indicator is suppressed.
final bool _suppressed;
/// Creates a new progress indicator.
_Progress(
this._message,
this._progressUpdatesOnStderr, {
bool transient = false,
Duration delay = Duration.zero,
bool suppressed = false,
}) : _transient = transient,
_suppressed = suppressed {
_stopwatch.start();
if (suppressed) {
return;
}
// The animation is only shown when it would be meaningful to a human.
// That means we're writing a visible message to a TTY at normal log levels
// with ANSI support and non-JSON output.
if (!_canUseAnsiCodes(_progressUpdatesOnStderr)) {
if (transient) {
// In non-terminal mode or without ANSI, transient progress produces no output.
return;
}
// Not animating, so just log the start and wait until the task is
// completed.
_sink.writeln('$_message...');
resetProgressGracePeriod();
return;
}
_activeProgress = this;
_timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
if (_stopwatch.elapsed < delay) return;
if (!_hasStarted) {
_sink.write('$_message... ');
_hasStarted = true;
progressGracePeriod.markProgressShown();
}
_update();
});
if (delay == Duration.zero) {
_sink.write('$_message... ');
_hasStarted = true;
progressGracePeriod.markProgressShown();
}
}
/// Erases the progress message from the terminal.
void _erase() {
_sink.write('\r$_eraseLine');
}
/// Stops animating the progress indicator.
void _stopAnimating() {
if (_timer == null) return;
if (_hasStarted) {
if (_transient) {
_erase();
} else {
_sink.write('\r$_message... $_eraseToLineEnd');
_sink.writeln();
resetProgressGracePeriod();
}
}
_timeLength = 0;
_timer!.cancel();
_timer = null;
}
/// Stops the progress indicator, leaving its line on the terminal.
///
/// The final elapsed time is shown if [showTiming] is `true`; otherwise any
/// time shown by the animation is erased.
void _stop({bool showTiming = true}) {
if (identical(_activeProgress, this)) _activeProgress = null;
_stopwatch.stop();
if (_timer == null) return;
_timer!.cancel();
_timer = null;
if (!_hasStarted) return;
if (showTiming) {
// Print one final update to show the user the final time.
_update();
} else if (_timeLength > 0) {
// Erase the time shown by the animation.
_sink.write('\r$_message... $_eraseToLineEnd');
_timeLength = 0;
}
_sink.writeln();
resetProgressGracePeriod();
}
/// Stops the progress indicator and erases it from the terminal.
void _stopAndClear() {
if (identical(_activeProgress, this)) _activeProgress = null;
_stopwatch.stop();
if (_timer != null) {
_timer!.cancel();
_timer = null;
if (_hasStarted) {
_erase();
}
}
}
/// Stops the progress indicator.
///
/// A transient indicator is erased from the terminal. Otherwise its line is
/// left on the terminal, showing the final elapsed time if [showTiming] is
/// `true`. A [message], if given, is then written on a line of its own
/// (unless progress output is suppressed).
@override
void finish({String? message, bool showTiming = false}) {
if (_transient) {
_stopAndClear();
} else {
_stop(showTiming: showTiming);
}
if (message != null && !_suppressed) {
_sink.writeln(message);
resetProgressGracePeriod();
}
}
/// Stops the progress indicator and erases it from the terminal.
@override
void cancel() {
_stopAndClear();
}
/// Refreshes the progress line.
void _update() {
// Show the time only once it gets noticeably long.
if (_stopwatch.elapsed.inSeconds == 0) return;
// Erase the last time that was printed. Erasing just the time using `\b`
// rather than using `\r` to erase the entire line ensures that we don't
// spam progress lines if they're wider than the terminal width.
_sink.write('\b' * _timeLength);
final time = _time;
_timeLength = time.length;
_sink.write(_grayText(time, _progressUpdatesOnStderr));
}
}
/// Returns a human-friendly representation of [duration].
String _niceDuration(Duration duration) {
final hasMinutes = duration.inMinutes > 0;
final result = hasMinutes ? '${duration.inMinutes}:' : '';
final s = duration.inSeconds % 60;
final ms = duration.inMilliseconds % 1000;
final msString = (ms ~/ 100).toString();
return "$result${hasMinutes ? s.toString().padLeft(2, '0') : s}"
'.${msString}s';
}
/// Wraps [text] in the ANSI escape codes to make it gray when on a platform
/// that supports that.
///
/// Honors the `NO_COLOR` convention (https://no-color.org): when the
/// `NO_COLOR` environment variable is set, [text] is returned uncolored.
/// `NO_COLOR` only affects colors, not the cursor-control sequences used to
/// animate the progress indicator.
String _grayText(String text, bool progressUpdatesOnStderr) {
if (_noColor || !_canUseAnsiCodes(progressUpdatesOnStderr)) return text;
return '\u001b[38;5;245m$text\u001b[0m';
}
/// ANSI escape sequence erasing the entire current line.
const _eraseLine = '\u001b[2K';
/// ANSI escape sequence erasing from the cursor to the end of the line.
const _eraseToLineEnd = '\u001b[0K';
/// Whether the `NO_COLOR` environment variable is set.
bool get _noColor => Platform.environment.containsKey('NO_COLOR');
/// Whether the sink for progress updates is a terminal that supports ANSI
/// escape codes.
bool _canUseAnsiCodes(bool progressUpdatesOnStderr) {
final sink = progressUpdatesOnStderr ? stderr : stdout;
return sink.hasTerminal && sink.supportsAnsiEscapes;
}