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