From b6bb8741a9ebbfbe10124b2a65631c1ff9a9238d Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 9 Jun 2026 01:13:45 +0200 Subject: [PATCH] feat(editor): persistent diagnostic strip + run-block + copyable run errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three connected fixes that close the loop the analyzer started: - **Strip lives at page level, not text-tab**. Moved _DiagnosticStrip out of _textTab and into _tabbedBody below the TabBarView, so the same error list + quick-fix buttons are visible on Graph + Text + Run. The Graph-tab operator now sees both the pulsing node AND the message explaining what's broken, without flipping tabs. One strip, one source of truth. - **Run button disabled when there are analyzer errors**. FlowEditorController.analyzerErrorCount counts `IssueType.error` issues; the action strip's Run button + the RunTab's Start button both check it. When > 0: · button greys out + icon flips to Icons.block · tooltip names the count ('2 errors prevent the run') · the Run tab grows an inline red explanation next to the disabled Start so the operator isn't left guessing Warnings (orange) do NOT block — they're nudges. Operators who want to run a half-broken flow during dev can still silence the analyzer via the strip. - **Run failures are copyable everywhere**. New _CopyableErrorBox replaces both the per-run failure detail and the per-step failure suffix in the RunTab. Selectable monospace + explicit Copy button with 'Copied' feedback. Scrollbar-capped at 220 px so the box doesn't push outputs off-screen. Mirrors the operator-visible contract: 'every error is copyable, always.' Closes the operator-reported regression where a failed run showed 'Lauf fehlgeschlagen' as plain Text, leaving the operator unable to paste it into a bug report. Bumped to 0.19.0. Signed-off-by: flemming-it --- lib/src/editor_controller.dart | 15 +++ lib/src/flow_editor_page.dart | 100 +++++++++-------- lib/src/widgets/run_tab.dart | 195 ++++++++++++++++++++++++++------- pubspec.yaml | 2 +- 4 files changed, 230 insertions(+), 82 deletions(-) diff --git a/lib/src/editor_controller.dart b/lib/src/editor_controller.dart index 8bcb11d..3b01091 100644 --- a/lib/src/editor_controller.dart +++ b/lib/src/editor_controller.dart @@ -95,6 +95,21 @@ class FlowEditorController extends ChangeNotifier { Map get stepIssueSeverity => codeController.stepSeverity; + /// Number of analyzer-flagged errors in the current buffer. + /// Used by the action strip + run tab to disable the Run + /// button when there's something the operator clearly should + /// fix first — otherwise the run just fails at the hub with + /// a less specific message. + int get analyzerErrorCount => codeController.analysisResult.issues + .where((i) => i.type == IssueType.error) + .length; + + /// Number of analyzer warnings (orange). Warnings do NOT + /// block the run — they're nudges, not blockers. + int get analyzerWarningCount => codeController.analysisResult.issues + .where((i) => i.type == IssueType.warning) + .length; + @override void dispose() { _reparseTimer?.cancel(); diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 40e1672..6ac08b8 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -546,9 +546,11 @@ outputs: tabs: _tabs, saving: _controller.isSaving, running: _controller.isRunning, + errorCount: _controller.analyzerErrorCount, onAddStep: _controller.activeName != null ? _addStep : null, onSave: _controller.activeName != null ? _save : null, - onRun: _controller.activeName != null + onRun: _controller.activeName != null && + _controller.analyzerErrorCount == 0 ? () => _tabs.animateTo(2) : null, ), @@ -569,6 +571,15 @@ outputs: ], ), ), + // Persistent diagnostic strip — lives below the tab bar + // so the same issue list is visible on Graph + Text + + // Run. The Graph tab no longer needs a separate + // per-node tooltip because the strip is right there with + // the message + quick-fix buttons. + _DiagnosticStrip( + controller: _controller.codeController, + onApplyFix: _applyQuickFix, + ), ], ); } @@ -714,41 +725,31 @@ outputs: height: 1.45, color: theme.colorScheme.onSurface, ); + // The bottom diagnostic strip now lives at the page level + // (one persistent strip across all three tabs) instead of + // being duplicated per-tab — see _tabbedBody. That keeps the + // graph + run tabs informed too, not just text. return CodeTheme( data: CodeThemeData(styles: _yamlStyle(theme)), - child: Column( - children: [ - Expanded( - child: CodeField( - controller: _controller.codeController, - textStyle: mono, - expands: true, - minLines: null, - maxLines: null, - gutterStyle: GutterStyle( - textStyle: mono.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - background: theme.colorScheme.surfaceContainer, - showLineNumbers: true, - // Disable the built-in error column entirely. - // The Icons.cancel pin is hard-positioned inside - // a 16-px slot, and its mouse-hover popup paints - // OVER the code area instead of beside it — the - // overlap was the operator-visible glitch in - // editor 0.15.1. Issues remain surfaced via the - // wavy underline (FlowYamlCodeController) plus - // the bottom diagnostic strip below. - showErrors: false, - ), - background: theme.colorScheme.surface, - ), + child: CodeField( + controller: _controller.codeController, + textStyle: mono, + expands: true, + minLines: null, + maxLines: null, + gutterStyle: GutterStyle( + textStyle: mono.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - _DiagnosticStrip( - controller: _controller.codeController, - onApplyFix: _applyQuickFix, - ), - ], + background: theme.colorScheme.surfaceContainer, + showLineNumbers: true, + // Disable the built-in error column entirely — its + // hard-positioned popup overlaps the code area. + // Issues are surfaced via the wavy underline + the + // page-level diagnostic strip. + showErrors: false, + ), + background: theme.colorScheme.surface, ), ); } @@ -939,6 +940,7 @@ class _TabActionStrip extends StatelessWidget { final TabController tabs; final bool saving; final bool running; + final int errorCount; final VoidCallback? onAddStep; final VoidCallback? onSave; final VoidCallback? onRun; @@ -947,6 +949,7 @@ class _TabActionStrip extends StatelessWidget { required this.tabs, required this.saving, required this.running, + required this.errorCount, required this.onAddStep, required this.onSave, required this.onRun, @@ -997,16 +1000,27 @@ class _TabActionStrip extends StatelessWidget { label: Text(strings.save), ), const SizedBox(width: FaiSpace.sm), - FilledButton.icon( - onPressed: onRun, - icon: running - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.play_arrow, size: 18), - label: Text(strings.run), + Tooltip( + message: errorCount > 0 + ? '$errorCount error${errorCount == 1 ? '' : 's'} ' + 'prevent the run — fix them first' + : '', + child: FilledButton.icon( + onPressed: onRun, + icon: running + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + errorCount > 0 + ? Icons.block + : Icons.play_arrow, + size: 18, + ), + label: Text(strings.run), + ), ), ], const Spacer(), diff --git a/lib/src/widgets/run_tab.dart b/lib/src/widgets/run_tab.dart index 82459ac..9ff37f3 100644 --- a/lib/src/widgets/run_tab.dart +++ b/lib/src/widgets/run_tab.dart @@ -22,9 +22,9 @@ import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../editor_controller.dart'; import '../l10n.dart'; @@ -165,17 +165,7 @@ class _RunTabState extends State { for (final entry in graph.inputs.entries) _inputField(theme, entry.key, entry.value), const SizedBox(height: FaiSpace.md), - FilledButton.icon( - onPressed: _running || widget.driver == null ? null : _start, - icon: _running - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.play_arrow, size: 18), - label: Text(strings.runStart), - ), + _startButtonRow(theme, strings), if (_liveSteps.isNotEmpty || _running) ...[ const SizedBox(height: FaiSpace.lg), _section( @@ -193,21 +183,7 @@ class _RunTabState extends State { if (_error != null) ...[ const SizedBox(height: FaiSpace.lg), _section(theme, strings.runTitleFailed), - Container( - padding: const EdgeInsets.all(FaiSpace.sm), - decoration: BoxDecoration( - color: theme.colorScheme.errorContainer, - borderRadius: BorderRadius.circular(FaiRadius.sm), - ), - child: Text( - _error.toString(), - style: TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: theme.colorScheme.onErrorContainer, - ), - ), - ), + _CopyableErrorBox(text: _error.toString()), ], ], ), @@ -338,33 +314,91 @@ class _RunTabState extends State { case _StepKind.error: glyph = Icons.close; color = theme.colorScheme.error; - if (state.error != null && state.error!.isNotEmpty) { - suffix = ' — ${state.error}'; - } case _StepKind.awaiting: glyph = Icons.pause_circle_outline; color = theme.colorScheme.tertiary; suffix = ' awaiting approval'; } + final isError = state.kind == _StepKind.error && + state.error != null && + state.error!.isNotEmpty; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(glyph, size: 16, color: color), + Row( + children: [ + Icon(glyph, size: 16, color: color), + const SizedBox(width: FaiSpace.sm), + Flexible( + child: SelectableText( + '$id$suffix', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: color, + ), + ), + ), + ], + ), + if (isError) ...[ + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.only(left: 24), + child: _CopyableErrorBox(text: state.error!), + ), + ], + ], + ), + ); + } + + /// Run button row. Disabled when the buffer carries analyzer + /// errors — an info chip next to the button names the count + /// and (when collapsed) the first message so the operator + /// sees the blocker without scrolling to the bottom strip. + Widget _startButtonRow(ThemeData theme, FlowEditorStrings strings) { + final errors = widget.controller.analyzerErrorCount; + final blocked = errors > 0; + return Row( + children: [ + Tooltip( + message: blocked + ? '$errors error${errors == 1 ? '' : 's'} prevent the run' + : '', + child: FilledButton.icon( + onPressed: _running || widget.driver == null || blocked + ? null + : _start, + icon: _running + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + blocked ? Icons.block : Icons.play_arrow, + size: 18, + ), + label: Text(strings.runStart), + ), + ), + if (blocked) ...[ const SizedBox(width: FaiSpace.sm), Flexible( child: Text( - '$id$suffix', - style: TextStyle( - fontFamily: 'monospace', - fontSize: 13, - color: color, + '$errors error${errors == 1 ? '' : 's'} prevent the run · ' + 'check the diagnostic strip', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, ), overflow: TextOverflow.ellipsis, ), ), ], - ), + ], ); } @@ -575,3 +609,88 @@ class _StepState { final String? error; const _StepState({required this.kind, this.durationMs, this.error}); } + +/// Selectable error box used for the run-failure detail + the +/// per-step failure detail. SelectableText + an explicit Copy +/// button cover both keyboard and trackpad operators — the +/// implicit selection gesture is too easy to miss on a long +/// failure message. Matches the visual treatment Studio's +/// `FaiErrorBox` uses elsewhere; kept local to this package +/// so flow_editor doesn't depend on Studio internals. +class _CopyableErrorBox extends StatefulWidget { + final String text; + const _CopyableErrorBox({required this.text}); + + @override + State<_CopyableErrorBox> createState() => _CopyableErrorBoxState(); +} + +class _CopyableErrorBoxState extends State<_CopyableErrorBox> { + bool _justCopied = false; + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: widget.text)); + if (!mounted) return; + setState(() => _justCopied = true); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _justCopied = false); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB( + FaiSpace.sm, + FaiSpace.xs, + FaiSpace.xs, + FaiSpace.sm, + ), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.4), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerRight, + child: Tooltip( + message: _justCopied ? 'Copied' : 'Copy', + child: IconButton( + icon: Icon( + _justCopied ? Icons.check : Icons.content_copy, + size: 14, + ), + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + onPressed: _copy, + ), + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 220), + child: Scrollbar( + child: SingleChildScrollView( + child: SelectableText( + widget.text, + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 12, + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index dc5e6b2..9697081 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fai_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.18.0 +version: 0.19.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor