Unverified Commit 79bc1bfa authored by Emmanuel Garcia's avatar Emmanuel Garcia Committed by GitHub

Add ability to wrap text messages in a box (#94391)

parent 139a4d39
......@@ -78,6 +78,8 @@ final List<GradleHandledError> gradleErrors = <GradleHandledError>[
incompatibleKotlinVersionHandler,
];
const String _boxTitle = 'Flutter Fix';
// Multidex error message.
@visibleForTesting
final GradleHandledError multidexErrorHandler = GradleHandledError(
......@@ -163,9 +165,9 @@ final GradleHandledError multidexErrorHandler = GradleHandledError(
}
}
} else {
globals.printStatus(
globals.printBox(
'Flutter multidex handling is disabled. If you wish to let the tool configure multidex, use the --mutidex flag.',
indent: 4,
title: _boxTitle,
);
}
return GradleBuildStatus.exit;
......@@ -185,11 +187,11 @@ final GradleHandledError permissionDeniedErrorHandler = GradleHandledError(
required bool usesAndroidX,
required bool multidexEnabled,
}) async {
globals.printStatus('${globals.logger.terminal.warningMark} Gradle does not have execution permission.', emphasis: true);
globals.printStatus(
globals.printBox(
'${globals.logger.terminal.warningMark} Gradle does not have execution permission.\n'
'You should change the ownership of the project directory to your user, '
'or move the project to a directory with execute permissions.',
indent: 4,
title: _boxTitle,
);
return GradleBuildStatus.exit;
},
......@@ -252,9 +254,12 @@ final GradleHandledError r8FailureHandler = GradleHandledError(
required bool usesAndroidX,
required bool multidexEnabled,
}) async {
globals.printStatus('${globals.logger.terminal.warningMark} The shrinker may have failed to optimize the Java bytecode.', emphasis: true);
globals.printStatus('To disable the shrinker, pass the `--no-shrink` flag to this command.', indent: 4);
globals.printStatus('To learn more, see: https://developer.android.com/studio/build/shrink-code', indent: 4);
globals.printBox(
'${globals.logger.terminal.warningMark} The shrinker may have failed to optimize the Java bytecode.\n'
'To disable the shrinker, pass the `--no-shrink` flag to this command.\n'
'To learn more, see: https://developer.android.com/studio/build/shrink-code',
title: _boxTitle,
);
return GradleBuildStatus.exit;
},
eventLabel: 'r8',
......@@ -280,12 +285,13 @@ final GradleHandledError licenseNotAcceptedHandler = GradleHandledError(
final RegExp licenseFailure = RegExp(licenseNotAcceptedMatcher, multiLine: true);
assert(licenseFailure != null);
final Match? licenseMatch = licenseFailure.firstMatch(line);
globals.printStatus(
globals.printBox(
'${globals.logger.terminal.warningMark} Unable to download needed Android SDK components, as the '
'following licenses have not been accepted:\n'
'following licenses have not been accepted: '
'${licenseMatch?.group(1)}\n\n'
'To resolve this, please run the following command in a Terminal:\n'
'flutter doctor --android-licenses'
'flutter doctor --android-licenses',
title: _boxTitle,
);
return GradleBuildStatus.exit;
},
......@@ -344,21 +350,23 @@ final GradleHandledError flavorUndefinedHandler = GradleHandledError(
}
}
}
globals.printStatus(
'\n${globals.logger.terminal.warningMark} Gradle project does not define a task suitable '
'for the requested build.'
);
final String errorMessage = '${globals.logger.terminal.warningMark} Gradle project does not define a task suitable for the requested build.';
final File buildGradle = project.directory.childDirectory('android').childDirectory('app').childFile('build.gradle');
if (productFlavors.isEmpty) {
globals.printStatus(
'The android/app/build.gradle file does not define '
globals.printBox(
'$errorMessage\n\n'
'The ${buildGradle.absolute.path} file does not define '
'any custom product flavors. '
'You cannot use the --flavor option.'
'You cannot use the --flavor option.',
title: _boxTitle,
);
} else {
globals.printStatus(
'The android/app/build.gradle file defines product '
'flavors: ${productFlavors.join(', ')} '
'You must specify a --flavor option to select one of them.'
globals.printBox(
'$errorMessage\n\n'
'The ${buildGradle.absolute.path} file defines product '
'flavors: ${productFlavors.join(', ')}. '
'You must specify a --flavor option to select one of them.',
title: _boxTitle,
);
}
return GradleBuildStatus.exit;
......@@ -389,7 +397,7 @@ final GradleHandledError minSdkVersion = GradleHandledError(
final Match? minSdkVersionMatch = _minSdkVersionPattern.firstMatch(line);
assert(minSdkVersionMatch?.groupCount == 3);
final String bold = globals.logger.terminal.bolden(
final String textInBold = globals.logger.terminal.bolden(
'Fix this issue by adding the following to the file ${gradleFile.path}:\n'
'android {\n'
' defaultConfig {\n'
......@@ -397,12 +405,12 @@ final GradleHandledError minSdkVersion = GradleHandledError(
' }\n'
'}\n'
);
globals.printStatus(
'\n'
globals.printBox(
'The plugin ${minSdkVersionMatch?.group(3)} requires a higher Android SDK version.\n'
'$bold\n'
'$textInBold\n'
"Note that your app won't be available to users running Android SDKs below ${minSdkVersionMatch?.group(2)}.\n"
'Alternatively, try to find a version of this plugin that supports these lower versions of the Android SDK.'
'Alternatively, try to find a version of this plugin that supports these lower versions of the Android SDK.',
title: _boxTitle,
);
return GradleBuildStatus.exit;
},
......@@ -426,7 +434,7 @@ final GradleHandledError transformInputIssue = GradleHandledError(
.childDirectory('android')
.childDirectory('app')
.childFile('build.gradle');
final String bold = globals.logger.terminal.bolden(
final String textInBold = globals.logger.terminal.bolden(
'Fix this issue by adding the following to the file ${gradleFile.path}:\n'
'android {\n'
' lintOptions {\n'
......@@ -434,10 +442,10 @@ final GradleHandledError transformInputIssue = GradleHandledError(
' }\n'
'}'
);
globals.printStatus(
'\n'
globals.printBox(
'This issue appears to be https://github.com/flutter/flutter/issues/58247.\n'
'$bold'
'$textInBold',
title: _boxTitle,
);
return GradleBuildStatus.exit;
},
......@@ -459,14 +467,14 @@ final GradleHandledError lockFileDepMissing = GradleHandledError(
final File gradleFile = project.directory
.childDirectory('android')
.childFile('build.gradle');
final String bold = globals.logger.terminal.bolden(
final String textInBold = globals.logger.terminal.bolden(
'To regenerate the lockfiles run: `./gradlew :generateLockfiles` in ${gradleFile.path}\n'
'To remove dependency locking, remove the `dependencyLocking` from ${gradleFile.path}\n'
'To remove dependency locking, remove the `dependencyLocking` from ${gradleFile.path}'
);
globals.printStatus(
'\n'
globals.printBox(
'You need to update the lockfile, or disable Gradle dependency locking.\n'
'$bold'
'$textInBold',
title: _boxTitle,
);
return GradleBuildStatus.exit;
},
......@@ -487,15 +495,11 @@ final GradleHandledError incompatibleKotlinVersionHandler = GradleHandledError(
final File gradleFile = project.directory
.childDirectory('android')
.childFile('build.gradle');
globals.printStatus(
'${globals.logger.terminal.warningMark} Your project requires a newer version of the Kotlin Gradle plugin.',
emphasis: true,
);
globals.printStatus(
'Find the latest version on https://kotlinlang.org/docs/gradle.html#plugin-and-versions, '
'then update ${gradleFile.path}:\n'
globals.printBox(
'${globals.logger.terminal.warningMark} Your project requires a newer version of the Kotlin Gradle plugin.\n'
'Find the latest version on https://kotlinlang.org/docs/gradle.html#plugin-and-versions, then update ${gradleFile.path}:\n'
"ext.kotlin_version = '<latest-version>'",
indent: 4,
title: _boxTitle,
);
return GradleBuildStatus.exit;
},
......
......@@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:math';
import 'package:meta/meta.dart';
......@@ -163,6 +164,32 @@ abstract class Logger {
bool? wrap,
});
/// Display the [message] inside a box.
///
/// For example, this is the generated output:
///
/// ┌─ [title] ─┐
/// │ [message] │
/// └───────────┘
///
/// If a terminal is attached, the lines in [message] are automatically wrapped based on
/// the available columns.
///
/// Use this utility only to highlight a message in the logs.
///
/// This is particularly useful when the message can be easily missed because of clutter
/// generated by other commands invoked by the tool.
///
/// One common use case is to provide actionable steps in a Flutter app when a Gradle
/// error is printed.
///
/// In the future, this output can be integrated with an IDE like VS Code to display a
/// notification, and allow the user to trigger an action. e.g. run a migration.
void printBox(
String message, {
String? title,
});
/// Use this for verbose tracing output. Users can turn this output on in order
/// to help diagnose issues with the toolchain or with their setup.
void printTrace(String message);
......@@ -312,6 +339,13 @@ class DelegatingLogger implements Logger {
);
}
@override
void printBox(String message, {
String? title,
}) {
_delegate.printBox(message, title: title);
}
@override
void printTrace(String message) {
_delegate.printTrace(message);
......@@ -479,6 +513,21 @@ class StdoutLogger extends Logger {
_status?.resume();
}
@override
void printBox(String message, {
String? title,
}) {
_status?.pause();
_generateBox(
title: title,
message: message,
wrapColumn: _outputPreferences.wrapColumn,
terminal: terminal,
write: writeToStdOut,
);
_status?.resume();
}
@protected
void writeToStdOut(String message) => _stdio.stdoutWrite(message);
......@@ -558,6 +607,77 @@ class StdoutLogger extends Logger {
}
}
typedef _Writter = void Function(String message);
/// Wraps the message in a box, and writes the bytes by calling [write].
///
/// Example output:
///
/// ┌─ [title] ─┐
/// │ [message] │
/// └───────────┘
///
/// When [title] is provided, the box will have a title above it.
///
/// The box width never exceeds [wrapColumn].
///
/// If [wrapColumn] is not provided, the default value is 100.
void _generateBox({
required String message,
required int wrapColumn,
required _Writter write,
required Terminal terminal,
String? title,
}) {
const int kPaddingLeftRight = 1;
const int kEdges = 2;
final int maxTextWidthPerLine = wrapColumn - kEdges - kPaddingLeftRight * 2;
final List<String> lines = wrapText(message, shouldWrap: true, columnWidth: maxTextWidthPerLine).split('\n');
final List<int> lineWidth = lines.map((String line) => _getColumnSize(line)).toList();
final int maxColumnSize = lineWidth.reduce((int currLen, int maxLen) => max(currLen, maxLen));
final int textWidth = min(maxColumnSize, maxTextWidthPerLine);
final int textWithPaddingWidth = textWidth + kPaddingLeftRight * 2;
write('\n');
// Write `┌─ [title] ─┐`.
write('┌');
write('─');
if (title == null) {
write('─' * (textWithPaddingWidth - 1));
} else {
write(' ${terminal.bolden(title)} ');
write('─' * (textWithPaddingWidth - title.length - 3));
}
write('┐');
write('\n');
// Write `│ [message] │`.
for (int lineIdx = 0; lineIdx < lines.length; lineIdx++) {
write('│');
write(' ' * kPaddingLeftRight);
write(lines[lineIdx]);
final int remainingSpacesToEnd = textWidth - lineWidth[lineIdx];
write(' ' * (remainingSpacesToEnd + kPaddingLeftRight));
write('│');
write('\n');
}
// Write `└───────────┘`.
write('└');
write('─' * textWithPaddingWidth);
write('┘');
write('\n');
}
final RegExp _ansiEscapePattern = RegExp('\x1B\\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]');
int _getColumnSize(String line) {
// Remove ANSI escape characters from the string.
return line.replaceAll(_ansiEscapePattern, '').length;
}
/// A [StdoutLogger] which replaces Unicode characters that cannot be printed to
/// the Windows console with alternative symbols.
///
......@@ -709,6 +829,19 @@ class BufferLogger extends Logger {
}
}
@override
void printBox(String message, {
String? title,
}) {
_generateBox(
title: title,
message: message,
wrapColumn: _outputPreferences.wrapColumn,
terminal: terminal,
write: _status.write,
);
}
@override
void printTrace(String message) => _trace.writeln(message);
......@@ -830,6 +963,23 @@ class VerboseLogger extends DelegatingLogger {
));
}
@override
void printBox(String message, {
String? title,
}) {
String composedMessage = '';
_generateBox(
title: title,
message: message,
wrapColumn: _outputPreferences.wrapColumn,
terminal: terminal,
write: (String line) {
composedMessage += line;
},
);
_emit(_LogType.status, composedMessage);
}
@override
void printTrace(String message) {
_emit(_LogType.trace, message);
......
......@@ -1036,6 +1036,13 @@ class NotifyingLogger extends DelegatingLogger {
_sendMessage(LogMessage('status', message));
}
@override
void printBox(String message, {
String title,
}) {
_sendMessage(LogMessage('status', title == null ? message : '$title: $message'));
}
@override
void printTrace(String message) {
if (!verbose) {
......
......@@ -204,6 +204,23 @@ void printStatus(
);
}
/// Display the [message] inside a box.
///
/// For example, this is the generated output:
///
/// ┌─ [title] ─┐
/// │ [message] │
/// └───────────┘
///
/// If a terminal is attached, the lines in [message] are automatically wrapped based on
/// the available columns.
void printBox(String message, {
String? title,
}) {
logger.printBox(message, title: title);
}
/// Use this for verbose tracing output. Users can turn this output on in order
/// to help diagnose issues with the toolchain or with their setup.
void printTrace(String message) => logger.printTrace(message);
......
......@@ -598,6 +598,18 @@ class StreamLogger extends Logger {
_log('[stdout] $message');
}
@override
void printBox(
String message, {
String title,
}) {
if (title == null) {
_log('[stdout] $message');
} else {
_log('[stdout] $title: $message');
}
}
@override
void printTrace(String message) {
_log('[verbose] $message');
......
......@@ -172,6 +172,25 @@ void main() {
Logger: () => notifyingLogger,
});
testUsingContext('printBox should log to stdout when logToStdout is enabled', () async {
final StringBuffer buffer = await capturedConsolePrint(() {
final StreamController<Map<String, dynamic>> commands = StreamController<Map<String, dynamic>>();
final StreamController<Map<String, dynamic>> responses = StreamController<Map<String, dynamic>>();
daemon = Daemon(
commands.stream,
responses.add,
notifyingLogger: notifyingLogger,
logToStdout: true,
);
globals.printBox('This is the box message', title: 'Sample title');
return Future<void>.value();
});
expect(buffer.toString().trim(), contains('Sample title: This is the box message'));
}, overrides: <Type, Generator>{
Logger: () => notifyingLogger,
});
testUsingContext('daemon.shutdown command should stop daemon', () async {
final StreamController<Map<String, dynamic>> commands = StreamController<Map<String, dynamic>>();
final StreamController<Map<String, dynamic>> responses = StreamController<Map<String, dynamic>>();
......
......@@ -282,6 +282,31 @@ void main() {
mockLogger.errorText,
matches('^$red' r'\[ (?: {0,2}\+[0-9]{1,4} ms| )\] ' '${bold}Helpless!$resetBold$resetColor' r'\n$'));
});
testWithoutContext('printBox', () {
final BufferLogger mockLogger = BufferLogger(
terminal: AnsiTerminal(
stdio: FakeStdio(),
platform: FakePlatform(stdoutSupportsAnsi: true),
),
outputPreferences: OutputPreferences.test(showColor: true),
);
final VerboseLogger verboseLogger = VerboseLogger(
mockLogger, stopwatchFactory: FakeStopwatchFactory(stopwatch: fakeStopWatch),
);
verboseLogger.printBox('This is the box message', title: 'Sample title');
expect(
mockLogger.statusText,
contains('[ ] \x1B[1m\x1B[22m\n'
'\x1B[1m ┌─ Sample title ──────────┐\x1B[22m\n'
'\x1B[1m │ This is the box message │\x1B[22m\n'
'\x1B[1m └─────────────────────────┘\x1B[22m\n'
'\x1B[1m \x1B[22m\n'
),
);
});
});
testWithoutContext('Logger does not throw when stdio write throws synchronously', () async {
......@@ -928,6 +953,184 @@ void main() {
expect(lines[0], equals(''));
});
testWithoutContext('Stdout printBox puts content inside a box', () {
final Logger logger = StdoutLogger(
terminal: AnsiTerminal(
stdio: fakeStdio,
platform: FakePlatform(),
),
stdio: fakeStdio,
outputPreferences: OutputPreferences.test(showColor: true),
);
logger.printBox('Hello world', title: 'Test title');
final String stdout = fakeStdio.writtenToStdout.join('');
expect(stdout,
contains(
'\n'
'┌─ Test title ┐\n'
'│ Hello world │\n'
'└─────────────┘\n'
),
);
});
testWithoutContext('Stdout printBox does not require title', () {
final Logger logger = StdoutLogger(
terminal: AnsiTerminal(
stdio: fakeStdio,
platform: FakePlatform(),
),
stdio: fakeStdio,
outputPreferences: OutputPreferences.test(showColor: true),
);
logger.printBox('Hello world');
final String stdout = fakeStdio.writtenToStdout.join('');
expect(stdout,
contains(
'\n'
'┌─────────────┐\n'
'│ Hello world │\n'
'└─────────────┘\n'
),
);
});
testWithoutContext('Stdout printBox handles new lines', () {
final Logger logger = StdoutLogger(
terminal: AnsiTerminal(
stdio: fakeStdio,
platform: FakePlatform(),
),
stdio: fakeStdio,
outputPreferences: OutputPreferences.test(showColor: true),
);
logger.printBox('Hello world\nThis is a new line', title: 'Test title');
final String stdout = fakeStdio.writtenToStdout.join('');
expect(stdout,
contains(
'\n'
'┌─ Test title ───────┐\n'
'│ Hello world │\n'
'│ This is a new line │\n'
'└────────────────────┘\n'
),
);
});
testWithoutContext('Stdout printBox handles content with ANSI escape characters', () {
final Logger logger = StdoutLogger(
terminal: AnsiTerminal(
stdio: fakeStdio,
platform: FakePlatform(),
),
stdio: fakeStdio,
outputPreferences: OutputPreferences.test(showColor: true),
);
const String bold = '\u001B[1m';
const String clear = '\u001B[2J\u001B[H';
logger.printBox('${bold}Hello world$clear', title: 'Test title');
final String stdout = fakeStdio.writtenToStdout.join('');
expect(stdout,
contains(
'\n'
'┌─ Test title ┐\n'
'│ ${bold}Hello world$clear\n'
'└─────────────┘\n'
),
);
});
testWithoutContext('Stdout printBox handles column limit', () {
const int columnLimit = 14;
final Logger logger = StdoutLogger(
terminal: AnsiTerminal(
stdio: fakeStdio,
platform: FakePlatform(),
),
stdio: fakeStdio,
outputPreferences: OutputPreferences.test(showColor: true, wrapColumn: columnLimit),
);
logger.printBox('This line is longer than $columnLimit characters', title: 'Test');
final String stdout = fakeStdio.writtenToStdout.join('');
final List<String> stdoutLines = stdout.split('\n');
expect(stdoutLines.length, greaterThan(1));
expect(stdoutLines[1].length, equals(columnLimit));
expect(stdout,
contains(
'\n'
'┌─ Test ─────┐\n'
'│ This line │\n'
'│ is longer │\n'
'│ than 14 │\n'
'│ characters │\n'
'└────────────┘\n'
),
);
});
testWithoutContext('Stdout printBox handles column limit and respects new lines', () {
const int columnLimit = 14;
final Logger logger = StdoutLogger(
terminal: AnsiTerminal(
stdio: fakeStdio,
platform: FakePlatform(),
),
stdio: fakeStdio,
outputPreferences: OutputPreferences.test(showColor: true, wrapColumn: columnLimit),
);
logger.printBox('This\nline is longer than\n\n$columnLimit characters', title: 'Test');
final String stdout = fakeStdio.writtenToStdout.join('');
final List<String> stdoutLines = stdout.split('\n');
expect(stdoutLines.length, greaterThan(1));
expect(stdoutLines[1].length, equals(columnLimit));
expect(stdout,
contains(
'\n'
'┌─ Test ─────┐\n'
'│ This │\n'
'│ line is │\n'
'│ longer │\n'
'│ than │\n'
'│ │\n'
'│ 14 │\n'
'│ characters │\n'
'└────────────┘\n'
),
);
});
testWithoutContext('Stdout printBox breaks long words that exceed the column limit', () {
const int columnLimit = 14;
final Logger logger = StdoutLogger(
terminal: AnsiTerminal(
stdio: fakeStdio,
platform: FakePlatform(),
),
stdio: fakeStdio,
outputPreferences: OutputPreferences.test(showColor: true, wrapColumn: columnLimit),
);
logger.printBox('Thiswordislongerthan${columnLimit}characters', title: 'Test');
final String stdout = fakeStdio.writtenToStdout.join('');
final List<String> stdoutLines = stdout.split('\n');
expect(stdoutLines.length, greaterThan(1));
expect(stdoutLines[1].length, equals(columnLimit));
expect(stdout,
contains(
'\n'
'┌─ Test ─────┐\n'
'│ Thiswordis │\n'
'│ longerthan │\n'
'│ 14characte │\n'
'│ rs │\n'
'└────────────┘\n'
),
);
});
testWithoutContext('Stdout startProgress on non-color terminal', () async {
final FakeStopwatch fakeStopwatch = FakeStopwatch();
final Logger logger = StdoutLogger(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment