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
|
|
@ -1,17 +1,38 @@
|
||||||
/// Inline YAML editor for F∆I Studio flows — swappable.
|
/// Inline flow editor for F∆I Studio — swappable.
|
||||||
///
|
///
|
||||||
/// Studio depends on this package via pubspec.yaml. To use a
|
/// Studio depends on this package via pubspec.yaml. To use
|
||||||
/// different editor, fork this repo, change the implementation,
|
/// a different editor, fork this repo, change the
|
||||||
/// point Studio's pubspec at the fork, rebuild Studio.
|
/// implementation, point Studio's pubspec at the fork,
|
||||||
|
/// rebuild Studio.
|
||||||
///
|
///
|
||||||
/// Public surface:
|
/// Public surface:
|
||||||
|
///
|
||||||
/// * [FlowEditorPage] — the page widget Studio embeds as
|
/// * [FlowEditorPage] — the page widget Studio embeds as
|
||||||
/// its "Editor" destination + as a route push from the
|
/// its Flows destination. Three tabs over one in-memory
|
||||||
/// Flows page's pencil icon.
|
/// flow: graph (drag-and-drop WYSIWYG), text (raw YAML),
|
||||||
/// * [FlowEditorLocale] — the locale the package renders
|
/// run (inputs form + live step progress + outputs).
|
||||||
/// its inline strings in. Studio passes its active
|
/// * [FlowEditorLocale] — render language for inline
|
||||||
/// Locale through.
|
/// strings. Studio passes its active Locale through.
|
||||||
|
/// * [FlowRunDriver] — host-supplied bridge to the hub.
|
||||||
|
/// Studio implements it with the gRPC SDK; a CLI host
|
||||||
|
/// could shell out; a test harness can stub it in-memory.
|
||||||
|
/// * [FlowRunEvent] family — events the driver streams
|
||||||
|
/// during a run (StepStarted, StepCompleted, …).
|
||||||
|
/// * [FlowOutputValue] family — typed outputs the driver
|
||||||
|
/// returns when the run finishes.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/flow_editor_page.dart' show FlowEditorPage;
|
export 'src/flow_editor_page.dart' show FlowEditorPage;
|
||||||
export 'src/l10n.dart' show FlowEditorLocale;
|
export 'src/l10n.dart' show FlowEditorLocale;
|
||||||
|
export 'src/run_driver.dart'
|
||||||
|
show
|
||||||
|
FlowRunDriver,
|
||||||
|
FlowRunEvent,
|
||||||
|
StepStarted,
|
||||||
|
StepCompleted,
|
||||||
|
StepFailed,
|
||||||
|
StepAwaitingApproval,
|
||||||
|
FlowOutputValue,
|
||||||
|
FlowOutputText,
|
||||||
|
FlowOutputJson,
|
||||||
|
FlowOutputBytes;
|
||||||
|
|
|
||||||
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
/// FlowEditorPage — host-agnostic inline YAML editor.
|
// FlowEditorPage — the public-facing widget Studio (and any
|
||||||
///
|
// other host) embeds as its flow surface.
|
||||||
/// The page reads + writes flow YAML directly under
|
//
|
||||||
/// `~/.fai/data/flows/` via dart:io. The hub picks up changes
|
// Layout:
|
||||||
/// on its next ListFlows / RunSavedFlow call.
|
//
|
||||||
///
|
// ┌─ Toolbar ─────────────────────────────────────────────┐
|
||||||
/// Studio passes:
|
// │ [back] file.yaml ● [+ Step] [Save] [Run] │
|
||||||
/// * [initialFlowName] — preload a specific flow.
|
// ├──────┬────────────────────────────────────────────────┤
|
||||||
/// * [locale] — render inline strings in EN or DE.
|
// │ FLOWS│ [Graph][Text][Run] │
|
||||||
/// * [onRun] — async callback that runs the named flow
|
// │ ⟲ │ │
|
||||||
/// against the host hub. Returns the typed outputs map.
|
// │ │ (active tab content) │
|
||||||
/// When null, the Run button is disabled.
|
// │ list │ │
|
||||||
|
// │ │ │
|
||||||
|
// └──────┴────────────────────────────────────────────────┘
|
||||||
|
// └ Properties (overlay)
|
||||||
|
//
|
||||||
|
// All three tabs read state from a single [FlowEditorController].
|
||||||
|
// The graph tab and text tab keep YAML in lockstep — graph
|
||||||
|
// edits emit fresh YAML; text edits re-parse the graph on
|
||||||
|
// debounce. The run tab reads the saved-on-disk version
|
||||||
|
// because that's what the hub's runSavedFlow consumes.
|
||||||
|
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
@ -17,11 +27,17 @@ import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||||
import 'package:highlight/languages/yaml.dart';
|
|
||||||
|
|
||||||
|
import 'editor_controller.dart';
|
||||||
import 'l10n.dart';
|
import 'l10n.dart';
|
||||||
|
import 'model/flow_graph.dart';
|
||||||
|
import 'run_driver.dart';
|
||||||
import 'tokens.dart';
|
import 'tokens.dart';
|
||||||
import 'widgets.dart';
|
import 'widgets.dart';
|
||||||
|
import 'widgets/capability_picker.dart';
|
||||||
|
import 'widgets/flow_canvas.dart';
|
||||||
|
import 'widgets/properties_panel.dart';
|
||||||
|
import 'widgets/run_tab.dart';
|
||||||
|
|
||||||
/// Compute the flows directory under the operator's home.
|
/// Compute the flows directory under the operator's home.
|
||||||
String _defaultFlowsDir() {
|
String _defaultFlowsDir() {
|
||||||
|
|
@ -33,45 +49,48 @@ String _defaultFlowsDir() {
|
||||||
}
|
}
|
||||||
|
|
||||||
class FlowEditorPage extends StatefulWidget {
|
class FlowEditorPage extends StatefulWidget {
|
||||||
|
/// Pre-load this flow on open. When null, the editor starts
|
||||||
|
/// empty and the operator picks from the file list.
|
||||||
final String? initialFlowName;
|
final String? initialFlowName;
|
||||||
|
|
||||||
|
/// Language for inline strings.
|
||||||
final FlowEditorLocale locale;
|
final FlowEditorLocale locale;
|
||||||
final Future<Map<String, Object>> Function(String name)? onRun;
|
|
||||||
|
/// Bridge between editor and host's hub. When null, the
|
||||||
|
/// Run tab is read-only.
|
||||||
|
final FlowRunDriver? runDriver;
|
||||||
|
|
||||||
|
/// Capabilities the operator can drop into a new step.
|
||||||
|
/// Studio supplies the list from `HubService.listCapabilities()`.
|
||||||
|
/// Empty list = the picker shows a free-form text field.
|
||||||
|
final List<String> availableCapabilities;
|
||||||
|
|
||||||
const FlowEditorPage({
|
const FlowEditorPage({
|
||||||
super.key,
|
super.key,
|
||||||
this.initialFlowName,
|
this.initialFlowName,
|
||||||
this.locale = FlowEditorLocale.en,
|
this.locale = FlowEditorLocale.en,
|
||||||
this.onRun,
|
this.runDriver,
|
||||||
|
this.availableCapabilities = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FlowEditorPage> createState() => _FlowEditorPageState();
|
State<FlowEditorPage> createState() => _FlowEditorPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FlowEditorPageState extends State<FlowEditorPage> {
|
class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
late final CodeController _code;
|
with TickerProviderStateMixin {
|
||||||
|
late final FlowEditorController _controller;
|
||||||
late final FlowEditorStrings _l;
|
late final FlowEditorStrings _l;
|
||||||
String? _activeName;
|
|
||||||
// Baseline captured AFTER the CodeController has finished
|
|
||||||
// processing the loaded text (line-ending normalisation,
|
|
||||||
// trailing-newline handling, fold-marker insertion, …).
|
|
||||||
// Comparing _code.fullText against this baseline avoids the
|
|
||||||
// false-positive "discard unsaved changes?" prompts the
|
|
||||||
// naive _code.text == _loadedText check used to trigger on
|
|
||||||
// files the controller silently rewrote during load.
|
|
||||||
String _baseline = '';
|
|
||||||
late Future<List<_FlowFile>> _files;
|
late Future<List<_FlowFile>> _files;
|
||||||
Object? _runError;
|
late final TabController _tabs;
|
||||||
Map<String, Object>? _runOutputs;
|
|
||||||
bool _running = false;
|
|
||||||
bool _saving = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_l = FlowEditorStrings(widget.locale);
|
_l = FlowEditorStrings(widget.locale);
|
||||||
_code = CodeController(text: '', language: yaml);
|
_controller = FlowEditorController();
|
||||||
_code.addListener(_onCodeChange);
|
_controller.addListener(_onCtrlChanged);
|
||||||
|
_tabs = TabController(length: 3, vsync: this);
|
||||||
_files = _listFiles();
|
_files = _listFiles();
|
||||||
final initial = widget.initialFlowName;
|
final initial = widget.initialFlowName;
|
||||||
if (initial != null && initial.isNotEmpty) {
|
if (initial != null && initial.isNotEmpty) {
|
||||||
|
|
@ -83,16 +102,17 @@ class _FlowEditorPageState extends State<FlowEditorPage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_code.removeListener(_onCodeChange);
|
_controller.removeListener(_onCtrlChanged);
|
||||||
_code.dispose();
|
_controller.dispose();
|
||||||
|
_tabs.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onCodeChange() {
|
void _onCtrlChanged() {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _dirty => _activeName != null && _code.fullText != _baseline;
|
// --- file ops ---
|
||||||
|
|
||||||
Future<List<_FlowFile>> _listFiles() async {
|
Future<List<_FlowFile>> _listFiles() async {
|
||||||
final dir = Directory(_defaultFlowsDir());
|
final dir = Directory(_defaultFlowsDir());
|
||||||
|
|
@ -120,42 +140,25 @@ class _FlowEditorPageState extends State<FlowEditorPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openByName(String name) async {
|
Future<void> _openByName(String name) async {
|
||||||
final dir = Directory(_defaultFlowsDir());
|
final path = '${_defaultFlowsDir()}/$name.yaml';
|
||||||
final path = '${dir.path}/$name.yaml';
|
final file = File(path);
|
||||||
final f = File(path);
|
if (!file.existsSync()) return;
|
||||||
if (!f.existsSync()) return;
|
final text = await file.readAsString();
|
||||||
final text = await f.readAsString();
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// Set the controller text first, then snapshot fullText
|
_controller.openFlow(name, text);
|
||||||
// as the baseline. Reading fullText after the assignment
|
|
||||||
// captures whatever line-ending normalisation / fold-
|
|
||||||
// marker insertion CodeController applied, so the dirty
|
|
||||||
// check is comparing apples to apples.
|
|
||||||
_code.fullText = text;
|
|
||||||
setState(() {
|
|
||||||
_activeName = name;
|
|
||||||
_baseline = _code.fullText;
|
|
||||||
_runOutputs = null;
|
|
||||||
_runError = null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openFile(_FlowFile f) async {
|
Future<void> _openFile(_FlowFile f) async {
|
||||||
if (_dirty) {
|
if (_controller.isDirty) {
|
||||||
final keep = await _confirmDiscard();
|
final keep = await _confirmDiscard();
|
||||||
if (keep == false || !mounted) return;
|
if (keep == false || !mounted) return;
|
||||||
}
|
}
|
||||||
final text = await File(f.path).readAsString();
|
final text = await File(f.path).readAsString();
|
||||||
_code.fullText = text;
|
if (!mounted) return;
|
||||||
setState(() {
|
_controller.openFlow(f.name, text);
|
||||||
_activeName = f.name;
|
|
||||||
_baseline = _code.fullText;
|
|
||||||
_runOutputs = null;
|
|
||||||
_runError = null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool?> _confirmDiscard() async {
|
Future<bool?> _confirmDiscard() {
|
||||||
return showDialog<bool>(
|
return showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|
@ -176,27 +179,26 @@ class _FlowEditorPageState extends State<FlowEditorPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
final name = _activeName;
|
final name = _controller.activeName;
|
||||||
if (name == null) return;
|
if (name == null) return;
|
||||||
setState(() => _saving = true);
|
_controller.saving = true;
|
||||||
try {
|
try {
|
||||||
final f = File('${_defaultFlowsDir()}/$name.yaml');
|
final file = File('${_defaultFlowsDir()}/$name.yaml');
|
||||||
// Write fullText — `text` is post-fold visible-only, so
|
await file.writeAsString(
|
||||||
// saving it would lose any content the editor has hidden
|
_controller.codeController.fullText,
|
||||||
// behind a collapsed fold.
|
flush: true,
|
||||||
await f.writeAsString(_code.fullText, flush: true);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
_controller.markSaved();
|
||||||
_baseline = _code.fullText;
|
|
||||||
_files = _listFiles();
|
_files = _listFiles();
|
||||||
});
|
setState(() {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text(e.toString())));
|
).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _saving = false);
|
_controller.saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,40 +208,40 @@ class _FlowEditorPageState extends State<FlowEditorPage> {
|
||||||
builder: (ctx) => _NewFlowDialog(strings: _l),
|
builder: (ctx) => _NewFlowDialog(strings: _l),
|
||||||
);
|
);
|
||||||
if (name == null || name.isEmpty || !mounted) return;
|
if (name == null || name.isEmpty || !mounted) return;
|
||||||
final template = '''# ${_l.newTemplateComment(name)}
|
final template =
|
||||||
|
'''# ${_l.newTemplateComment(name)}
|
||||||
|
|
||||||
|
name: $name
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
name:
|
text:
|
||||||
type: text
|
type: text
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: greet
|
- id: echo
|
||||||
use: debug.echo@^0
|
use: debug.echo@^0
|
||||||
with:
|
with:
|
||||||
text: "Hello, \${{ inputs.name }}!"
|
message: \$inputs.text
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
greeting: \${{ steps.greet.result }}
|
result: \$echo.echoed
|
||||||
''';
|
''';
|
||||||
try {
|
try {
|
||||||
final dir = Directory(_defaultFlowsDir());
|
final dir = Directory(_defaultFlowsDir());
|
||||||
if (!dir.existsSync()) await dir.create(recursive: true);
|
if (!dir.existsSync()) await dir.create(recursive: true);
|
||||||
final f = File('${dir.path}/$name.yaml');
|
final file = File('${dir.path}/$name.yaml');
|
||||||
if (f.existsSync()) {
|
if (file.existsSync()) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text(_l.alreadyExists(name))));
|
).showSnackBar(SnackBar(content: Text(_l.alreadyExists(name))));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await f.writeAsString(template, flush: true);
|
await file.writeAsString(template, flush: true);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_code.fullText = template;
|
_controller.openFlow(name, template);
|
||||||
setState(() {
|
|
||||||
_activeName = name;
|
|
||||||
_baseline = _code.fullText;
|
|
||||||
_files = _listFiles();
|
_files = _listFiles();
|
||||||
});
|
setState(() {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
|
|
@ -248,37 +250,44 @@ outputs:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _run() async {
|
Future<void> _refreshFiles() async {
|
||||||
final name = _activeName;
|
|
||||||
final onRun = widget.onRun;
|
|
||||||
if (name == null || onRun == null) return;
|
|
||||||
if (_dirty) await _save();
|
|
||||||
setState(() {
|
|
||||||
_running = true;
|
|
||||||
_runOutputs = null;
|
|
||||||
_runError = null;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
final out = await onRun(name);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _runOutputs = out);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _runError = e);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _running = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
|
||||||
setState(() => _files = _listFiles());
|
setState(() => _files = _listFiles());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _addStep() async {
|
||||||
|
final picked = await CapabilityPicker.show(
|
||||||
|
context,
|
||||||
|
capabilities: widget.availableCapabilities,
|
||||||
|
strings: _l,
|
||||||
|
);
|
||||||
|
if (picked == null || !mounted) return;
|
||||||
|
final graph = _controller.graph;
|
||||||
|
// Generate a fresh id — base on the capability's local
|
||||||
|
// name, deduplicating against existing step ids.
|
||||||
|
final localPart = picked.split('/').last.split('@').first;
|
||||||
|
final baseId = localPart.split('.').last;
|
||||||
|
var id = baseId;
|
||||||
|
var i = 1;
|
||||||
|
while (graph.steps.any((s) => s.id == id)) {
|
||||||
|
id = '${baseId}_$i';
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
_controller.applyGraphEdit(
|
||||||
|
graph.withStepAdded(FlowStep(id: id, use: picked, with_: const {})),
|
||||||
|
);
|
||||||
|
_controller.selectStep(id);
|
||||||
|
_tabs.animateTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- build ---
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
// Keyboard shortcuts: Cmd/Ctrl+S to save, Cmd/Ctrl+Enter
|
||||||
|
// to start a run. The text tab also keeps these but the
|
||||||
final shortcuts = <ShortcutActivator, Intent>{
|
// global wrapper covers the graph + run tabs too.
|
||||||
|
return Shortcuts(
|
||||||
|
shortcuts: <ShortcutActivator, Intent>{
|
||||||
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyS):
|
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyS):
|
||||||
const _SaveIntent(),
|
const _SaveIntent(),
|
||||||
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
|
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
|
||||||
|
|
@ -287,51 +296,48 @@ outputs:
|
||||||
const _RunIntent(),
|
const _RunIntent(),
|
||||||
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter):
|
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter):
|
||||||
const _RunIntent(),
|
const _RunIntent(),
|
||||||
};
|
},
|
||||||
|
|
||||||
return Shortcuts(
|
|
||||||
shortcuts: shortcuts,
|
|
||||||
child: Actions(
|
child: Actions(
|
||||||
actions: <Type, Action<Intent>>{
|
actions: <Type, Action<Intent>>{
|
||||||
_SaveIntent: CallbackAction<_SaveIntent>(
|
_SaveIntent: CallbackAction<_SaveIntent>(
|
||||||
onInvoke: (_) {
|
onInvoke: (_) {
|
||||||
if (_activeName != null && !_saving) _save();
|
if (_controller.activeName != null) _save();
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_RunIntent: CallbackAction<_RunIntent>(
|
_RunIntent: CallbackAction<_RunIntent>(
|
||||||
onInvoke: (_) {
|
onInvoke: (_) {
|
||||||
if (_activeName != null && !_running && widget.onRun != null) {
|
if (_controller.activeName != null) _tabs.animateTo(2);
|
||||||
_run();
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
child: Focus(autofocus: true, child: _buildScaffold(theme)),
|
child: Focus(autofocus: true, child: _buildScaffold()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScaffold(ThemeData theme) {
|
Widget _buildScaffold() {
|
||||||
|
final theme = Theme.of(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Column(
|
body: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_Toolbar(
|
_Toolbar(
|
||||||
strings: _l,
|
strings: _l,
|
||||||
activeName: _activeName,
|
activeName: _controller.activeName,
|
||||||
dirty: _dirty,
|
dirty: _controller.isDirty,
|
||||||
saving: _saving,
|
saving: _controller.isSaving,
|
||||||
running: _running,
|
running: _controller.isRunning,
|
||||||
onBack: Navigator.of(context).canPop()
|
onBack: Navigator.of(context).canPop()
|
||||||
? () => Navigator.of(context).maybePop()
|
? () => Navigator.of(context).maybePop()
|
||||||
: null,
|
: null,
|
||||||
onSave: _activeName != null ? _save : null,
|
onSave: _controller.activeName != null ? _save : null,
|
||||||
onRun: _activeName != null && !_running && widget.onRun != null
|
onAddStep: _controller.activeName != null ? _addStep : null,
|
||||||
? _run
|
|
||||||
: null,
|
|
||||||
onNew: _newFlow,
|
onNew: _newFlow,
|
||||||
|
onRun: _controller.activeName != null
|
||||||
|
? () => _tabs.animateTo(2)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -342,24 +348,17 @@ outputs:
|
||||||
width: 240,
|
width: 240,
|
||||||
child: _FileList(
|
child: _FileList(
|
||||||
filesFuture: _files,
|
filesFuture: _files,
|
||||||
activeName: _activeName,
|
activeName: _controller.activeName,
|
||||||
strings: _l,
|
strings: _l,
|
||||||
onOpen: _openFile,
|
onOpen: _openFile,
|
||||||
onRefresh: _refresh,
|
onRefresh: _refreshFiles,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const VerticalDivider(width: 1),
|
const VerticalDivider(width: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _activeName == null
|
child: _controller.activeName == null
|
||||||
? _EmptyState(strings: _l)
|
? _EmptyState(strings: _l)
|
||||||
: _EditorPane(
|
: _tabbedBody(theme),
|
||||||
controller: _code,
|
|
||||||
theme: theme,
|
|
||||||
outputs: _runOutputs,
|
|
||||||
runError: _runError,
|
|
||||||
running: _running,
|
|
||||||
strings: _l,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -368,8 +367,127 @@ outputs:
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _tabbedBody(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: theme.colorScheme.surfaceContainerLowest,
|
||||||
|
child: TabBar(
|
||||||
|
controller: _tabs,
|
||||||
|
isScrollable: true,
|
||||||
|
tabAlignment: TabAlignment.start,
|
||||||
|
tabs: [
|
||||||
|
Tab(
|
||||||
|
text: _l.tabGraph,
|
||||||
|
icon: const Icon(Icons.account_tree, size: 16),
|
||||||
|
),
|
||||||
|
Tab(text: _l.tabText, icon: const Icon(Icons.notes, size: 16)),
|
||||||
|
Tab(
|
||||||
|
text: _l.tabRun,
|
||||||
|
icon: const Icon(Icons.play_arrow, size: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
controller: _tabs,
|
||||||
|
// Disable the lateral swipe gesture so it doesn't
|
||||||
|
// race with the canvas's pan handlers.
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
_graphTab(theme),
|
||||||
|
_textTab(theme),
|
||||||
|
RunTab(
|
||||||
|
controller: _controller,
|
||||||
|
strings: _l,
|
||||||
|
driver: widget.runDriver,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _graphTab(ThemeData theme) {
|
||||||
|
// When a step is selected, show the properties panel on
|
||||||
|
// the right as a fixed sidebar. Selection is owned by
|
||||||
|
// the controller and ChangeNotifier rebuilds drive the
|
||||||
|
// panel show / hide.
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(child: FlowCanvas(controller: _controller)),
|
||||||
|
if (_controller.selectedStepId != null) ...[
|
||||||
|
const VerticalDivider(width: 1),
|
||||||
|
SizedBox(
|
||||||
|
width: 320,
|
||||||
|
child: PropertiesPanel(
|
||||||
|
controller: _controller,
|
||||||
|
strings: _l,
|
||||||
|
availableCapabilities: widget.availableCapabilities,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _textTab(ThemeData theme) {
|
||||||
|
final mono = TextStyle(
|
||||||
|
fontFamily: 'JetBrains Mono',
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.45,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
);
|
||||||
|
return CodeTheme(
|
||||||
|
data: CodeThemeData(styles: _yamlStyle(theme)),
|
||||||
|
child: CodeField(
|
||||||
|
controller: _controller.codeController,
|
||||||
|
textStyle: mono,
|
||||||
|
expands: true,
|
||||||
|
minLines: null,
|
||||||
|
maxLines: null,
|
||||||
|
gutterStyle: GutterStyle(
|
||||||
|
textStyle: mono.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
background: theme.colorScheme.surfaceContainer,
|
||||||
|
showLineNumbers: true,
|
||||||
|
),
|
||||||
|
background: theme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, TextStyle> _yamlStyle(ThemeData theme) {
|
||||||
|
final cs = theme.colorScheme;
|
||||||
|
final monoBase = TextStyle(
|
||||||
|
fontFamily: 'JetBrains Mono',
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.45,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
'root': monoBase.copyWith(color: cs.onSurface),
|
||||||
|
// YAML key — bright primary so the structure pops at a
|
||||||
|
// glance. attr is the highlight grammar's key token.
|
||||||
|
'attr': monoBase.copyWith(color: cs.primary, fontWeight: FontWeight.w600),
|
||||||
|
'string': monoBase.copyWith(color: cs.tertiary),
|
||||||
|
'number': monoBase.copyWith(color: cs.secondary),
|
||||||
|
'bullet': monoBase.copyWith(color: cs.onSurfaceVariant),
|
||||||
|
'literal': monoBase.copyWith(color: cs.secondary),
|
||||||
|
'comment': monoBase.copyWith(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
'meta': monoBase.copyWith(color: cs.secondary),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shortcuts ---
|
||||||
|
|
||||||
class _SaveIntent extends Intent {
|
class _SaveIntent extends Intent {
|
||||||
const _SaveIntent();
|
const _SaveIntent();
|
||||||
}
|
}
|
||||||
|
|
@ -378,16 +496,7 @@ class _RunIntent extends Intent {
|
||||||
const _RunIntent();
|
const _RunIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FlowFile {
|
// --- toolbar ---
|
||||||
final String name;
|
|
||||||
final String path;
|
|
||||||
final int sizeBytes;
|
|
||||||
const _FlowFile({
|
|
||||||
required this.name,
|
|
||||||
required this.path,
|
|
||||||
required this.sizeBytes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Toolbar extends StatelessWidget {
|
class _Toolbar extends StatelessWidget {
|
||||||
final FlowEditorStrings strings;
|
final FlowEditorStrings strings;
|
||||||
|
|
@ -397,8 +506,9 @@ class _Toolbar extends StatelessWidget {
|
||||||
final bool running;
|
final bool running;
|
||||||
final VoidCallback? onBack;
|
final VoidCallback? onBack;
|
||||||
final VoidCallback? onSave;
|
final VoidCallback? onSave;
|
||||||
final VoidCallback? onRun;
|
final VoidCallback? onAddStep;
|
||||||
final VoidCallback onNew;
|
final VoidCallback onNew;
|
||||||
|
final VoidCallback? onRun;
|
||||||
const _Toolbar({
|
const _Toolbar({
|
||||||
required this.strings,
|
required this.strings,
|
||||||
required this.activeName,
|
required this.activeName,
|
||||||
|
|
@ -407,8 +517,9 @@ class _Toolbar extends StatelessWidget {
|
||||||
required this.running,
|
required this.running,
|
||||||
required this.onBack,
|
required this.onBack,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
required this.onRun,
|
required this.onAddStep,
|
||||||
required this.onNew,
|
required this.onNew,
|
||||||
|
required this.onRun,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -453,6 +564,13 @@ class _Toolbar extends StatelessWidget {
|
||||||
label: Text(strings.newFlow),
|
label: Text(strings.newFlow),
|
||||||
),
|
),
|
||||||
const SizedBox(width: FaiSpace.sm),
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
if (onAddStep != null)
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: onAddStep,
|
||||||
|
icon: const Icon(Icons.add_box_outlined, size: 16),
|
||||||
|
label: Text(strings.addStep),
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: saving ? null : onSave,
|
onPressed: saving ? null : onSave,
|
||||||
icon: saving
|
icon: saving
|
||||||
|
|
@ -482,6 +600,19 @@ class _Toolbar extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- file list ---
|
||||||
|
|
||||||
|
class _FlowFile {
|
||||||
|
final String name;
|
||||||
|
final String path;
|
||||||
|
final int sizeBytes;
|
||||||
|
const _FlowFile({
|
||||||
|
required this.name,
|
||||||
|
required this.path,
|
||||||
|
required this.sizeBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class _FileList extends StatelessWidget {
|
class _FileList extends StatelessWidget {
|
||||||
final Future<List<_FlowFile>> filesFuture;
|
final Future<List<_FlowFile>> filesFuture;
|
||||||
final String? activeName;
|
final String? activeName;
|
||||||
|
|
@ -505,13 +636,6 @@ class _FileList extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// List header — refresh icon lives here (not in the
|
|
||||||
// main toolbar) because Refresh re-lists the files,
|
|
||||||
// it doesn't touch the open editor's content. Keeping
|
|
||||||
// it in the file-list panel keeps the editor toolbar
|
|
||||||
// focused on the four primary actions (back / new /
|
|
||||||
// save / run) without a stylistically-outlier outline
|
|
||||||
// icon sandwiched between filled buttons.
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: FaiSpace.md,
|
horizontal: FaiSpace.md,
|
||||||
|
|
@ -626,248 +750,85 @@ class _FileList extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EditorPane extends StatelessWidget {
|
// --- empty state ---
|
||||||
final CodeController controller;
|
|
||||||
final ThemeData theme;
|
|
||||||
final Map<String, Object>? outputs;
|
|
||||||
final Object? runError;
|
|
||||||
final bool running;
|
|
||||||
final FlowEditorStrings strings;
|
|
||||||
const _EditorPane({
|
|
||||||
required this.controller,
|
|
||||||
required this.theme,
|
|
||||||
required this.outputs,
|
|
||||||
required this.runError,
|
|
||||||
required this.running,
|
|
||||||
required this.strings,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
class _EmptyState extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final mono = TextStyle(
|
|
||||||
fontFamily: 'JetBrains Mono',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 1.45,
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
);
|
|
||||||
return Row(
|
|
||||||
// Stretch + CodeField(expands: true) is what actually
|
|
||||||
// pins the editor to the top edge. The previous attempt
|
|
||||||
// (just CrossAxisAlignment.stretch with a
|
|
||||||
// SingleChildScrollView) only stretched the SCROLL
|
|
||||||
// viewport — the CodeField inside still had its natural,
|
|
||||||
// content-sized height and sat vertically centered within
|
|
||||||
// the viewport because CodeField defers to its parent's
|
|
||||||
// alignment when smaller than the viewport. Removing the
|
|
||||||
// SCV wrapper and setting expands:true on the CodeField
|
|
||||||
// (with explicit minLines:null + maxLines:null, which is
|
|
||||||
// what the underlying TextField requires) makes the
|
|
||||||
// CodeField fill its parent's bounded height. The line-
|
|
||||||
// number gutter then runs the full height of the editor
|
|
||||||
// pane, line 1 sits at the top edge, and vertical
|
|
||||||
// scrolling is handled by the CodeField itself for long
|
|
||||||
// files. Standard behaviour for any IDE-grade editor
|
|
||||||
// widget.
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: CodeTheme(
|
|
||||||
data: CodeThemeData(styles: _yamlStyle(theme)),
|
|
||||||
child: CodeField(
|
|
||||||
controller: controller,
|
|
||||||
textStyle: mono,
|
|
||||||
expands: true,
|
|
||||||
minLines: null,
|
|
||||||
maxLines: null,
|
|
||||||
gutterStyle: GutterStyle(
|
|
||||||
textStyle: mono.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
background: theme.colorScheme.surfaceContainer,
|
|
||||||
showLineNumbers: true,
|
|
||||||
),
|
|
||||||
background: theme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (outputs != null || runError != null) ...[
|
|
||||||
const VerticalDivider(width: 1),
|
|
||||||
SizedBox(
|
|
||||||
width: 320,
|
|
||||||
child: _RunResult(
|
|
||||||
outputs: outputs,
|
|
||||||
error: runError,
|
|
||||||
running: running,
|
|
||||||
strings: strings,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RunResult extends StatelessWidget {
|
|
||||||
final Map<String, Object>? outputs;
|
|
||||||
final Object? error;
|
|
||||||
final bool running;
|
|
||||||
final FlowEditorStrings strings;
|
final FlowEditorStrings strings;
|
||||||
const _RunResult({
|
const _EmptyState({required this.strings});
|
||||||
required this.outputs,
|
|
||||||
required this.error,
|
|
||||||
required this.running,
|
|
||||||
required this.strings,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
color: theme.colorScheme.surface,
|
color: theme.colorScheme.surface,
|
||||||
padding: const EdgeInsets.all(FaiSpace.md),
|
child: Center(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
strings.runOutput,
|
|
||||||
style: theme.textTheme.labelMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: FaiSpace.sm),
|
|
||||||
if (running) const LinearProgressIndicator(),
|
|
||||||
if (error != null)
|
|
||||||
FaiErrorBox(error: error, isError: true)
|
|
||||||
else if (outputs != null)
|
|
||||||
Expanded(
|
|
||||||
child: ListView(
|
|
||||||
children: [
|
|
||||||
for (final entry in outputs!.entries)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: FaiSpace.md),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
entry.key,
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: FaiSpace.xs),
|
|
||||||
SelectableText(
|
|
||||||
entry.value.toString(),
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'JetBrains Mono',
|
|
||||||
fontSize: 12,
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EmptyState extends StatelessWidget {
|
|
||||||
final FlowEditorStrings strings;
|
|
||||||
const _EmptyState({required this.strings});
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(FaiSpace.xxl),
|
padding: const EdgeInsets.all(FaiSpace.xl),
|
||||||
child: FaiEmptyState(
|
child: FaiEmptyState(
|
||||||
icon: Icons.edit_note,
|
icon: Icons.description_outlined,
|
||||||
title: strings.emptyTitle,
|
title: strings.emptyTitle,
|
||||||
hint: strings.emptyBody,
|
hint: strings.emptyBody,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- new flow dialog ---
|
||||||
|
|
||||||
class _NewFlowDialog extends StatefulWidget {
|
class _NewFlowDialog extends StatefulWidget {
|
||||||
final FlowEditorStrings strings;
|
final FlowEditorStrings strings;
|
||||||
const _NewFlowDialog({required this.strings});
|
const _NewFlowDialog({required this.strings});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_NewFlowDialog> createState() => _NewFlowDialogState();
|
State<_NewFlowDialog> createState() => _NewFlowDialogState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NewFlowDialogState extends State<_NewFlowDialog> {
|
class _NewFlowDialogState extends State<_NewFlowDialog> {
|
||||||
final _ctrl = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_ctrl.dispose();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final name = _controller.text.trim();
|
||||||
|
if (!RegExp(r'^[a-z0-9_-]+$').hasMatch(name)) {
|
||||||
|
setState(() => _error = widget.strings.newDialogHelper);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.pop(context, name);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = widget.strings;
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(s.newDialogTitle),
|
title: Text(widget.strings.newDialogTitle),
|
||||||
content: TextField(
|
content: TextField(
|
||||||
controller: _ctrl,
|
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
controller: _controller,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: s.newDialogLabel,
|
labelText: widget.strings.newDialogLabel,
|
||||||
hintText: 'my-flow',
|
helperText: widget.strings.newDialogHelper,
|
||||||
helperText: s.newDialogHelper,
|
errorText: _error,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
inputFormatters: [
|
onSubmitted: (_) => _submit(),
|
||||||
FilteringTextInputFormatter.allow(RegExp(r'[a-z0-9_\-]')),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text(s.newDialogCancel),
|
child: Text(widget.strings.newDialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: _submit,
|
||||||
if (_ctrl.text.isNotEmpty) {
|
child: Text(widget.strings.newDialogCreate),
|
||||||
Navigator.pop(context, _ctrl.text);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Text(s.newDialogCreate),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, TextStyle> _yamlStyle(ThemeData theme) {
|
|
||||||
final base = TextStyle(
|
|
||||||
fontFamily: 'JetBrains Mono',
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
'root': base,
|
|
||||||
'attr': base.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
'string': base.copyWith(color: theme.colorScheme.tertiary),
|
|
||||||
'number': base.copyWith(color: theme.colorScheme.secondary),
|
|
||||||
'literal': base.copyWith(color: theme.colorScheme.secondary),
|
|
||||||
'comment': base.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
|
||||||
'meta': base.copyWith(color: theme.colorScheme.primary),
|
|
||||||
'subst': base.copyWith(
|
|
||||||
color: theme.colorScheme.error,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,8 @@ class FlowEditorStrings {
|
||||||
'Click New flow to scaffold one from a template.',
|
'Click New flow to scaffold one from a template.',
|
||||||
'Klicke Neuer Flow um aus einer Vorlage zu starten.',
|
'Klicke Neuer Flow um aus einer Vorlage zu starten.',
|
||||||
);
|
);
|
||||||
String get discardTitle => _t(
|
String get discardTitle =>
|
||||||
'Discard unsaved changes?',
|
_t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?');
|
||||||
'Ungespeicherte Änderungen verwerfen?',
|
|
||||||
);
|
|
||||||
String get discardBody => _t(
|
String get discardBody => _t(
|
||||||
"Switching files will throw away the edits you haven't saved.",
|
"Switching files will throw away the edits you haven't saved.",
|
||||||
'Beim Wechsel der Datei gehen alle noch nicht gespeicherten Änderungen verloren.',
|
'Beim Wechsel der Datei gehen alle noch nicht gespeicherten Änderungen verloren.',
|
||||||
|
|
@ -57,4 +55,58 @@ class FlowEditorStrings {
|
||||||
'A flow named "$name" already exists.',
|
'A flow named "$name" already exists.',
|
||||||
'Ein Flow namens "$name" existiert bereits.',
|
'Ein Flow namens "$name" existiert bereits.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Tab labels — three workflows over the same flow.
|
||||||
|
String get tabGraph => _t('Graph', 'Grafisch');
|
||||||
|
String get tabText => _t('Text', 'Text');
|
||||||
|
String get tabRun => _t('Run', 'Ausführen');
|
||||||
|
|
||||||
|
// Graph tab toolbar.
|
||||||
|
String get addStep => _t('Add step', 'Schritt hinzufügen');
|
||||||
|
String get autoLayout => _t('Auto-layout', 'Auto-Anordnung');
|
||||||
|
String get zoomToFit => _t('Zoom to fit', 'Einpassen');
|
||||||
|
String get graphEmptyTitle => _t('No steps yet', 'Noch keine Schritte');
|
||||||
|
String get graphEmptyBody => _t(
|
||||||
|
'Click Add step to drop the first capability onto the canvas.',
|
||||||
|
'Klicke Schritt hinzufügen um die erste Capability auf die Fläche zu setzen.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Properties panel.
|
||||||
|
String get propPanelTitle => _t('Step details', 'Schritt-Details');
|
||||||
|
String get propNoSelection => _t(
|
||||||
|
'Click a step on the canvas to edit it.',
|
||||||
|
'Klicke einen Schritt auf der Fläche an, um ihn zu bearbeiten.',
|
||||||
|
);
|
||||||
|
String get propStepId => _t('ID', 'ID');
|
||||||
|
String get propCapability => _t('Capability', 'Capability');
|
||||||
|
String get propInputs => _t('Inputs (with:)', 'Eingaben (with:)');
|
||||||
|
String get propAddInput => _t('Add input', 'Eingabe hinzufügen');
|
||||||
|
String get propRemoveInput => _t('Remove input', 'Eingabe entfernen');
|
||||||
|
String get propDeleteStep => _t('Delete step', 'Schritt löschen');
|
||||||
|
|
||||||
|
// Capability picker.
|
||||||
|
String get pickerTitle => _t('Pick a capability', 'Capability auswählen');
|
||||||
|
String get pickerSearch => _t('Search…', 'Suchen…');
|
||||||
|
String get pickerEmpty => _t(
|
||||||
|
'No matching capabilities installed.',
|
||||||
|
'Keine passenden Capabilities installiert.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run tab.
|
||||||
|
String get runTitleIdle => _t('Run this flow', 'Diesen Flow ausführen');
|
||||||
|
String get runTitleRunning => _t('Running…', 'Läuft…');
|
||||||
|
String get runTitleDone => _t('Result', 'Ergebnis');
|
||||||
|
String get runTitleFailed => _t('Run failed', 'Lauf fehlgeschlagen');
|
||||||
|
String get runInputs => _t('Inputs', 'Eingaben');
|
||||||
|
String get runStart => _t('Start run', 'Lauf starten');
|
||||||
|
String get runUnsavedBanner => _t(
|
||||||
|
'You have unsaved changes — save first to run the latest version.',
|
||||||
|
'Du hast ungespeicherte Änderungen — bitte zuerst speichern, um die neueste Version auszuführen.',
|
||||||
|
);
|
||||||
|
String get runNoFlow => _t(
|
||||||
|
'Open a flow from the left to run it.',
|
||||||
|
'Öffne links einen Flow um ihn auszuführen.',
|
||||||
|
);
|
||||||
|
String get runOutputs => _t('Outputs', 'Ausgaben');
|
||||||
|
String get runChooseFile => _t('Choose file…', 'Datei wählen…');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
100
lib/src/run_driver.dart
Normal file
100
lib/src/run_driver.dart
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
// Host-supplied bridge between the editor and the hub.
|
||||||
|
//
|
||||||
|
// The editor package can't talk to the hub directly — that's
|
||||||
|
// the host's job (Studio uses the gRPC SDK, a CLI host could
|
||||||
|
// shell out to `fai run`, a test harness can stub everything
|
||||||
|
// in-memory). The bridge keeps the editor reusable across
|
||||||
|
// any host that can implement the two methods below.
|
||||||
|
//
|
||||||
|
// Lifecycle expected by the editor:
|
||||||
|
//
|
||||||
|
// 1. Subscribe to `events()` BEFORE calling `runFlow()`.
|
||||||
|
// The hub starts emitting `step.started` events the
|
||||||
|
// instant the flow begins; the host's subscription must
|
||||||
|
// already be live so the first one doesn't get lost.
|
||||||
|
// 2. Call `runFlow()` with the flow name + collected inputs.
|
||||||
|
// The returned Future resolves with the final outputs
|
||||||
|
// (or throws on failure).
|
||||||
|
// 3. Unsubscribe from `events()` when the run resolves.
|
||||||
|
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
abstract class FlowRunDriver {
|
||||||
|
/// Trigger a run of [flowName] with the supplied inputs.
|
||||||
|
/// Text + file inputs come in separate maps so the host
|
||||||
|
/// can wire them to the right payload shape. Returns the
|
||||||
|
/// flow's final outputs map keyed by output name.
|
||||||
|
Future<Map<String, FlowOutputValue>> runFlow({
|
||||||
|
required String flowName,
|
||||||
|
required Map<String, String> textInputs,
|
||||||
|
required Map<String, Uint8List> fileInputs,
|
||||||
|
required Map<String, String> fileMimes,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Live event stream scoped to whatever the host can
|
||||||
|
/// observe — typically the hub's `StreamEvents` RPC
|
||||||
|
/// filtered to step.* event types for the running flow.
|
||||||
|
/// May arrive late (subscribe BEFORE calling runFlow!) and
|
||||||
|
/// may emit events for unrelated runs; the editor filters
|
||||||
|
/// by [FlowRunEvent.flowName] before applying.
|
||||||
|
Stream<FlowRunEvent> events();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the editor needs out of a single event tick. Maps
|
||||||
|
/// 1:1 to the hub's `step.*` events but with the typing
|
||||||
|
/// projected into the editor's vocabulary so we don't carry
|
||||||
|
/// the SDK's proto types across the package boundary.
|
||||||
|
sealed class FlowRunEvent {
|
||||||
|
final String flowName;
|
||||||
|
final String stepId;
|
||||||
|
const FlowRunEvent({required this.flowName, required this.stepId});
|
||||||
|
}
|
||||||
|
|
||||||
|
class StepStarted extends FlowRunEvent {
|
||||||
|
const StepStarted({required super.flowName, required super.stepId});
|
||||||
|
}
|
||||||
|
|
||||||
|
class StepCompleted extends FlowRunEvent {
|
||||||
|
final int durationMs;
|
||||||
|
const StepCompleted({
|
||||||
|
required super.flowName,
|
||||||
|
required super.stepId,
|
||||||
|
required this.durationMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class StepFailed extends FlowRunEvent {
|
||||||
|
final String error;
|
||||||
|
const StepFailed({
|
||||||
|
required super.flowName,
|
||||||
|
required super.stepId,
|
||||||
|
required this.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class StepAwaitingApproval extends FlowRunEvent {
|
||||||
|
const StepAwaitingApproval({required super.flowName, required super.stepId});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One output value, after the run completed. The editor
|
||||||
|
/// renders text inline, JSON pretty-printed, bytes as a
|
||||||
|
/// download badge.
|
||||||
|
sealed class FlowOutputValue {
|
||||||
|
const FlowOutputValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlowOutputText extends FlowOutputValue {
|
||||||
|
final String value;
|
||||||
|
const FlowOutputText(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlowOutputJson extends FlowOutputValue {
|
||||||
|
final Object? value;
|
||||||
|
const FlowOutputJson(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlowOutputBytes extends FlowOutputValue {
|
||||||
|
final Uint8List value;
|
||||||
|
final String mimeType;
|
||||||
|
const FlowOutputBytes(this.value, this.mimeType);
|
||||||
|
}
|
||||||
|
|
@ -29,11 +29,7 @@ class FaiEmptyState extends StatelessWidget {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(icon, size: 40, color: theme.colorScheme.onSurfaceVariant),
|
||||||
icon,
|
|
||||||
size: 40,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
|
|
@ -91,11 +87,7 @@ class FaiErrorBox extends StatelessWidget {
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: SelectableText(
|
child: SelectableText(
|
||||||
error?.toString() ?? '',
|
error?.toString() ?? '',
|
||||||
style: TextStyle(
|
style: TextStyle(fontFamily: 'monospace', fontSize: 12, color: fg),
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 12,
|
|
||||||
color: fg,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
103
lib/src/widgets/capability_picker.dart
Normal file
103
lib/src/widgets/capability_picker.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// Capability picker — modal dialog for choosing which
|
||||||
|
// capability a new step should use. Studio provides the
|
||||||
|
// list (it lives on the hub); the picker is a thin
|
||||||
|
// searchable list on top.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../l10n.dart';
|
||||||
|
import '../tokens.dart';
|
||||||
|
|
||||||
|
class CapabilityPicker extends StatefulWidget {
|
||||||
|
final List<String> capabilities;
|
||||||
|
final FlowEditorStrings strings;
|
||||||
|
const CapabilityPicker({
|
||||||
|
super.key,
|
||||||
|
required this.capabilities,
|
||||||
|
required this.strings,
|
||||||
|
});
|
||||||
|
|
||||||
|
static Future<String?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required List<String> capabilities,
|
||||||
|
required FlowEditorStrings strings,
|
||||||
|
}) {
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) =>
|
||||||
|
CapabilityPicker(capabilities: capabilities, strings: strings),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CapabilityPicker> createState() => _CapabilityPickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CapabilityPickerState extends State<CapabilityPicker> {
|
||||||
|
String _query = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final filtered = _query.isEmpty
|
||||||
|
? widget.capabilities
|
||||||
|
: widget.capabilities
|
||||||
|
.where((c) => c.toLowerCase().contains(_query.toLowerCase()))
|
||||||
|
.toList();
|
||||||
|
return Dialog(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 480,
|
||||||
|
height: 520,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(FaiSpace.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.strings.pickerTitle,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
TextField(
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 18),
|
||||||
|
hintText: widget.strings.pickerSearch,
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (v) => setState(() => _query = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
Expanded(
|
||||||
|
child: filtered.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
widget.strings.pickerEmpty,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: filtered.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final cap = filtered[i];
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(
|
||||||
|
cap,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.pop(context, cap),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
lib/src/widgets/edge_painter.dart
Normal file
105
lib/src/widgets/edge_painter.dart
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// EdgePainter — draws every connection on the canvas as a
|
||||||
|
// cubic-bezier curve. Pure render layer: receives a flat list
|
||||||
|
// of port-to-port positions (already resolved against node
|
||||||
|
// positions) and paints them in one go.
|
||||||
|
//
|
||||||
|
// Connections are painted in a single CustomPaint so we don't
|
||||||
|
// pay the widget-tree cost of one painter per edge. Hit
|
||||||
|
// testing is delegated to port hit boxes drawn over the
|
||||||
|
// canvas; the painter itself is wrapped in IgnorePointer in
|
||||||
|
// the canvas so nothing here eats taps meant for nodes.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class EdgeSegment {
|
||||||
|
final Offset from;
|
||||||
|
final Offset to;
|
||||||
|
final EdgeAccent accent;
|
||||||
|
const EdgeSegment({
|
||||||
|
required this.from,
|
||||||
|
required this.to,
|
||||||
|
this.accent = EdgeAccent.normal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EdgeAccent { normal, highlight, draftDrag }
|
||||||
|
|
||||||
|
class EdgePainter extends CustomPainter {
|
||||||
|
final List<EdgeSegment> segments;
|
||||||
|
final Color baseColor;
|
||||||
|
final Color highlightColor;
|
||||||
|
final Color draftColor;
|
||||||
|
|
||||||
|
EdgePainter({
|
||||||
|
required this.segments,
|
||||||
|
required this.baseColor,
|
||||||
|
required this.highlightColor,
|
||||||
|
required this.draftColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
for (final seg in segments) {
|
||||||
|
final color = switch (seg.accent) {
|
||||||
|
EdgeAccent.normal => baseColor,
|
||||||
|
EdgeAccent.highlight => highlightColor,
|
||||||
|
EdgeAccent.draftDrag => draftColor,
|
||||||
|
};
|
||||||
|
final paint = Paint()
|
||||||
|
..color = color
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = seg.accent == EdgeAccent.highlight ? 2.5 : 1.8
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
final path = _bezier(seg.from, seg.to);
|
||||||
|
canvas.drawPath(path, paint);
|
||||||
|
// End-cap arrow: small filled triangle pointing at the
|
||||||
|
// target port so the reader can tell direction at a
|
||||||
|
// glance without inspecting the geometry.
|
||||||
|
_drawArrowHead(canvas, seg.to, seg.from, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Path _bezier(Offset from, Offset to) {
|
||||||
|
// Cubic with horizontal handles — typical "flow diagram"
|
||||||
|
// smooth curve. The handle length scales with the
|
||||||
|
// horizontal distance so steep verticals still look
|
||||||
|
// graceful.
|
||||||
|
final dx = (to.dx - from.dx).abs();
|
||||||
|
final handleLen = (dx / 2).clamp(40.0, 220.0);
|
||||||
|
return Path()
|
||||||
|
..moveTo(from.dx, from.dy)
|
||||||
|
..cubicTo(
|
||||||
|
from.dx + handleLen,
|
||||||
|
from.dy,
|
||||||
|
to.dx - handleLen,
|
||||||
|
to.dy,
|
||||||
|
to.dx,
|
||||||
|
to.dy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawArrowHead(Canvas canvas, Offset tip, Offset from, Color color) {
|
||||||
|
// Approximate the incoming direction by sampling a
|
||||||
|
// little before the tip. Good enough since the curve is
|
||||||
|
// nearly horizontal near the end.
|
||||||
|
final dir = tip.dx - from.dx;
|
||||||
|
final orient = dir >= 0 ? 1.0 : -1.0;
|
||||||
|
const size = 6.0;
|
||||||
|
final p1 = tip;
|
||||||
|
final p2 = Offset(tip.dx - size * orient, tip.dy - size * 0.7);
|
||||||
|
final p3 = Offset(tip.dx - size * orient, tip.dy + size * 0.7);
|
||||||
|
final path = Path()
|
||||||
|
..moveTo(p1.dx, p1.dy)
|
||||||
|
..lineTo(p2.dx, p2.dy)
|
||||||
|
..lineTo(p3.dx, p3.dy)
|
||||||
|
..close();
|
||||||
|
canvas.drawPath(path, Paint()..color = color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(EdgePainter old) =>
|
||||||
|
old.segments != segments ||
|
||||||
|
old.baseColor != baseColor ||
|
||||||
|
old.highlightColor != highlightColor ||
|
||||||
|
old.draftColor != draftColor;
|
||||||
|
}
|
||||||
611
lib/src/widgets/flow_canvas.dart
Normal file
611
lib/src/widgets/flow_canvas.dart
Normal 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;
|
||||||
|
}
|
||||||
264
lib/src/widgets/flow_node.dart
Normal file
264
lib/src/widgets/flow_node.dart
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
// FlowNode — the visual representation of one step on the
|
||||||
|
// canvas. Three visual variants:
|
||||||
|
//
|
||||||
|
// - module step: rounded card, gear icon, primary border
|
||||||
|
// when selected
|
||||||
|
// - approval step: tertiary tint, hand-up icon, marks where
|
||||||
|
// the flow waits for a human
|
||||||
|
// - inputs / outputs sidebar nodes: tall, pinned to edges
|
||||||
|
//
|
||||||
|
// The card exposes named ports on its left (one per `with:`
|
||||||
|
// key) and a single combined port on its right that all
|
||||||
|
// downstream edges leave from. Ports are small filled circles
|
||||||
|
// just outside the card border so edges can visually anchor
|
||||||
|
// to them.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../model/flow_graph.dart';
|
||||||
|
import '../tokens.dart';
|
||||||
|
|
||||||
|
/// Geometry constants the canvas + edge painter rely on to
|
||||||
|
/// place ports without measuring widgets at runtime. Changing
|
||||||
|
/// any of these requires reviewing edge_painter.dart.
|
||||||
|
class NodeGeometry {
|
||||||
|
static const double width = 220;
|
||||||
|
static const double headerHeight = 36;
|
||||||
|
static const double portRowHeight = 22;
|
||||||
|
static const double bottomPadding = 12;
|
||||||
|
static const double portRadius = 6;
|
||||||
|
// Offset from the card's outer edge for the port's center.
|
||||||
|
// Half-out so the dot visually "attaches" to the side.
|
||||||
|
static const double portInset = 0;
|
||||||
|
|
||||||
|
/// Total height for a step node given its with-field count.
|
||||||
|
/// Approval and inputs/outputs variants use the same maths
|
||||||
|
/// so the canvas can place every node identically.
|
||||||
|
static double heightFor(int portCount) {
|
||||||
|
final base = headerHeight + bottomPadding;
|
||||||
|
final ports = portCount * portRowHeight;
|
||||||
|
return base + ports + 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Y offset of an input port's centre, measured from the
|
||||||
|
/// top of the card. The first input port sits below the
|
||||||
|
/// header with a small gap.
|
||||||
|
static double inputPortY(int index) {
|
||||||
|
return headerHeight + 6 + index * portRowHeight + portRowHeight / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Y offset of the single output port. Vertically centred
|
||||||
|
/// in the header band so the edge entry feels balanced.
|
||||||
|
static double outputPortY() {
|
||||||
|
return headerHeight / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NodeVisualKind { module, approval, inputs, outputs }
|
||||||
|
|
||||||
|
class FlowNode extends StatelessWidget {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final List<String> inputPortLabels;
|
||||||
|
final NodeVisualKind kind;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final void Function(Offset delta)? onDrag;
|
||||||
|
final VoidCallback? onDragEnd;
|
||||||
|
|
||||||
|
/// Live status from the most recent run, when this node is
|
||||||
|
/// a step. Coloured dot in the header so the operator can
|
||||||
|
/// glance at the canvas and see what's running.
|
||||||
|
final FlowNodeStatus status;
|
||||||
|
|
||||||
|
const FlowNode({
|
||||||
|
super.key,
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.inputPortLabels = const [],
|
||||||
|
this.kind = NodeVisualKind.module,
|
||||||
|
this.selected = false,
|
||||||
|
this.status = FlowNodeStatus.idle,
|
||||||
|
this.onTap,
|
||||||
|
this.onDrag,
|
||||||
|
this.onDragEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final accent = _accent(theme.colorScheme);
|
||||||
|
final height = NodeGeometry.heightFor(inputPortLabels.length);
|
||||||
|
return SizedBox(
|
||||||
|
width: NodeGeometry.width,
|
||||||
|
height: height,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onTap,
|
||||||
|
onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta),
|
||||||
|
onPanEnd: onDragEnd == null ? null : (_) => onDragEnd!(),
|
||||||
|
child: Material(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
elevation: selected ? 6 : 2,
|
||||||
|
shadowColor: selected ? accent.withValues(alpha: 0.35) : null,
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? accent : theme.dividerColor,
|
||||||
|
width: selected ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_header(theme, accent),
|
||||||
|
Expanded(child: _body(theme)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _header(ThemeData theme, Color accent) {
|
||||||
|
return Container(
|
||||||
|
height: NodeGeometry.headerHeight,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent.withValues(alpha: 0.16),
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(FaiRadius.md),
|
||||||
|
topRight: Radius.circular(FaiRadius.md),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(_iconFor(kind), size: 16, color: accent),
|
||||||
|
const SizedBox(width: FaiSpace.xs),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (status != FlowNodeStatus.idle) _statusDot(theme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _body(ThemeData theme) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
FaiSpace.sm,
|
||||||
|
4,
|
||||||
|
FaiSpace.sm,
|
||||||
|
FaiSpace.sm,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (subtitle != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Text(
|
||||||
|
subtitle!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final label in inputPortLabels)
|
||||||
|
SizedBox(
|
||||||
|
height: NodeGeometry.portRowHeight,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.xs),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _statusDot(ThemeData theme) {
|
||||||
|
final color = switch (status) {
|
||||||
|
FlowNodeStatus.running => theme.colorScheme.primary,
|
||||||
|
FlowNodeStatus.done => Colors.green.shade400,
|
||||||
|
FlowNodeStatus.failed => theme.colorScheme.error,
|
||||||
|
FlowNodeStatus.awaiting => theme.colorScheme.tertiary,
|
||||||
|
FlowNodeStatus.idle => Colors.transparent,
|
||||||
|
};
|
||||||
|
return Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: status == FlowNodeStatus.running
|
||||||
|
? [BoxShadow(color: color.withValues(alpha: 0.6), blurRadius: 6)]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _iconFor(NodeVisualKind k) {
|
||||||
|
return switch (k) {
|
||||||
|
NodeVisualKind.module => Icons.widgets_outlined,
|
||||||
|
NodeVisualKind.approval => Icons.pan_tool_outlined,
|
||||||
|
NodeVisualKind.inputs => Icons.input,
|
||||||
|
NodeVisualKind.outputs => Icons.output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _accent(ColorScheme cs) {
|
||||||
|
return switch (kind) {
|
||||||
|
NodeVisualKind.module => cs.primary,
|
||||||
|
NodeVisualKind.approval => cs.tertiary,
|
||||||
|
NodeVisualKind.inputs => cs.secondary,
|
||||||
|
NodeVisualKind.outputs => cs.secondary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FlowNodeStatus { idle, running, done, failed, awaiting }
|
||||||
|
|
||||||
|
/// Render-time helper: figure out which visual variant to use
|
||||||
|
/// for a parsed step.
|
||||||
|
NodeVisualKind kindForStep(FlowStep step) {
|
||||||
|
if (step.isApproval) return NodeVisualKind.approval;
|
||||||
|
return NodeVisualKind.module;
|
||||||
|
}
|
||||||
404
lib/src/widgets/properties_panel.dart
Normal file
404
lib/src/widgets/properties_panel.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
564
lib/src/widgets/run_tab.dart
Normal file
564
lib/src/widgets/run_tab.dart
Normal file
|
|
@ -0,0 +1,564 @@
|
||||||
|
// RunTab — the third tab. Shows:
|
||||||
|
//
|
||||||
|
// 1. A form for the flow's declared inputs (one field per
|
||||||
|
// `inputs:` entry). Text inputs use a TextField; bytes
|
||||||
|
// inputs use a "Choose file…" button + filename badge.
|
||||||
|
//
|
||||||
|
// 2. A Start button that calls the host's FlowRunDriver
|
||||||
|
// with the collected inputs.
|
||||||
|
//
|
||||||
|
// 3. A live step list driven by the driver's event stream,
|
||||||
|
// identical visually to the CLI's `fai run` block:
|
||||||
|
// ◻ pending, · running, ✔ done + duration, ✗ failed,
|
||||||
|
// ⏸ awaiting approval.
|
||||||
|
//
|
||||||
|
// 4. The flow's outputs once the run resolves.
|
||||||
|
//
|
||||||
|
// The tab requires the file to be saved on disk — the hub's
|
||||||
|
// runSavedFlow reads from `~/.fai/data/flows/<name>.yaml`,
|
||||||
|
// not from the in-memory buffer. The dirty banner reminds
|
||||||
|
// the operator to save before running so they don't run a
|
||||||
|
// stale version by surprise.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../editor_controller.dart';
|
||||||
|
import '../l10n.dart';
|
||||||
|
import '../model/flow_graph.dart';
|
||||||
|
import '../run_driver.dart';
|
||||||
|
import '../tokens.dart';
|
||||||
|
|
||||||
|
class RunTab extends StatefulWidget {
|
||||||
|
final FlowEditorController controller;
|
||||||
|
final FlowEditorStrings strings;
|
||||||
|
final FlowRunDriver? driver;
|
||||||
|
const RunTab({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.strings,
|
||||||
|
this.driver,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RunTab> createState() => _RunTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RunTabState extends State<RunTab> {
|
||||||
|
final Map<String, TextEditingController> _textInputs = {};
|
||||||
|
final Map<String, _FilePick> _fileInputs = {};
|
||||||
|
|
||||||
|
StreamSubscription<FlowRunEvent>? _eventSub;
|
||||||
|
bool _running = false;
|
||||||
|
Map<String, FlowOutputValue>? _outputs;
|
||||||
|
Object? _error;
|
||||||
|
// Insertion-ordered: shows steps in the order the hub
|
||||||
|
// actually started them, matching the CLI rendering.
|
||||||
|
final Map<String, _StepState> _liveSteps = {};
|
||||||
|
String? _runFlowName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.controller.addListener(_onControllerChanged);
|
||||||
|
_syncInputs();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.controller.removeListener(_onControllerChanged);
|
||||||
|
_eventSub?.cancel();
|
||||||
|
for (final c in _textInputs.values) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onControllerChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
_syncInputs();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncInputs() {
|
||||||
|
final graph = widget.controller.graph;
|
||||||
|
final keep = <String>{};
|
||||||
|
for (final entry in graph.inputs.entries) {
|
||||||
|
keep.add(entry.key);
|
||||||
|
if (entry.value.type == 'bytes' || entry.value.type == 'file') {
|
||||||
|
// bytes input — keep its file pick if already chosen.
|
||||||
|
_fileInputs.putIfAbsent(entry.key, () => const _FilePick.empty());
|
||||||
|
} else {
|
||||||
|
_textInputs.putIfAbsent(
|
||||||
|
entry.key,
|
||||||
|
() => TextEditingController(text: entry.value.defaultValue ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop controllers for inputs that no longer exist.
|
||||||
|
_textInputs.removeWhere((k, c) {
|
||||||
|
if (keep.contains(k)) return false;
|
||||||
|
c.dispose();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
_fileInputs.removeWhere((k, _) => !keep.contains(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final controller = widget.controller;
|
||||||
|
final strings = widget.strings;
|
||||||
|
if (controller.activeName == null) {
|
||||||
|
return _empty(theme, strings.runNoFlow);
|
||||||
|
}
|
||||||
|
final graph = controller.graph;
|
||||||
|
return Container(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(FaiSpace.lg),
|
||||||
|
children: [
|
||||||
|
_titleRow(theme),
|
||||||
|
const SizedBox(height: FaiSpace.md),
|
||||||
|
if (controller.isDirty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(FaiSpace.sm),
|
||||||
|
margin: const EdgeInsets.only(bottom: FaiSpace.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.tertiaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
strings.runUnsavedBanner,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_section(theme, strings.runInputs),
|
||||||
|
if (graph.inputs.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
||||||
|
child: Text(
|
||||||
|
'—',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
for (final entry in graph.inputs.entries)
|
||||||
|
_inputField(theme, entry.key, entry.value),
|
||||||
|
const SizedBox(height: FaiSpace.md),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _running || widget.driver == null ? null : _start,
|
||||||
|
icon: _running
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.play_arrow, size: 18),
|
||||||
|
label: Text(strings.runStart),
|
||||||
|
),
|
||||||
|
if (_liveSteps.isNotEmpty || _running) ...[
|
||||||
|
const SizedBox(height: FaiSpace.lg),
|
||||||
|
_section(
|
||||||
|
theme,
|
||||||
|
_running ? strings.runTitleRunning : strings.runTitleDone,
|
||||||
|
),
|
||||||
|
_stepList(theme),
|
||||||
|
],
|
||||||
|
if (_outputs != null) ...[
|
||||||
|
const SizedBox(height: FaiSpace.lg),
|
||||||
|
_section(theme, strings.runOutputs),
|
||||||
|
for (final entry in _outputs!.entries)
|
||||||
|
_outputRow(theme, entry.key, entry.value),
|
||||||
|
],
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: FaiSpace.lg),
|
||||||
|
_section(theme, strings.runTitleFailed),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(FaiSpace.sm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_error.toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _titleRow(ThemeData theme) {
|
||||||
|
final title = _running
|
||||||
|
? widget.strings.runTitleRunning
|
||||||
|
: _error != null
|
||||||
|
? widget.strings.runTitleFailed
|
||||||
|
: _outputs != null
|
||||||
|
? widget.strings.runTitleDone
|
||||||
|
: widget.strings.runTitleIdle;
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleMedium?.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 _empty(ThemeData theme, String hint) {
|
||||||
|
return Container(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
padding: const EdgeInsets.all(FaiSpace.lg),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
hint,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _inputField(ThemeData theme, String name, FlowInput input) {
|
||||||
|
final type = input.type;
|
||||||
|
if (type == 'bytes' || type == 'file') {
|
||||||
|
final pick = _fileInputs[name] ?? const _FilePick.empty();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 140,
|
||||||
|
child: Text(
|
||||||
|
'$name ($type)',
|
||||||
|
style: const TextStyle(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _pickFile(name),
|
||||||
|
icon: const Icon(Icons.attach_file, size: 16),
|
||||||
|
label: Text(
|
||||||
|
pick.fileName ?? widget.strings.runChooseFile,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 140,
|
||||||
|
child: Text(
|
||||||
|
'$name ($type)',
|
||||||
|
style: const TextStyle(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _textInputs[name],
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: input.hint,
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stepList(ThemeData theme) {
|
||||||
|
final entries = _liveSteps.entries.toList();
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (final entry in entries) _stepRow(theme, entry.key, entry.value),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stepRow(ThemeData theme, String id, _StepState state) {
|
||||||
|
final IconData glyph;
|
||||||
|
final Color color;
|
||||||
|
String suffix = '';
|
||||||
|
switch (state.kind) {
|
||||||
|
case _StepKind.running:
|
||||||
|
glyph = Icons.refresh;
|
||||||
|
color = theme.colorScheme.primary;
|
||||||
|
case _StepKind.done:
|
||||||
|
glyph = Icons.check;
|
||||||
|
color = Colors.green.shade600;
|
||||||
|
suffix = ' ${(state.durationMs ?? 0) / 1000.0}s';
|
||||||
|
case _StepKind.error:
|
||||||
|
glyph = Icons.close;
|
||||||
|
color = theme.colorScheme.error;
|
||||||
|
if (state.error != null && state.error!.isNotEmpty) {
|
||||||
|
suffix = ' — ${state.error}';
|
||||||
|
}
|
||||||
|
case _StepKind.awaiting:
|
||||||
|
glyph = Icons.pause_circle_outline;
|
||||||
|
color = theme.colorScheme.tertiary;
|
||||||
|
suffix = ' awaiting approval';
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(glyph, size: 16, color: color),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'$id$suffix',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _outputRow(ThemeData theme, String name, FlowOutputValue value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: FaiSpace.sm),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name.toUpperCase(),
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
_outputBody(theme, value),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _outputBody(ThemeData theme, FlowOutputValue value) {
|
||||||
|
if (value is FlowOutputText) {
|
||||||
|
return SelectableText(
|
||||||
|
value.value,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (value is FlowOutputJson) {
|
||||||
|
return SelectableText(
|
||||||
|
value.value.toString(),
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (value is FlowOutputBytes) {
|
||||||
|
return Text(
|
||||||
|
'${value.value.length} bytes · ${value.mimeType}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickFile(String name) async {
|
||||||
|
// For 0.2.0 the editor relies on the host to inject a
|
||||||
|
// file-picker via callback; minimal version uses
|
||||||
|
// dart:io directly for desktop. Studio uses file_picker;
|
||||||
|
// we fall back to a manual path entry dialog if no host
|
||||||
|
// picker is available.
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final ctx = context;
|
||||||
|
final result = await showDialog<String>(
|
||||||
|
context: ctx,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: Text(widget.strings.runChooseFile),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '/path/to/file',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, null),
|
||||||
|
child: Text(widget.strings.newDialogCancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result == null || result.isEmpty || !mounted) return;
|
||||||
|
try {
|
||||||
|
final file = File(result);
|
||||||
|
if (!file.existsSync()) return;
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
setState(() {
|
||||||
|
_fileInputs[name] = _FilePick(
|
||||||
|
fileName: file.uri.pathSegments.last,
|
||||||
|
bytes: Uint8List.fromList(bytes),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// swallow — file unreadable, leave the pick empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _start() async {
|
||||||
|
final driver = widget.driver;
|
||||||
|
if (driver == null) return;
|
||||||
|
final flowName = widget.controller.activeName;
|
||||||
|
if (flowName == null) return;
|
||||||
|
setState(() {
|
||||||
|
_running = true;
|
||||||
|
_outputs = null;
|
||||||
|
_error = null;
|
||||||
|
_liveSteps.clear();
|
||||||
|
_runFlowName = flowName;
|
||||||
|
});
|
||||||
|
widget.controller.running = true;
|
||||||
|
// Subscribe BEFORE submitting so the first event isn't
|
||||||
|
// lost to the broadcast's dead-letter.
|
||||||
|
_eventSub?.cancel();
|
||||||
|
_eventSub = driver.events().listen(_onEvent);
|
||||||
|
final textInputs = <String, String>{
|
||||||
|
for (final entry in _textInputs.entries) entry.key: entry.value.text,
|
||||||
|
};
|
||||||
|
final fileInputs = <String, Uint8List>{};
|
||||||
|
final fileMimes = <String, String>{};
|
||||||
|
for (final entry in _fileInputs.entries) {
|
||||||
|
final bytes = entry.value.bytes;
|
||||||
|
if (bytes == null) continue;
|
||||||
|
fileInputs[entry.key] = bytes;
|
||||||
|
fileMimes[entry.key] = _mimeFor(entry.value.fileName ?? '');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final outputs = await driver.runFlow(
|
||||||
|
flowName: flowName,
|
||||||
|
textInputs: textInputs,
|
||||||
|
fileInputs: fileInputs,
|
||||||
|
fileMimes: fileMimes,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_running = false;
|
||||||
|
_outputs = outputs;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_running = false;
|
||||||
|
_error = e;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
widget.controller.running = false;
|
||||||
|
_eventSub?.cancel();
|
||||||
|
_eventSub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onEvent(FlowRunEvent event) {
|
||||||
|
final flowName = _runFlowName;
|
||||||
|
if (flowName == null) return;
|
||||||
|
if (event.flowName != flowName) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
switch (event) {
|
||||||
|
case StepStarted():
|
||||||
|
_liveSteps[event.stepId] = const _StepState(kind: _StepKind.running);
|
||||||
|
case StepCompleted():
|
||||||
|
_liveSteps[event.stepId] = _StepState(
|
||||||
|
kind: _StepKind.done,
|
||||||
|
durationMs: event.durationMs,
|
||||||
|
);
|
||||||
|
case StepFailed():
|
||||||
|
_liveSteps[event.stepId] = _StepState(
|
||||||
|
kind: _StepKind.error,
|
||||||
|
error: event.error,
|
||||||
|
);
|
||||||
|
case StepAwaitingApproval():
|
||||||
|
_liveSteps[event.stepId] = const _StepState(kind: _StepKind.awaiting);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _mimeFor(String name) {
|
||||||
|
final lower = name.toLowerCase();
|
||||||
|
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||||
|
if (lower.endsWith('.docx')) {
|
||||||
|
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||||
|
}
|
||||||
|
if (lower.endsWith('.txt')) return 'text/plain';
|
||||||
|
if (lower.endsWith('.json')) return 'application/json';
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilePick {
|
||||||
|
final String? fileName;
|
||||||
|
final Uint8List? bytes;
|
||||||
|
const _FilePick({this.fileName, this.bytes});
|
||||||
|
const _FilePick.empty() : fileName = null, bytes = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _StepKind { running, done, error, awaiting }
|
||||||
|
|
||||||
|
class _StepState {
|
||||||
|
final _StepKind kind;
|
||||||
|
final int? durationMs;
|
||||||
|
final String? error;
|
||||||
|
const _StepState({required this.kind, this.durationMs, this.error});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue