986 lines
31 KiB
Dart
986 lines
31 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 `~/.chain/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 'package:flutter/material.dart';
|
||
import 'package:flutter/services.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),
|
||
_startButtonRow(theme, strings),
|
||
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),
|
||
_CopyableErrorBox(
|
||
text: _error.toString(),
|
||
strings: widget.strings,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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 strings = widget.strings;
|
||
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;
|
||
case _StepKind.awaiting:
|
||
glyph = Icons.pause_circle_outline;
|
||
color = theme.colorScheme.tertiary;
|
||
suffix = ' ${strings.runAwaitingApproval}';
|
||
}
|
||
final isError = state.kind == _StepKind.error &&
|
||
state.error != null &&
|
||
state.error!.isNotEmpty;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
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!,
|
||
strings: widget.strings,
|
||
),
|
||
),
|
||
],
|
||
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(() {});
|
||
},
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 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 ? strings.runErrorsBlockTooltip(errors) : '',
|
||
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),
|
||
Flexible(
|
||
child: Text(
|
||
'${strings.runErrorsBlockRun(errors)} · '
|
||
'${strings.runErrorsBlockHint}',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.error,
|
||
),
|
||
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});
|
||
}
|
||
|
||
/// 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
|
||
/// `ChainErrorBox` uses elsewhere; kept local to this package
|
||
/// so flow_editor doesn't depend on Studio internals.
|
||
class _CopyableErrorBox extends StatefulWidget {
|
||
final String text;
|
||
final FlowEditorStrings strings;
|
||
const _CopyableErrorBox({required this.text, required this.strings});
|
||
|
||
@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 ? widget.strings.runCopied : widget.strings.runCopy,
|
||
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',
|
||
fontFamilyFallback: const [
|
||
'Menlo',
|
||
'Consolas',
|
||
'Courier New',
|
||
'monospace',
|
||
],
|
||
fontSize: 12,
|
||
color: theme.colorScheme.onErrorContainer,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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<void> _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<void> _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),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|