feat: initial scaffold — swappable flow editor

Extracted from fai/studio's lib/pages/flow_editor.dart so the
editor can be swapped out independently of the host.

Public surface kept minimal — a single FlowEditorPage widget
with three named parameters (initialFlowName, locale, onRun).
The package brings its own design tokens, empty/error
widgets, l10n table; no host-internal types leak through.

Studio depends on this repo via pubspec.yaml git reference.
Forks point Studio at a different URL and rebuild.

See README.md for the swap recipe.

Signed-off-by: F∆I Platform <platform@flemming.ai>
This commit is contained in:
F∆I Platform 2026-05-30 14:33:03 +02:00
commit 51f9a1d2b1
8 changed files with 1128 additions and 0 deletions

View file

@ -0,0 +1,798 @@
/// 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.
library;
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 'l10n.dart';
import 'tokens.dart';
import 'widgets.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 {
final String? initialFlowName;
final FlowEditorLocale locale;
final Future<Map<String, Object>> Function(String name)? onRun;
const FlowEditorPage({
super.key,
this.initialFlowName,
this.locale = FlowEditorLocale.en,
this.onRun,
});
@override
State<FlowEditorPage> createState() => _FlowEditorPageState();
}
class _FlowEditorPageState extends State<FlowEditorPage> {
late final CodeController _code;
late final FlowEditorStrings _l;
String? _activeName;
String _loadedText = '';
late Future<List<_FlowFile>> _files;
Object? _runError;
Map<String, Object>? _runOutputs;
bool _running = false;
bool _saving = false;
@override
void initState() {
super.initState();
_l = FlowEditorStrings(widget.locale);
_code = CodeController(text: '', language: yaml);
_code.addListener(_onCodeChange);
_files = _listFiles();
final initial = widget.initialFlowName;
if (initial != null && initial.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _openByName(initial);
});
}
}
@override
void dispose() {
_code.removeListener(_onCodeChange);
_code.dispose();
super.dispose();
}
void _onCodeChange() {
if (mounted) setState(() {});
}
bool get _dirty => _activeName != null && _code.text != _loadedText;
Future<List<_FlowFile>> _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<File>()
.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<void> _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();
if (!mounted) return;
setState(() {
_activeName = name;
_loadedText = text;
_code.text = text;
_runOutputs = null;
_runError = null;
});
}
Future<void> _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<bool?> _confirmDiscard() async {
return showDialog<bool>(
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<void> _save() async {
final name = _activeName;
if (name == null) return;
setState(() => _saving = true);
try {
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;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _newFlow() async {
final name = await showDialog<String>(
context: context,
builder: (ctx) => _NewFlowDialog(strings: _l),
);
if (name == null || name.isEmpty || !mounted) return;
final template = '''# ${_l.newTemplateComment(name)}
inputs:
name:
type: text
steps:
- id: greet
use: debug.echo@^0
with:
text: "Hello, \${{ inputs.name }}!"
outputs:
greeting: \${{ steps.greet.result }}
''';
try {
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.alreadyExists(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;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}
}
Future<void> _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<void> _refresh() async {
setState(() => _files = _listFiles());
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final shortcuts = <ShortcutActivator, Intent>{
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: <Type, Action<Intent>>{
_SaveIntent: CallbackAction<_SaveIntent>(
onInvoke: (_) {
if (_activeName != null && !_saving) _save();
return null;
},
),
_RunIntent: CallbackAction<_RunIntent>(
onInvoke: (_) {
if (_activeName != null && !_running && widget.onRun != null) {
_run();
}
return null;
},
),
},
child: Focus(autofocus: true, child: _buildScaffold(theme)),
),
);
}
Widget _buildScaffold(ThemeData theme) {
return Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Toolbar(
strings: _l,
activeName: _activeName,
dirty: _dirty,
saving: _saving,
running: _running,
onBack: Navigator.of(context).canPop()
? () => Navigator.of(context).maybePop()
: null,
onSave: _activeName != null ? _save : null,
onRun: _activeName != null && !_running && widget.onRun != null
? _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,
strings: _l,
onOpen: _openFile,
onRefresh: _refresh,
),
),
const VerticalDivider(width: 1),
Expanded(
child: _activeName == null
? _EmptyState(strings: _l)
: _EditorPane(
controller: _code,
theme: theme,
outputs: _runOutputs,
runError: _runError,
running: _running,
strings: _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 FlowEditorStrings strings;
final String? activeName;
final bool dirty;
final bool saving;
final bool running;
final VoidCallback? onBack;
final VoidCallback? onSave;
final VoidCallback? onRun;
final VoidCallback onNew;
final VoidCallback onRefresh;
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.onRun,
required this.onNew,
required this.onRefresh,
});
@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),
IconButton.outlined(
onPressed: onRefresh,
tooltip: strings.refresh,
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(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),
),
],
),
);
}
}
class _FileList extends StatelessWidget {
final Future<List<_FlowFile>> 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: FutureBuilder<List<_FlowFile>>(
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,
),
),
],
),
),
);
},
);
},
),
);
}
}
class _EditorPane extends StatelessWidget {
final CodeController controller;
final ThemeData theme;
final Map<String, Object>? outputs;
final Object? runError;
final bool running;
final FlowEditorStrings strings;
const _EditorPane({
required this.controller,
required this.theme,
required this.outputs,
required this.runError,
required this.running,
required this.strings,
});
@override
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,
strings: strings,
),
),
],
],
);
}
}
class _RunResult extends StatelessWidget {
final Map<String, Object>? outputs;
final Object? error;
final bool running;
final FlowEditorStrings strings;
const _RunResult({
required this.outputs,
required this.error,
required this.running,
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,
),
),
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,
),
),
);
}
}
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();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final s = widget.strings;
return AlertDialog(
title: Text(s.newDialogTitle),
content: TextField(
controller: _ctrl,
autofocus: true,
decoration: InputDecoration(
labelText: s.newDialogLabel,
hintText: 'my-flow',
helperText: s.newDialogHelper,
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[a-z0-9_\-]')),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(s.newDialogCancel),
),
FilledButton(
onPressed: () {
if (_ctrl.text.isNotEmpty) {
Navigator.pop(context, _ctrl.text);
}
},
child: Text(s.newDialogCreate),
),
],
);
}
}
Map<String, TextStyle> _yamlStyle(ThemeData theme) {
final base = TextStyle(
fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface,
);
return {
'root': base,
'attr': base.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
'string': base.copyWith(color: theme.colorScheme.tertiary),
'number': base.copyWith(color: theme.colorScheme.secondary),
'literal': base.copyWith(color: theme.colorScheme.secondary),
'comment': base.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
'meta': base.copyWith(color: theme.colorScheme.primary),
'subst': base.copyWith(
color: theme.colorScheme.error,
fontWeight: FontWeight.w600,
),
};
}