feat(editor): persistent diagnostic strip + run-block + copyable run errors

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 <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-09 01:13:45 +02:00
parent efdfa7dd79
commit b6bb8741a9
4 changed files with 230 additions and 82 deletions

View file

@ -95,6 +95,21 @@ class FlowEditorController extends ChangeNotifier {
Map<String, IssueType> get stepIssueSeverity => Map<String, IssueType> get stepIssueSeverity =>
codeController.stepSeverity; 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 @override
void dispose() { void dispose() {
_reparseTimer?.cancel(); _reparseTimer?.cancel();

View file

@ -546,9 +546,11 @@ outputs:
tabs: _tabs, tabs: _tabs,
saving: _controller.isSaving, saving: _controller.isSaving,
running: _controller.isRunning, running: _controller.isRunning,
errorCount: _controller.analyzerErrorCount,
onAddStep: _controller.activeName != null ? _addStep : null, onAddStep: _controller.activeName != null ? _addStep : null,
onSave: _controller.activeName != null ? _save : null, onSave: _controller.activeName != null ? _save : null,
onRun: _controller.activeName != null onRun: _controller.activeName != null &&
_controller.analyzerErrorCount == 0
? () => _tabs.animateTo(2) ? () => _tabs.animateTo(2)
: null, : 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, height: 1.45,
color: theme.colorScheme.onSurface, 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( return CodeTheme(
data: CodeThemeData(styles: _yamlStyle(theme)), data: CodeThemeData(styles: _yamlStyle(theme)),
child: Column( child: CodeField(
children: [ controller: _controller.codeController,
Expanded( textStyle: mono,
child: CodeField( expands: true,
controller: _controller.codeController, minLines: null,
textStyle: mono, maxLines: null,
expands: true, gutterStyle: GutterStyle(
minLines: null, textStyle: mono.copyWith(
maxLines: null, color: theme.colorScheme.onSurfaceVariant,
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,
),
), ),
_DiagnosticStrip( background: theme.colorScheme.surfaceContainer,
controller: _controller.codeController, showLineNumbers: true,
onApplyFix: _applyQuickFix, // 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 TabController tabs;
final bool saving; final bool saving;
final bool running; final bool running;
final int errorCount;
final VoidCallback? onAddStep; final VoidCallback? onAddStep;
final VoidCallback? onSave; final VoidCallback? onSave;
final VoidCallback? onRun; final VoidCallback? onRun;
@ -947,6 +949,7 @@ class _TabActionStrip extends StatelessWidget {
required this.tabs, required this.tabs,
required this.saving, required this.saving,
required this.running, required this.running,
required this.errorCount,
required this.onAddStep, required this.onAddStep,
required this.onSave, required this.onSave,
required this.onRun, required this.onRun,
@ -997,16 +1000,27 @@ class _TabActionStrip extends StatelessWidget {
label: Text(strings.save), label: Text(strings.save),
), ),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
FilledButton.icon( Tooltip(
onPressed: onRun, message: errorCount > 0
icon: running ? '$errorCount error${errorCount == 1 ? '' : 's'} '
? const SizedBox( 'prevent the run — fix them first'
width: 14, : '',
height: 14, child: FilledButton.icon(
child: CircularProgressIndicator(strokeWidth: 2), onPressed: onRun,
) icon: running
: const Icon(Icons.play_arrow, size: 18), ? const SizedBox(
label: Text(strings.run), width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
errorCount > 0
? Icons.block
: Icons.play_arrow,
size: 18,
),
label: Text(strings.run),
),
), ),
], ],
const Spacer(), const Spacer(),

View file

@ -22,9 +22,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../editor_controller.dart'; import '../editor_controller.dart';
import '../l10n.dart'; import '../l10n.dart';
@ -165,17 +165,7 @@ class _RunTabState extends State<RunTab> {
for (final entry in graph.inputs.entries) for (final entry in graph.inputs.entries)
_inputField(theme, entry.key, entry.value), _inputField(theme, entry.key, entry.value),
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
FilledButton.icon( _startButtonRow(theme, strings),
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),
),
if (_liveSteps.isNotEmpty || _running) ...[ if (_liveSteps.isNotEmpty || _running) ...[
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
_section( _section(
@ -193,21 +183,7 @@ class _RunTabState extends State<RunTab> {
if (_error != null) ...[ if (_error != null) ...[
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
_section(theme, strings.runTitleFailed), _section(theme, strings.runTitleFailed),
Container( _CopyableErrorBox(text: _error.toString()),
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,
),
),
),
], ],
], ],
), ),
@ -338,33 +314,91 @@ class _RunTabState extends State<RunTab> {
case _StepKind.error: case _StepKind.error:
glyph = Icons.close; glyph = Icons.close;
color = theme.colorScheme.error; color = theme.colorScheme.error;
if (state.error != null && state.error!.isNotEmpty) {
suffix = '${state.error}';
}
case _StepKind.awaiting: case _StepKind.awaiting:
glyph = Icons.pause_circle_outline; glyph = Icons.pause_circle_outline;
color = theme.colorScheme.tertiary; color = theme.colorScheme.tertiary;
suffix = ' awaiting approval'; suffix = ' awaiting approval';
} }
final isError = state.kind == _StepKind.error &&
state.error != null &&
state.error!.isNotEmpty;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 2), padding: const EdgeInsets.symmetric(vertical: 2),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), const SizedBox(width: FaiSpace.sm),
Flexible( Flexible(
child: Text( child: Text(
'$id$suffix', '$errors error${errors == 1 ? '' : 's'} prevent the run · '
style: TextStyle( 'check the diagnostic strip',
fontFamily: 'monospace', style: theme.textTheme.bodySmall?.copyWith(
fontSize: 13, color: theme.colorScheme.error,
color: color,
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
], ],
), ],
); );
} }
@ -575,3 +609,88 @@ class _StepState {
final String? error; final String? error;
const _StepState({required this.kind, this.durationMs, this.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<void> _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,
),
),
),
),
),
],
),
);
}
}

View file

@ -1,6 +1,6 @@
name: fai_studio_flow_editor name: fai_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows. description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.18.0 version: 0.19.0
publish_to: 'none' publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor repository: https://git.flemming.ai/fai/studio-flow-editor