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:
parent
dbd9a0004f
commit
870cbc29f7
12 changed files with 2769 additions and 408 deletions
184
lib/src/editor_controller.dart
Normal file
184
lib/src/editor_controller.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue