// 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'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_code_editor/flutter_code_editor.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() { final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; return '$home/.fai/data/flows'; } 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; /// 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.runDriver, this.availableCapabilities = const [], }); @override State createState() => _FlowEditorPageState(); } class _FlowEditorPageState extends State with TickerProviderStateMixin { late final FlowEditorController _controller; late final FlowEditorStrings _l; late Future> _files; late final TabController _tabs; @override void initState() { super.initState(); _l = FlowEditorStrings(widget.locale); _controller = FlowEditorController(); _controller.addListener(_onCtrlChanged); _tabs = TabController(length: 3, vsync: this); _files = _listFiles(); final initial = widget.initialFlowName; if (initial != null && initial.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) async { await _openByName(initial); }); } } @override void dispose() { _controller.removeListener(_onCtrlChanged); _controller.dispose(); _tabs.dispose(); super.dispose(); } void _onCtrlChanged() { if (mounted) setState(() {}); } // --- file ops --- Future> _listFiles() async { final dir = Directory(_defaultFlowsDir()); if (!dir.existsSync()) return <_FlowFile>[]; final entries = await dir .list() .where((e) => e is File && e.path.endsWith('.yaml')) .cast() .toList(); final files = entries .map( (f) => _FlowFile( name: f.uri.pathSegments.last.replaceAll( RegExp(r'\.yaml$'), '', ), path: f.path, sizeBytes: f.statSync().size, ), ) .toList() ..sort((a, b) => a.name.compareTo(b.name)); return files; } Future _openByName(String name) async { final path = '${_defaultFlowsDir()}/$name.yaml'; final file = File(path); if (!file.existsSync()) return; final text = await file.readAsString(); if (!mounted) return; _controller.openFlow(name, text); } Future _openFile(_FlowFile f) async { if (_controller.isDirty) { final keep = await _confirmDiscard(); if (keep == false || !mounted) return; } final text = await File(f.path).readAsString(); if (!mounted) return; _controller.openFlow(f.name, text); } Future _confirmDiscard() { return showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(_l.discardTitle), content: Text(_l.discardBody), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(_l.discardKeep), ), TextButton( onPressed: () => Navigator.pop(ctx, true), child: Text(_l.discardThrow), ), ], ), ); } Future _save() async { final name = _controller.activeName; if (name == null) return; _controller.saving = true; try { final file = File('${_defaultFlowsDir()}/$name.yaml'); await file.writeAsString( _controller.codeController.fullText, flush: true, ); if (!mounted) return; _controller.markSaved(); _files = _listFiles(); setState(() {}); } catch (e) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(e.toString()))); } finally { _controller.saving = false; } } Future _newFlow() async { final name = await showDialog( context: context, builder: (ctx) => _NewFlowDialog(strings: _l), ); if (name == null || name.isEmpty || !mounted) return; final template = '''# ${_l.newTemplateComment(name)} name: $name inputs: text: type: text steps: - id: echo use: debug.echo@^0 with: message: \$inputs.text outputs: result: \$echo.echoed '''; try { final dir = Directory(_defaultFlowsDir()); if (!dir.existsSync()) await dir.create(recursive: true); 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 file.writeAsString(template, flush: true); if (!mounted) return; _controller.openFlow(name, template); _files = _listFiles(); setState(() {}); } catch (e) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(e.toString()))); } } 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) { // 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: { 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 (_controller.activeName != null) _save(); return null; }, ), _RunIntent: CallbackAction<_RunIntent>( onInvoke: (_) { if (_controller.activeName != null) _tabs.animateTo(2); return null; }, ), }, child: Focus(autofocus: true, child: _buildScaffold()), ), ); } Widget _buildScaffold() { final theme = Theme.of(context); return Scaffold( body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Toolbar( strings: _l, activeName: _controller.activeName, dirty: _controller.isDirty, saving: _controller.isSaving, running: _controller.isRunning, onBack: Navigator.of(context).canPop() ? () => Navigator.of(context).maybePop() : 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( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: 240, child: _FileList( filesFuture: _files, activeName: _controller.activeName, strings: _l, onOpen: _openFile, onRefresh: _refreshFiles, ), ), const VerticalDivider(width: 1), Expanded( child: _controller.activeName == null ? _EmptyState(strings: _l) : _tabbedBody(theme), ), ], ), ), ], ), ); } 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(); } class _RunIntent extends Intent { const _RunIntent(); } // --- toolbar --- class _Toolbar extends StatelessWidget { final FlowEditorStrings strings; final String? activeName; final bool dirty; final bool saving; final bool running; final VoidCallback? onBack; final VoidCallback? onSave; final VoidCallback? onAddStep; final VoidCallback onNew; final VoidCallback? onRun; const _Toolbar({ required this.strings, required this.activeName, required this.dirty, required this.saving, required this.running, required this.onBack, required this.onSave, required this.onAddStep, required this.onNew, required this.onRun, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, vertical: FaiSpace.sm, ), color: theme.colorScheme.surfaceContainer, child: Row( children: [ if (onBack != null) ...[ IconButton( icon: const Icon(Icons.arrow_back, size: 18), tooltip: strings.backTooltip, onPressed: onBack, ), const SizedBox(width: FaiSpace.xs), ], Text( activeName == null ? strings.emptyTitle : '${activeName!}.yaml', style: theme.textTheme.titleSmall?.copyWith( fontFamily: 'monospace', fontWeight: FontWeight.w600, ), ), if (dirty) Padding( padding: const EdgeInsets.only(left: FaiSpace.xs), child: Icon( Icons.circle, size: 8, color: theme.colorScheme.primary, ), ), const Spacer(), FilledButton.tonalIcon( onPressed: onNew, icon: const Icon(Icons.add, size: 16), 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 ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save_outlined, size: 16), label: Text(strings.save), ), const SizedBox(width: FaiSpace.sm), FilledButton.icon( onPressed: onRun, icon: running ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.play_arrow, size: 18), label: Text(strings.run), ), ], ), ); } } // --- 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; final FlowEditorStrings strings; final void Function(_FlowFile) onOpen; final VoidCallback onRefresh; const _FileList({ required this.filesFuture, required this.activeName, required this.strings, required this.onOpen, required this.onRefresh, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( color: theme.colorScheme.surface, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, vertical: FaiSpace.xs, ), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: theme.dividerColor)), ), child: Row( children: [ Expanded( child: Text( strings.listHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), ), IconButton( onPressed: onRefresh, tooltip: strings.refresh, icon: const Icon(Icons.refresh, size: 16), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 28, minHeight: 28, ), ), ], ), ), Expanded(child: _buildBody(context, theme)), ], ), ); } Widget _buildBody(BuildContext context, ThemeData theme) { return FutureBuilder>( future: filesFuture, builder: (context, snap) { if (snap.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snap.hasError) { return Padding( padding: const EdgeInsets.all(FaiSpace.md), child: FaiErrorBox(error: snap.error, isError: true), ); } final files = snap.data ?? <_FlowFile>[]; if (files.isEmpty) { return Padding( padding: const EdgeInsets.all(FaiSpace.md), child: FaiEmptyState( icon: Icons.folder_outlined, title: strings.listEmptyTitle, hint: strings.listEmptyBody, ), ); } return ListView.builder( padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs), itemCount: files.length, itemBuilder: (_, i) { final f = files[i]; final isActive = f.name == activeName; return InkWell( onTap: () => onOpen(f), child: Container( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, vertical: FaiSpace.sm, ), decoration: BoxDecoration( color: isActive ? theme.colorScheme.secondaryContainer : null, border: Border( left: BorderSide( width: 3, color: isActive ? theme.colorScheme.primary : Colors.transparent, ), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( f.name, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), overflow: TextOverflow.ellipsis, ), Text( '${f.sizeBytes} B', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ); }, ); }, ); } } // --- empty state --- class _EmptyState extends StatelessWidget { final FlowEditorStrings strings; const _EmptyState({required this.strings}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( color: theme.colorScheme.surface, child: Center( child: Padding( padding: const EdgeInsets.all(FaiSpace.xl), child: FaiEmptyState( icon: Icons.description_outlined, 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 _controller = TextEditingController(); String? _error; @override void 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) { return AlertDialog( title: Text(widget.strings.newDialogTitle), content: TextField( autofocus: true, controller: _controller, decoration: InputDecoration( labelText: widget.strings.newDialogLabel, helperText: widget.strings.newDialogHelper, errorText: _error, border: const OutlineInputBorder(), ), onSubmitted: (_) => _submit(), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(widget.strings.newDialogCancel), ), FilledButton( onPressed: _submit, child: Text(widget.strings.newDialogCreate), ), ], ); } }