From 870cbc29f71a7fd9972755203714274ac128897b Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 1 Jun 2026 00:48:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(editor):=20three-tab=20WYSIWYG=20editor=20?= =?UTF-8?q?=E2=80=94=20graph=20/=20text=20/=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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, // 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 --- lib/fai_studio_flow_editor.dart | 39 +- lib/src/editor_controller.dart | 184 +++++++ lib/src/flow_editor_page.dart | 695 ++++++++++++------------- lib/src/l10n.dart | 96 +++- lib/src/run_driver.dart | 100 ++++ lib/src/widgets.dart | 12 +- lib/src/widgets/capability_picker.dart | 103 ++++ lib/src/widgets/edge_painter.dart | 105 ++++ lib/src/widgets/flow_canvas.dart | 611 ++++++++++++++++++++++ lib/src/widgets/flow_node.dart | 264 ++++++++++ lib/src/widgets/properties_panel.dart | 404 ++++++++++++++ lib/src/widgets/run_tab.dart | 564 ++++++++++++++++++++ 12 files changed, 2769 insertions(+), 408 deletions(-) create mode 100644 lib/src/editor_controller.dart create mode 100644 lib/src/run_driver.dart create mode 100644 lib/src/widgets/capability_picker.dart create mode 100644 lib/src/widgets/edge_painter.dart create mode 100644 lib/src/widgets/flow_canvas.dart create mode 100644 lib/src/widgets/flow_node.dart create mode 100644 lib/src/widgets/properties_panel.dart create mode 100644 lib/src/widgets/run_tab.dart diff --git a/lib/fai_studio_flow_editor.dart b/lib/fai_studio_flow_editor.dart index 7a21f46..ad9f3c4 100644 --- a/lib/fai_studio_flow_editor.dart +++ b/lib/fai_studio_flow_editor.dart @@ -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 -/// different editor, fork this repo, change the implementation, -/// point Studio's pubspec at the fork, rebuild Studio. +/// Studio depends on this package via pubspec.yaml. To use +/// a different editor, fork this repo, change the +/// implementation, point Studio's pubspec at the fork, +/// rebuild Studio. /// /// Public surface: +/// /// * [FlowEditorPage] — the page widget Studio embeds as -/// its "Editor" destination + as a route push from the -/// Flows page's pencil icon. -/// * [FlowEditorLocale] — the locale the package renders -/// its inline strings in. Studio passes its active -/// Locale through. +/// its Flows destination. Three tabs over one in-memory +/// flow: graph (drag-and-drop WYSIWYG), text (raw YAML), +/// run (inputs form + live step progress + outputs). +/// * [FlowEditorLocale] — render language for inline +/// 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; export 'src/flow_editor_page.dart' show FlowEditorPage; export 'src/l10n.dart' show FlowEditorLocale; +export 'src/run_driver.dart' + show + FlowRunDriver, + FlowRunEvent, + StepStarted, + StepCompleted, + StepFailed, + StepAwaitingApproval, + FlowOutputValue, + FlowOutputText, + FlowOutputJson, + FlowOutputBytes; diff --git a/lib/src/editor_controller.dart b/lib/src/editor_controller.dart new file mode 100644 index 0000000..778e51d --- /dev/null +++ b/lib/src/editor_controller.dart @@ -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(); + }); + } +} diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 5c37757..10e52a9 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -1,15 +1,25 @@ -/// FlowEditorPage — host-agnostic inline YAML editor. -/// -/// The page reads + writes flow YAML directly under -/// `~/.fai/data/flows/` via dart:io. The hub picks up changes -/// on its next ListFlows / RunSavedFlow call. -/// -/// Studio passes: -/// * [initialFlowName] — preload a specific flow. -/// * [locale] — render inline strings in EN or DE. -/// * [onRun] — async callback that runs the named flow -/// against the host hub. Returns the typed outputs map. -/// When null, the Run button is disabled. +// FlowEditorPage — the public-facing widget Studio (and any +// other host) embeds as its flow surface. +// +// Layout: +// +// ┌─ Toolbar ─────────────────────────────────────────────┐ +// │ [back] file.yaml ● [+ Step] [Save] [Run] │ +// ├──────┬────────────────────────────────────────────────┤ +// │ FLOWS│ [Graph][Text][Run] │ +// │ ⟲ │ │ +// │ │ (active tab content) │ +// │ 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; import 'dart:io'; @@ -17,11 +27,17 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart'; -import 'package:highlight/languages/yaml.dart'; +import 'editor_controller.dart'; import 'l10n.dart'; +import 'model/flow_graph.dart'; +import 'run_driver.dart'; import 'tokens.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. String _defaultFlowsDir() { @@ -33,45 +49,48 @@ String _defaultFlowsDir() { } 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; + + /// Language for inline strings. final FlowEditorLocale locale; - final Future> 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 availableCapabilities; const FlowEditorPage({ super.key, this.initialFlowName, this.locale = FlowEditorLocale.en, - this.onRun, + this.runDriver, + this.availableCapabilities = const [], }); @override State createState() => _FlowEditorPageState(); } -class _FlowEditorPageState extends State { - late final CodeController _code; +class _FlowEditorPageState extends State + with TickerProviderStateMixin { + late final FlowEditorController _controller; 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> _files; - Object? _runError; - Map? _runOutputs; - bool _running = false; - bool _saving = false; + late final TabController _tabs; @override void initState() { super.initState(); _l = FlowEditorStrings(widget.locale); - _code = CodeController(text: '', language: yaml); - _code.addListener(_onCodeChange); + _controller = FlowEditorController(); + _controller.addListener(_onCtrlChanged); + _tabs = TabController(length: 3, vsync: this); _files = _listFiles(); final initial = widget.initialFlowName; if (initial != null && initial.isNotEmpty) { @@ -83,16 +102,17 @@ class _FlowEditorPageState extends State { @override void dispose() { - _code.removeListener(_onCodeChange); - _code.dispose(); + _controller.removeListener(_onCtrlChanged); + _controller.dispose(); + _tabs.dispose(); super.dispose(); } - void _onCodeChange() { + void _onCtrlChanged() { if (mounted) setState(() {}); } - bool get _dirty => _activeName != null && _code.fullText != _baseline; + // --- file ops --- Future> _listFiles() async { final dir = Directory(_defaultFlowsDir()); @@ -120,42 +140,25 @@ class _FlowEditorPageState extends State { } Future _openByName(String name) async { - final dir = Directory(_defaultFlowsDir()); - final path = '${dir.path}/$name.yaml'; - final f = File(path); - if (!f.existsSync()) return; - final text = await f.readAsString(); + final path = '${_defaultFlowsDir()}/$name.yaml'; + final file = File(path); + if (!file.existsSync()) return; + final text = await file.readAsString(); if (!mounted) return; - // Set the controller text first, then snapshot fullText - // 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; - }); + _controller.openFlow(name, text); } Future _openFile(_FlowFile f) async { - if (_dirty) { + if (_controller.isDirty) { final keep = await _confirmDiscard(); if (keep == false || !mounted) return; } final text = await File(f.path).readAsString(); - _code.fullText = text; - setState(() { - _activeName = f.name; - _baseline = _code.fullText; - _runOutputs = null; - _runError = null; - }); + if (!mounted) return; + _controller.openFlow(f.name, text); } - Future _confirmDiscard() async { + Future _confirmDiscard() { return showDialog( context: context, builder: (ctx) => AlertDialog( @@ -176,27 +179,26 @@ class _FlowEditorPageState extends State { } Future _save() async { - final name = _activeName; + final name = _controller.activeName; if (name == null) return; - setState(() => _saving = true); + _controller.saving = true; try { - final f = File('${_defaultFlowsDir()}/$name.yaml'); - // Write fullText — `text` is post-fold visible-only, so - // saving it would lose any content the editor has hidden - // behind a collapsed fold. - await f.writeAsString(_code.fullText, flush: true); + final file = File('${_defaultFlowsDir()}/$name.yaml'); + await file.writeAsString( + _controller.codeController.fullText, + flush: true, + ); if (!mounted) return; - setState(() { - _baseline = _code.fullText; - _files = _listFiles(); - }); + _controller.markSaved(); + _files = _listFiles(); + setState(() {}); } catch (e) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(e.toString()))); } finally { - if (mounted) setState(() => _saving = false); + _controller.saving = false; } } @@ -206,40 +208,40 @@ class _FlowEditorPageState extends State { builder: (ctx) => _NewFlowDialog(strings: _l), ); if (name == null || name.isEmpty || !mounted) return; - final template = '''# ${_l.newTemplateComment(name)} + final template = + '''# ${_l.newTemplateComment(name)} + +name: $name inputs: - name: + text: type: text steps: - - id: greet + - id: echo use: debug.echo@^0 with: - text: "Hello, \${{ inputs.name }}!" + message: \$inputs.text outputs: - greeting: \${{ steps.greet.result }} + result: \$echo.echoed '''; try { final dir = Directory(_defaultFlowsDir()); if (!dir.existsSync()) await dir.create(recursive: true); - final f = File('${dir.path}/$name.yaml'); - if (f.existsSync()) { + final file = File('${dir.path}/$name.yaml'); + if (file.existsSync()) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(_l.alreadyExists(name)))); return; } - await f.writeAsString(template, flush: true); + await file.writeAsString(template, flush: true); if (!mounted) return; - _code.fullText = template; - setState(() { - _activeName = name; - _baseline = _code.fullText; - _files = _listFiles(); - }); + _controller.openFlow(name, template); + _files = _listFiles(); + setState(() {}); } catch (e) { if (!mounted) return; ScaffoldMessenger.of( @@ -248,90 +250,94 @@ outputs: } } - Future _run() 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 _refresh() async { + Future _refreshFiles() async { setState(() => _files = _listFiles()); } + Future _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 Widget build(BuildContext context) { - final theme = Theme.of(context); - - final shortcuts = { - LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyS): - const _SaveIntent(), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS): - const _SaveIntent(), - LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.enter): - const _RunIntent(), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter): - const _RunIntent(), - }; - + // Keyboard shortcuts: Cmd/Ctrl+S to save, Cmd/Ctrl+Enter + // to start a run. The text tab also keeps these but the + // global wrapper covers the graph + run tabs too. return Shortcuts( - shortcuts: shortcuts, + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyS): + const _SaveIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS): + const _SaveIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.enter): + const _RunIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter): + const _RunIntent(), + }, child: Actions( actions: >{ _SaveIntent: CallbackAction<_SaveIntent>( onInvoke: (_) { - if (_activeName != null && !_saving) _save(); + if (_controller.activeName != null) _save(); return null; }, ), _RunIntent: CallbackAction<_RunIntent>( onInvoke: (_) { - if (_activeName != null && !_running && widget.onRun != null) { - _run(); - } + if (_controller.activeName != null) _tabs.animateTo(2); 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( body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Toolbar( strings: _l, - activeName: _activeName, - dirty: _dirty, - saving: _saving, - running: _running, + activeName: _controller.activeName, + dirty: _controller.isDirty, + saving: _controller.isSaving, + running: _controller.isRunning, onBack: Navigator.of(context).canPop() ? () => Navigator.of(context).maybePop() : null, - onSave: _activeName != null ? _save : null, - onRun: _activeName != null && !_running && widget.onRun != null - ? _run - : null, + onSave: _controller.activeName != null ? _save : null, + onAddStep: _controller.activeName != null ? _addStep : null, onNew: _newFlow, + onRun: _controller.activeName != null + ? () => _tabs.animateTo(2) + : null, ), const Divider(height: 1), Expanded( @@ -342,24 +348,17 @@ outputs: width: 240, child: _FileList( filesFuture: _files, - activeName: _activeName, + activeName: _controller.activeName, strings: _l, onOpen: _openFile, - onRefresh: _refresh, + onRefresh: _refreshFiles, ), ), const VerticalDivider(width: 1), Expanded( - child: _activeName == null + child: _controller.activeName == null ? _EmptyState(strings: _l) - : _EditorPane( - controller: _code, - theme: theme, - outputs: _runOutputs, - runError: _runError, - running: _running, - strings: _l, - ), + : _tabbedBody(theme), ), ], ), @@ -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 _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 { const _SaveIntent(); } @@ -378,16 +496,7 @@ class _RunIntent extends Intent { const _RunIntent(); } -class _FlowFile { - final String name; - final String path; - final int sizeBytes; - const _FlowFile({ - required this.name, - required this.path, - required this.sizeBytes, - }); -} +// --- toolbar --- class _Toolbar extends StatelessWidget { final FlowEditorStrings strings; @@ -397,8 +506,9 @@ class _Toolbar extends StatelessWidget { final bool running; final VoidCallback? onBack; final VoidCallback? onSave; - final VoidCallback? onRun; + final VoidCallback? onAddStep; final VoidCallback onNew; + final VoidCallback? onRun; const _Toolbar({ required this.strings, required this.activeName, @@ -407,8 +517,9 @@ class _Toolbar extends StatelessWidget { required this.running, required this.onBack, required this.onSave, - required this.onRun, + required this.onAddStep, required this.onNew, + required this.onRun, }); @override @@ -453,6 +564,13 @@ class _Toolbar extends StatelessWidget { label: Text(strings.newFlow), ), 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( onPressed: saving ? null : onSave, 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 { final Future> filesFuture; final String? activeName; @@ -505,13 +636,6 @@ class _FileList extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, 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( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, @@ -626,248 +750,85 @@ class _FileList extends StatelessWidget { } } -class _EditorPane extends StatelessWidget { - final CodeController controller; - final ThemeData theme; - final Map? 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, - }); +// --- empty state --- - @override - 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? outputs; - final Object? error; - final bool running; +class _EmptyState extends StatelessWidget { final FlowEditorStrings strings; - const _RunResult({ - required this.outputs, - required this.error, - required this.running, - required this.strings, - }); + const _EmptyState({required this.strings}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( color: theme.colorScheme.surface, - padding: const EdgeInsets.all(FaiSpace.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - strings.runOutput, - style: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 0.6, - ), + child: Center( + child: Padding( + padding: const EdgeInsets.all(FaiSpace.xl), + child: FaiEmptyState( + icon: Icons.description_outlined, + title: strings.emptyTitle, + hint: strings.emptyBody, ), - 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( - padding: const EdgeInsets.all(FaiSpace.xxl), - child: FaiEmptyState( - icon: Icons.edit_note, - title: strings.emptyTitle, - hint: strings.emptyBody, ), ), ); } } +// --- new flow dialog --- + class _NewFlowDialog extends StatefulWidget { final FlowEditorStrings strings; const _NewFlowDialog({required this.strings}); + @override State<_NewFlowDialog> createState() => _NewFlowDialogState(); } class _NewFlowDialogState extends State<_NewFlowDialog> { - final _ctrl = TextEditingController(); + final _controller = TextEditingController(); + String? _error; @override void dispose() { - _ctrl.dispose(); + _controller.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 Widget build(BuildContext context) { - final s = widget.strings; return AlertDialog( - title: Text(s.newDialogTitle), + title: Text(widget.strings.newDialogTitle), content: TextField( - controller: _ctrl, autofocus: true, + controller: _controller, decoration: InputDecoration( - labelText: s.newDialogLabel, - hintText: 'my-flow', - helperText: s.newDialogHelper, + labelText: widget.strings.newDialogLabel, + helperText: widget.strings.newDialogHelper, + errorText: _error, + border: const OutlineInputBorder(), ), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[a-z0-9_\-]')), - ], + onSubmitted: (_) => _submit(), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text(s.newDialogCancel), + child: Text(widget.strings.newDialogCancel), ), FilledButton( - onPressed: () { - if (_ctrl.text.isNotEmpty) { - Navigator.pop(context, _ctrl.text); - } - }, - child: Text(s.newDialogCreate), + onPressed: _submit, + child: Text(widget.strings.newDialogCreate), ), ], ); } } - -Map _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, - ), - }; -} diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index a8ee8f9..97e0a89 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -22,39 +22,91 @@ class FlowEditorStrings { String get runOutput => _t('RUN OUTPUT', 'LAUF-ERGEBNIS'); String get emptyTitle => _t('No flow open', 'Kein Flow geöffnet'); String get emptyBody => _t( - 'Pick one from the left, or click New flow to start a fresh one.', - 'Wähle links einen aus oder klicke Neuer Flow um einen neuen anzulegen.', - ); + 'Pick one from the left, or click New flow to start a fresh one.', + 'Wähle links einen aus oder klicke Neuer Flow um einen neuen anzulegen.', + ); String get listEmptyTitle => _t('No saved flows', 'Keine gespeicherten Flows'); String get listEmptyBody => _t( - 'Click New flow to scaffold one from a template.', - 'Klicke Neuer Flow um aus einer Vorlage zu starten.', - ); - String get discardTitle => _t( - 'Discard unsaved changes?', - 'Ungespeicherte Änderungen verwerfen?', - ); + 'Click New flow to scaffold one from a template.', + 'Klicke Neuer Flow um aus einer Vorlage zu starten.', + ); + String get discardTitle => + _t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?'); String get discardBody => _t( - "Switching files will throw away the edits you haven't saved.", - 'Beim Wechsel der Datei gehen alle noch nicht gespeicherten Änderungen verloren.', - ); + "Switching files will throw away the edits you haven't saved.", + 'Beim Wechsel der Datei gehen alle noch nicht gespeicherten Änderungen verloren.', + ); String get discardKeep => _t('Keep editing', 'Weiterbearbeiten'); String get discardThrow => _t('Throw away', 'Verwerfen'); String get newDialogTitle => _t('New flow', 'Neuer Flow'); String get newDialogLabel => _t('Flow name', 'Flow-Name'); String get newDialogHelper => _t( - 'lowercase letters, digits, hyphen, underscore', - 'Kleinbuchstaben, Ziffern, Bindestrich, Unterstrich', - ); + 'lowercase letters, digits, hyphen, underscore', + 'Kleinbuchstaben, Ziffern, Bindestrich, Unterstrich', + ); String get newDialogCancel => _t('Cancel', 'Abbrechen'); String get newDialogCreate => _t('Create', 'Anlegen'); String newTemplateComment(String name) => _t( - "Generated by Studio's Flow editor. Edit, save, run.", - 'Von Studios Flow-Editor erstellt. Bearbeiten, speichern, ausführen.', - ); + "Generated by Studio's Flow editor. Edit, save, run.", + 'Von Studios Flow-Editor erstellt. Bearbeiten, speichern, ausführen.', + ); String alreadyExists(String name) => _t( - 'A flow named "$name" already exists.', - 'Ein Flow namens "$name" existiert bereits.', - ); + 'A flow named "$name" already exists.', + '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…'); } diff --git a/lib/src/run_driver.dart b/lib/src/run_driver.dart new file mode 100644 index 0000000..1aa16e6 --- /dev/null +++ b/lib/src/run_driver.dart @@ -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> runFlow({ + required String flowName, + required Map textInputs, + required Map fileInputs, + required Map 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 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); +} diff --git a/lib/src/widgets.dart b/lib/src/widgets.dart index 8e14ffc..6f6b33f 100644 --- a/lib/src/widgets.dart +++ b/lib/src/widgets.dart @@ -29,11 +29,7 @@ class FaiEmptyState extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon( - icon, - size: 40, - color: theme.colorScheme.onSurfaceVariant, - ), + Icon(icon, size: 40, color: theme.colorScheme.onSurfaceVariant), const SizedBox(height: FaiSpace.md), Text( title, @@ -91,11 +87,7 @@ class FaiErrorBox extends StatelessWidget { child: SingleChildScrollView( child: SelectableText( error?.toString() ?? '', - style: TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: fg, - ), + style: TextStyle(fontFamily: 'monospace', fontSize: 12, color: fg), ), ), ); diff --git a/lib/src/widgets/capability_picker.dart b/lib/src/widgets/capability_picker.dart new file mode 100644 index 0000000..1936453 --- /dev/null +++ b/lib/src/widgets/capability_picker.dart @@ -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 capabilities; + final FlowEditorStrings strings; + const CapabilityPicker({ + super.key, + required this.capabilities, + required this.strings, + }); + + static Future show( + BuildContext context, { + required List capabilities, + required FlowEditorStrings strings, + }) { + return showDialog( + context: context, + builder: (_) => + CapabilityPicker(capabilities: capabilities, strings: strings), + ); + } + + @override + State createState() => _CapabilityPickerState(); +} + +class _CapabilityPickerState extends State { + 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), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/edge_painter.dart b/lib/src/widgets/edge_painter.dart new file mode 100644 index 0000000..989675e --- /dev/null +++ b/lib/src/widgets/edge_painter.dart @@ -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 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; +} diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart new file mode 100644 index 0000000..cb34698 --- /dev/null +++ b/lib/src/widgets/flow_canvas.dart @@ -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 stepStatuses; + const FlowCanvas({ + super.key, + required this.controller, + this.stepStatuses = const {}, + }); + + @override + State createState() => _FlowCanvasState(); +} + +class _FlowCanvasState extends State { + 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 _buildSegments( + FlowGraph graph, + FlowLayout layout, + double outputsX, + ) { + final out = []; + 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 _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; +} diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart new file mode 100644 index 0000000..e7c9c7a --- /dev/null +++ b/lib/src/widgets/flow_node.dart @@ -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 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; +} diff --git a/lib/src/widgets/properties_panel.dart b/lib/src/widgets/properties_panel.dart new file mode 100644 index 0000000..d3092ce --- /dev/null +++ b/lib/src/widgets/properties_panel.dart @@ -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 availableCapabilities; + const PropertiesPanel({ + super.key, + required this.controller, + required this.strings, + this.availableCapabilities = const [], + }); + + @override + State createState() => _PropertiesPanelState(); +} + +class _PropertiesPanelState extends State { + // 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 _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( + 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 _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 = { + 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 a, Map 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; + } +} diff --git a/lib/src/widgets/run_tab.dart b/lib/src/widgets/run_tab.dart new file mode 100644 index 0000000..73d34ad --- /dev/null +++ b/lib/src/widgets/run_tab.dart @@ -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/.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 createState() => _RunTabState(); +} + +class _RunTabState extends State { + final Map _textInputs = {}; + final Map _fileInputs = {}; + + StreamSubscription? _eventSub; + bool _running = false; + Map? _outputs; + Object? _error; + // Insertion-ordered: shows steps in the order the hub + // actually started them, matching the CLI rendering. + final Map _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 = {}; + 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 _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( + 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 _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 = { + for (final entry in _textInputs.entries) entry.key: entry.value.text, + }; + final fileInputs = {}; + final fileMimes = {}; + 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}); +}