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