// 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'; /// Mirrors `FlowNodeStatus` from the flow_node widget, but /// lives in the controller so the run tab can update it /// without importing widgets. The canvas adapts between /// the two. enum StepRunStatus { idle, running, done, failed, awaiting } 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; /// Live per-step status for the in-flight or most-recent /// run. The canvas pulls colours and icons out of this map /// so the graph view shows real-time progress even when /// the operator triggered the run from the run tab. Map get stepStatuses => Map.unmodifiable(_stepStatuses); final Map _stepStatuses = {}; /// 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; if (v) { _stepStatuses.clear(); } notifyListeners(); } /// Update the live status of one step. Called by the run /// tab as events arrive from the host's FlowRunDriver. void updateStepStatus(String stepId, StepRunStatus status) { if (_stepStatuses[stepId] == status) return; _stepStatuses[stepId] = status; notifyListeners(); } /// User clicked a node on the canvas. Clicking the /// already-selected step deselects it (toggle semantics) — /// matches every modern node editor. Pass null explicitly /// to deselect without re-clicking (e.g. background tap). void selectStep(String? stepId) { final next = stepId != null && _selectedStepId == stepId ? null : stepId; if (_selectedStepId == next) return; _selectedStepId = next; 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(); } /// Wipe every position in the sidecar and re-run auto- /// layout from scratch. Useful when manual drags have /// drifted into spaghetti and the operator wants a clean /// topological column layout again. The YAML is untouched. void resetLayout() { _layout = AutoLayout.layout(_graph, FlowLayout.empty()); 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(); }); } }