refactor: rename internal Fai* design system + fai_ helpers to chain
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:
flemming-it 2026-06-16 17:53:17 +02:00
parent 68d23ab7dd
commit 891acd2ba2
52 changed files with 1225 additions and 1225 deletions

View file

@ -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>[];
}
}