// ChainLogViewer — modal sheet that renders a log file inline with // line numbers, basic syntax colouring and a Copy-all button. // // Use [showFaiLogViewer] to open it. Two log shapes are supported // out of the box: // // - JSON-per-line (Studio's `~/.chain/logs/studio-errors.log`): // fields `ts`, `level`, `source`, `error` get distinct colours; // anything else renders neutral. // - tracing text (the daemon's `~/.chain/run/.log`): // `[INFO]`, `[WARN]`, `[ERROR]`, `[DEBUG]` substrings get the // matching tone from the ChainTheme palette. // // The viewer reads the *last* N lines (default 500) to keep // rendering snappy on huge log files. A Refresh button re-reads // the file in place. An "Open externally" button still hands the // path to the OS so power users can pipe it through their tool // of choice. import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; /// Open [path] inside a modal log viewer. /// /// [title] is shown in the AppBar (e.g. `"local.log"` or /// `"studio-errors.log"`); falls back to the basename of [path]. Future showFaiLogViewer( BuildContext context, { required String path, String? title, int tailLines = 500, bool fromTop = false, bool plain = false, }) { return showDialog( context: context, barrierDismissible: true, builder: (_) => ChainLogViewer( 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 ChainLogViewer 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 ChainLogViewer({ super.key, required this.path, required this.title, this.tailLines = 500, this.fromTop = false, this.plain = false, }); @override State createState() => _FaiLogViewerState(); } class _FaiLogViewerState extends State { List _lines = const []; bool _loading = true; bool _justCopied = false; @override void initState() { super.initState(); _reload(); } Future _reload() async { setState(() => _loading = true); final lines = await _readLines( widget.path, widget.tailLines, fromTop: widget.fromTop, ); if (!mounted) return; setState(() { _lines = lines; _loading = false; }); } Future _copyAll() async { await Clipboard.setData(ClipboardData(text: _lines.join('\n'))); if (!mounted) return; setState(() => _justCopied = true); Future.delayed(const Duration(seconds: 2), () { if (mounted) setState(() => _justCopied = false); }); } Future _openExternal() async { await SystemActions.openOrReveal(widget.path, isDirectory: false); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Dialog( insetPadding: const EdgeInsets.symmetric( horizontal: ChainSpace.xl, vertical: ChainSpace.xl, ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 980, maxHeight: 720), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Header( title: widget.title, path: widget.path, lineCount: _lines.length, tailLimit: widget.tailLines, loading: _loading, justCopied: _justCopied, onCopy: _copyAll, onReload: _reload, onOpenExternal: _openExternal, onClose: () => Navigator.of(context).pop(), ), Divider(height: 1, color: theme.colorScheme.outlineVariant), Expanded( child: _loading ? const Center(child: CircularProgressIndicator()) : _lines.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(ChainSpace.xl), child: Text( l.logViewerEmpty, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ) : _LogBody(lines: _lines, plain: widget.plain), ), ], ), ), ); } } class _Header extends StatelessWidget { final String title; final String path; final int lineCount; final int tailLimit; final bool loading; final bool justCopied; final VoidCallback onCopy; final VoidCallback onReload; final VoidCallback onOpenExternal; final VoidCallback onClose; const _Header({ required this.title, required this.path, required this.lineCount, required this.tailLimit, required this.loading, required this.justCopied, required this.onCopy, required this.onReload, required this.onOpenExternal, required this.onClose, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.fromLTRB( ChainSpace.lg, ChainSpace.md, ChainSpace.sm, ChainSpace.md, ), child: Row( children: [ Icon( Icons.description_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: ChainSpace.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( l.logViewerTitle(title, tailLimit), style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( '$path · ${l.logViewerLineCount(lineCount)}', style: ChainTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, ), overflow: TextOverflow.ellipsis, ), ], ), ), IconButton( tooltip: justCopied ? l.buttonCopied : l.logViewerCopyAll, icon: Icon( justCopied ? Icons.check : Icons.content_copy, size: 16, ), onPressed: loading || lineCount == 0 ? null : onCopy, ), IconButton( tooltip: l.buttonRefresh, icon: const Icon(Icons.refresh, size: 16), onPressed: loading ? null : onReload, ), IconButton( tooltip: l.logViewerOpenExternal, icon: const Icon(Icons.open_in_new, size: 16), onPressed: onOpenExternal, ), IconButton( tooltip: l.buttonClose, icon: const Icon(Icons.close, size: 16), onPressed: onClose, ), ], ), ); } } class _LogBody extends StatelessWidget { final List lines; final bool plain; const _LogBody({required this.lines, this.plain = false}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final gutterWidth = 12.0 + 8.0 * lines.length.toString().length; return Scrollbar( child: ListView.builder( padding: const EdgeInsets.symmetric(vertical: ChainSpace.sm), itemCount: lines.length, itemBuilder: (context, i) => _LogLine( lineNumber: i + 1, text: lines[i], gutterWidth: gutterWidth, theme: theme, plain: plain, ), ), ); } } class _LogLine extends StatelessWidget { final int lineNumber; 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 Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: ChainSpace.sm, vertical: 1), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: gutterWidth, child: Text( '$lineNumber', textAlign: TextAlign.right, style: ChainTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant.withValues( alpha: 0.6, ), ), ), ), const SizedBox(width: ChainSpace.sm), Expanded( child: SelectableText.rich( plain ? TextSpan( text: text, style: TextStyle(color: theme.colorScheme.onSurface), ) : _highlightLine(text, theme), style: ChainTheme.mono( size: 11, color: theme.colorScheme.onSurface, ), ), ), ], ), ); } } /// Lightweight tokeniser shared by both supported log shapes. /// /// We don't reach for a full grammar here on purpose: log files /// are short-lived, the operator just needs the eye to land on /// the level/source quickly. Real syntax highlighting belongs in /// the flow editor; logs only need three or four accent colours. TextSpan _highlightLine(String line, ThemeData theme) { // JSON-per-line path (the Studio error log). Looks like: // {"ts":"…","level":"error","source":"flows.run","error":"…"} if (line.startsWith('{') && line.endsWith('}')) { return _highlightJsonLine(line, theme); } return _highlightTraceLine(line, theme); } TextSpan _highlightJsonLine(String line, ThemeData theme) { final spans = []; final base = TextStyle(color: theme.colorScheme.onSurface); final keyStyle = TextStyle( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ); final stringStyle = TextStyle(color: ChainColors.success); final errorStringStyle = TextStyle(color: theme.colorScheme.error); final keyRe = RegExp(r'"(ts|level|source|error|context)"\s*:'); final stringRe = RegExp(r'"((?:\\.|[^"\\])*)"'); int cursor = 0; bool nextStringIsError = false; while (cursor < line.length) { final keyMatch = keyRe.matchAsPrefix(line, cursor); if (keyMatch != null) { spans.add(TextSpan(text: keyMatch.group(0), style: keyStyle)); nextStringIsError = keyMatch.group(1) == 'error'; cursor = keyMatch.end; continue; } final stringMatch = stringRe.matchAsPrefix(line, cursor); if (stringMatch != null) { spans.add( TextSpan( text: stringMatch.group(0), style: nextStringIsError ? errorStringStyle : stringStyle, ), ); cursor = stringMatch.end; nextStringIsError = false; continue; } spans.add(TextSpan(text: line[cursor], style: base)); cursor++; } return TextSpan(children: spans); } TextSpan _highlightTraceLine(String line, ThemeData theme) { // Tracing line: prefix tag like [INFO] / [WARN] / [ERROR] / // [DEBUG] gets the tone colour, everything else stays neutral. final tagRe = RegExp(r'\[(INFO|WARN|WARNING|ERROR|DEBUG|TRACE)\]'); final match = tagRe.firstMatch(line); if (match == null) { return TextSpan( text: line, style: TextStyle(color: theme.colorScheme.onSurface), ); } Color tone; switch (match.group(1)) { case 'ERROR': tone = theme.colorScheme.error; break; case 'WARN': case 'WARNING': tone = ChainColors.warning; break; case 'INFO': tone = theme.colorScheme.primary; break; default: tone = theme.colorScheme.onSurfaceVariant; } return TextSpan( children: [ TextSpan( text: line.substring(0, match.start), style: TextStyle(color: theme.colorScheme.onSurface), ), TextSpan( text: match.group(0), style: TextStyle(color: tone, fontWeight: FontWeight.w600), ), TextSpan( text: line.substring(match.end), style: TextStyle(color: theme.colorScheme.onSurface), ), ], ); } /// 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').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 fromTop ? lines.sublist(0, maxLines) : lines.sublist(lines.length - maxLines); } catch (_) { return const []; } }