refactor: rename internal Fai* design system + fai_ helpers to chain
Some checks failed
Security / Security check (push) Failing after 2s
Some checks failed
Security / Security check (push) Failing after 2s
The Studio design system, widgets and helpers carried a Fai* / fai_ prefix (FaiSpace, FaiColors, FaiTheme, FaiLog, 17 fai_*.dart files, the faiBinary* l10n keys). Studio is the Ch∆In product, so rename them to Chain* / chain_ — carefully preserving English fail/failure/failed. Also fix stale references: the 'fai' binary in l10n strings -> 'chain', FAI_* env vars (FAI_BIN/DATA_DIR/MODULES_DIR/TODAY/BOOTSTRAP_TOKEN) -> CHAIN_*, fai_platform -> fai_chain, fai_hub -> chain_hub. Vendor security-hook tooling (FAI_BANNED_TERMS_FILE) + the .fai bundle ext left. flutter analyze + test: clean (20 passed). Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
68d23ab7dd
commit
891acd2ba2
52 changed files with 1225 additions and 1225 deletions
|
|
@ -1,509 +0,0 @@
|
|||
// FaiLogViewer — 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/<channel>.log`):
|
||||
// `[INFO]`, `[WARN]`, `[ERROR]`, `[DEBUG]` substrings get the
|
||||
// matching tone from the FaiTheme 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<void> showFaiLogViewer(
|
||||
BuildContext context, {
|
||||
required String path,
|
||||
String? title,
|
||||
int tailLines = 500,
|
||||
bool fromTop = false,
|
||||
bool plain = false,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (_) => FaiLogViewer(
|
||||
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<void> 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
|
||||
State<FaiLogViewer> createState() => _FaiLogViewerState();
|
||||
}
|
||||
|
||||
class _FaiLogViewerState extends State<FaiLogViewer> {
|
||||
List<String> _lines = const <String>[];
|
||||
bool _loading = true;
|
||||
bool _justCopied = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_reload();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _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: FaiSpace.xl,
|
||||
vertical: FaiSpace.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(FaiSpace.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(
|
||||
FaiSpace.lg,
|
||||
FaiSpace.md,
|
||||
FaiSpace.sm,
|
||||
FaiSpace.md,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.description_outlined,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.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: FaiTheme.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<String> 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: FaiSpace.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: FaiSpace.sm, vertical: 1),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: gutterWidth,
|
||||
child: Text(
|
||||
'$lineNumber',
|
||||
textAlign: TextAlign.right,
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Expanded(
|
||||
child: SelectableText.rich(
|
||||
plain
|
||||
? TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
)
|
||||
: _highlightLine(text, theme),
|
||||
style: FaiTheme.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 = <TextSpan>[];
|
||||
final base = TextStyle(color: theme.colorScheme.onSurface);
|
||||
final keyStyle = TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
final stringStyle = TextStyle(color: FaiColors.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 = FaiColors.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<List<String>> _readLines(
|
||||
String path,
|
||||
int maxLines, {
|
||||
bool fromTop = false,
|
||||
}) async {
|
||||
try {
|
||||
final f = File(path);
|
||||
if (!await f.exists()) return const <String>[];
|
||||
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 <String>[];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue