feat(editor): three-tab WYSIWYG editor — graph / text / run

Full rewrite of the editor surface, layered on top of the
FlowGraph foundation. One in-memory flow drives three tabs
that the operator can flip between freely:

 - Graph: a drag-and-drop canvas. Nodes are step cards with
   port dots on their left (one per `with:` field) and a
   combined output port on the right. Pinned inputs and
   outputs pseudo-nodes sit at the left and right edges so
   every flow has a visually obvious source and sink. Pan +
   zoom via InteractiveViewer; drag a node by its body to
   reposition it (positions persisted to a sidecar JSON file
   under ~/.fai/data/flows/.layout/<name>.json — kept OUT of
   the YAML so `fai run` stays byte-stable).
 - Text: the existing YAML CodeField with expands:true so
   line 1 anchors at the top edge. YAML-aware syntax
   highlighting picks up the theme's primary / secondary /
   tertiary palette for keys / strings / numbers.
 - Run: an inputs form (text fields + file-pick), a Start
   button that calls the host's FlowRunDriver, a live step
   list driven by the driver's event stream (matches the
   `fai run` CLI rendering — ◻ pending, · running, ✔ done +
   duration, ✗ failed, ⏸ awaiting approval), and the typed
   outputs once the run resolves.

Source of truth = the YAML text. Graph edits emit fresh YAML
into the shared CodeController; text edits re-parse the
graph on a 350 ms debounce. Layout sidecar persists drag
positions only.

New public API (lib/fai_studio_flow_editor.dart):

  FlowEditorPage(
    initialFlowName: ...,
    locale: ...,
    runDriver: FlowRunDriver?,        // NEW — host bridge
    availableCapabilities: List<String>, // NEW — for the
                                          // capability picker
                                          // dialog when adding
                                          // a step
  )

The host (Studio) implements FlowRunDriver to bridge the
hub's gRPC SDK into the editor's event vocabulary. The
StepStarted/Completed/Failed/AwaitingApproval events are
shared verbatim with the CLI's run_progress renderer so
both surfaces speak the same visual language.

Files in this commit:
 - lib/src/editor_controller.dart       — shared state +
   debounced reparse loop
 - lib/src/run_driver.dart              — host bridge
   interface + event types
 - lib/src/widgets/flow_canvas.dart     — pan / zoom / drag /
   port-to-port connection drawing
 - lib/src/widgets/flow_node.dart       — node card primitive
   (module / approval / inputs / outputs variants)
 - lib/src/widgets/edge_painter.dart    — single CustomPainter
   for every edge + draft drag line, cubic bezier with
   arrow-head caps
 - lib/src/widgets/properties_panel.dart — right-side editor
   when a step is selected (rename id, change capability, add
   / remove / rename with-fields, delete step)
 - lib/src/widgets/capability_picker.dart — searchable list
   dialog used by Add-step
 - lib/src/widgets/run_tab.dart         — inputs form +
   live step progress + outputs renderer
 - lib/src/flow_editor_page.dart        — host scaffolding,
   toolbar, file list, three-tab body, keyboard shortcuts
 - lib/src/l10n.dart                    — EN + DE strings for
   every new label
 - lib/fai_studio_flow_editor.dart      — exports the new
   public types (FlowRunDriver, FlowRunEvent variants,
   FlowOutputValue variants)

flutter analyze: 0 issues. flutter test: 7/7 green.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 00:48:35 +02:00
parent dbd9a0004f
commit 870cbc29f7
12 changed files with 2769 additions and 408 deletions

View file

@ -1,17 +1,38 @@
/// Inline YAML editor for FI Studio flows swappable. /// Inline flow editor for FI Studio swappable.
/// ///
/// Studio depends on this package via pubspec.yaml. To use a /// Studio depends on this package via pubspec.yaml. To use
/// different editor, fork this repo, change the implementation, /// a different editor, fork this repo, change the
/// point Studio's pubspec at the fork, rebuild Studio. /// implementation, point Studio's pubspec at the fork,
/// rebuild Studio.
/// ///
/// Public surface: /// Public surface:
///
/// * [FlowEditorPage] the page widget Studio embeds as /// * [FlowEditorPage] the page widget Studio embeds as
/// its "Editor" destination + as a route push from the /// its Flows destination. Three tabs over one in-memory
/// Flows page's pencil icon. /// flow: graph (drag-and-drop WYSIWYG), text (raw YAML),
/// * [FlowEditorLocale] the locale the package renders /// run (inputs form + live step progress + outputs).
/// its inline strings in. Studio passes its active /// * [FlowEditorLocale] render language for inline
/// Locale through. /// strings. Studio passes its active Locale through.
/// * [FlowRunDriver] host-supplied bridge to the hub.
/// Studio implements it with the gRPC SDK; a CLI host
/// could shell out; a test harness can stub it in-memory.
/// * [FlowRunEvent] family events the driver streams
/// during a run (StepStarted, StepCompleted, ).
/// * [FlowOutputValue] family typed outputs the driver
/// returns when the run finishes.
library; library;
export 'src/flow_editor_page.dart' show FlowEditorPage; export 'src/flow_editor_page.dart' show FlowEditorPage;
export 'src/l10n.dart' show FlowEditorLocale; export 'src/l10n.dart' show FlowEditorLocale;
export 'src/run_driver.dart'
show
FlowRunDriver,
FlowRunEvent,
StepStarted,
StepCompleted,
StepFailed,
StepAwaitingApproval,
FlowOutputValue,
FlowOutputText,
FlowOutputJson,
FlowOutputBytes;

View file

@ -0,0 +1,184 @@
// FlowEditorController shared state for the three tabs.
//
// The Graph, Text, and Run tabs all observe one controller.
// The controller owns:
//
// - The active flow's name + YAML text + last-saved baseline.
// - The parsed FlowGraph (kept in sync with the text buffer).
// - The FlowLayout sidecar (node positions).
// - Selection (which node is highlighted).
// - Dirty + saving + running flags.
//
// All three tabs read state via [ChangeNotifier] and call
// mutators (`setText`, `applyGraphEdit`, ). The controller
// debounces text -> graph reparsing so typing in the text
// tab doesn't thrash the canvas.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:highlight/languages/yaml.dart';
import 'model/auto_layout.dart';
import 'model/flow_graph.dart';
import 'model/layout_store.dart';
class FlowEditorController extends ChangeNotifier {
/// Active flow file name (without `.yaml`). `null` while
/// the editor is empty.
String? get activeName => _activeName;
String? _activeName;
/// Underlying text controller the text tab's source of
/// truth. The graph + run tabs derive everything from this.
late final CodeController codeController;
/// Last-parsed graph. May be one debounce-tick behind the
/// text buffer; never null after a flow is opened.
FlowGraph get graph => _graph;
FlowGraph _graph = FlowGraph.empty();
/// Persistent node positions. Layout-only edits (drag a
/// node) update this immediately and write to the sidecar.
FlowLayout get layout => _layout;
FlowLayout _layout = FlowLayout.empty();
/// Step id currently highlighted in the canvas + targeted
/// by the properties panel. `null` when nothing is selected.
String? get selectedStepId => _selectedStepId;
String? _selectedStepId;
/// Baseline YAML captured immediately after loading a flow
/// from disk (or saving). `dirty` is the difference between
/// the buffer and this baseline. Comparing _code.fullText
/// against this baseline (not against the raw on-disk text)
/// avoids the false-positive prompt the controller's own
/// normalisation used to trigger.
String _baseline = '';
bool get isDirty =>
_activeName != null && codeController.fullText != _baseline;
bool get isSaving => _saving;
bool _saving = false;
bool get isRunning => _running;
bool _running = false;
/// Flag flipped during graph -> YAML emission so we don't
/// loop: the text change listener skips a reparse if it's
/// already mid-emit.
bool _emittingFromGraph = false;
Timer? _reparseTimer;
FlowEditorController() {
codeController = CodeController(text: '', language: yaml);
codeController.addListener(_onCodeChanged);
}
@override
void dispose() {
_reparseTimer?.cancel();
codeController.removeListener(_onCodeChanged);
codeController.dispose();
super.dispose();
}
/// Open a flow into the controller. Sets the text, parses
/// the graph, loads the layout sidecar, and resets the
/// baseline so `dirty` starts at false.
void openFlow(String name, String yamlText) {
_activeName = name;
_emittingFromGraph = true;
codeController.fullText = yamlText;
_emittingFromGraph = false;
_baseline = codeController.fullText;
final parsed = FlowGraph.tryParse(_baseline) ?? FlowGraph.empty();
_graph = parsed;
_layout = LayoutStore.loadSync(name);
// Fill any missing positions deterministically so the
// first open of a flow doesn't show a blank canvas.
_layout = AutoLayout.layout(_graph, _layout);
LayoutStore.saveSync(name, _layout);
_selectedStepId = null;
notifyListeners();
}
/// Mark the buffer as saved call right after writing to
/// disk so `dirty` returns to false until the next edit.
void markSaved() {
_baseline = codeController.fullText;
notifyListeners();
}
/// External-state hooks for the host (Studio sets these via
/// callbacks during run / save).
set saving(bool v) {
if (_saving == v) return;
_saving = v;
notifyListeners();
}
set running(bool v) {
if (_running == v) return;
_running = v;
notifyListeners();
}
/// User clicked a node on the canvas.
void selectStep(String? stepId) {
if (_selectedStepId == stepId) return;
_selectedStepId = stepId;
notifyListeners();
}
/// Persist a node's new position and write the sidecar.
/// Layout-only does not mutate the YAML, does not affect
/// `dirty`.
void moveStep(String stepId, NodePosition position) {
_layout = _layout.withPosition(stepId, position);
if (_activeName != null) {
LayoutStore.saveSync(_activeName!, _layout);
}
notifyListeners();
}
/// Apply a graph-side edit (rename step, change `use`,
/// adjust `with`, add/remove step, add edge). Emits fresh
/// YAML into the text controller, then bumps the layout to
/// include any newly-added step.
void applyGraphEdit(FlowGraph next) {
_graph = next;
_layout = AutoLayout.layout(next, _layout);
if (_activeName != null) {
LayoutStore.saveSync(_activeName!, _layout);
}
_emittingFromGraph = true;
codeController.fullText = next.toYaml();
_emittingFromGraph = false;
notifyListeners();
}
void _onCodeChanged() {
if (_emittingFromGraph) return;
// Debounce 350ms fast enough that the canvas feels
// responsive while the operator pauses between
// keystrokes, slow enough that each frame of typing
// doesn't trigger a fresh parse + AutoLayout pass.
_reparseTimer?.cancel();
_reparseTimer = Timer(const Duration(milliseconds: 350), () {
final parsed = FlowGraph.tryParse(codeController.fullText);
if (parsed == null) {
// Keep the last-good graph so the canvas stays
// visible while the operator is mid-typo.
notifyListeners();
return;
}
_graph = parsed;
_layout = AutoLayout.layout(parsed, _layout);
if (_activeName != null) {
LayoutStore.saveSync(_activeName!, _layout);
}
notifyListeners();
});
}
}

View file

@ -1,15 +1,25 @@
/// FlowEditorPage host-agnostic inline YAML editor. // FlowEditorPage the public-facing widget Studio (and any
/// // other host) embeds as its flow surface.
/// The page reads + writes flow YAML directly under //
/// `~/.fai/data/flows/` via dart:io. The hub picks up changes // Layout:
/// on its next ListFlows / RunSavedFlow call. //
/// // Toolbar
/// Studio passes: // [back] file.yaml [+ Step] [Save] [Run]
/// * [initialFlowName] preload a specific flow. //
/// * [locale] render inline strings in EN or DE. // FLOWS [Graph][Text][Run]
/// * [onRun] async callback that runs the named flow //
/// against the host hub. Returns the typed outputs map. // (active tab content)
/// When null, the Run button is disabled. // list
//
//
// Properties (overlay)
//
// All three tabs read state from a single [FlowEditorController].
// The graph tab and text tab keep YAML in lockstep graph
// edits emit fresh YAML; text edits re-parse the graph on
// debounce. The run tab reads the saved-on-disk version
// because that's what the hub's runSavedFlow consumes.
library; library;
import 'dart:io'; import 'dart:io';
@ -17,11 +27,17 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:highlight/languages/yaml.dart';
import 'editor_controller.dart';
import 'l10n.dart'; import 'l10n.dart';
import 'model/flow_graph.dart';
import 'run_driver.dart';
import 'tokens.dart'; import 'tokens.dart';
import 'widgets.dart'; import 'widgets.dart';
import 'widgets/capability_picker.dart';
import 'widgets/flow_canvas.dart';
import 'widgets/properties_panel.dart';
import 'widgets/run_tab.dart';
/// Compute the flows directory under the operator's home. /// Compute the flows directory under the operator's home.
String _defaultFlowsDir() { String _defaultFlowsDir() {
@ -33,45 +49,48 @@ String _defaultFlowsDir() {
} }
class FlowEditorPage extends StatefulWidget { class FlowEditorPage extends StatefulWidget {
/// Pre-load this flow on open. When null, the editor starts
/// empty and the operator picks from the file list.
final String? initialFlowName; final String? initialFlowName;
/// Language for inline strings.
final FlowEditorLocale locale; final FlowEditorLocale locale;
final Future<Map<String, Object>> Function(String name)? onRun;
/// Bridge between editor and host's hub. When null, the
/// Run tab is read-only.
final FlowRunDriver? runDriver;
/// Capabilities the operator can drop into a new step.
/// Studio supplies the list from `HubService.listCapabilities()`.
/// Empty list = the picker shows a free-form text field.
final List<String> availableCapabilities;
const FlowEditorPage({ const FlowEditorPage({
super.key, super.key,
this.initialFlowName, this.initialFlowName,
this.locale = FlowEditorLocale.en, this.locale = FlowEditorLocale.en,
this.onRun, this.runDriver,
this.availableCapabilities = const [],
}); });
@override @override
State<FlowEditorPage> createState() => _FlowEditorPageState(); State<FlowEditorPage> createState() => _FlowEditorPageState();
} }
class _FlowEditorPageState extends State<FlowEditorPage> { class _FlowEditorPageState extends State<FlowEditorPage>
late final CodeController _code; with TickerProviderStateMixin {
late final FlowEditorController _controller;
late final FlowEditorStrings _l; late final FlowEditorStrings _l;
String? _activeName;
// Baseline captured AFTER the CodeController has finished
// processing the loaded text (line-ending normalisation,
// trailing-newline handling, fold-marker insertion, ).
// Comparing _code.fullText against this baseline avoids the
// false-positive "discard unsaved changes?" prompts the
// naive _code.text == _loadedText check used to trigger on
// files the controller silently rewrote during load.
String _baseline = '';
late Future<List<_FlowFile>> _files; late Future<List<_FlowFile>> _files;
Object? _runError; late final TabController _tabs;
Map<String, Object>? _runOutputs;
bool _running = false;
bool _saving = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_l = FlowEditorStrings(widget.locale); _l = FlowEditorStrings(widget.locale);
_code = CodeController(text: '', language: yaml); _controller = FlowEditorController();
_code.addListener(_onCodeChange); _controller.addListener(_onCtrlChanged);
_tabs = TabController(length: 3, vsync: this);
_files = _listFiles(); _files = _listFiles();
final initial = widget.initialFlowName; final initial = widget.initialFlowName;
if (initial != null && initial.isNotEmpty) { if (initial != null && initial.isNotEmpty) {
@ -83,16 +102,17 @@ class _FlowEditorPageState extends State<FlowEditorPage> {
@override @override
void dispose() { void dispose() {
_code.removeListener(_onCodeChange); _controller.removeListener(_onCtrlChanged);
_code.dispose(); _controller.dispose();
_tabs.dispose();
super.dispose(); super.dispose();
} }
void _onCodeChange() { void _onCtrlChanged() {
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
bool get _dirty => _activeName != null && _code.fullText != _baseline; // --- file ops ---
Future<List<_FlowFile>> _listFiles() async { Future<List<_FlowFile>> _listFiles() async {
final dir = Directory(_defaultFlowsDir()); final dir = Directory(_defaultFlowsDir());
@ -120,42 +140,25 @@ class _FlowEditorPageState extends State<FlowEditorPage> {
} }
Future<void> _openByName(String name) async { Future<void> _openByName(String name) async {
final dir = Directory(_defaultFlowsDir()); final path = '${_defaultFlowsDir()}/$name.yaml';
final path = '${dir.path}/$name.yaml'; final file = File(path);
final f = File(path); if (!file.existsSync()) return;
if (!f.existsSync()) return; final text = await file.readAsString();
final text = await f.readAsString();
if (!mounted) return; if (!mounted) return;
// Set the controller text first, then snapshot fullText _controller.openFlow(name, text);
// as the baseline. Reading fullText after the assignment
// captures whatever line-ending normalisation / fold-
// marker insertion CodeController applied, so the dirty
// check is comparing apples to apples.
_code.fullText = text;
setState(() {
_activeName = name;
_baseline = _code.fullText;
_runOutputs = null;
_runError = null;
});
} }
Future<void> _openFile(_FlowFile f) async { Future<void> _openFile(_FlowFile f) async {
if (_dirty) { if (_controller.isDirty) {
final keep = await _confirmDiscard(); final keep = await _confirmDiscard();
if (keep == false || !mounted) return; if (keep == false || !mounted) return;
} }
final text = await File(f.path).readAsString(); final text = await File(f.path).readAsString();
_code.fullText = text; if (!mounted) return;
setState(() { _controller.openFlow(f.name, text);
_activeName = f.name;
_baseline = _code.fullText;
_runOutputs = null;
_runError = null;
});
} }
Future<bool?> _confirmDiscard() async { Future<bool?> _confirmDiscard() {
return showDialog<bool>( return showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@ -176,27 +179,26 @@ class _FlowEditorPageState extends State<FlowEditorPage> {
} }
Future<void> _save() async { Future<void> _save() async {
final name = _activeName; final name = _controller.activeName;
if (name == null) return; if (name == null) return;
setState(() => _saving = true); _controller.saving = true;
try { try {
final f = File('${_defaultFlowsDir()}/$name.yaml'); final file = File('${_defaultFlowsDir()}/$name.yaml');
// Write fullText `text` is post-fold visible-only, so await file.writeAsString(
// saving it would lose any content the editor has hidden _controller.codeController.fullText,
// behind a collapsed fold. flush: true,
await f.writeAsString(_code.fullText, flush: true); );
if (!mounted) return; if (!mounted) return;
setState(() { _controller.markSaved();
_baseline = _code.fullText;
_files = _listFiles(); _files = _listFiles();
}); setState(() {});
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(e.toString()))); ).showSnackBar(SnackBar(content: Text(e.toString())));
} finally { } finally {
if (mounted) setState(() => _saving = false); _controller.saving = false;
} }
} }
@ -206,40 +208,40 @@ class _FlowEditorPageState extends State<FlowEditorPage> {
builder: (ctx) => _NewFlowDialog(strings: _l), builder: (ctx) => _NewFlowDialog(strings: _l),
); );
if (name == null || name.isEmpty || !mounted) return; if (name == null || name.isEmpty || !mounted) return;
final template = '''# ${_l.newTemplateComment(name)} final template =
'''# ${_l.newTemplateComment(name)}
name: $name
inputs: inputs:
name: text:
type: text type: text
steps: steps:
- id: greet - id: echo
use: debug.echo@^0 use: debug.echo@^0
with: with:
text: "Hello, \${{ inputs.name }}!" message: \$inputs.text
outputs: outputs:
greeting: \${{ steps.greet.result }} result: \$echo.echoed
'''; ''';
try { try {
final dir = Directory(_defaultFlowsDir()); final dir = Directory(_defaultFlowsDir());
if (!dir.existsSync()) await dir.create(recursive: true); if (!dir.existsSync()) await dir.create(recursive: true);
final f = File('${dir.path}/$name.yaml'); final file = File('${dir.path}/$name.yaml');
if (f.existsSync()) { if (file.existsSync()) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(_l.alreadyExists(name)))); ).showSnackBar(SnackBar(content: Text(_l.alreadyExists(name))));
return; return;
} }
await f.writeAsString(template, flush: true); await file.writeAsString(template, flush: true);
if (!mounted) return; if (!mounted) return;
_code.fullText = template; _controller.openFlow(name, template);
setState(() {
_activeName = name;
_baseline = _code.fullText;
_files = _listFiles(); _files = _listFiles();
}); setState(() {});
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(
@ -248,37 +250,44 @@ outputs:
} }
} }
Future<void> _run() async { Future<void> _refreshFiles() async {
final name = _activeName;
final onRun = widget.onRun;
if (name == null || onRun == null) return;
if (_dirty) await _save();
setState(() {
_running = true;
_runOutputs = null;
_runError = null;
});
try {
final out = await onRun(name);
if (!mounted) return;
setState(() => _runOutputs = out);
} catch (e) {
if (!mounted) return;
setState(() => _runError = e);
} finally {
if (mounted) setState(() => _running = false);
}
}
Future<void> _refresh() async {
setState(() => _files = _listFiles()); setState(() => _files = _listFiles());
} }
Future<void> _addStep() async {
final picked = await CapabilityPicker.show(
context,
capabilities: widget.availableCapabilities,
strings: _l,
);
if (picked == null || !mounted) return;
final graph = _controller.graph;
// Generate a fresh id base on the capability's local
// name, deduplicating against existing step ids.
final localPart = picked.split('/').last.split('@').first;
final baseId = localPart.split('.').last;
var id = baseId;
var i = 1;
while (graph.steps.any((s) => s.id == id)) {
id = '${baseId}_$i';
i++;
}
_controller.applyGraphEdit(
graph.withStepAdded(FlowStep(id: id, use: picked, with_: const {})),
);
_controller.selectStep(id);
_tabs.animateTo(0);
}
// --- build ---
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); // Keyboard shortcuts: Cmd/Ctrl+S to save, Cmd/Ctrl+Enter
// to start a run. The text tab also keeps these but the
final shortcuts = <ShortcutActivator, Intent>{ // global wrapper covers the graph + run tabs too.
return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyS): LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyS):
const _SaveIntent(), const _SaveIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS): LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
@ -287,51 +296,48 @@ outputs:
const _RunIntent(), const _RunIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter): LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter):
const _RunIntent(), const _RunIntent(),
}; },
return Shortcuts(
shortcuts: shortcuts,
child: Actions( child: Actions(
actions: <Type, Action<Intent>>{ actions: <Type, Action<Intent>>{
_SaveIntent: CallbackAction<_SaveIntent>( _SaveIntent: CallbackAction<_SaveIntent>(
onInvoke: (_) { onInvoke: (_) {
if (_activeName != null && !_saving) _save(); if (_controller.activeName != null) _save();
return null; return null;
}, },
), ),
_RunIntent: CallbackAction<_RunIntent>( _RunIntent: CallbackAction<_RunIntent>(
onInvoke: (_) { onInvoke: (_) {
if (_activeName != null && !_running && widget.onRun != null) { if (_controller.activeName != null) _tabs.animateTo(2);
_run();
}
return null; return null;
}, },
), ),
}, },
child: Focus(autofocus: true, child: _buildScaffold(theme)), child: Focus(autofocus: true, child: _buildScaffold()),
), ),
); );
} }
Widget _buildScaffold(ThemeData theme) { Widget _buildScaffold() {
final theme = Theme.of(context);
return Scaffold( return Scaffold(
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_Toolbar( _Toolbar(
strings: _l, strings: _l,
activeName: _activeName, activeName: _controller.activeName,
dirty: _dirty, dirty: _controller.isDirty,
saving: _saving, saving: _controller.isSaving,
running: _running, running: _controller.isRunning,
onBack: Navigator.of(context).canPop() onBack: Navigator.of(context).canPop()
? () => Navigator.of(context).maybePop() ? () => Navigator.of(context).maybePop()
: null, : null,
onSave: _activeName != null ? _save : null, onSave: _controller.activeName != null ? _save : null,
onRun: _activeName != null && !_running && widget.onRun != null onAddStep: _controller.activeName != null ? _addStep : null,
? _run
: null,
onNew: _newFlow, onNew: _newFlow,
onRun: _controller.activeName != null
? () => _tabs.animateTo(2)
: null,
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
@ -342,24 +348,17 @@ outputs:
width: 240, width: 240,
child: _FileList( child: _FileList(
filesFuture: _files, filesFuture: _files,
activeName: _activeName, activeName: _controller.activeName,
strings: _l, strings: _l,
onOpen: _openFile, onOpen: _openFile,
onRefresh: _refresh, onRefresh: _refreshFiles,
), ),
), ),
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
Expanded( Expanded(
child: _activeName == null child: _controller.activeName == null
? _EmptyState(strings: _l) ? _EmptyState(strings: _l)
: _EditorPane( : _tabbedBody(theme),
controller: _code,
theme: theme,
outputs: _runOutputs,
runError: _runError,
running: _running,
strings: _l,
),
), ),
], ],
), ),
@ -368,8 +367,127 @@ outputs:
), ),
); );
} }
Widget _tabbedBody(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
color: theme.colorScheme.surfaceContainerLowest,
child: TabBar(
controller: _tabs,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: [
Tab(
text: _l.tabGraph,
icon: const Icon(Icons.account_tree, size: 16),
),
Tab(text: _l.tabText, icon: const Icon(Icons.notes, size: 16)),
Tab(
text: _l.tabRun,
icon: const Icon(Icons.play_arrow, size: 16),
),
],
),
),
Expanded(
child: TabBarView(
controller: _tabs,
// Disable the lateral swipe gesture so it doesn't
// race with the canvas's pan handlers.
physics: const NeverScrollableScrollPhysics(),
children: [
_graphTab(theme),
_textTab(theme),
RunTab(
controller: _controller,
strings: _l,
driver: widget.runDriver,
),
],
),
),
],
);
}
Widget _graphTab(ThemeData theme) {
// When a step is selected, show the properties panel on
// the right as a fixed sidebar. Selection is owned by
// the controller and ChangeNotifier rebuilds drive the
// panel show / hide.
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: FlowCanvas(controller: _controller)),
if (_controller.selectedStepId != null) ...[
const VerticalDivider(width: 1),
SizedBox(
width: 320,
child: PropertiesPanel(
controller: _controller,
strings: _l,
availableCapabilities: widget.availableCapabilities,
),
),
],
],
);
}
Widget _textTab(ThemeData theme) {
final mono = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 13,
height: 1.45,
color: theme.colorScheme.onSurface,
);
return CodeTheme(
data: CodeThemeData(styles: _yamlStyle(theme)),
child: CodeField(
controller: _controller.codeController,
textStyle: mono,
expands: true,
minLines: null,
maxLines: null,
gutterStyle: GutterStyle(
textStyle: mono.copyWith(color: theme.colorScheme.onSurfaceVariant),
background: theme.colorScheme.surfaceContainer,
showLineNumbers: true,
),
background: theme.colorScheme.surface,
),
);
}
Map<String, TextStyle> _yamlStyle(ThemeData theme) {
final cs = theme.colorScheme;
final monoBase = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 13,
height: 1.45,
);
return {
'root': monoBase.copyWith(color: cs.onSurface),
// YAML key bright primary so the structure pops at a
// glance. attr is the highlight grammar's key token.
'attr': monoBase.copyWith(color: cs.primary, fontWeight: FontWeight.w600),
'string': monoBase.copyWith(color: cs.tertiary),
'number': monoBase.copyWith(color: cs.secondary),
'bullet': monoBase.copyWith(color: cs.onSurfaceVariant),
'literal': monoBase.copyWith(color: cs.secondary),
'comment': monoBase.copyWith(
color: cs.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
'meta': monoBase.copyWith(color: cs.secondary),
};
}
} }
// --- shortcuts ---
class _SaveIntent extends Intent { class _SaveIntent extends Intent {
const _SaveIntent(); const _SaveIntent();
} }
@ -378,16 +496,7 @@ class _RunIntent extends Intent {
const _RunIntent(); const _RunIntent();
} }
class _FlowFile { // --- toolbar ---
final String name;
final String path;
final int sizeBytes;
const _FlowFile({
required this.name,
required this.path,
required this.sizeBytes,
});
}
class _Toolbar extends StatelessWidget { class _Toolbar extends StatelessWidget {
final FlowEditorStrings strings; final FlowEditorStrings strings;
@ -397,8 +506,9 @@ class _Toolbar extends StatelessWidget {
final bool running; final bool running;
final VoidCallback? onBack; final VoidCallback? onBack;
final VoidCallback? onSave; final VoidCallback? onSave;
final VoidCallback? onRun; final VoidCallback? onAddStep;
final VoidCallback onNew; final VoidCallback onNew;
final VoidCallback? onRun;
const _Toolbar({ const _Toolbar({
required this.strings, required this.strings,
required this.activeName, required this.activeName,
@ -407,8 +517,9 @@ class _Toolbar extends StatelessWidget {
required this.running, required this.running,
required this.onBack, required this.onBack,
required this.onSave, required this.onSave,
required this.onRun, required this.onAddStep,
required this.onNew, required this.onNew,
required this.onRun,
}); });
@override @override
@ -453,6 +564,13 @@ class _Toolbar extends StatelessWidget {
label: Text(strings.newFlow), label: Text(strings.newFlow),
), ),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
if (onAddStep != null)
FilledButton.tonalIcon(
onPressed: onAddStep,
icon: const Icon(Icons.add_box_outlined, size: 16),
label: Text(strings.addStep),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.tonalIcon( FilledButton.tonalIcon(
onPressed: saving ? null : onSave, onPressed: saving ? null : onSave,
icon: saving icon: saving
@ -482,6 +600,19 @@ class _Toolbar extends StatelessWidget {
} }
} }
// --- file list ---
class _FlowFile {
final String name;
final String path;
final int sizeBytes;
const _FlowFile({
required this.name,
required this.path,
required this.sizeBytes,
});
}
class _FileList extends StatelessWidget { class _FileList extends StatelessWidget {
final Future<List<_FlowFile>> filesFuture; final Future<List<_FlowFile>> filesFuture;
final String? activeName; final String? activeName;
@ -505,13 +636,6 @@ class _FileList extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// List header refresh icon lives here (not in the
// main toolbar) because Refresh re-lists the files,
// it doesn't touch the open editor's content. Keeping
// it in the file-list panel keeps the editor toolbar
// focused on the four primary actions (back / new /
// save / run) without a stylistically-outlier outline
// icon sandwiched between filled buttons.
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md, horizontal: FaiSpace.md,
@ -626,248 +750,85 @@ class _FileList extends StatelessWidget {
} }
} }
class _EditorPane extends StatelessWidget { // --- empty state ---
final CodeController controller;
final ThemeData theme;
final Map<String, Object>? outputs;
final Object? runError;
final bool running;
final FlowEditorStrings strings;
const _EditorPane({
required this.controller,
required this.theme,
required this.outputs,
required this.runError,
required this.running,
required this.strings,
});
@override class _EmptyState extends StatelessWidget {
Widget build(BuildContext context) {
final mono = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 13,
height: 1.45,
color: theme.colorScheme.onSurface,
);
return Row(
// Stretch + CodeField(expands: true) is what actually
// pins the editor to the top edge. The previous attempt
// (just CrossAxisAlignment.stretch with a
// SingleChildScrollView) only stretched the SCROLL
// viewport the CodeField inside still had its natural,
// content-sized height and sat vertically centered within
// the viewport because CodeField defers to its parent's
// alignment when smaller than the viewport. Removing the
// SCV wrapper and setting expands:true on the CodeField
// (with explicit minLines:null + maxLines:null, which is
// what the underlying TextField requires) makes the
// CodeField fill its parent's bounded height. The line-
// number gutter then runs the full height of the editor
// pane, line 1 sits at the top edge, and vertical
// scrolling is handled by the CodeField itself for long
// files. Standard behaviour for any IDE-grade editor
// widget.
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: CodeTheme(
data: CodeThemeData(styles: _yamlStyle(theme)),
child: CodeField(
controller: controller,
textStyle: mono,
expands: true,
minLines: null,
maxLines: null,
gutterStyle: GutterStyle(
textStyle: mono.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
background: theme.colorScheme.surfaceContainer,
showLineNumbers: true,
),
background: theme.colorScheme.surface,
),
),
),
if (outputs != null || runError != null) ...[
const VerticalDivider(width: 1),
SizedBox(
width: 320,
child: _RunResult(
outputs: outputs,
error: runError,
running: running,
strings: strings,
),
),
],
],
);
}
}
class _RunResult extends StatelessWidget {
final Map<String, Object>? outputs;
final Object? error;
final bool running;
final FlowEditorStrings strings; final FlowEditorStrings strings;
const _RunResult({ const _EmptyState({required this.strings});
required this.outputs,
required this.error,
required this.running,
required this.strings,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
color: theme.colorScheme.surface, color: theme.colorScheme.surface,
padding: const EdgeInsets.all(FaiSpace.md), child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
strings.runOutput,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
const SizedBox(height: FaiSpace.sm),
if (running) const LinearProgressIndicator(),
if (error != null)
FaiErrorBox(error: error, isError: true)
else if (outputs != null)
Expanded(
child: ListView(
children: [
for (final entry in outputs!.entries)
Padding(
padding: const EdgeInsets.only(bottom: FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.key,
style: theme.textTheme.titleSmall?.copyWith(
fontFamily: 'monospace',
),
),
const SizedBox(height: FaiSpace.xs),
SelectableText(
entry.value.toString(),
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: theme.colorScheme.onSurface,
),
),
],
),
),
],
),
),
],
),
);
}
}
class _EmptyState extends StatelessWidget {
final FlowEditorStrings strings;
const _EmptyState({required this.strings});
@override
Widget build(BuildContext context) {
return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(FaiSpace.xxl), padding: const EdgeInsets.all(FaiSpace.xl),
child: FaiEmptyState( child: FaiEmptyState(
icon: Icons.edit_note, icon: Icons.description_outlined,
title: strings.emptyTitle, title: strings.emptyTitle,
hint: strings.emptyBody, hint: strings.emptyBody,
), ),
), ),
),
); );
} }
} }
// --- new flow dialog ---
class _NewFlowDialog extends StatefulWidget { class _NewFlowDialog extends StatefulWidget {
final FlowEditorStrings strings; final FlowEditorStrings strings;
const _NewFlowDialog({required this.strings}); const _NewFlowDialog({required this.strings});
@override @override
State<_NewFlowDialog> createState() => _NewFlowDialogState(); State<_NewFlowDialog> createState() => _NewFlowDialogState();
} }
class _NewFlowDialogState extends State<_NewFlowDialog> { class _NewFlowDialogState extends State<_NewFlowDialog> {
final _ctrl = TextEditingController(); final _controller = TextEditingController();
String? _error;
@override @override
void dispose() { void dispose() {
_ctrl.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
void _submit() {
final name = _controller.text.trim();
if (!RegExp(r'^[a-z0-9_-]+$').hasMatch(name)) {
setState(() => _error = widget.strings.newDialogHelper);
return;
}
Navigator.pop(context, name);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = widget.strings;
return AlertDialog( return AlertDialog(
title: Text(s.newDialogTitle), title: Text(widget.strings.newDialogTitle),
content: TextField( content: TextField(
controller: _ctrl,
autofocus: true, autofocus: true,
controller: _controller,
decoration: InputDecoration( decoration: InputDecoration(
labelText: s.newDialogLabel, labelText: widget.strings.newDialogLabel,
hintText: 'my-flow', helperText: widget.strings.newDialogHelper,
helperText: s.newDialogHelper, errorText: _error,
border: const OutlineInputBorder(),
), ),
inputFormatters: [ onSubmitted: (_) => _submit(),
FilteringTextInputFormatter.allow(RegExp(r'[a-z0-9_\-]')),
],
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text(s.newDialogCancel), child: Text(widget.strings.newDialogCancel),
), ),
FilledButton( FilledButton(
onPressed: () { onPressed: _submit,
if (_ctrl.text.isNotEmpty) { child: Text(widget.strings.newDialogCreate),
Navigator.pop(context, _ctrl.text);
}
},
child: Text(s.newDialogCreate),
), ),
], ],
); );
} }
} }
Map<String, TextStyle> _yamlStyle(ThemeData theme) {
final base = TextStyle(
fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface,
);
return {
'root': base,
'attr': base.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
'string': base.copyWith(color: theme.colorScheme.tertiary),
'number': base.copyWith(color: theme.colorScheme.secondary),
'literal': base.copyWith(color: theme.colorScheme.secondary),
'comment': base.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
'meta': base.copyWith(color: theme.colorScheme.primary),
'subst': base.copyWith(
color: theme.colorScheme.error,
fontWeight: FontWeight.w600,
),
};
}

View file

@ -31,10 +31,8 @@ class FlowEditorStrings {
'Click New flow to scaffold one from a template.', 'Click New flow to scaffold one from a template.',
'Klicke Neuer Flow um aus einer Vorlage zu starten.', 'Klicke Neuer Flow um aus einer Vorlage zu starten.',
); );
String get discardTitle => _t( String get discardTitle =>
'Discard unsaved changes?', _t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?');
'Ungespeicherte Änderungen verwerfen?',
);
String get discardBody => _t( String get discardBody => _t(
"Switching files will throw away the edits you haven't saved.", "Switching files will throw away the edits you haven't saved.",
'Beim Wechsel der Datei gehen alle noch nicht gespeicherten Änderungen verloren.', 'Beim Wechsel der Datei gehen alle noch nicht gespeicherten Änderungen verloren.',
@ -57,4 +55,58 @@ class FlowEditorStrings {
'A flow named "$name" already exists.', 'A flow named "$name" already exists.',
'Ein Flow namens "$name" existiert bereits.', 'Ein Flow namens "$name" existiert bereits.',
); );
// Tab labels three workflows over the same flow.
String get tabGraph => _t('Graph', 'Grafisch');
String get tabText => _t('Text', 'Text');
String get tabRun => _t('Run', 'Ausführen');
// Graph tab toolbar.
String get addStep => _t('Add step', 'Schritt hinzufügen');
String get autoLayout => _t('Auto-layout', 'Auto-Anordnung');
String get zoomToFit => _t('Zoom to fit', 'Einpassen');
String get graphEmptyTitle => _t('No steps yet', 'Noch keine Schritte');
String get graphEmptyBody => _t(
'Click Add step to drop the first capability onto the canvas.',
'Klicke Schritt hinzufügen um die erste Capability auf die Fläche zu setzen.',
);
// Properties panel.
String get propPanelTitle => _t('Step details', 'Schritt-Details');
String get propNoSelection => _t(
'Click a step on the canvas to edit it.',
'Klicke einen Schritt auf der Fläche an, um ihn zu bearbeiten.',
);
String get propStepId => _t('ID', 'ID');
String get propCapability => _t('Capability', 'Capability');
String get propInputs => _t('Inputs (with:)', 'Eingaben (with:)');
String get propAddInput => _t('Add input', 'Eingabe hinzufügen');
String get propRemoveInput => _t('Remove input', 'Eingabe entfernen');
String get propDeleteStep => _t('Delete step', 'Schritt löschen');
// Capability picker.
String get pickerTitle => _t('Pick a capability', 'Capability auswählen');
String get pickerSearch => _t('Search…', 'Suchen…');
String get pickerEmpty => _t(
'No matching capabilities installed.',
'Keine passenden Capabilities installiert.',
);
// Run tab.
String get runTitleIdle => _t('Run this flow', 'Diesen Flow ausführen');
String get runTitleRunning => _t('Running…', 'Läuft…');
String get runTitleDone => _t('Result', 'Ergebnis');
String get runTitleFailed => _t('Run failed', 'Lauf fehlgeschlagen');
String get runInputs => _t('Inputs', 'Eingaben');
String get runStart => _t('Start run', 'Lauf starten');
String get runUnsavedBanner => _t(
'You have unsaved changes — save first to run the latest version.',
'Du hast ungespeicherte Änderungen — bitte zuerst speichern, um die neueste Version auszuführen.',
);
String get runNoFlow => _t(
'Open a flow from the left to run it.',
'Öffne links einen Flow um ihn auszuführen.',
);
String get runOutputs => _t('Outputs', 'Ausgaben');
String get runChooseFile => _t('Choose file…', 'Datei wählen…');
} }

