From 8f5cd2528b066c349370c6758d11af5c0a9e93f3 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 4 Jun 2026 02:08:15 +0200 Subject: [PATCH] feat(studio): central error helpers + inline log viewer + Today CTAs Bundles the error-UX overhaul and the inline log viewer: - New `FaiLog` (`~/.fai/logs/studio-errors.log`, 256 KiB rotation, JSON-per-line). Every operator-visible failure is appended so `fai admin doctor` and the new viewer can show the trail without the operator having to reproduce the failure. - New `showFaiErrorSnack` / `showFaiErrorDialog` helpers wrap `FaiErrorBox` in copyable surfaces; 27 ad-hoc `SnackBar(content: Text(e.toString()))` sites swept to use them (settings, doctor, audit, store, module sheet, system-AI editor, flow output). - New `FaiLogViewer` modal (`showFaiLogViewer`) renders log files inline with line numbers, JSON-key + `[level]` token colouring, Copy-all, Refresh, Open-externally. Doctor's daemon-paths panel grows a "View" button next to "Open" for every `.log` row and now also lists the Studio errors log. - Today carousel: CTAs now actually re-run search after a `filterCategory` / `runQuery` story is tapped (was only flipping the chip state). Fallback story list bumped to 8. - Editor bumped to git ref carrying 0.15.0 (type-token colouring + analyzer diagnostics). Studio bumped to 0.62.0. Signed-off-by: flemming-it --- lib/data/error_presentation.dart | 114 +++++++ lib/data/fai_log.dart | 119 +++++++ lib/data/today_story_loader.dart | 27 +- lib/l10n/app_de.arb | 20 +- lib/l10n/app_en.arb | 20 +- lib/l10n/app_localizations.dart | 42 +++ lib/l10n/app_localizations_de.dart | 31 ++ lib/l10n/app_localizations_en.dart | 31 ++ lib/main.dart | 2 +- lib/pages/audit.dart | 20 +- lib/pages/doctor.dart | 31 +- lib/pages/store.dart | 224 +++++++++---- lib/pages/welcome.dart | 1 + lib/widgets/fai_flow_output.dart | 12 +- lib/widgets/fai_log_viewer.dart | 442 ++++++++++++++++++++++++++ lib/widgets/fai_module_sheet.dart | 5 +- lib/widgets/fai_settings_dialog.dart | 51 +-- lib/widgets/fai_system_ai_editor.dart | 6 +- lib/widgets/theme_picker_grid.dart | 1 + lib/widgets/widgets.dart | 1 + pubspec.lock | 10 +- pubspec.yaml | 11 +- 22 files changed, 1087 insertions(+), 134 deletions(-) create mode 100644 lib/data/error_presentation.dart create mode 100644 lib/data/fai_log.dart create mode 100644 lib/widgets/fai_log_viewer.dart diff --git a/lib/data/error_presentation.dart b/lib/data/error_presentation.dart new file mode 100644 index 0000000..2e372c2 --- /dev/null +++ b/lib/data/error_presentation.dart @@ -0,0 +1,114 @@ +// Single entry point for surfacing an error to the operator. +// Every error pathway Studio shows (SnackBar, dialog, inline +// strip) should go through one of these helpers so the +// operator gets: +// +// • a copyable, selectable rendition of the message +// • a friendly headline (gRPC codes mapped via +// friendlyError) instead of a wall-of-stack-trace +// • an automatic append to ~/.fai/logs/studio-errors.log so +// the trail survives the dismiss action +// +// Call sites used to scatter `ScaffoldMessenger.of(ctx).showSnackBar( +// SnackBar(content: Text(e.toString())))` — that violated the +// "errors must be copyable" rule everywhere. These helpers +// replace those open-coded sites. + +import 'package:flutter/material.dart'; + +import '../widgets/fai_error_box.dart'; +import '../theme/tokens.dart'; +import 'fai_log.dart'; + +/// Show [error] as a SnackBar with a copy affordance. +/// +/// [source] is a short tag (`flows.run`, `audit.clear`, …) +/// used to group entries in the on-disk log so an operator or +/// `fai doctor` can scan recent failures by area without +/// reading the full free-text. Keep it stable; treat it like +/// a metric label, not free-form text. +/// +/// [context] is the build-context the snack lands on. Falls +/// back to a Material default duration of 6 seconds — twice +/// the framework default so the operator has time to click +/// copy on an unfamiliar message. +void showFaiErrorSnack( + BuildContext context, + String source, + Object error, { + String? title, + Duration duration = const Duration(seconds: 6), +}) { + // Fire-and-forget log write. If the disk's full or the dir + // is read-only the UI still surfaces the error. + FaiLog.instance.error(source, error, context: title); + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + // No Scaffold above — the caller is likely showing the + // error from inside a dialog. We've already logged; bail + // rather than throw. + return; + } + messenger.hideCurrentSnackBar(); + final theme = Theme.of(context); + messenger.showSnackBar( + SnackBar( + duration: duration, + behavior: SnackBarBehavior.floating, + // Default SnackBar padding crushes our error box; reset + // to zero and let the box's own padding take over. + padding: EdgeInsets.zero, + content: Padding( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.sm, + vertical: FaiSpace.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (title != null) ...[ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: FaiSpace.xs), + ], + FaiErrorBox(error: error, isError: true), + ], + ), + ), + ), + ); +} + +/// Show [error] as a centered modal dialog. Use this when the +/// failure blocks meaningful interaction (auth missing, hub +/// unreachable) — a SnackBar would be too easy to dismiss. +Future showFaiErrorDialog( + BuildContext context, + String source, + Object error, { + String? title, +}) async { + FaiLog.instance.error(source, error); + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: title == null ? null : Text(title), + content: SizedBox( + width: 480, + child: FaiErrorBox(error: error, isError: true, maxHeight: 240), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).maybePop(), + child: const Text('OK'), + ), + ], + ), + ); +} diff --git a/lib/data/fai_log.dart b/lib/data/fai_log.dart new file mode 100644 index 0000000..2a7d316 --- /dev/null +++ b/lib/data/fai_log.dart @@ -0,0 +1,119 @@ +// Central error log for Studio. Every error surfaced to the +// operator (SnackBar, error sheet, recoverable dialog) is also +// appended here so `fai doctor` and "Open log" can show what +// went wrong without the operator having to reproduce the +// failure. +// +// Path: `~/.fai/logs/studio-errors.log` (cross-platform via +// HOME / USERPROFILE). One line per event, JSON-shaped so +// `fai doctor` can parse it; humans can still read it because +// the JSON is single-line and short. +// +// Best-effort by design: a failed write to the log file must +// never break the UI layer that asked us to log. We swallow +// IO errors silently — the worst case is the operator loses +// the trail of one failure, not the failure itself. +// +// Rotation: when the file passes 256 KiB we move it to +// `studio-errors.log.1` and start fresh. One rotation level is +// enough — anything older is noise, and a Studio session +// rarely produces more than a few KiB of error text. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +class FaiLog { + FaiLog._(); + + static final FaiLog instance = FaiLog._(); + + // Serialise concurrent writes — Future-based mutex so a burst + // of errors (rare but happens during failed install retries) + // doesn't interleave half-lines on disk. + Future _tail = Future.value(); + + static const int _rotateBytes = 256 * 1024; + + String get _path { + final home = + Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + '.'; + return p.join(home, '.fai', 'logs', 'studio-errors.log'); + } + + /// Absolute path of the studio-errors log. Public so the + /// Doctor page and the in-app log viewer can read it without + /// hard-coding the layout twice. + String get path => _path; + + /// Append a single error event. The [source] is a short tag + /// (`flows.run`, `daemon.logs`, …) that lets a reader group + /// related failures. [error] can be any thrown object — gRPC + /// codes, plain strings, exceptions; we serialise it via + /// [error.toString()] so the on-disk record matches what the + /// operator saw on screen. + Future error(String source, Object error, {Object? context}) { + final entry = { + 'ts': DateTime.now().toUtc().toIso8601String(), + 'level': 'error', + 'source': source, + 'error': error.toString(), + if (context != null) 'context': context.toString(), + }; + return _append(entry); + } + + /// Read the last [maxLines] lines back. Used by `fai doctor` + /// + Settings → "Recent errors" view. Returns oldest first so + /// the caller can render in chronological order. + Future> tail({int maxLines = 50}) async { + try { + final f = File(_path); + if (!await f.exists()) return const []; + final raw = await f.readAsString(); + final lines = raw.split('\n').where((l) => l.isNotEmpty).toList(); + if (lines.length <= maxLines) return lines; + return lines.sublist(lines.length - maxLines); + } catch (_) { + return const []; + } + } + + Future _append(Map entry) { + final next = _tail.then((_) async { + try { + final f = File(_path); + final dir = Directory(p.dirname(_path)); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + // Rotate before write so a hot-loop never balloons past + // the cap. Single-level rotation: keep the previous file + // as `.1`, anything older drops on the floor. + if (await f.exists()) { + final size = await f.length(); + if (size >= _rotateBytes) { + final rotated = File('$_path.1'); + if (await rotated.exists()) { + await rotated.delete(); + } + await f.rename(rotated.path); + } + } + await f.writeAsString( + '${jsonEncode(entry)}\n', + mode: FileMode.append, + flush: false, + ); + } catch (_) { + // Best-effort. A failed log write must not crash the UI. + } + }); + _tail = next.catchError((_) {}); + return next; + } +} diff --git a/lib/data/today_story_loader.dart b/lib/data/today_story_loader.dart index 2dce740..09514a0 100644 --- a/lib/data/today_story_loader.dart +++ b/lib/data/today_story_loader.dart @@ -20,7 +20,19 @@ import 'package:yaml/yaml.dart'; /// closed and ABI-stable — adding a new variant without bumping /// the schema would let an old Studio render an unknown action /// as a no-op button. -enum TodayCta { none, openSettings } +/// +/// `filterCategory` and `runQuery` both consume the optional +/// [TodayStoryData.ctaPayload] — a wire-string the Store page +/// dispatches into its filter / search box. Keeping the action +/// surface inside the Store means a hero story can teach about a +/// section without forcing a page-route change. +enum TodayCta { + none, + openSettings, + filterCategory, + runQuery, + showSuggestedSources, +} /// Bilingual story rendered by the Today-Hero card. Either /// loaded from disk or supplied as a const fallback. @@ -37,6 +49,11 @@ class TodayStoryData { final IconData icon; final TodayCta cta; + /// Wire payload used by [TodayCta.filterCategory] (category id + /// to apply to the Store grid) and [TodayCta.runQuery] (free-text + /// to drop into the search box). Empty for other variants. + final String ctaPayload; + const TodayStoryData({ required this.badgeEn, required this.badgeDe, @@ -48,6 +65,7 @@ class TodayStoryData { required this.ctaLabelDe, required this.icon, required this.cta, + this.ctaPayload = '', }); } @@ -130,6 +148,7 @@ class TodayStoryLoader { ctaLabelDe: s('cta_label_de'), icon: icon, cta: cta, + ctaPayload: s('cta_payload'), ); } @@ -150,6 +169,12 @@ class TodayStoryLoader { return TodayCta.none; case 'openSettings': return TodayCta.openSettings; + case 'filterCategory': + return TodayCta.filterCategory; + case 'runQuery': + return TodayCta.runQuery; + case 'showSuggestedSources': + return TodayCta.showSuggestedSources; default: return null; } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index cb44682..8fed805 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1423,5 +1423,23 @@ "placeholders": { "name": {"type": "String"} } - } + }, + "buttonView": "Ansehen", + "logViewerTitle": "{title} — letzte {count} Zeilen", + "@logViewerTitle": { + "placeholders": { + "title": {"type": "String"}, + "count": {"type": "int"} + } + }, + "logViewerEmpty": "Log-Datei ist leer oder existiert noch nicht.", + "logViewerCopyAll": "Alles kopieren", + "logViewerOpenExternal": "Im Standardeditor öffnen", + "logViewerLineCount": "{count, plural, =1{1 Zeile} other{{count} Zeilen}}", + "@logViewerLineCount": { + "placeholders": { + "count": {"type": "int"} + } + }, + "doctorPathStudioErrors": "Studio-Fehler" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f839a6b..f943708 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1426,5 +1426,23 @@ "placeholders": { "name": {"type": "String"} } - } + }, + "buttonView": "View", + "logViewerTitle": "{title} — last {count} lines", + "@logViewerTitle": { + "placeholders": { + "title": {"type": "String"}, + "count": {"type": "int"} + } + }, + "logViewerEmpty": "Log file is empty or does not exist yet.", + "logViewerCopyAll": "Copy all", + "logViewerOpenExternal": "Open in default editor", + "logViewerLineCount": "{count, plural, =1{1 line} other{{count} lines}}", + "@logViewerLineCount": { + "placeholders": { + "count": {"type": "int"} + } + }, + "doctorPathStudioErrors": "Studio errors" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 87bcf10..f3f33a3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3913,6 +3913,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'A flow named \"{name}\" already exists.'** String flowEditorAlreadyExists(String name); + + /// No description provided for @buttonView. + /// + /// In en, this message translates to: + /// **'View'** + String get buttonView; + + /// No description provided for @logViewerTitle. + /// + /// In en, this message translates to: + /// **'{title} — last {count} lines'** + String logViewerTitle(String title, int count); + + /// No description provided for @logViewerEmpty. + /// + /// In en, this message translates to: + /// **'Log file is empty or does not exist yet.'** + String get logViewerEmpty; + + /// No description provided for @logViewerCopyAll. + /// + /// In en, this message translates to: + /// **'Copy all'** + String get logViewerCopyAll; + + /// No description provided for @logViewerOpenExternal. + /// + /// In en, this message translates to: + /// **'Open in default editor'** + String get logViewerOpenExternal; + + /// No description provided for @logViewerLineCount. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 line} other{{count} lines}}'** + String logViewerLineCount(int count); + + /// No description provided for @doctorPathStudioErrors. + /// + /// In en, this message translates to: + /// **'Studio errors'** + String get doctorPathStudioErrors; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e76038f..43aca8d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2282,4 +2282,35 @@ class AppLocalizationsDe extends AppLocalizations { String flowEditorAlreadyExists(String name) { return 'Ein Flow namens \"$name\" existiert bereits.'; } + + @override + String get buttonView => 'Ansehen'; + + @override + String logViewerTitle(String title, int count) { + return '$title — letzte $count Zeilen'; + } + + @override + String get logViewerEmpty => 'Log-Datei ist leer oder existiert noch nicht.'; + + @override + String get logViewerCopyAll => 'Alles kopieren'; + + @override + String get logViewerOpenExternal => 'Im Standardeditor öffnen'; + + @override + String logViewerLineCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Zeilen', + one: '1 Zeile', + ); + return '$_temp0'; + } + + @override + String get doctorPathStudioErrors => 'Studio-Fehler'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f21b233..90ed4b1 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2287,4 +2287,35 @@ class AppLocalizationsEn extends AppLocalizations { String flowEditorAlreadyExists(String name) { return 'A flow named \"$name\" already exists.'; } + + @override + String get buttonView => 'View'; + + @override + String logViewerTitle(String title, int count) { + return '$title — last $count lines'; + } + + @override + String get logViewerEmpty => 'Log file is empty or does not exist yet.'; + + @override + String get logViewerCopyAll => 'Copy all'; + + @override + String get logViewerOpenExternal => 'Open in default editor'; + + @override + String logViewerLineCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count lines', + one: '1 line', + ); + return '$_temp0'; + } + + @override + String get doctorPathStudioErrors => 'Studio errors'; } diff --git a/lib/main.dart b/lib/main.dart index ca6f4d0..eebd850 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,7 +26,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.54.0'; +const String kStudioVersion = '0.62.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 01b48ce..fdedb88 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import '../data/error_presentation.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; @@ -53,21 +54,16 @@ class _AuditPageState extends State { _refresh(); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l.auditClearFailed(_friendly(e))))); + showFaiErrorSnack(context, 'audit.clear', e); } } - String _friendly(Object e) { - final s = e.toString(); - // The hub returns FailedPrecondition for compliance-locked - // channels; tonic stringifies that as "failed_precondition: - // …". Strip the noise so the operator sees the real reason. - final marker = 'failed_precondition: '; - final idx = s.toLowerCase().indexOf(marker); - return idx >= 0 ? s.substring(idx + marker.length) : s; - } + // _friendly was an inline shadow of friendlyError() in + // data/friendly_error.dart — kept only because the original + // call site predated the central mapper. Removed in favour + // of showFaiErrorSnack, which uses friendlyError under the + // hood (with proper gRPC-code mapping + selectable + // copy-affordance). Future _refresh() async { try { diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 2eca97a..737fb9a 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../data/error_presentation.dart'; +import '../data/fai_log.dart'; import '../data/hub.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; @@ -343,6 +345,12 @@ class _DaemonPathsPanel extends StatelessWidget { // Explorer instead of failing the OS "open" handler. final entries = <(String, String, IconData, bool)>[ (l.doctorPathLog, paths.logPath, Icons.description_outlined, false), + ( + l.doctorPathStudioErrors, + FaiLog.instance.path, + Icons.report_problem_outlined, + false, + ), (l.doctorPathConfig, paths.configPath, Icons.settings_outlined, false), (l.doctorPathDb, paths.dbPath, Icons.storage_outlined, false), (l.doctorPathModules, paths.modulesDir, Icons.extension_outlined, true), @@ -387,6 +395,8 @@ class _PathRow extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + final isLog = !isDirectory && path.toLowerCase().endsWith('.log'); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( @@ -412,6 +422,18 @@ class _PathRow extends StatelessWidget { ), ), const SizedBox(width: FaiSpace.sm), + if (isLog) ...[ + OutlinedButton.icon( + onPressed: () => + showFaiLogViewer(context, path: path, title: label), + icon: const Icon(Icons.visibility_outlined, size: 14), + label: Text(l.buttonView), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + const SizedBox(width: FaiSpace.xs), + ], OutlinedButton.icon( onPressed: () async { final r = await SystemActions.openOrReveal( @@ -420,9 +442,10 @@ class _PathRow extends StatelessWidget { ); if (!context.mounted) return; if (!r.ok) { - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.doctorOpenError(r.stderr))), + showFaiErrorSnack( + context, + 'doctor.open-path', + r.stderr.isEmpty ? 'open failed' : r.stderr, ); } }, @@ -430,7 +453,7 @@ class _PathRow extends StatelessWidget { isDirectory ? Icons.folder_open : Icons.open_in_new, size: 14, ), - label: Text(AppLocalizations.of(context)!.buttonOpen), + label: Text(l.buttonOpen), style: OutlinedButton.styleFrom( visualDensity: VisualDensity.compact, ), diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 474a277..e017406 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import '../data/error_presentation.dart'; import '../data/hub.dart'; import '../data/system_actions.dart'; import '../data/today_story_loader.dart'; @@ -310,19 +311,16 @@ class _StorePageState extends State { _source.isEmpty && !_installedOnly; final hasNoFederation = !raw.any((e) => e.isFederated); - // Past-onboarding signal: the operator has - // 3+ modules installed (wasm + federated - // combined). At that point the editorial - // hero stops earning its scroll cost — they - // know what F∆I does. They can still bring - // it back via the "Tour" button (TODO) or - // by clearing _todayDismissed in prefs. - final installedCount = raw - .where((e) => e.installed) - .length; - final pastOnboarding = installedCount >= 3; - final showToday = - !_todayDismissed && isBrowsing && !pastOnboarding; + // Today carousel stays visible until the + // operator explicitly dismisses it. The earlier + // installedCount≥3 auto-hide killed the strip + // for anyone past first-launch — meaning the + // rotating editorial stories (themes, audit, + // SDK, sandbox…) were never seen by experienced + // operators, which is exactly the audience the + // wider story set is for. One-click dismiss is + // enough opt-out. + final showToday = !_todayDismissed && isBrowsing; // Recommended-source quick-adds are inlined in // the Today footer when both apply, so the // operator never sees two competing editorial @@ -385,22 +383,24 @@ class _StorePageState extends State { : null, onDismiss: () => setState(() => _todayDismissed = true), - onCta: + onCta: _ctaCallbackFor( + _todayStories[_todayIndex % _todayStories.length], + ), + // Per-story brand chips: only the MCP + // story shows DeepWiki / Semgrep + // quick-adds, so the chips don't read + // as ads on stories that aren't about + // sources. Standalone "Suggested + // sources" strip below still catches + // the dismissed-Today / no-federation + // edge case. + recommendedSources: _todayStories[_todayIndex % _todayStories.length] .cta == - TodayCta.openSettings - ? () => FaiSettingsDialog.show(context) - : null, - // Quick-add chips removed from the - // editorial hero — they showed the - // same DeepWiki / Semgrep names every - // session, which felt like ads. Public - // sources still live one click away - // under Settings → MCP Clients, and - // the standalone "Suggested sources" - // strip below picks them up. - recommendedSources: const <_RecommendedSource>[], + TodayCta.showSuggestedSources + ? _kRecommendedSources + : const <_RecommendedSource>[], recommendedAdding: _addingSources, onAddRecommended: _addRecommendedSource, ), @@ -554,6 +554,47 @@ class _StorePageState extends State { } } + /// Resolve a Today story's CTA descriptor into a real callback + /// the hero card can wire to its button. `null` collapses the + /// button out of the layout — used for narrative-only stories + /// (audit / sandbox explainer) where there's nowhere obvious + /// to send the operator next. + VoidCallback? _ctaCallbackFor(TodayStoryData story) { + switch (story.cta) { + case TodayCta.none: + return null; + case TodayCta.openSettings: + return () => FaiSettingsDialog.show(context); + case TodayCta.filterCategory: + if (story.ctaPayload.isEmpty) return null; + return () { + // Two-step refresh: setState makes the dropdown + // header re-render with the new selection, then + // _runSearch re-issues the backend query with + // `category:` in its filter — without the second + // call the grid keeps showing the unfiltered list + // and the carousel button looked broken. + setState(() => _category = story.ctaPayload); + _runSearch(); + }; + case TodayCta.runQuery: + if (story.ctaPayload.isEmpty) return null; + return () { + _queryCtrl.text = story.ctaPayload; + setState(() {}); + _runSearch(); + }; + case TodayCta.showSuggestedSources: + // Stays on this slide — the chips render in the hero + // footer for stories with this CTA. The button just + // scrolls focus to them; for now the chips are + // immediately visible above the fold, so the click is + // effectively a no-op acknowledgement. Keeping it + // wired avoids a dead button. + return () {}; + } + } + void _clearQuery() { setState(() { _queryCtrl.clear(); @@ -669,10 +710,11 @@ class _StorePageState extends State { _runSearch(); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l.storeProviderAddFailed(s.label, e.toString())), - ), + showFaiErrorSnack( + context, + 'store.provider.add', + e, + title: l.storeProviderAddFailed(s.label, ''), ); } finally { if (mounted) setState(() => _addingSources.remove(s.name)); @@ -2143,9 +2185,10 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { final r = await SystemActions.openInOs(widget.item.repository); if (!mounted) return; if (!r.ok) { - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.storeOpenBrowserFailed(r.stderr))), + showFaiErrorSnack( + context, + 'store.repository.open', + r.stderr.isEmpty ? 'open failed' : r.stderr, ); } } @@ -2926,18 +2969,18 @@ class _ScreenshotPlaceholder extends StatelessWidget { /// on the dedicated Welcome page, not in the store carousel. const List _kFallbackTodayStories = [ TodayStoryData( - badgeEn: 'MODULES', - badgeDe: 'MODULE', - titleEn: 'Modules are the building blocks', - titleDe: 'Module sind die Bausteine', + badgeEn: 'MCP', + badgeDe: 'MCP', + titleEn: 'Plug in any MCP server', + titleDe: 'Jeden MCP-Server einbinden', bodyEn: - 'Each tile in the grid below is a sandboxed WASM module. Pick one, hit Install, and the hub adds it to your local registry. Modules declare their permissions up front; the hub denies anything else.', + 'F∆I speaks the Model Context Protocol out of the box. Add a public server (DeepWiki, Semgrep) or a private one — its tools show up in this grid next to the built-in modules. Same install button, same audit trail.', bodyDe: - 'Jede Kachel unten ist ein sandboxes WASM-Modul. Auswählen, Installieren klicken — der Hub fügt es deiner lokalen Registry hinzu. Module deklarieren ihre Berechtigungen explizit; der Hub verweigert alles andere.', - ctaLabelEn: '', - ctaLabelDe: '', - icon: Icons.extension_outlined, - cta: TodayCta.none, + 'F∆I spricht das Model Context Protocol von Haus aus. Einen öffentlichen Server (DeepWiki, Semgrep) oder einen eigenen hinzufügen — seine Tools tauchen in diesem Grid neben den eingebauten Modulen auf. Gleicher Install-Button, gleiche Audit-Spur.', + ctaLabelEn: 'Browse suggested sources', + ctaLabelDe: 'Empfohlene Quellen ansehen', + icon: Icons.hub_outlined, + cta: TodayCta.showSuggestedSources, ), TodayStoryData( badgeEn: 'FLOWS', @@ -2948,24 +2991,97 @@ const List _kFallbackTodayStories = [ 'A flow is a YAML file with three keys — inputs, steps, outputs. Each step calls one module. Every run lands in the audit log with a verifiable hash chain. Build flows graphically or write the YAML directly — either way, the run is reproducible.', bodyDe: 'Ein Flow ist eine YAML-Datei mit drei Schlüsseln — inputs, steps, outputs. Jeder Schritt ruft ein Modul auf. Jeder Lauf landet im Audit-Log mit verifizierbarer Hash-Kette. Flows grafisch bauen oder direkt YAML schreiben — der Lauf bleibt reproduzierbar.', + ctaLabelEn: 'Show featured flows', + ctaLabelDe: 'Featured-Flows zeigen', + icon: Icons.account_tree_outlined, + cta: TodayCta.runQuery, + ctaPayload: 'hello', + ), + TodayStoryData( + badgeEn: 'THEMES', + badgeDe: 'THEMES', + titleEn: 'Make Studio yours — pick a theme', + titleDe: 'Studio nach deinem Geschmack — Theme wählen', + bodyEn: + 'Solarized, Sunflower, Space, Sunset, Glass-Apple, Accessibility — Studio plugins re-skin the whole app at runtime. Or pick a single accent colour and let Material 3 derive the rest. Browse the studio-plugin category for the full set.', + bodyDe: + 'Solarized, Sunflower, Space, Sunset, Glass-Apple, Accessibility — Studio-Plugins themen die App zur Laufzeit komplett um. Oder eine einzelne Akzentfarbe wählen und Material 3 leitet den Rest ab. Die Kategorie Studio-Plugin zeigt das volle Set.', + ctaLabelEn: 'Show themes', + ctaLabelDe: 'Themes anzeigen', + icon: Icons.palette_outlined, + cta: TodayCta.filterCategory, + ctaPayload: 'studio-plugin', + ), + TodayStoryData( + badgeEn: 'TEXT TOOLS', + badgeDe: 'TEXT-TOOLS', + titleEn: 'Extract, anonymise, translate, summarise', + titleDe: 'Extrahieren, anonymisieren, übersetzen, zusammenfassen', + bodyEn: + 'The text-processing bundle is F∆I\'s document-analysis backbone. PDF and DOCX in, plain text out. Then anonymise PII, translate, or summarise — every step audit-grade, every Ollama call recorded with model digest.', + bodyDe: + 'Das Text-Processing-Bündel ist F∆Is Dokumentenanalyse-Rückgrat. PDF und DOCX rein, Klartext raus. Dann PII anonymisieren, übersetzen oder zusammenfassen — jeder Schritt audit-tauglich, jeder Ollama-Aufruf mit Modell-Digest protokolliert.', + ctaLabelEn: 'Show text modules', + ctaLabelDe: 'Text-Module zeigen', + icon: Icons.article_outlined, + cta: TodayCta.filterCategory, + ctaPayload: 'text-processing', + ), + TodayStoryData( + badgeEn: 'SANDBOX', + badgeDe: 'SANDBOX', + titleEn: 'Every module declares its permissions', + titleDe: 'Jedes Modul deklariert seine Berechtigungen', + bodyEn: + 'F∆I modules are WASM components. They run in a sandbox with no ambient authority — every filesystem path, every network host, every env var has to be listed in module.yaml. The hub enforces them. No declaration, no access.', + bodyDe: + 'F∆I-Module sind WASM-Komponenten. Sie laufen in einer Sandbox ohne Ambient-Authority — jeder Dateipfad, jeder Netzwerk-Host, jede Env-Var muss in module.yaml stehen. Der Hub setzt es durch. Keine Deklaration, kein Zugriff.', + ctaLabelEn: 'Review permissions', + ctaLabelDe: 'Berechtigungen prüfen', + icon: Icons.shield_outlined, + cta: TodayCta.openSettings, + ), + TodayStoryData( + badgeEn: 'AUDIT', + badgeDe: 'AUDIT', + titleEn: 'Tamper-evident hash chain — built in', + titleDe: 'Manipulationssicher per Hash-Kette — eingebaut', + bodyEn: + 'Every flow run, every install, every approval lands in ~/.fai/audit/ as a hash-chained event log. Any later edit invalidates the chain. CRA-ready out of the box — no compliance product to buy on top.', + bodyDe: + 'Jeder Flow-Lauf, jede Installation, jede Freigabe landet in ~/.fai/audit/ als hash-verkettetes Event-Log. Jede spätere Änderung bricht die Kette. CRA-tauglich von Haus aus — kein Compliance-Produkt zum Aufkaufen nötig.', ctaLabelEn: '', ctaLabelDe: '', - icon: Icons.account_tree_outlined, + icon: Icons.timeline_outlined, cta: TodayCta.none, ), TodayStoryData( - badgeEn: 'EXTENSIBILITY', - badgeDe: 'ERWEITERBAR', - titleEn: 'Bring your own sources', - titleDe: 'Eigene Quellen anbinden', + badgeEn: 'TEST FIRST', + badgeDe: 'ZUERST TESTEN', + titleEn: 'fai doctor — diagnose before you ship', + titleDe: 'fai doctor — diagnostizieren bevor ausgerollt wird', bodyEn: - 'The hub federates external module sources — public MCP servers, n8n workflows, your own internal registry. Configure a source under Settings → MCP Clients and its tools appear in this store alongside the built-in modules. Same install button, same audit trail.', + 'Before installing a module on a production hub, run `fai doctor` in the CLI. It validates manifest, signatures, declared permissions against the operator policy ceiling, and reports anything the hub would reject — no surprises at install time.', bodyDe: - 'Der Hub bindet externe Modul-Quellen ein — öffentliche MCP-Server, n8n-Workflows, eigene interne Registries. Eine Quelle unter Einstellungen → MCP-Clients konfigurieren, und ihre Tools erscheinen hier im Store neben den eingebauten Modulen. Gleicher Install-Button, gleiche Audit-Spur.', - ctaLabelEn: 'Manage sources', - ctaLabelDe: 'Quellen verwalten', - icon: Icons.hub_outlined, - cta: TodayCta.openSettings, + 'Bevor ein Modul auf einem Produktions-Hub installiert wird, in der CLI `fai doctor` laufen lassen. Validiert Manifest, Signaturen und deklarierte Berechtigungen gegen die Operator-Policy-Decke — keine Überraschungen beim Install.', + ctaLabelEn: '', + ctaLabelDe: '', + icon: Icons.health_and_safety_outlined, + cta: TodayCta.none, + ), + TodayStoryData( + badgeEn: 'BUILD ONE', + badgeDe: 'EIGENES MODUL', + titleEn: 'Build a module in a single Rust file', + titleDe: 'Ein Modul in einer einzigen Rust-Datei bauen', + bodyEn: + 'fai-module-sdk gives you a #[fai::module] macro and a Cargo target. Declare inputs / outputs in module.yaml, write the function, cargo build, drop the wasm in ~/.fai/modules. The hub picks it up at next list. Polyglot SDKs follow in Phase 1.', + bodyDe: + 'fai-module-sdk liefert ein #[fai::module]-Makro und ein Cargo-Target. Inputs / Outputs in module.yaml deklarieren, Funktion schreiben, cargo build, das wasm in ~/.fai/modules legen. Der Hub erkennt es beim nächsten Listing. Polyglott-SDKs folgen in Phase 1.', + ctaLabelEn: '', + ctaLabelDe: '', + icon: Icons.code_outlined, + cta: TodayCta.none, ), ]; diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index b92c138..9767ed1 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -720,6 +720,7 @@ class _ChecklistRow extends StatelessWidget { final bool done; final String title; final String hint; + /// Tap handler — takes the operator to the page where /// they can actually complete the item. null = no-op /// (e.g. AI item when System AI was already configured). diff --git a/lib/widgets/fai_flow_output.dart b/lib/widgets/fai_flow_output.dart index 72af67a..8e59a03 100644 --- a/lib/widgets/fai_flow_output.dart +++ b/lib/widgets/fai_flow_output.dart @@ -19,6 +19,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import '../data/error_presentation.dart'; import '../data/flow_output.dart'; import '../data/format.dart'; import '../data/system_actions.dart'; @@ -172,9 +173,7 @@ class _BytesView extends StatelessWidget { ).showSnackBar(SnackBar(content: Text(l.flowsOutputSavedAt(path)))); } catch (e) { if (!context.mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l.flowsOutputSaveFailed('$e')))); + showFaiErrorSnack(context, 'flows.output.save', e); } } @@ -235,10 +234,11 @@ class _FileView extends StatelessWidget { final target = uri.startsWith('file://') ? uri.substring(7) : uri; final r = await SystemActions.openInOs(target); if (!context.mounted || r.ok) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of( + showFaiErrorSnack( context, - ).showSnackBar(SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr)))); + 'flows.output.open', + r.stderr.isEmpty ? 'open failed' : r.stderr, + ); } @override diff --git a/lib/widgets/fai_log_viewer.dart b/lib/widgets/fai_log_viewer.dart new file mode 100644 index 0000000..c4fd243 --- /dev/null +++ b/lib/widgets/fai_log_viewer.dart @@ -0,0 +1,442 @@ +// 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 `~/.fai/logs/studio-errors.log`): +// fields `ts`, `level`, `source`, `error` get distinct colours; +// anything else renders neutral. +// - tracing text (the daemon's `~/.fai/run/.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 showFaiLogViewer( + BuildContext context, { + required String path, + String? title, + int tailLines = 500, +}) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (_) => FaiLogViewer( + path: path, + title: title ?? path.split(Platform.pathSeparator).last, + tailLines: tailLines, + ), + ); +} + +class FaiLogViewer extends StatefulWidget { + final String path; + final String title; + final int tailLines; + + const FaiLogViewer({ + super.key, + required this.path, + required this.title, + this.tailLines = 500, + }); + + @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 _readTail(widget.path, widget.tailLines); + 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: 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), + ), + ], + ), + ), + ); + } +} + +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 lines; + + const _LogBody({required this.lines}); + + @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, + ), + ), + ); + } +} + +class _LogLine extends StatelessWidget { + final int lineNumber; + final String text; + final double gutterWidth; + final ThemeData theme; + + const _LogLine({ + required this.lineNumber, + required this.text, + required this.gutterWidth, + required this.theme, + }); + + @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( + _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 = []; + 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), + ), + ], + ); +} + +/// 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 { + try { + final f = File(path); + if (!await f.exists()) return const []; + final raw = await f.readAsString(); + final lines = raw.split('\n'); + // 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); + } catch (_) { + return const []; + } +} diff --git a/lib/widgets/fai_module_sheet.dart b/lib/widgets/fai_module_sheet.dart index fe53e1a..5a9511d 100644 --- a/lib/widgets/fai_module_sheet.dart +++ b/lib/widgets/fai_module_sheet.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; +import '../data/error_presentation.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; @@ -104,9 +105,7 @@ class _FaiModuleSheetState extends State { } catch (e) { if (!mounted) return; setState(() => _uninstalling = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.modulesUninstallFailed(e.toString()))), - ); + showFaiErrorSnack(context, 'modules.uninstall', e); } } diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 98785f3..a84a473 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -5,6 +5,7 @@ import 'package:fai_client_sdk/fai_client_sdk.dart'; import 'package:flutter/material.dart'; +import '../data/error_presentation.dart'; import '../data/hub.dart'; import '../data/hub_auth_token.dart'; import '../data/registry_token.dart'; @@ -92,10 +93,7 @@ class _FaiSettingsDialogState extends State { ).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast))); } catch (e) { if (!mounted) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.hubAuthTokenSaveFailedToast(e.toString()))), - ); + showFaiErrorSnack(context, 'auth.hub-token.save', e); } } @@ -111,10 +109,7 @@ class _FaiSettingsDialogState extends State { ).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast))); } catch (e) { if (!mounted) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.hubAuthTokenSaveFailedToast(e.toString()))), - ); + showFaiErrorSnack(context, 'auth.hub-token.clear', e); } } @@ -139,10 +134,7 @@ class _FaiSettingsDialogState extends State { ).showSnackBar(SnackBar(content: Text(l.registryTokenSavedToast))); } catch (e) { if (!mounted) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))), - ); + showFaiErrorSnack(context, 'auth.registry-token.save', e); } } @@ -157,10 +149,7 @@ class _FaiSettingsDialogState extends State { ).showSnackBar(SnackBar(content: Text(l.registryTokenClearedToast))); } catch (e) { if (!mounted) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))), - ); + showFaiErrorSnack(context, 'auth.registry-token.clear', e); } } @@ -190,10 +179,7 @@ class _FaiSettingsDialogState extends State { ).showSnackBar(SnackBar(content: Text(l.addedN8nToast(draft.name)))); } catch (e) { if (!mounted) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString())))); + showFaiErrorSnack(context, 'settings.n8n.add', e); } } @@ -204,10 +190,7 @@ class _FaiSettingsDialogState extends State { setState(() => _n8nEndpoints = list); } catch (e) { if (!mounted) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.removeFailedToast(e.toString()))), - ); + showFaiErrorSnack(context, 'settings.n8n.remove', e); } } @@ -218,10 +201,7 @@ class _FaiSettingsDialogState extends State { setState(() => _n8nEndpoints = list); } catch (e) { if (!mounted) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.refreshFailedToast(e.toString()))), - ); + showFaiErrorSnack(context, 'settings.n8n.refresh', e); } } @@ -251,10 +231,7 @@ class _FaiSettingsDialogState extends State { ).showSnackBar(SnackBar(content: Text(l.addedMcpToast(draft.name)))); } catch (e) { if (!mounted) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString())))); + showFaiErrorSnack(context, 'settings.mcp.add', e); } } @@ -265,10 +242,7 @@ class _FaiSettingsDialogState extends State { setState(() => _mcpClients = list); } catch (e) { if (!mounted) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.removeFailedToast(e.toString()))), - ); + showFaiErrorSnack(context, 'settings.mcp.remove', e); } } @@ -279,10 +253,7 @@ class _FaiSettingsDialogState extends State { setState(() => _mcpClients = list); } catch (e) { if (!mounted) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.refreshFailedToast(e.toString()))), - ); + showFaiErrorSnack(context, 'settings.mcp.refresh', e); } } diff --git a/lib/widgets/fai_system_ai_editor.dart b/lib/widgets/fai_system_ai_editor.dart index 6ad98b1..57da186 100644 --- a/lib/widgets/fai_system_ai_editor.dart +++ b/lib/widgets/fai_system_ai_editor.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; +import '../data/error_presentation.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; @@ -249,10 +250,7 @@ class _FaiSystemAiEditorState extends State { }); } catch (e) { if (!mounted) return; - final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.systemAiCacheClearFailed(e.toString()))), - ); + showFaiErrorSnack(context, 'settings.system-ai.cache-clear', e); } } diff --git a/lib/widgets/theme_picker_grid.dart b/lib/widgets/theme_picker_grid.dart index 1956a51..f4108c6 100644 --- a/lib/widgets/theme_picker_grid.dart +++ b/lib/widgets/theme_picker_grid.dart @@ -204,6 +204,7 @@ class _Tile extends StatelessWidget { final List swatches; final bool selected; final bool loading; + /// Adds a small tune icon at the top-right, telegraphing /// "this tile opens an editor instead of applying /// immediately". Used for the Custom tile so the operator diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index ac2402a..b9554ed 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -11,6 +11,7 @@ export 'fai_empty_state.dart'; export 'fai_en_badge.dart'; export 'fai_error_box.dart'; export 'fai_flow_output.dart'; +export 'fai_log_viewer.dart'; export 'fai_module_sheet.dart'; export 'fai_pill.dart'; export 'fai_settings_dialog.dart'; diff --git a/pubspec.lock b/pubspec.lock index 003b691..15dd1d8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -123,12 +123,10 @@ packages: fai_studio_flow_editor: dependency: "direct main" description: - path: "." - ref: main - resolved-ref: "59aa8fe78e387bfd80fc1dfc28a912d149a91c8f" - url: "https://git.flemming.ai/fai/studio-flow-editor" - source: git - version: "0.13.0" + path: "../fai_studio_flow_editor" + relative: true + source: path + version: "0.15.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8274749..9919880 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.60.0 +version: 0.62.0 environment: sdk: ^3.11.0-200.1.beta @@ -48,6 +48,15 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 +# Local-development override: while editing the swappable flow +# editor alongside Studio, point at the sibling working tree so +# changes are picked up on the next Studio reload without push +# + `pub upgrade`. Comment the block out before tagging a Studio +# release so the committed Git ref is what builds on Forgejo CI. +dependency_overrides: + fai_studio_flow_editor: + path: ../fai_studio_flow_editor + flutter: uses-material-design: true generate: true