feat(editor): Fix-dialog from diagnostic strip + inline approval in Run tab

Two big operator-UX wins that the previous batch missed:

1. Diagnostic-strip header gets a 'Beheben' (Fix) button next
   to 'Copy all'. Clicking opens a focused modal listing every
   issue with its quick-fix actions plus an 'apply every quick
   fix' master button. Replaces the cramped expand-strip flow
   for operators who want a deliberate dialog instead of
   scrolling in a 200-px footer.

2. Run tab inlines the Approve / Reject form right under any
   step that the hub paused on system.approval@^0. The
   FlowRunDriver gains three new methods (host-implements
   them; defaults throw with a clear message):

     - pendingApprovalIdForStep(flowName, stepId) → String?
     - approveApproval(approvalId, reviewer) → Future<void>
     - rejectApproval(approvalId, reviewer, reason)

   The _InlineApprovalCard polls the driver for ~3s waiting for
   the hub to materialise the approval row (event-stream race
   against the hub's create), then renders the same form the
   Approvals page does — reviewer + optional reason + Approve /
   Reject buttons. After submit the hub picks up the decision
   on its next poll and emits step.approved / step.rejected,
   which the existing event stream already maps to the right
   step status.

   Closes the operator question 'wie soll das gehen dass da
   Freigaben landen?': they land in the Approvals page AND
   inline in the Run tab; the inline path is now the natural
   workflow.

All 36 editor tests + 24 Studio tests green. Bumped to 0.21.0.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-09 09:18:17 +02:00
parent 8b38584555
commit 303e318ba8
5 changed files with 593 additions and 1 deletions

View file

@ -1427,6 +1427,24 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> {
}
}
/// Opens the "Fix flow issues" modal. Each issue gets a row
/// with its quick fixes; rows without a fix surface the
/// no-fix hint so the operator knows to edit YAML manually.
/// A master "apply all" runs every available fix sequentially,
/// pausing on first failure.
Future<void> _showFixDialog(List<Issue> issues, ThemeData theme) async {
await showDialog<void>(
context: context,
builder: (_) => _FixDialog(
issues: issues,
controller: widget.controller,
strings: widget.strings,
toneForIssue: _toneForIssue,
onApplyFix: _runFix,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -1507,6 +1525,33 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> {
),
),
),
// "Beheben" opens a modal enumerating each
// issue with its fixes plus an "apply all"
// master button. Only shows when at least one
// issue carries a quick fix; otherwise the
// strip stays minimal.
if (issues.any(
(i) => widget.controller.fixesFor(i).isNotEmpty,
))
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: FilledButton.tonalIcon(
onPressed: () => _showFixDialog(issues, theme),
icon: const Icon(Icons.auto_fix_high, size: 14),
label: Text(
widget.strings.diagnosticFixButton,
style: _monoTextStyle(size: 11),
),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
minimumSize: const Size(0, 28),
),
),
),
IconButton(
tooltip: widget.strings.diagnosticCopyAll,
icon: const Icon(Icons.content_copy, size: 14),
@ -1818,3 +1863,198 @@ class _IssueHoverCard extends StatelessWidget {
);
}
}
/// "Fix flow issues" modal opens from the diagnostic strip's
/// header "Beheben" button. Lists each issue with its
/// available quick fixes and an "apply every quick fix"
/// master action. Replaces the inline expand-strip workflow
/// for operators who prefer a deliberate dialog over scrolling
/// in a 200-px-tall footer.
class _FixDialog extends StatefulWidget {
final List<Issue> issues;
final FlowYamlCodeController controller;
final FlowEditorStrings strings;
final Color Function(IssueType, ThemeData) toneForIssue;
final Future<void> Function(QuickFix) onApplyFix;
const _FixDialog({
required this.issues,
required this.controller,
required this.strings,
required this.toneForIssue,
required this.onApplyFix,
});
@override
State<_FixDialog> createState() => _FixDialogState();
}
class _FixDialogState extends State<_FixDialog> {
bool _applyingAll = false;
Future<void> _applyAll() async {
setState(() => _applyingAll = true);
try {
for (final issue in widget.issues) {
final fixes = widget.controller.fixesFor(issue);
if (fixes.isEmpty) continue;
// Pick the first fix per issue the analyzer orders
// Replace-fixes before Install/AddSource, so a typo
// suggestion wins over a remote install round-trip.
await widget.onApplyFix(fixes.first);
}
} finally {
if (mounted) setState(() => _applyingAll = false);
}
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final strings = widget.strings;
final fixCount = widget.issues
.where((i) => widget.controller.fixesFor(i).isNotEmpty)
.length;
return AlertDialog(
title: Row(
children: [
Icon(
Icons.auto_fix_high,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(strings.diagnosticFixDialogTitle),
],
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 480),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
strings.diagnosticFixDialogBody(widget.issues.length),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
for (final issue in widget.issues)
_FixDialogRow(
issue: issue,
fixes: widget.controller.fixesFor(issue),
tone: widget.toneForIssue(issue.type, theme),
strings: strings,
onApplyFix: (fix) async {
await widget.onApplyFix(fix);
if (mounted) setState(() {});
},
),
],
),
),
),
actions: [
if (fixCount > 1)
FilledButton.tonalIcon(
onPressed: _applyingAll ? null : _applyAll,
icon: _applyingAll
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_fix_high, size: 16),
label: Text(strings.diagnosticFixApplyAll(fixCount)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(strings.diagnosticFixDialogClose),
),
],
);
}
}
class _FixDialogRow extends StatelessWidget {
final Issue issue;
final List<QuickFix> fixes;
final Color tone;
final FlowEditorStrings strings;
final Future<void> Function(QuickFix) onApplyFix;
const _FixDialogRow({
required this.issue,
required this.fixes,
required this.tone,
required this.strings,
required this.onApplyFix,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
border: Border(left: BorderSide(color: tone, width: 3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
strings.diagnosticLinePrefix(issue.line + 1),
style: _monoTextStyle(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
Expanded(
child: SelectableText(
issue.message,
style: theme.textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: 8),
if (fixes.isEmpty)
Text(
strings.diagnosticNoFixesAvailable,
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurfaceVariant,
),
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final fix in fixes)
FilledButton.tonalIcon(
onPressed: () => onApplyFix(fix),
icon: const Icon(Icons.auto_fix_high, size: 13),
label: Text(
fix.label,
style: _monoTextStyle(size: 11),
),
),
],
),
],
),
),
);
}
}