100
lib/src/run_driver.dart Normal file
View file

@ -0,0 +1,100 @@
// Host-supplied bridge between the editor and the hub.
//
// The editor package can't talk to the hub directly — that's
// the host's job (Studio uses the gRPC SDK, a CLI host could
// shell out to `fai run`, a test harness can stub everything
// in-memory). The bridge keeps the editor reusable across
// any host that can implement the two methods below.
//
// Lifecycle expected by the editor:
//
// 1. Subscribe to `events()` BEFORE calling `runFlow()`.
// The hub starts emitting `step.started` events the
// instant the flow begins; the host's subscription must
// already be live so the first one doesn't get lost.
// 2. Call `runFlow()` with the flow name + collected inputs.
// The returned Future resolves with the final outputs
// (or throws on failure).
// 3. Unsubscribe from `events()` when the run resolves.
import 'dart:typed_data';
abstract class FlowRunDriver {
/// Trigger a run of [flowName] with the supplied inputs.
/// Text + file inputs come in separate maps so the host
/// can wire them to the right payload shape. Returns the
/// flow's final outputs map keyed by output name.
Future<Map<String, FlowOutputValue>> runFlow({
required String flowName,
required Map<String, String> textInputs,
required Map<String, Uint8List> fileInputs,
required Map<String, String> fileMimes,
});
/// Live event stream scoped to whatever the host can
/// observe typically the hub's `StreamEvents` RPC
/// filtered to step.* event types for the running flow.
/// May arrive late (subscribe BEFORE calling runFlow!) and
/// may emit events for unrelated runs; the editor filters
/// by [FlowRunEvent.flowName] before applying.
Stream<FlowRunEvent> events();
}
/// What the editor needs out of a single event tick. Maps
/// 1:1 to the hub's `step.*` events but with the typing
/// projected into the editor's vocabulary so we don't carry
/// the SDK's proto types across the package boundary.
sealed class FlowRunEvent {
final String flowName;
final String stepId;
const FlowRunEvent({required this.flowName, required this.stepId});
}
class StepStarted extends FlowRunEvent {
const StepStarted({required super.flowName, required super.stepId});
}
class StepCompleted extends FlowRunEvent {
final int durationMs;
const StepCompleted({
required super.flowName,
required super.stepId,
required this.durationMs,
});
}
class StepFailed extends FlowRunEvent {
final String error;
const StepFailed({
required super.flowName,
required super.stepId,
required this.error,
});
}
class StepAwaitingApproval extends FlowRunEvent {
const StepAwaitingApproval({required super.flowName, required super.stepId});
}
/// One output value, after the run completed. The editor
/// renders text inline, JSON pretty-printed, bytes as a
/// download badge.
sealed class FlowOutputValue {
const FlowOutputValue();
}
class FlowOutputText extends FlowOutputValue {
final String value;
const FlowOutputText(this.value);
}
class FlowOutputJson extends FlowOutputValue {
final Object? value;
const FlowOutputJson(this.value);
}
class FlowOutputBytes extends FlowOutputValue {
final Uint8List value;
final String mimeType;
const FlowOutputBytes(this.value, this.mimeType);
}

View file

@ -29,11 +29,7 @@ class FaiEmptyState extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon( Icon(icon, size: 40, color: theme.colorScheme.onSurfaceVariant),
icon,
size: 40,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Text( Text(
title, title,
@ -91,11 +87,7 @@ class FaiErrorBox extends StatelessWidget {
child: SingleChildScrollView( child: SingleChildScrollView(
child: SelectableText( child: SelectableText(
error?.toString() ?? '', error?.toString() ?? '',
style: TextStyle( style: TextStyle(fontFamily: 'monospace', fontSize: 12, color: fg),
fontFamily: 'monospace',
fontSize: 12,
color: fg,
),
), ),
), ),
); );

View file

@ -0,0 +1,103 @@
// Capability picker modal dialog for choosing which
// capability a new step should use. Studio provides the
// list (it lives on the hub); the picker is a thin
// searchable list on top.
import 'package:flutter/material.dart';
import '../l10n.dart';
import '../tokens.dart';
class CapabilityPicker extends StatefulWidget {
final List<String> capabilities;
final FlowEditorStrings strings;
const CapabilityPicker({
super.key,
required this.capabilities,
required this.strings,
});
static Future<String?> show(
BuildContext context, {
required List<String> capabilities,
required FlowEditorStrings strings,
}) {
return showDialog<String>(
context: context,
builder: (_) =>
CapabilityPicker(capabilities: capabilities, strings: strings),
);
}
@override
State<CapabilityPicker> createState() => _CapabilityPickerState();
}
class _CapabilityPickerState extends State<CapabilityPicker> {
String _query = '';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final filtered = _query.isEmpty
? widget.capabilities
: widget.capabilities
.where((c) => c.toLowerCase().contains(_query.toLowerCase()))
.toList();
return Dialog(
child: SizedBox(
width: 480,
height: 520,
child: Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.strings.pickerTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: FaiSpace.sm),
TextField(
autofocus: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search, size: 18),
hintText: widget.strings.pickerSearch,
isDense: true,
border: const OutlineInputBorder(),
),
onChanged: (v) => setState(() => _query = v),
),
const SizedBox(height: FaiSpace.sm),
Expanded(
child: filtered.isEmpty
? Center(
child: Text(
widget.strings.pickerEmpty,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) {
final cap = filtered[i];
return ListTile(
dense: true,
title: Text(
cap,
style: const TextStyle(fontFamily: 'monospace'),
),
onTap: () => Navigator.pop(context, cap),
);
},
),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,105 @@
// EdgePainter draws every connection on the canvas as a
// cubic-bezier curve. Pure render layer: receives a flat list
// of port-to-port positions (already resolved against node
// positions) and paints them in one go.
//
// Connections are painted in a single CustomPaint so we don't
// pay the widget-tree cost of one painter per edge. Hit
// testing is delegated to port hit boxes drawn over the
// canvas; the painter itself is wrapped in IgnorePointer in
// the canvas so nothing here eats taps meant for nodes.
import 'package:flutter/material.dart';
class EdgeSegment {
final Offset from;
final Offset to;
final EdgeAccent accent;
const EdgeSegment({
required this.from,
required this.to,
this.accent = EdgeAccent.normal,
});
}
enum EdgeAccent { normal, highlight, draftDrag }
class EdgePainter extends CustomPainter {
final List<EdgeSegment> segments;
final Color baseColor;
final Color highlightColor;
final Color draftColor;
EdgePainter({
required this.segments,
required this.baseColor,
required this.highlightColor,
required this.draftColor,
});
@override
void paint(Canvas canvas, Size size) {
for (final seg in segments) {
final color = switch (seg.accent) {
EdgeAccent.normal => baseColor,
EdgeAccent.highlight => highlightColor,
EdgeAccent.draftDrag => draftColor,
};
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = seg.accent == EdgeAccent.highlight ? 2.5 : 1.8
..strokeCap = StrokeCap.round;
final path = _bezier(seg.from, seg.to);
canvas.drawPath(path, paint);
// End-cap arrow: small filled triangle pointing at the
// target port so the reader can tell direction at a
// glance without inspecting the geometry.
_drawArrowHead(canvas, seg.to, seg.from, color);
}
}
Path _bezier(Offset from, Offset to) {
// Cubic with horizontal handles typical "flow diagram"
// smooth curve. The handle length scales with the
// horizontal distance so steep verticals still look
// graceful.
final dx = (to.dx - from.dx).abs();
final handleLen = (dx / 2).clamp(40.0, 220.0);
return Path()
..moveTo(from.dx, from.dy)
..cubicTo(
from.dx + handleLen,
from.dy,
to.dx - handleLen,
to.dy,
to.dx,
to.dy,
);
}
void _drawArrowHead(Canvas canvas, Offset tip, Offset from, Color color) {
// Approximate the incoming direction by sampling a
// little before the tip. Good enough since the curve is
// nearly horizontal near the end.
final dir = tip.dx - from.dx;
final orient = dir >= 0 ? 1.0 : -1.0;
const size = 6.0;
final p1 = tip;
final p2 = Offset(tip.dx - size * orient, tip.dy - size * 0.7);
final p3 = Offset(tip.dx - size * orient, tip.dy + size * 0.7);
final path = Path()
..moveTo(p1.dx, p1.dy)
..lineTo(p2.dx, p2.dy)
..lineTo(p3.dx, p3.dy)
..close();
canvas.drawPath(path, Paint()..color = color);
}
@override
bool shouldRepaint(EdgePainter old) =>
old.segments != segments ||
old.baseColor != baseColor ||
old.highlightColor != highlightColor ||
old.draftColor != draftColor;
}

View file

@ -0,0 +1,611 @@
// FlowCanvas the graphical flow editor surface.
//
// Layout:
//
//
//
//
// in step step out
//
//
//
// InteractiveViewer (pan + zoom)
//
// Special "inputs" and "outputs" pseudo-nodes are pinned to
// the left and right of the layout so every flow has a
// visually obvious source and sink. Step nodes live in the
// middle and can be dragged anywhere by the operator. Edges
// are derived live from the FlowGraph's $ref expressions.
//
// Interactions:
//
// - Click a node: selects it; properties panel hooks
// into the selection.
// - Drag the node body: repositions the node in canvas
// coords (sidecar saved on pan end).
// - Drag an output port drop on an input port:
// creates a `$source.field` reference
// in the target step's `with:` block.
// The properties panel will let the
// operator refine which output field
// the reference points at.
// - Background pan: scrolls the canvas via the parent
// InteractiveViewer.
import 'package:flutter/material.dart';
import '../editor_controller.dart';
import '../model/flow_graph.dart';
import '../model/layout_store.dart';
import 'edge_painter.dart';
import 'flow_node.dart';
/// Canvas dimensions. Big enough that any plausible flow fits
/// with margin to spare; the InteractiveViewer scrolls /
/// scales as needed.
const double _canvasWidth = 4000;
const double _canvasHeight = 3000;
// Fixed canvas-coords for the inputs/outputs pseudo-nodes.
// Steps auto-layout starts at AutoLayout.originX (=320), so
// inputs at x=40 leaves a comfortable gap; outputs slides to
// the right of the right-most step on render.
const double _inputsX = 40;
const double _inputsY = 80;
class FlowCanvas extends StatefulWidget {
final FlowEditorController controller;
final Map<String, FlowNodeStatus> stepStatuses;
const FlowCanvas({
super.key,
required this.controller,
this.stepStatuses = const {},
});
@override
State<FlowCanvas> createState() => _FlowCanvasState();
}
class _FlowCanvasState extends State<FlowCanvas> {
final TransformationController _transform = TransformationController();
// Active connection-drag state. When non-null, the canvas
// paints a draft edge from the source port to the cursor
// and accepts a drop on any input port.
_ConnectionDraft? _draft;
@override
void initState() {
super.initState();
widget.controller.addListener(_onControllerChanged);
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChanged);
_transform.dispose();
super.dispose();
}
void _onControllerChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final graph = widget.controller.graph;
final layout = widget.controller.layout;
final outputsX = _outputsX(graph, layout);
return Container(
color: theme.colorScheme.surface,
child: InteractiveViewer(
transformationController: _transform,
constrained: false,
boundaryMargin: const EdgeInsets.all(400),
minScale: 0.4,
maxScale: 2.0,
child: SizedBox(
width: _canvasWidth,
height: _canvasHeight,
child: Stack(
children: [
_grid(theme),
// Edges first so nodes paint on top of them.
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: EdgePainter(
segments: _buildSegments(graph, layout, outputsX),
baseColor: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary,
),
),
),
),
// Inputs pseudo-node.
Positioned(
left: _inputsX,
top: _inputsY,
child: FlowNode(
id: '__inputs__',
title: 'inputs',
kind: NodeVisualKind.inputs,
inputPortLabels: graph.inputs.keys
.map((k) => '$k: ${graph.inputs[k]!.type}')
.toList(),
selected: false,
),
),
// Outputs pseudo-node.
Positioned(
left: outputsX,
top: _inputsY,
child: FlowNode(
id: '__outputs__',
title: 'outputs',
kind: NodeVisualKind.outputs,
inputPortLabels: graph.outputs.keys.toList(),
selected: false,
),
),
// Step nodes positioned absolutely, drag to
// move, click to select.
for (final step in graph.steps)
_stepPositioned(step, layout, outputsX),
// Port hit-targets for connection drawing. A
// transparent overlay positioned over each port
// easier to manage than per-port GestureDetectors
// inside the node widget because connection drags
// need to cross node boundaries (start in one node,
// end in another).
..._portOverlays(graph, layout, outputsX),
if (_draft != null)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: EdgePainter(
segments: [
EdgeSegment(
from: _draft!.from,
to: _draft!.cursor,
accent: EdgeAccent.draftDrag,
),
],
baseColor: theme.colorScheme.primary,
highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary,
),
),
),
),
],
),
),
),
);
}
// --- Step positioning + drag ---
Widget _stepPositioned(FlowStep step, FlowLayout layout, double _) {
final pos = layout.positions[step.id];
if (pos == null) return const SizedBox.shrink();
final selected = widget.controller.selectedStepId == step.id;
final status = widget.stepStatuses[step.id] ?? FlowNodeStatus.idle;
return Positioned(
left: pos.x,
top: pos.y,
child: FlowNode(
id: step.id,
title: step.id,
subtitle: step.use,
inputPortLabels: step.with_.keys.toList(),
kind: kindForStep(step),
selected: selected,
status: status,
onTap: () => widget.controller.selectStep(step.id),
onDrag: (delta) {
final scale = _transform.value.getMaxScaleOnAxis();
final scaledDelta = delta / scale;
final newPos = NodePosition(
(pos.x + scaledDelta.dx).clamp(
0.0,
_canvasWidth - NodeGeometry.width,
),
(pos.y + scaledDelta.dy).clamp(
0.0,
_canvasHeight - NodeGeometry.heightFor(step.with_.length),
),
);
widget.controller.moveStep(step.id, newPos);
},
),
);
}
// --- Port positions in canvas coordinates ---
Offset _outputPortPosition(
String nodeId,
FlowLayout layout,
double outputsX,
) {
if (nodeId == '__inputs__') {
// Doesn't have an "output" in the canvas sense — the
// inputs pseudo-node exposes its declared inputs as
// ports on its right edge, one per input. The
// _outputPortPosition is called only for step nodes.
// This branch is unreachable; return safely.
return const Offset(0, 0);
}
final pos = layout.positions[nodeId];
if (pos == null) return const Offset(0, 0);
return Offset(
pos.x + NodeGeometry.width,
pos.y + NodeGeometry.outputPortY(),
);
}
Offset _inputPortPosition(
String nodeId,
int portIndex,
FlowLayout layout,
double outputsX,
) {
if (nodeId == '__outputs__') {
return Offset(outputsX, _inputsY + NodeGeometry.inputPortY(portIndex));
}
final pos = layout.positions[nodeId];
if (pos == null) return const Offset(0, 0);
return Offset(pos.x, pos.y + NodeGeometry.inputPortY(portIndex));
}
// Inputs node exposes one port per declared input on its
// RIGHT edge every input is a "source" of data.
Offset _inputsPseudoPortPosition(int portIndex) {
return Offset(
_inputsX + NodeGeometry.width,
_inputsY + NodeGeometry.inputPortY(portIndex),
);
}
// --- Edge build (graph -> render segments) ---
List<EdgeSegment> _buildSegments(
FlowGraph graph,
FlowLayout layout,
double outputsX,
) {
final out = <EdgeSegment>[];
final inputsList = graph.inputs.keys.toList();
for (final edge in graph.edges) {
Offset? from;
Offset? to;
if (edge.fromKind == EdgeEndpointKind.inputs) {
final idx = inputsList.indexOf(edge.fromField);
if (idx >= 0) from = _inputsPseudoPortPosition(idx);
} else if (edge.fromKind == EdgeEndpointKind.step) {
from = _outputPortPosition(edge.fromId, layout, outputsX);
}
if (edge.toKind == EdgeEndpointKind.step) {
final step = graph.steps.firstWhere(
(s) => s.id == edge.toId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
final idx = step.with_.keys.toList().indexOf(edge.toField);
if (idx >= 0) {
to = _inputPortPosition(edge.toId, idx, layout, outputsX);
}
} else if (edge.toKind == EdgeEndpointKind.outputs) {
final outputsList = graph.outputs.keys.toList();
final idx = outputsList.indexOf(edge.toField);
if (idx >= 0) {
to = _inputPortPosition('__outputs__', idx, layout, outputsX);
}
}
if (from == null || to == null) continue;
final highlight =
edge.fromId == widget.controller.selectedStepId ||
edge.toId == widget.controller.selectedStepId;
out.add(
EdgeSegment(
from: from,
to: to,
accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal,
),
);
}
return out;
}
// --- Port overlays (drag handles for creating edges) ---
Iterable<Widget> _portOverlays(
FlowGraph graph,
FlowLayout layout,
double outputsX,
) sync* {
// Output ports only on step nodes. Inputs node has its
// own input-list port handles below.
for (final step in graph.steps) {
final p = _outputPortPosition(step.id, layout, outputsX);
yield _portDot(
center: p,
isSource: true,
onDragStart: () => _draft = _ConnectionDraft(
fromKind: _DraftSourceKind.step,
fromId: step.id,
from: p,
cursor: p,
),
);
}
// Inputs pseudo-node output ports (one per input).
final inputs = graph.inputs.keys.toList();
for (var i = 0; i < inputs.length; i++) {
final p = _inputsPseudoPortPosition(i);
final fieldName = inputs[i];
yield _portDot(
center: p,
isSource: true,
onDragStart: () => _draft = _ConnectionDraft(
fromKind: _DraftSourceKind.inputsField,
fromId: fieldName,
from: p,
cursor: p,
),
);
}
// Step input port targets.
for (final step in graph.steps) {
final keys = step.with_.keys.toList();
for (var i = 0; i < keys.length; i++) {
final p = _inputPortPosition(step.id, i, layout, outputsX);
yield _portDot(
center: p,
isSource: false,
onDropTarget: _dropTargetFor(step.id, keys[i], _DraftTargetKind.step),
);
}
}
// Outputs pseudo-node input ports.
final outs = graph.outputs.keys.toList();
for (var i = 0; i < outs.length; i++) {
final p = _inputPortPosition('__outputs__', i, layout, outputsX);
yield _portDot(
center: p,
isSource: false,
onDropTarget: _dropTargetFor(
'__outputs__',
outs[i],
_DraftTargetKind.outputsField,
),
);
}
}
Widget _portDot({
required Offset center,
required bool isSource,
VoidCallback? onDragStart,
void Function(Offset cursor)? onDropTarget,
}) {
final theme = Theme.of(context);
const size = 16.0;
return Positioned(
left: center.dx - size / 2,
top: center.dy - size / 2,
width: size,
height: size,
child: MouseRegion(
cursor: isSource ? SystemMouseCursors.grab : SystemMouseCursors.cell,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: !isSource
? null
: (details) {
onDragStart?.call();
setState(() {});
},
onPanUpdate: !isSource || _draft == null
? null
: (details) {
final scale = _transform.value.getMaxScaleOnAxis();
setState(() {
_draft = _draft!.withCursor(
_draft!.cursor + details.delta / scale,
);
});
},
onPanEnd: !isSource || _draft == null
? null
: (_) {
_finalizeDraft();
},
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSource
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
border: Border.all(color: theme.colorScheme.primary, width: 1.5),
),
),
),
),
);
}
void Function(Offset) _dropTargetFor(
String targetId,
String targetField,
_DraftTargetKind kind,
) {
return (cursor) {
// Snap detection happens in _finalizeDraft via spatial
// search; this callback is for future port-level hit
// tracking if we add fine-grained drop highlighting.
};
}
void _finalizeDraft() {
final draft = _draft;
setState(() => _draft = null);
if (draft == null) return;
// Find the closest input port within tolerance.
final graph = widget.controller.graph;
final layout = widget.controller.layout;
final outputsX = _outputsX(graph, layout);
_DropTarget? best;
double bestDist = double.infinity;
const maxDist = 32.0;
for (final step in graph.steps) {
final keys = step.with_.keys.toList();
for (var i = 0; i < keys.length; i++) {
final p = _inputPortPosition(step.id, i, layout, outputsX);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
bestDist = d;
best = _DropTarget(
kind: _DraftTargetKind.step,
id: step.id,
field: keys[i],
);
}
}
}
final outs = graph.outputs.keys.toList();
for (var i = 0; i < outs.length; i++) {
final p = _inputPortPosition('__outputs__', i, layout, outputsX);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
bestDist = d;
best = _DropTarget(
kind: _DraftTargetKind.outputsField,
id: '__outputs__',
field: outs[i],
);
}
}
if (best == null) return;
_applyConnection(draft, best);
}
void _applyConnection(_ConnectionDraft draft, _DropTarget target) {
final graph = widget.controller.graph;
// Compose the $source.field expression. For step
// sources, we don't know the precise output field name
// (modules have varied output names); use "result" as a
// placeholder so the YAML is syntactically valid, and
// let the operator refine it in the properties panel.
final String expression;
switch (draft.fromKind) {
case _DraftSourceKind.step:
expression = '\$${draft.fromId}.result';
case _DraftSourceKind.inputsField:
expression = '\$inputs.${draft.fromId}';
}
switch (target.kind) {
case _DraftTargetKind.step:
final step = graph.steps.firstWhere((s) => s.id == target.id);
final newWith = {...step.with_, target.field: expression};
widget.controller.applyGraphEdit(
graph.withStepUpdated(target.id, step.copyWith(with_: newWith)),
);
case _DraftTargetKind.outputsField:
widget.controller.applyGraphEdit(
FlowGraph(
name: graph.name,
inputs: graph.inputs,
steps: graph.steps,
outputs: {...graph.outputs, target.field: expression},
leadingComment: graph.leadingComment,
),
);
}
}
// --- Outputs node placement ---
double _outputsX(FlowGraph graph, FlowLayout layout) {
double maxX = _inputsX + NodeGeometry.width + 200;
for (final step in graph.steps) {
final pos = layout.positions[step.id];
if (pos == null) continue;
final right = pos.x + NodeGeometry.width;
if (right > maxX) maxX = right;
}
return maxX + 120;
}
// --- Background ---
Widget _grid(ThemeData theme) {
return Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _DotGridPainter(
color: theme.dividerColor.withValues(alpha: 0.55),
),
),
),
);
}
}
enum _DraftSourceKind { step, inputsField }
enum _DraftTargetKind { step, outputsField }
class _ConnectionDraft {
final _DraftSourceKind fromKind;
final String fromId;
final Offset from;
final Offset cursor;
const _ConnectionDraft({
required this.fromKind,
required this.fromId,
required this.from,
required this.cursor,
});
_ConnectionDraft withCursor(Offset c) => _ConnectionDraft(
fromKind: fromKind,
fromId: fromId,
from: from,
cursor: c,
);
}
class _DropTarget {
final _DraftTargetKind kind;
final String id;
final String field;
const _DropTarget({
required this.kind,
required this.id,
required this.field,
});
}
class _DotGridPainter extends CustomPainter {
final Color color;
_DotGridPainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
const spacing = 24.0;
final paint = Paint()..color = color;
for (double x = 0; x < size.width; x += spacing) {
for (double y = 0; y < size.height; y += spacing) {
canvas.drawCircle(Offset(x, y), 0.8, paint);
}
}
}
@override
bool shouldRepaint(_DotGridPainter old) => old.color != color;
}

View file

@ -0,0 +1,264 @@
// FlowNode the visual representation of one step on the
// canvas. Three visual variants:
//
// - module step: rounded card, gear icon, primary border
// when selected
// - approval step: tertiary tint, hand-up icon, marks where
// the flow waits for a human
// - inputs / outputs sidebar nodes: tall, pinned to edges
//
// The card exposes named ports on its left (one per `with:`
// key) and a single combined port on its right that all
// downstream edges leave from. Ports are small filled circles
// just outside the card border so edges can visually anchor
// to them.
import 'package:flutter/material.dart';
import '../model/flow_graph.dart';
import '../tokens.dart';
/// Geometry constants the canvas + edge painter rely on to
/// place ports without measuring widgets at runtime. Changing
/// any of these requires reviewing edge_painter.dart.
class NodeGeometry {
static const double width = 220;
static const double headerHeight = 36;
static const double portRowHeight = 22;
static const double bottomPadding = 12;
static const double portRadius = 6;
// Offset from the card's outer edge for the port's center.
// Half-out so the dot visually "attaches" to the side.
static const double portInset = 0;
/// Total height for a step node given its with-field count.
/// Approval and inputs/outputs variants use the same maths
/// so the canvas can place every node identically.
static double heightFor(int portCount) {
final base = headerHeight + bottomPadding;
final ports = portCount * portRowHeight;
return base + ports + 8;
}
/// Y offset of an input port's centre, measured from the
/// top of the card. The first input port sits below the
/// header with a small gap.
static double inputPortY(int index) {
return headerHeight + 6 + index * portRowHeight + portRowHeight / 2;
}
/// Y offset of the single output port. Vertically centred
/// in the header band so the edge entry feels balanced.
static double outputPortY() {
return headerHeight / 2;
}
}
enum NodeVisualKind { module, approval, inputs, outputs }
class FlowNode extends StatelessWidget {
final String id;
final String title;
final String? subtitle;
final List<String> inputPortLabels;
final NodeVisualKind kind;
final bool selected;
final VoidCallback? onTap;
final void Function(Offset delta)? onDrag;
final VoidCallback? onDragEnd;
/// Live status from the most recent run, when this node is
/// a step. Coloured dot in the header so the operator can
/// glance at the canvas and see what's running.
final FlowNodeStatus status;
const FlowNode({
super.key,
required this.id,
required this.title,
this.subtitle,
this.inputPortLabels = const [],
this.kind = NodeVisualKind.module,
this.selected = false,
this.status = FlowNodeStatus.idle,
this.onTap,
this.onDrag,
this.onDragEnd,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _accent(theme.colorScheme);
final height = NodeGeometry.heightFor(inputPortLabels.length);
return SizedBox(
width: NodeGeometry.width,
height: height,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta),
onPanEnd: onDragEnd == null ? null : (_) => onDragEnd!(),
child: Material(
color: theme.colorScheme.surfaceContainerHigh,
elevation: selected ? 6 : 2,
shadowColor: selected ? accent.withValues(alpha: 0.35) : null,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(
color: selected ? accent : theme.dividerColor,
width: selected ? 2 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(theme, accent),
Expanded(child: _body(theme)),
],
),
),
),
),
);
}
Widget _header(ThemeData theme, Color accent) {
return Container(
height: NodeGeometry.headerHeight,
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.16),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(FaiRadius.md),
topRight: Radius.circular(FaiRadius.md),
),
),
child: Row(
children: [
Icon(_iconFor(kind), size: 16, color: accent),
const SizedBox(width: FaiSpace.xs),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
color: accent,
),
),
),
if (status != FlowNodeStatus.idle) _statusDot(theme),
],
),
);
}
Widget _body(ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(
FaiSpace.sm,
4,
FaiSpace.sm,
FaiSpace.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
),
),
),
for (final label in inputPortLabels)
SizedBox(
height: NodeGeometry.portRowHeight,
child: Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant,
shape: BoxShape.circle,
),
),
const SizedBox(width: FaiSpace.xs),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurface,
),
),
),
],
),
),
],
),
);
}
Widget _statusDot(ThemeData theme) {
final color = switch (status) {
FlowNodeStatus.running => theme.colorScheme.primary,
FlowNodeStatus.done => Colors.green.shade400,
FlowNodeStatus.failed => theme.colorScheme.error,
FlowNodeStatus.awaiting => theme.colorScheme.tertiary,
FlowNodeStatus.idle => Colors.transparent,
};
return Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: status == FlowNodeStatus.running
? [BoxShadow(color: color.withValues(alpha: 0.6), blurRadius: 6)]
: null,
),
);
}
IconData _iconFor(NodeVisualKind k) {
return switch (k) {
NodeVisualKind.module => Icons.widgets_outlined,
NodeVisualKind.approval => Icons.pan_tool_outlined,
NodeVisualKind.inputs => Icons.input,
NodeVisualKind.outputs => Icons.output,
};
}
Color _accent(ColorScheme cs) {
return switch (kind) {
NodeVisualKind.module => cs.primary,
NodeVisualKind.approval => cs.tertiary,
NodeVisualKind.inputs => cs.secondary,
NodeVisualKind.outputs => cs.secondary,
};
}
}
enum FlowNodeStatus { idle, running, done, failed, awaiting }
/// Render-time helper: figure out which visual variant to use
/// for a parsed step.
NodeVisualKind kindForStep(FlowStep step) {
if (step.isApproval) return NodeVisualKind.approval;
return NodeVisualKind.module;
}

View file

@ -0,0 +1,404 @@
// PropertiesPanel side drawer showing the currently
// selected step's details.
//
// Lets the operator edit:
// - step `id` (with collision warning before applying)
// - step `use` (capability spec)
// - each `with:` key/value pair (rename key, edit value,
// add new key, remove key)
// - delete the entire step
//
// The panel emits FlowGraph mutations through the controller,
// which re-emits the YAML buffer + updates the canvas in one
// pass.
import 'package:flutter/material.dart';
import '../editor_controller.dart';
import '../l10n.dart';
import '../model/flow_graph.dart';
import '../tokens.dart';
class PropertiesPanel extends StatefulWidget {
final FlowEditorController controller;
final FlowEditorStrings strings;
final List<String> availableCapabilities;
const PropertiesPanel({
super.key,
required this.controller,
required this.strings,
this.availableCapabilities = const [],
});
@override
State<PropertiesPanel> createState() => _PropertiesPanelState();
}
class _PropertiesPanelState extends State<PropertiesPanel> {
// Local edit buffers committed back to the controller
// on focus loss / explicit save so each keystroke doesn't
// round-trip through YAML emission.
final _idCtrl = TextEditingController();
final _useCtrl = TextEditingController();
// Per-with-field controllers, keyed by current field name.
final Map<String, TextEditingController> _withCtrls = {};
String? _trackedStepId;
@override
void initState() {
super.initState();
widget.controller.addListener(_sync);
_sync();
}
@override
void dispose() {
widget.controller.removeListener(_sync);
_idCtrl.dispose();
_useCtrl.dispose();
for (final c in _withCtrls.values) {
c.dispose();
}
super.dispose();
}
void _sync() {
final selected = widget.controller.selectedStepId;
if (selected != _trackedStepId) {
_trackedStepId = selected;
for (final c in _withCtrls.values) {
c.dispose();
}
_withCtrls.clear();
if (selected != null) {
final step = widget.controller.graph.steps.firstWhere(
(s) => s.id == selected,
orElse: () => _empty,
);
_idCtrl.text = step.id;
_useCtrl.text = step.use;
for (final entry in step.with_.entries) {
_withCtrls[entry.key] = TextEditingController(
text: entry.value.toString(),
);
}
}
}
if (mounted) setState(() {});
}
static const _empty = FlowStep(id: '', use: '');
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final controller = widget.controller;
final selected = controller.selectedStepId;
if (selected == null) return _emptyHint(theme);
final step = controller.graph.steps.firstWhere(
(s) => s.id == selected,
orElse: () => _empty,
);
if (step.id.isEmpty) return _emptyHint(theme);
return Container(
color: theme.colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(theme, step),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_section(theme, widget.strings.propStepId),
_idField(theme),
const SizedBox(height: FaiSpace.md),
_section(theme, widget.strings.propCapability),
_useField(theme),
const SizedBox(height: FaiSpace.md),
_section(theme, widget.strings.propInputs),
..._withFields(theme),
TextButton.icon(
onPressed: _addWithField,
icon: const Icon(Icons.add, size: 16),
label: Text(widget.strings.propAddInput),
),
const SizedBox(height: FaiSpace.lg),
OutlinedButton.icon(
onPressed: () => _deleteStep(step.id),
icon: const Icon(Icons.delete_outline, size: 16),
label: Text(widget.strings.propDeleteStep),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
),
),
],
),
),
),
],
),
);
}
Widget _emptyHint(ThemeData theme) {
return Container(
color: theme.colorScheme.surface,
padding: const EdgeInsets.all(FaiSpace.lg),
child: Center(
child: Text(
widget.strings.propNoSelection,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
Widget _header(ThemeData theme, FlowStep step) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
border: Border(bottom: BorderSide(color: theme.dividerColor)),
),
child: Row(
children: [
Icon(
step.isApproval ? Icons.pan_tool_outlined : Icons.widgets_outlined,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: FaiSpace.xs),
Expanded(
child: Text(
widget.strings.propPanelTitle,
style: theme.textTheme.titleSmall?.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 _idField(ThemeData theme) {
return TextField(
controller: _idCtrl,
style: const TextStyle(fontFamily: 'monospace'),
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (_) => _commitId(),
onTapOutside: (_) => _commitId(),
);
}
Widget _useField(ThemeData theme) {
final caps = widget.availableCapabilities;
if (caps.isEmpty) {
return TextField(
controller: _useCtrl,
style: const TextStyle(fontFamily: 'monospace'),
decoration: const InputDecoration(
isDense: true,
hintText: 'e.g. text.extract@^0',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _commitUse(),
onTapOutside: (_) => _commitUse(),
);
}
return Autocomplete<String>(
initialValue: TextEditingValue(text: _useCtrl.text),
optionsBuilder: (input) {
final query = input.text.toLowerCase();
if (query.isEmpty) return caps;
return caps.where((c) => c.toLowerCase().contains(query));
},
onSelected: (sel) {
_useCtrl.text = sel;
_commitUse();
},
fieldViewBuilder: (ctx, fieldCtrl, focus, onSubmit) {
fieldCtrl.text = _useCtrl.text;
return TextField(
controller: fieldCtrl,
focusNode: focus,
style: const TextStyle(fontFamily: 'monospace'),
decoration: const InputDecoration(
isDense: true,
hintText: 'e.g. text.extract@^0',
border: OutlineInputBorder(),
),
onSubmitted: (val) {
_useCtrl.text = val;
_commitUse();
},
onTapOutside: (_) {
_useCtrl.text = fieldCtrl.text;
_commitUse();
},
);
},
);
}
List<Widget> _withFields(ThemeData theme) {
final entries = _withCtrls.entries.toList();
return [
for (final entry in entries)
Padding(
padding: const EdgeInsets.only(bottom: FaiSpace.sm),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: TextField(
controller: TextEditingController(text: entry.key),
style: const TextStyle(fontFamily: 'monospace'),
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (newKey) => _renameWithField(entry.key, newKey),
),
),
const SizedBox(width: FaiSpace.xs),
Expanded(
child: TextField(
controller: entry.value,
style: const TextStyle(fontFamily: 'monospace'),
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (_) => _commitWithFields(),
onTapOutside: (_) => _commitWithFields(),
),
),
IconButton(
onPressed: () => _removeWithField(entry.key),
icon: const Icon(Icons.close, size: 16),
tooltip: widget.strings.propRemoveInput,
visualDensity: VisualDensity.compact,
),
],
),
),
];
}
void _commitId() {
final selected = widget.controller.selectedStepId;
if (selected == null) return;
final newId = _idCtrl.text.trim();
if (newId.isEmpty || newId == selected) return;
final graph = widget.controller.graph;
// Reject collisions silently UI should ideally show a
// warning. For now: refuse to apply, snap controller back.
if (graph.steps.any((s) => s.id == newId)) {
_idCtrl.text = selected;
return;
}
final step = graph.steps.firstWhere((s) => s.id == selected);
widget.controller.applyGraphEdit(
graph.withStepUpdated(selected, step.copyWith(id: newId)),
);
widget.controller.selectStep(newId);
}
void _commitUse() {
final selected = widget.controller.selectedStepId;
if (selected == null) return;
final graph = widget.controller.graph;
final step = graph.steps.firstWhere((s) => s.id == selected);
if (step.use == _useCtrl.text) return;
widget.controller.applyGraphEdit(
graph.withStepUpdated(selected, step.copyWith(use: _useCtrl.text)),
);
}
void _commitWithFields() {
final selected = widget.controller.selectedStepId;
if (selected == null) return;
final graph = widget.controller.graph;
final step = graph.steps.firstWhere((s) => s.id == selected);
final newWith = <String, dynamic>{
for (final entry in _withCtrls.entries) entry.key: entry.value.text,
};
if (_mapsEqual(step.with_, newWith)) return;
widget.controller.applyGraphEdit(
graph.withStepUpdated(selected, step.copyWith(with_: newWith)),
);
}
void _renameWithField(String oldKey, String newKey) {
final selected = widget.controller.selectedStepId;
if (selected == null) return;
if (newKey.isEmpty || newKey == oldKey) return;
if (_withCtrls.containsKey(newKey)) return; // collision
final ctrl = _withCtrls.remove(oldKey);
if (ctrl != null) _withCtrls[newKey] = ctrl;
_commitWithFields();
}
void _addWithField() {
final base = 'value';
String name = base;
var i = 1;
while (_withCtrls.containsKey(name)) {
name = '${base}_$i';
i++;
}
setState(() {
_withCtrls[name] = TextEditingController(text: '');
});
_commitWithFields();
}
void _removeWithField(String key) {
final ctrl = _withCtrls.remove(key);
ctrl?.dispose();
setState(() {});
_commitWithFields();
}
void _deleteStep(String id) {
widget.controller.applyGraphEdit(
widget.controller.graph.withStepRemoved(id),
);
widget.controller.selectStep(null);
}
bool _mapsEqual(Map<String, dynamic> a, Map<String, dynamic> b) {
if (a.length != b.length) return false;
for (final entry in a.entries) {
if (b[entry.key].toString() != entry.value.toString()) return false;
}
return true;
}
}

View file

@ -0,0 +1,564 @@
// 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);
case StepCompleted():
_liveSteps[event.stepId] = _StepState(
kind: _StepKind.done,
durationMs: event.durationMs,
);
case StepFailed():
_liveSteps[event.stepId] = _StepState(
kind: _StepKind.error,
error: event.error,
);
case StepAwaitingApproval():
_liveSteps[event.stepId] = const _StepState(kind: _StepKind.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});
}