diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 247934b..4b3fe0a 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -402,7 +402,15 @@ class _PathRow extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; - final isLog = !isDirectory && path.toLowerCase().endsWith('.log'); + final lower = path.toLowerCase(); + final isLog = !isDirectory && lower.endsWith('.log'); + // Text config files get an in-Studio viewer too (read top-down, + // no log colouring) so the operator can read config.yaml + // without leaving Studio or hunting for an external editor. + final isConfig = !isDirectory && + (lower.endsWith('.yaml') || + lower.endsWith('.yml') || + lower.endsWith('.toml')); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( @@ -428,10 +436,11 @@ class _PathRow extends StatelessWidget { ), ), const SizedBox(width: FaiSpace.sm), - if (isLog) ...[ + if (isLog || isConfig) ...[ OutlinedButton.icon( - onPressed: () => - showFaiLogViewer(context, path: path, title: label), + onPressed: () => isConfig + ? showFaiConfigViewer(context, path: path, title: label) + : showFaiLogViewer(context, path: path, title: label), icon: const Icon(Icons.visibility_outlined, size: 14), label: Text(l.buttonView), style: OutlinedButton.styleFrom( @@ -482,6 +491,11 @@ class _DaemonActionsCard extends StatefulWidget { class _DaemonActionsCardState extends State<_DaemonActionsCard> { bool _busy = false; String? _output; + + /// True when the last daemon action failed because no `fai` + /// binary could be located. Swaps the raw output line for the + /// actionable [FaiBinaryRecovery] block. + bool _binaryMissing = false; ChannelStatusSnapshot? _channels; @override @@ -511,13 +525,19 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { final r = await action(); if (!mounted) return; final l = AppLocalizations.of(context)!; + final binaryMissing = !r.ok && r.stderr.trim() == kFaiBinaryNotFound; setState(() { _busy = false; - _output = - (r.ok - ? '${l.daemonActionResultOk(label)}\n${r.stdout}' - : '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}') - .trim(); + _binaryMissing = binaryMissing; + // When the binary is missing we render the actionable + // recovery block instead of a raw output line — the + // sentinel is not a user-facing string. + _output = binaryMissing + ? null + : (r.ok + ? '${l.daemonActionResultOk(label)}\n${r.stdout}' + : '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}') + .trim(); }); // Refresh the status pill — restart / stop / start all // change the running flag. @@ -659,6 +679,15 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { ], ), ], + if (_binaryMissing) ...[ + const SizedBox(height: FaiSpace.md), + FaiBinaryRecovery( + onLocated: () => _run( + l.daemonActionStatus, + () => SystemActions.faiDaemon(['status']), + ), + ), + ], if (_output != null) ...[ const SizedBox(height: FaiSpace.md), Container( diff --git a/lib/widgets/fai_log_viewer.dart b/lib/widgets/fai_log_viewer.dart index c4fd243..a4b67eb 100644 --- a/lib/widgets/fai_log_viewer.dart +++ b/lib/widgets/fai_log_viewer.dart @@ -36,6 +36,8 @@ Future showFaiLogViewer( required String path, String? title, int tailLines = 500, + bool fromTop = false, + bool plain = false, }) { return showDialog( context: context, @@ -44,20 +46,51 @@ Future showFaiLogViewer( path: path, title: title ?? path.split(Platform.pathSeparator).last, tailLines: tailLines, + fromTop: fromTop, + plain: plain, ), ); } +/// Open a config / plain-text file (e.g. `config.yaml`) inside the +/// same modal viewer, read top-down with no log-level colouring. +/// A thin wrapper over [showFaiLogViewer] so call sites read by +/// intent. +Future showFaiConfigViewer( + BuildContext context, { + required String path, + String? title, + int maxLines = 2000, +}) { + return showFaiLogViewer( + context, + path: path, + title: title, + tailLines: maxLines, + fromTop: true, + plain: true, + ); +} + class FaiLogViewer extends StatefulWidget { final String path; final String title; final int tailLines; + /// Read from the top of the file (config) vs the tail (logs). + final bool fromTop; + + /// Skip log-level colouring and render every line neutral + /// (for config / plain-text files). + final bool plain; + const FaiLogViewer({ super.key, required this.path, required this.title, this.tailLines = 500, + this.fromTop = false, + this.plain = false, }); @override @@ -77,7 +110,11 @@ class _FaiLogViewerState extends State { Future _reload() async { setState(() => _loading = true); - final lines = await _readTail(widget.path, widget.tailLines); + final lines = await _readLines( + widget.path, + widget.tailLines, + fromTop: widget.fromTop, + ); if (!mounted) return; setState(() { _lines = lines; @@ -141,7 +178,7 @@ class _FaiLogViewerState extends State { ), ), ) - : _LogBody(lines: _lines), + : _LogBody(lines: _lines, plain: widget.plain), ), ], ), @@ -249,8 +286,9 @@ class _Header extends StatelessWidget { class _LogBody extends StatelessWidget { final List lines; + final bool plain; - const _LogBody({required this.lines}); + const _LogBody({required this.lines, this.plain = false}); @override Widget build(BuildContext context) { @@ -265,6 +303,7 @@ class _LogBody extends StatelessWidget { text: lines[i], gutterWidth: gutterWidth, theme: theme, + plain: plain, ), ), ); @@ -276,12 +315,14 @@ class _LogLine extends StatelessWidget { final String text; final double gutterWidth; final ThemeData theme; + final bool plain; const _LogLine({ required this.lineNumber, required this.text, required this.gutterWidth, required this.theme, + this.plain = false, }); @override @@ -307,7 +348,12 @@ class _LogLine extends StatelessWidget { const SizedBox(width: FaiSpace.sm), Expanded( child: SelectableText.rich( - _highlightLine(text, theme), + plain + ? TextSpan( + text: text, + style: TextStyle(color: theme.colorScheme.onSurface), + ) + : _highlightLine(text, theme), style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurface, @@ -420,22 +466,43 @@ TextSpan _highlightTraceLine(String line, ThemeData theme) { ); } -/// Read the last [maxLines] of [path]. Returns an empty list on -/// any I/O failure — the viewer renders an empty-state hint in -/// that case rather than throwing. -Future> _readTail(String path, int maxLines) async { +/// Matches ANSI / VT100 escape sequences (colour codes, cursor +/// moves) plus stray control characters. Daemon logs written to a +/// file by older `fai` builds carried `\x1b[..m` colour codes that +/// rendered as garbled "special characters" in the viewer; we +/// strip them defensively so existing log files render clean even +/// though current builds no longer write ANSI to files. +final RegExp _ansiAndControl = RegExp( + r'\x1B\[[0-9;?]*[ -/]*[@-~]|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', +); + +String _sanitizeLine(String line) => line.replaceAll(_ansiAndControl, ''); + +/// Read up to [maxLines] of [path], sanitising each line of ANSI / +/// control characters. When [fromTop] is true the *first* +/// [maxLines] are returned (config files, read top-down); +/// otherwise the *last* [maxLines] are returned (logs, tail-first). +/// Returns an empty list on any I/O failure — the viewer renders +/// an empty-state hint rather than throwing. +Future> _readLines( + String path, + int maxLines, { + bool fromTop = false, +}) async { try { final f = File(path); if (!await f.exists()) return const []; final raw = await f.readAsString(); - final lines = raw.split('\n'); + final lines = raw.split('\n').map(_sanitizeLine).toList(); // Drop the trailing blank line that `split` leaves behind // when the file ends with `\n`. if (lines.isNotEmpty && lines.last.isEmpty) { lines.removeLast(); } if (lines.length <= maxLines) return lines; - return lines.sublist(lines.length - maxLines); + return fromTop + ? lines.sublist(0, maxLines) + : lines.sublist(lines.length - maxLines); } catch (_) { return const []; }