diff --git a/lib/src/screens/pipeline_logs/controller_pipeline_logs.dart b/lib/src/screens/pipeline_logs/controller_pipeline_logs.dart index a0b8c947..89d28817 100644 --- a/lib/src/screens/pipeline_logs/controller_pipeline_logs.dart +++ b/lib/src/screens/pipeline_logs/controller_pipeline_logs.dart @@ -36,6 +36,110 @@ class _PipelineLogsController with ShareMixin { return null; } + // Matches ANSI SGR (color/style) escape sequences, e.g. ESC[31m, ESC[1;31m, ESC[0m. + static final _ansiSgrRegex = RegExp(r'\x1B\[([0-9;]*)m'); + + // Matches any other (non-SGR) ANSI escape sequence, e.g. cursor moves or ESC[0K. + static final _ansiOtherRegex = RegExp(r'\x1B\[[0-9;]*[A-HJKSTfhlmnsu]'); + + /// Maps a foreground SGR color code to a Flutter color legible on a dark background. + /// Returns `null` for unknown codes or the default-text code (37) so the line falls + /// back to its default color. + Color? _ansiColor(String code) { + switch (code) { + case '30': + return Colors.grey; + case '31': + return Colors.red; + case '32': + return Colors.green; + case '33': + return Colors.amber; + case '34': + return Colors.blue; + case '35': + return Colors.purpleAccent; + case '36': + return Colors.cyan; + case '37': + return null; + case '90': + return Colors.grey.shade400; + case '91': + return Colors.redAccent; + case '92': + return Colors.greenAccent; + case '93': + return Colors.amberAccent; + case '94': + return Colors.blueAccent; + case '95': + return Colors.purpleAccent.shade100; + case '96': + return Colors.cyanAccent; + case '97': + return Colors.white; + default: + return null; + } + } + + /// Converts a (date-trimmed) log line into colored/bold [InlineSpan]s by parsing + /// the ANSI SGR escape sequences emitted by CLI tools (e.g. Terraform). The line's + /// Azure `##[...]` color (see [logColor]) is used as the default color so tagged + /// lines stay colored. Stray non-SGR escape sequences are stripped. + List parseLogLine(String line, TextStyle baseStyle) { + final defaultColor = logColor(line); + final clean = line.replaceAll('##[section]', ''); + + final spans = []; + Color? color = defaultColor; + var bold = false; + var last = 0; + + void emit(String text) { + if (text.isEmpty) return; + final t = text.replaceAll(_ansiOtherRegex, ''); + if (t.isEmpty) return; + spans.add( + TextSpan( + text: t, + style: baseStyle.copyWith(color: color, fontWeight: bold ? FontWeight.bold : null), + ), + ); + } + + for (final m in _ansiSgrRegex.allMatches(clean)) { + emit(clean.substring(last, m.start)); + last = m.end; + + final group = m.group(1) ?? ''; + final codes = group.isEmpty ? ['0'] : group.split(';'); + for (final code in codes) { + switch (code) { + case '0': + color = defaultColor; + bold = false; + case '1': + bold = true; + case '22': + bold = false; + case '39': + color = defaultColor; + default: + final c = _ansiColor(code); + if (c != null) color = c; + } + } + } + emit(clean.substring(last)); + + // Preserve the height of blank lines (or lines that were only escape codes). + if (spans.isEmpty) spans.add(TextSpan(text: '', style: baseStyle)); + + return spans; + } + String _getBuildWebUrl() { return '${api.basePath}/${args.project}/_build/results?buildId=${args.pipelineId}&view=logs&j=${args.parentTaskId}&t=${args.taskId}'; } diff --git a/lib/src/screens/pipeline_logs/screen_pipeline_logs.dart b/lib/src/screens/pipeline_logs/screen_pipeline_logs.dart index a19a56f2..740c7169 100644 --- a/lib/src/screens/pipeline_logs/screen_pipeline_logs.dart +++ b/lib/src/screens/pipeline_logs/screen_pipeline_logs.dart @@ -36,11 +36,12 @@ class _PipelineLogsScreen extends StatelessWidget { .split('\n') .map(ctrl.trimDate) .map( - (l) => Text( - l.replaceAll('##[section]', ''), - style: context.textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.normal, - color: ctrl.logColor(l), + (l) => Text.rich( + TextSpan( + children: ctrl.parseLogLine( + l, + context.textTheme.bodySmall!.copyWith(fontWeight: FontWeight.normal), + ), ), ), ),