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:
parent
efdfa7dd79
commit
b6bb8741a9
4 changed files with 230 additions and 82 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue