Step run events were previously visible only on the run tab. Operators who triggered a run there and then switched to the graph tab saw a static graph — the canvas had no idea something was executing. Wire the run events through the FlowEditorController so all three tabs share the same status snapshot: - New StepRunStatus enum on the controller (idle / running / done / failed / awaiting). - controller.stepStatuses exposes the live map. - controller.updateStepStatus is called from the run tab's event handler — one source of truth, both views read it. - FlowCanvas drops its own stepStatuses parameter and pulls from the controller instead, so the canvas now shows the running pulse + done check + error cross + pause icon in each step's header live, in lockstep with the step list on the run tab. When a fresh run starts (controller.running = true) the status map is cleared so stale events from a previous run don't paint the new one. Signed-off-by: flemming-it <sf@flemming.it>
577 lines
17 KiB
Dart
577 lines
17 KiB
Dart
// RunTab — the third tab. Shows:
|
|
//
|
|
// 1. A form for the flow's declared inputs (one field per
|
|
// `inputs:` entry). Text inputs use a TextField; bytes
|
|
// inputs use a "Choose file…" button + filename badge.
|
|
//
|
|
// 2. A Start button that calls the host's FlowRunDriver
|
|
// with the collected inputs.
|
|
//
|
|
// 3. A live step list driven by the driver's event stream,
|
|
// identical visually to the CLI's `fai run` block:
|
|
// ◻ pending, · running, ✔ done + duration, ✗ failed,
|
|
// ⏸ awaiting approval.
|
|
//
|
|
// 4. The flow's outputs once the run resolves.
|
|
//
|
|
// The tab requires the file to be saved on disk — the hub's
|
|
// runSavedFlow reads from `~/.fai/data/flows/<name>.yaml`,
|
|
// not from the in-memory buffer. The dirty banner reminds
|
|
// the operator to save before running so they don't run a
|
|
// stale version by surprise.
|
|
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../editor_controller.dart';
|
|
import '../l10n.dart';
|
|
import '../model/flow_graph.dart';
|
|
import '../run_driver.dart';
|
|
import '../tokens.dart';
|
|
|
|
class RunTab extends StatefulWidget {
|
|
final FlowEditorController controller;
|
|
final FlowEditorStrings strings;
|
|
final FlowRunDriver? driver;
|
|
const RunTab({
|
|
super.key,
|
|
required this.controller,
|
|
required this.strings,
|
|
this.driver,
|
|
});
|
|
|
|
@override
|
|
State<RunTab> createState() => _RunTabState();
|
|
}
|
|
|
|
class _RunTabState extends State<RunTab> {
|
|
final Map<String, TextEditingController> _textInputs = {};
|
|
final Map<String, _FilePick> _fileInputs = {};
|
|
|
|
StreamSubscription<FlowRunEvent>? _eventSub;
|
|
bool _running = false;
|
|
Map<String, FlowOutputValue>? _outputs;
|
|
Object? _error;
|
|
// Insertion-ordered: shows steps in the order the hub
|
|
// actually started them, matching the CLI rendering.
|
|
final Map<String, _StepState> _liveSteps = {};
|
|
String? _runFlowName;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.controller.addListener(_onControllerChanged);
|
|
_syncInputs();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.controller.removeListener(_onControllerChanged);
|
|
_eventSub?.cancel();
|
|
for (final c in _textInputs.values) {
|
|
c.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
void _onControllerChanged() {
|
|
if (!mounted) return;
|
|
_syncInputs();
|
|
setState(() {});
|
|
}
|
|
|
|
void _syncInputs() {
|
|
final graph = widget.controller.graph;
|
|
final keep = <String>{};
|
|
for (final entry in graph.inputs.entries) {
|
|
keep.add(entry.key);
|
|
if (entry.value.type == 'bytes' || entry.value.type == 'file') {
|
|
// bytes input — keep its file pick if already chosen.
|
|
_fileInputs.putIfAbsent(entry.key, () => const _FilePick.empty());
|
|
} else {
|
|
_textInputs.putIfAbsent(
|
|
entry.key,
|
|
() => TextEditingController(text: entry.value.defaultValue ?? ''),
|
|
);
|
|
}
|
|
}
|
|
// Drop controllers for inputs that no longer exist.
|
|
_textInputs.removeWhere((k, c) {
|
|
if (keep.contains(k)) return false;
|
|
c.dispose();
|
|
return true;
|
|
});
|
|
_fileInputs.removeWhere((k, _) => !keep.contains(k));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final controller = widget.controller;
|
|
final strings = widget.strings;
|
|
if (controller.activeName == null) {
|
|
return _empty(theme, strings.runNoFlow);
|
|
}
|
|
final graph = controller.graph;
|
|
return Container(
|
|
color: theme.colorScheme.surface,
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(FaiSpace.lg),
|
|
children: [
|
|
_titleRow(theme),
|
|
const SizedBox(height: FaiSpace.md),
|
|
if (controller.isDirty)
|
|
Container(
|
|
padding: const EdgeInsets.all(FaiSpace.sm),
|
|
margin: const EdgeInsets.only(bottom: FaiSpace.md),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.tertiaryContainer,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
size: 16,
|
|
color: theme.colorScheme.onTertiaryContainer,
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
Expanded(
|
|
child: Text(
|
|
strings.runUnsavedBanner,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onTertiaryContainer,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_section(theme, strings.runInputs),
|
|
if (graph.inputs.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
|
child: Text(
|
|
'—',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
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),
|
|
),
|
|
if (_liveSteps.isNotEmpty || _running) ...[
|
|
const SizedBox(height: FaiSpace.lg),
|
|
_section(
|
|
theme,
|
|
_running ? strings.runTitleRunning : strings.runTitleDone,
|
|
),
|
|
_stepList(theme),
|
|
],
|
|
if (_outputs != null) ...[
|
|
const SizedBox(height: FaiSpace.lg),
|
|
_section(theme, strings.runOutputs),
|
|
for (final entry in _outputs!.entries)
|
|
_outputRow(theme, entry.key, entry.value),
|
|
],
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _titleRow(ThemeData theme) {
|
|
final title = _running
|
|
? widget.strings.runTitleRunning
|
|
: _error != null
|
|
? widget.strings.runTitleFailed
|
|
: _outputs != null
|
|
? widget.strings.runTitleDone
|
|
: widget.strings.runTitleIdle;
|
|
return Text(
|
|
title,
|
|
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
|
);
|
|
}
|
|
|
|
Widget _section(ThemeData theme, String label) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Text(
|
|
label,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _empty(ThemeData theme, String hint) {
|
|
return Container(
|
|
color: theme.colorScheme.surface,
|
|
padding: const EdgeInsets.all(FaiSpace.lg),
|
|
child: Center(
|
|
child: Text(
|
|
hint,
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _inputField(ThemeData theme, String name, FlowInput input) {
|
|
final type = input.type;
|
|
if (type == 'bytes' || type == 'file') {
|
|
final pick = _fileInputs[name] ?? const _FilePick.empty();
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 140,
|
|
child: Text(
|
|
'$name ($type)',
|
|
style: const TextStyle(fontFamily: 'monospace'),
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
OutlinedButton.icon(
|
|
onPressed: () => _pickFile(name),
|
|
icon: const Icon(Icons.attach_file, size: 16),
|
|
label: Text(
|
|
pick.fileName ?? widget.strings.runChooseFile,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 140,
|
|
child: Text(
|
|
'$name ($type)',
|
|
style: const TextStyle(fontFamily: 'monospace'),
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _textInputs[name],
|
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
|
decoration: InputDecoration(
|
|
hintText: input.hint,
|
|
isDense: true,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _stepList(ThemeData theme) {
|
|
final entries = _liveSteps.entries.toList();
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
for (final entry in entries) _stepRow(theme, entry.key, entry.value),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _stepRow(ThemeData theme, String id, _StepState state) {
|
|
final IconData glyph;
|
|
final Color color;
|
|
String suffix = '';
|
|
switch (state.kind) {
|
|
case _StepKind.running:
|
|
glyph = Icons.refresh;
|
|
color = theme.colorScheme.primary;
|
|
case _StepKind.done:
|
|
glyph = Icons.check;
|
|
color = Colors.green.shade600;
|
|
suffix = ' ${(state.durationMs ?? 0) / 1000.0}s';
|
|
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';
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Row(
|
|
children: [
|
|
Icon(glyph, size: 16, color: color),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
Flexible(
|
|
child: Text(
|
|
'$id$suffix',
|
|
style: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 13,
|
|
color: color,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _outputRow(ThemeData theme, String name, FlowOutputValue value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: FaiSpace.sm),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
name.toUpperCase(),
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
_outputBody(theme, value),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _outputBody(ThemeData theme, FlowOutputValue value) {
|
|
if (value is FlowOutputText) {
|
|
return SelectableText(
|
|
value.value,
|
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
|
);
|
|
}
|
|
if (value is FlowOutputJson) {
|
|
return SelectableText(
|
|
value.value.toString(),
|
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
|
);
|
|
}
|
|
if (value is FlowOutputBytes) {
|
|
return Text(
|
|
'${value.value.length} bytes · ${value.mimeType}',
|
|
style: theme.textTheme.bodySmall,
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
Future<void> _pickFile(String name) async {
|
|
// For 0.2.0 the editor relies on the host to inject a
|
|
// file-picker via callback; minimal version uses
|
|
// dart:io directly for desktop. Studio uses file_picker;
|
|
// we fall back to a manual path entry dialog if no host
|
|
// picker is available.
|
|
final controller = TextEditingController();
|
|
final ctx = context;
|
|
final result = await showDialog<String>(
|
|
context: ctx,
|
|
builder: (_) => AlertDialog(
|
|
title: Text(widget.strings.runChooseFile),
|
|
content: TextField(
|
|
controller: controller,
|
|
decoration: const InputDecoration(
|
|
hintText: '/path/to/file',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, null),
|
|
child: Text(widget.strings.newDialogCancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
|
|
child: const Text('OK'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (result == null || result.isEmpty || !mounted) return;
|
|
try {
|
|
final file = File(result);
|
|
if (!file.existsSync()) return;
|
|
final bytes = await file.readAsBytes();
|
|
setState(() {
|
|
_fileInputs[name] = _FilePick(
|
|
fileName: file.uri.pathSegments.last,
|
|
bytes: Uint8List.fromList(bytes),
|
|
);
|
|
});
|
|
} catch (_) {
|
|
// swallow — file unreadable, leave the pick empty
|
|
}
|
|
}
|
|
|
|
Future<void> _start() async {
|
|
final driver = widget.driver;
|
|
if (driver == null) return;
|
|
final flowName = widget.controller.activeName;
|
|
if (flowName == null) return;
|
|
setState(() {
|
|
_running = true;
|
|
_outputs = null;
|
|
_error = null;
|
|
_liveSteps.clear();
|
|
_runFlowName = flowName;
|
|
});
|
|
widget.controller.running = true;
|
|
// Subscribe BEFORE submitting so the first event isn't
|
|
// lost to the broadcast's dead-letter.
|
|
_eventSub?.cancel();
|
|
_eventSub = driver.events().listen(_onEvent);
|
|
final textInputs = <String, String>{
|
|
for (final entry in _textInputs.entries) entry.key: entry.value.text,
|
|
};
|
|
final fileInputs = <String, Uint8List>{};
|
|
final fileMimes = <String, String>{};
|
|
for (final entry in _fileInputs.entries) {
|
|
final bytes = entry.value.bytes;
|
|
if (bytes == null) continue;
|
|
fileInputs[entry.key] = bytes;
|
|
fileMimes[entry.key] = _mimeFor(entry.value.fileName ?? '');
|
|
}
|
|
try {
|
|
final outputs = await driver.runFlow(
|
|
flowName: flowName,
|
|
textInputs: textInputs,
|
|
fileInputs: fileInputs,
|
|
fileMimes: fileMimes,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_running = false;
|
|
_outputs = outputs;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_running = false;
|
|
_error = e;
|
|
});
|
|
} finally {
|
|
widget.controller.running = false;
|
|
_eventSub?.cancel();
|
|
_eventSub = null;
|
|
}
|
|
}
|
|
|
|
void _onEvent(FlowRunEvent event) {
|
|
final flowName = _runFlowName;
|
|
if (flowName == null) return;
|
|
if (event.flowName != flowName) return;
|
|
if (!mounted) return;
|
|
setState(() {
|
|
switch (event) {
|
|
case StepStarted():
|
|
_liveSteps[event.stepId] = const _StepState(kind: _StepKind.running);
|
|
widget.controller.updateStepStatus(
|
|
event.stepId,
|
|
StepRunStatus.running,
|
|
);
|
|
case StepCompleted():
|
|
_liveSteps[event.stepId] = _StepState(
|
|
kind: _StepKind.done,
|
|
durationMs: event.durationMs,
|
|
);
|
|
widget.controller.updateStepStatus(event.stepId, StepRunStatus.done);
|
|
case StepFailed():
|
|
_liveSteps[event.stepId] = _StepState(
|
|
kind: _StepKind.error,
|
|
error: event.error,
|
|
);
|
|
widget.controller.updateStepStatus(
|
|
event.stepId,
|
|
StepRunStatus.failed,
|
|
);
|
|
case StepAwaitingApproval():
|
|
_liveSteps[event.stepId] = const _StepState(kind: _StepKind.awaiting);
|
|
widget.controller.updateStepStatus(
|
|
event.stepId,
|
|
StepRunStatus.awaiting,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
String _mimeFor(String name) {
|
|
final lower = name.toLowerCase();
|
|
if (lower.endsWith('.pdf')) return 'application/pdf';
|
|
if (lower.endsWith('.docx')) {
|
|
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
}
|
|
if (lower.endsWith('.txt')) return 'text/plain';
|
|
if (lower.endsWith('.json')) return 'application/json';
|
|
return 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
class _FilePick {
|
|
final String? fileName;
|
|
final Uint8List? bytes;
|
|
const _FilePick({this.fileName, this.bytes});
|
|
const _FilePick.empty() : fileName = null, bytes = null;
|
|
}
|
|
|
|
enum _StepKind { running, done, error, awaiting }
|
|
|
|
class _StepState {
|
|
final _StepKind kind;
|
|
final int? durationMs;
|
|
final String? error;
|
|
const _StepState({required this.kind, this.durationMs, this.error});
|
|
}
|