diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index d4a60e1..400235c 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -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 _showFixDialog(List issues, ThemeData theme) async { + await showDialog( + 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 issues; + final FlowYamlCodeController controller; + final FlowEditorStrings strings; + final Color Function(IssueType, ThemeData) toneForIssue; + final Future 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 _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 fixes; + final Color tone; + final FlowEditorStrings strings; + final Future 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), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 201fc23..0424b0f 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -165,6 +165,22 @@ class FlowEditorStrings { 'bitte zuerst beheben', ); + // Inline approval card (Run tab). + String get approvalApprove => _t('Approve', 'Freigeben'); + String get approvalReject => _t('Reject', 'Ablehnen'); + String get approvalReviewerLabel => _t('Reviewer', 'Prüfer'); + String get approvalReasonLabel => + _t('Reason (optional)', 'Begründung (optional)'); + String get approvalSubmitting => _t('Sending…', 'Sende…'); + String get approvalDecided => _t( + 'Decision sent. Waiting for the flow to resume…', + 'Entscheidung gesendet. Warte bis der Flow weiterläuft…', + ); + String get approvalNotFound => _t( + 'No pending approval for this step yet — the hub may still be creating it.', + 'Noch keine ausstehende Freigabe für diesen Step — der Hub legt sie ggf. gerade an.', + ); + // Bottom diagnostic strip + hover card. String get diagnosticNoIssues => _t('No issues', 'Keine Probleme'); String diagnosticErrors(int n) => @@ -173,6 +189,31 @@ class FlowEditorStrings { _t('$n warning${n == 1 ? '' : 's'}', '$n Warnungen'); String get diagnosticCopyAll => _t('Copy all', 'Alle kopieren'); String diagnosticLinePrefix(int line) => 'L$line'; + String get diagnosticFixButton => _t('Fix', 'Beheben'); + String get diagnosticFixDialogTitle => + _t('Fix flow issues', 'Flow-Probleme beheben'); + String diagnosticFixDialogBody(int n) => + _t( + n == 1 + ? '1 issue found. Pick an action below.' + : '$n issues found. Pick an action below.', + n == 1 + ? '1 Problem gefunden. Aktion unten wählen.' + : '$n Probleme gefunden. Aktion unten wählen.', + ); + String get diagnosticFixDialogClose => _t('Close', 'Schließen'); + String diagnosticFixApplyAll(int n) => _t( + n == 1 + ? 'Apply the 1 quick fix' + : 'Apply $n quick fixes', + n == 1 + ? 'Den 1 Quick-Fix anwenden' + : '$n Quick-Fixes anwenden', + ); + String get diagnosticNoFixesAvailable => _t( + 'No quick fix available — review the message and edit the YAML.', + 'Kein Quick-Fix verfügbar — Meldung lesen und das YAML manuell anpassen.', + ); // Analyzer-emitted messages — used by FlowAnalyzer via the // [AnalyzerStrings] adapter below so the same strings can be diff --git a/lib/src/run_driver.dart b/lib/src/run_driver.dart index a6351ce..413b4b9 100644 --- a/lib/src/run_driver.dart +++ b/lib/src/run_driver.dart @@ -51,6 +51,40 @@ abstract class FlowRunDriver { /// A real implementation hits the hub's ModuleInfo RPC and /// maps the result to [ModuleSpec]. Future moduleInfo(String capability) async => null; + + /// Resolve the pending approval id for `(flowName, stepId)` + /// when the flow is paused on a `system.approval@^0` step. + /// Default returns `null` so legacy hosts that didn't update + /// keep compiling — the editor's Run tab then falls back to + /// directing the operator at the standalone Approvals page. + Future pendingApprovalIdForStep({ + required String flowName, + required String stepId, + }) async => + null; + + /// Approve the named approval. `reviewer` is the audit-log + /// identity the host wants attributed (typically OS user + + /// '@studio'). Throws on hub-side failure; UI surfaces it. + Future approveApproval({ + required String approvalId, + required String reviewer, + }) async { + throw UnsupportedError( + 'FlowRunDriver host did not implement approveApproval', + ); + } + + /// Reject the named approval with a free-form reason. + Future rejectApproval({ + required String approvalId, + required String reviewer, + required String reason, + }) async { + throw UnsupportedError( + 'FlowRunDriver host did not implement rejectApproval', + ); + } } /// Declared inputs + outputs of one installed module, as seen diff --git a/lib/src/widgets/run_tab.dart b/lib/src/widgets/run_tab.dart index 7e496ee..69f65ba 100644 --- a/lib/src/widgets/run_tab.dart +++ b/lib/src/widgets/run_tab.dart @@ -357,6 +357,25 @@ class _RunTabState extends State { ), ), ], + if (state.kind == _StepKind.awaiting) ...[ + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.only(left: 24), + child: _InlineApprovalCard( + stepId: id, + flowName: _runFlowName ?? '', + driver: widget.driver, + strings: widget.strings, + onDecided: () { + // After approve/reject the hub keeps polling + // and either resumes the flow or fails it; the + // event stream surfaces the outcome. Nothing to + // do here besides redraw so the buttons go away. + if (mounted) setState(() {}); + }, + ), + ), + ], ], ), ); @@ -707,3 +726,261 @@ class _CopyableErrorBoxState extends State<_CopyableErrorBox> { ); } } + +/// Inline Approve / Reject card rendered under a step that +/// the hub has paused on `system.approval@^0`. Removes the +/// "go to the Approvals page" detour the operator otherwise +/// has to take — the same decision can happen in-context while +/// the rest of the run is on screen. +/// +/// Lifecycle: +/// 1. Card mounts in awaiting-state with a fetch-in-flight +/// indicator. +/// 2. Driver returns the pending approval id for this +/// (flowName, stepId) pair. Card switches to the Approve / +/// Reject form. +/// 3. Operator submits a decision. Card disables further +/// interaction and waits — the next event (step.approved / +/// step.rejected) is what causes the hub to resume the +/// flow, and the StepStarted/StepFailed event will redraw +/// the parent step row + remove this card. +class _InlineApprovalCard extends StatefulWidget { + final String stepId; + final String flowName; + final FlowRunDriver? driver; + final FlowEditorStrings strings; + final VoidCallback onDecided; + + const _InlineApprovalCard({ + required this.stepId, + required this.flowName, + required this.driver, + required this.strings, + required this.onDecided, + }); + + @override + State<_InlineApprovalCard> createState() => _InlineApprovalCardState(); +} + +class _InlineApprovalCardState extends State<_InlineApprovalCard> { + String? _approvalId; + bool _fetching = true; + bool _submitting = false; + bool _submitted = false; + String? _error; + late final TextEditingController _reasonCtrl; + late final TextEditingController _reviewerCtrl; + + @override + void initState() { + super.initState(); + _reasonCtrl = TextEditingController(); + _reviewerCtrl = TextEditingController(text: _defaultReviewer()); + _fetch(); + } + + @override + void dispose() { + _reasonCtrl.dispose(); + _reviewerCtrl.dispose(); + super.dispose(); + } + + String _defaultReviewer() { + final user = Platform.environment['USER'] ?? + Platform.environment['USERNAME'] ?? + 'studio'; + return '$user@studio'; + } + + Future _fetch() async { + final driver = widget.driver; + if (driver == null) { + setState(() => _fetching = false); + return; + } + // The hub creates the approval row when the flow hits the + // awaiting state. Race against the event arrival: poll up + // to 5 × 600 ms before giving up. + for (var i = 0; i < 5; i++) { + try { + final id = await driver.pendingApprovalIdForStep( + flowName: widget.flowName, + stepId: widget.stepId, + ); + if (!mounted) return; + if (id != null) { + setState(() { + _approvalId = id; + _fetching = false; + }); + return; + } + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _fetching = false; + }); + return; + } + await Future.delayed(const Duration(milliseconds: 600)); + } + if (mounted) setState(() => _fetching = false); + } + + Future _decide({required bool approve}) async { + final id = _approvalId; + final driver = widget.driver; + if (id == null || driver == null) return; + setState(() => _submitting = true); + try { + if (approve) { + await driver.approveApproval( + approvalId: id, + reviewer: _reviewerCtrl.text.trim(), + ); + } else { + await driver.rejectApproval( + approvalId: id, + reviewer: _reviewerCtrl.text.trim(), + reason: _reasonCtrl.text.trim(), + ); + } + if (!mounted) return; + setState(() { + _submitted = true; + _submitting = false; + }); + widget.onDecided(); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _submitting = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final strings = widget.strings; + return Container( + padding: const EdgeInsets.all(FaiSpace.sm), + decoration: BoxDecoration( + color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.tertiary.withValues(alpha: 0.4), + ), + ), + child: _fetching + ? Row( + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: FaiSpace.sm), + Text(strings.runAwaitingApproval), + ], + ) + : _submitted + ? Row( + children: [ + Icon( + Icons.check_circle_outline, + size: 16, + color: theme.colorScheme.tertiary, + ), + const SizedBox(width: FaiSpace.sm), + Flexible(child: Text(strings.approvalDecided)), + ], + ) + : _approvalId == null + ? Row( + children: [ + Icon( + Icons.hourglass_empty, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: FaiSpace.sm), + Flexible( + child: Text( + _error ?? strings.approvalNotFound, + style: theme.textTheme.bodySmall, + ), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _reviewerCtrl, + decoration: InputDecoration( + labelText: strings.approvalReviewerLabel, + border: const OutlineInputBorder(), + isDense: true, + ), + ), + ), + const SizedBox(width: FaiSpace.md), + Expanded( + flex: 2, + child: TextField( + controller: _reasonCtrl, + decoration: InputDecoration( + labelText: strings.approvalReasonLabel, + border: const OutlineInputBorder(), + isDense: true, + ), + ), + ), + ], + ), + if (_error != null) ...[ + const SizedBox(height: FaiSpace.sm), + Text( + _error!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + const SizedBox(height: FaiSpace.sm), + Row( + children: [ + FilledButton.icon( + onPressed: _submitting + ? null + : () => _decide(approve: true), + icon: const Icon(Icons.check, size: 16), + label: Text( + _submitting + ? strings.approvalSubmitting + : strings.approvalApprove, + ), + ), + const SizedBox(width: FaiSpace.sm), + OutlinedButton.icon( + onPressed: _submitting + ? null + : () => _decide(approve: false), + icon: const Icon(Icons.close, size: 16), + label: Text(strings.approvalReject), + ), + ], + ), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 9dc664f..0a55a98 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.20.1 +version: 0.21.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor