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

@ -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;
}