// Flow Editor — inline IDE for hub-managed flow YAML. // // Two-pane layout: // left pane — list of saved flows under ~/.fai/data/flows/ // right pane — code editor with YAML syntax highlighting // // Top bar exposes Save, Run, New Flow, Refresh. Bottom bar // shows cursor position + a dirty mark. // // File I/O note: the v1 implementation reads + writes flow // YAML directly via `dart:io` because Studio and the hub share // the local filesystem in the supported deployment. A future // GetFlowText / SetFlowText pair of RPCs will let Studio talk // to a remote hub the same way (planned, not blocking). import 'dart:async'; 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 '../data/friendly_error.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; /// Compute the default flow directory under the operator's /// home. Studio runs on the same machine as the hub in every /// supported deployment, so a direct FS path is the simplest /// and matches what the hub itself writes to. String _defaultFlowsDir() { final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; return '$home/.fai/data/flows'; } /// Top-level flow editor page. Keeps the file-tree loader and /// the editor state alongside each other because Save needs /// both to know what name to write under. class FlowEditorPage extends StatefulWidget { const FlowEditorPage({super.key}); @override State createState() => _FlowEditorPageState(); } class _FlowEditorPageState extends State { late final CodeController _code; String? _activeName; String _loadedText = ''; late Future> _files; Object? _runError; Map? _runOutputs; bool _running = false; bool _saving = false; @override void initState() { super.initState(); _code = CodeController(text: '', language: yaml); _code.addListener(_onCodeChange); _files = _listFiles(); } @override void dispose() { _code.removeListener(_onCodeChange); _code.dispose(); super.dispose(); } void _onCodeChange() { if (mounted) setState(() {}); } bool get _dirty => _activeName != null && _code.text != _loadedText; 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 _openFile(_FlowFile f) async { if (_dirty) { final keep = await _confirmDiscard(); if (keep == false || !mounted) return; } final text = await File(f.path).readAsString(); setState(() { _activeName = f.name; _loadedText = text; _code.text = text; _runOutputs = null; _runError = null; }); } Future _confirmDiscard() async { final l = AppLocalizations.of(context)!; return showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l.flowEditorDiscardTitle), content: Text(l.flowEditorDiscardBody), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(l.flowEditorDiscardKeep), ), TextButton( onPressed: () => Navigator.pop(ctx, true), child: Text(l.flowEditorDiscardThrow), ), ], ), ); } Future _save() async { final name = _activeName; if (name == null) return; setState(() => _saving = true); try { // Write directly via dart:io. The hub reads the saved- // flows directory on every list / run, so the next // runSavedFlow picks up our changes without an RPC. A // follow-up patch can route through the hub's SaveFlow // RPC once the SDK wraps it as a typed call; the file // shape is identical either way. final f = File('${_defaultFlowsDir()}/$name.yaml'); await f.writeAsString(_code.text, flush: true); if (!mounted) return; setState(() { _loadedText = _code.text; _files = _listFiles(); }); } catch (e) { if (!mounted) return; final l = AppLocalizations.of(context)!; final fe = friendlyError(e, l); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(fe.headline)), ); } finally { if (mounted) setState(() => _saving = false); } } Future _newFlow() async { final l = AppLocalizations.of(context)!; final name = await showDialog( context: context, builder: (ctx) => _NewFlowDialog(), ); if (name == null || name.isEmpty || !mounted) return; final template = '''# ${l.flowEditorNewTemplateComment(name)} inputs: name: type: text steps: - id: greet use: debug.echo@^0 with: text: "Hello, \${{ inputs.name }}!" outputs: greeting: \${{ steps.greet.result }} '''; try { // Create file on disk + ensure the directory exists. final dir = Directory(_defaultFlowsDir()); if (!dir.existsSync()) await dir.create(recursive: true); final f = File('${dir.path}/$name.yaml'); if (f.existsSync()) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.flowEditorAlreadyExists(name))), ); return; } await f.writeAsString(template, flush: true); if (!mounted) return; setState(() { _activeName = name; _loadedText = template; _code.text = template; _files = _listFiles(); }); } catch (e) { if (!mounted) return; final fe = friendlyError(e, l); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(fe.headline)), ); } } Future _run() async { final name = _activeName; if (name == null) return; // Save first so the hub runs the latest version. if (_dirty) await _save(); setState(() { _running = true; _runOutputs = null; _runError = null; }); try { // For v1 we run with empty inputs and let the operator // discover what's needed via the resulting error if any // input is required. A later iteration could read the // flow's input definitions and prompt for each. final out = await HubService.instance.runSavedFlow(name: name); if (!mounted) return; setState(() => _runOutputs = out); } catch (e) { if (!mounted) return; setState(() => _runError = e); } finally { if (mounted) setState(() => _running = false); } } Future _refresh() async { setState(() => _files = _listFiles()); } @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final theme = Theme.of(context); // Cmd+S / Ctrl+S → save, Cmd+Enter / Ctrl+Enter → run. // We layer the Shortcuts widget at the page root so the // bindings work whether the editor area has focus or not. 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(), }; return Shortcuts( shortcuts: shortcuts, child: Actions( actions: >{ _SaveIntent: CallbackAction<_SaveIntent>( onInvoke: (_) { if (_activeName != null && !_saving) _save(); return null; }, ), _RunIntent: CallbackAction<_RunIntent>( onInvoke: (_) { if (_activeName != null && !_running) _run(); return null; }, ), }, child: Focus(autofocus: true, child: _buildScaffold(l, theme)), ), ); } Widget _buildScaffold(AppLocalizations l, ThemeData theme) { return Scaffold( body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Toolbar( activeName: _activeName, dirty: _dirty, saving: _saving, running: _running, onSave: _activeName != null ? _save : null, onRun: _activeName != null && !_running ? _run : null, onNew: _newFlow, onRefresh: _refresh, ), const Divider(height: 1), Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: 240, child: _FileList( filesFuture: _files, activeName: _activeName, onOpen: _openFile, onRefresh: _refresh, ), ), const VerticalDivider(width: 1), Expanded( child: _activeName == null ? _EmptyState() : _EditorPane( controller: _code, theme: theme, outputs: _runOutputs, runError: _runError, running: _running, l: l, ), ), ], ), ), ], ), ); } } class _SaveIntent extends Intent { const _SaveIntent(); } 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, }); } class _Toolbar extends StatelessWidget { final String? activeName; final bool dirty; final bool saving; final bool running; final VoidCallback? onSave; final VoidCallback? onRun; final VoidCallback onNew; final VoidCallback onRefresh; const _Toolbar({ required this.activeName, required this.dirty, required this.saving, required this.running, required this.onSave, required this.onRun, required this.onNew, required this.onRefresh, }); @override Widget build(BuildContext context) { final l = AppLocalizations.of(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: [ Text( activeName == null ? l.flowEditorEmptyTitle : '${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(l.flowEditorNew), ), const SizedBox(width: FaiSpace.sm), IconButton.outlined( onPressed: onRefresh, tooltip: l.flowEditorRefresh, icon: const Icon(Icons.refresh, size: 18), ), 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(l.flowEditorSave), ), 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(l.flowEditorRun), ), ], ), ); } } class _FileList extends StatelessWidget { final Future> filesFuture; final String? activeName; final void Function(_FlowFile) onOpen; final VoidCallback onRefresh; const _FileList({ required this.filesFuture, required this.activeName, required this.onOpen, required this.onRefresh, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Container( color: theme.colorScheme.surface, child: 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: l.flowEditorListEmptyTitle, hint: l.flowEditorListEmptyBody, ), ); } 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, ), ), ], ), ), ); }, ); }, ), ); } } class _EditorPane extends StatelessWidget { final CodeController controller; final ThemeData theme; final Map? outputs; final Object? runError; final bool running; final AppLocalizations l; const _EditorPane({ required this.controller, required this.theme, required this.outputs, required this.runError, required this.running, required this.l, }); @override Widget build(BuildContext context) { final mono = TextStyle( fontFamily: 'JetBrains Mono', fontSize: 13, height: 1.45, color: theme.colorScheme.onSurface, ); return Row( children: [ Expanded( flex: 3, child: CodeTheme( data: CodeThemeData(styles: _yamlStyle(theme)), child: SingleChildScrollView( child: CodeField( controller: controller, textStyle: mono, expands: false, 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, l: l, ), ), ], ], ); } } class _RunResult extends StatelessWidget { final Map? outputs; final Object? error; final bool running; final AppLocalizations l; const _RunResult({ required this.outputs, required this.error, required this.running, required this.l, }); @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( l.flowEditorRunOutput, style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), const SizedBox(height: FaiSpace.sm), if (running) const LinearProgressIndicator(), if (error != null) ...[ // Map raw exceptions through friendlyError so the // operator sees a sentence + a recovery hint // instead of a stack trace. The verbatim error // stays accessible via FaiErrorBox's expand-on-tap. Builder( builder: (ctx) { final fe = friendlyError(error!, l); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( fe.headline, style: Theme.of(ctx).textTheme.bodyMedium?.copyWith( color: Theme.of(ctx).colorScheme.error, ), ), if (fe.hint != null) ...[ const SizedBox(height: FaiSpace.xs), Text( fe.hint!, style: Theme.of(ctx).textTheme.bodySmall, ), ], const SizedBox(height: FaiSpace.sm), FaiErrorBox( error: fe.detail, isError: true, maxHeight: 200, ), ], ); }, ), ] 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: FaiTheme.mono( size: 12, color: theme.colorScheme.onSurface, ), ), ], ), ), ], ), ), ], ), ); } } class _EmptyState extends StatelessWidget { @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return Center( child: Padding( padding: const EdgeInsets.all(FaiSpace.xxl), child: FaiEmptyState( icon: Icons.edit_note, title: l.flowEditorEmptyTitle, hint: l.flowEditorEmptyBody, ), ), ); } } class _NewFlowDialog extends StatefulWidget { @override State<_NewFlowDialog> createState() => _NewFlowDialogState(); } class _NewFlowDialogState extends State<_NewFlowDialog> { final _ctrl = TextEditingController(); @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return AlertDialog( title: Text(l.flowEditorNewDialogTitle), content: TextField( controller: _ctrl, autofocus: true, decoration: InputDecoration( labelText: l.flowEditorNewDialogLabel, hintText: 'my-flow', helperText: l.flowEditorNewDialogHelper, ), inputFormatters: [ // Filenames are restricted to lowercase, digits, // hyphen, underscore. The hub gets cranky about // path-injection characters and Windows file system // doesn't like colons / pipes either. FilteringTextInputFormatter.allow(RegExp(r'[a-z0-9_\-]')), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(l.flowEditorNewDialogCancel), ), FilledButton( onPressed: () { if (_ctrl.text.isNotEmpty) { Navigator.pop(context, _ctrl.text); } }, child: Text(l.flowEditorNewDialogCreate), ), ], ); } } // Lightweight YAML colour map. The full highlight package // ships dozens of token classes; we map only the ones that // actually appear in flow YAML to avoid styling-system noise // against the warm-minimalism palette. Map _yamlStyle(ThemeData theme) { final base = TextStyle( fontFamily: 'JetBrains Mono', color: theme.colorScheme.onSurface, ); return { 'root': base, // Keys before the colon — primary accent for structure. 'attr': base.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), // Strings (with or without quotes). 'string': base.copyWith(color: theme.colorScheme.tertiary), // Numbers and booleans. 'number': base.copyWith(color: theme.colorScheme.secondary), 'literal': base.copyWith(color: theme.colorScheme.secondary), // Comments — dim, italic. 'comment': base.copyWith( color: theme.colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), // Pipe/dash list/key indicators. 'meta': base.copyWith(color: theme.colorScheme.primary), // Template syntax ${{ ... }} — high-contrast, mono. 'subst': base.copyWith( color: theme.colorScheme.error, fontWeight: FontWeight.w600, ), }; }