feat(editor): three-tab WYSIWYG editor — graph / text / run

Full rewrite of the editor surface, layered on top of the
FlowGraph foundation. One in-memory flow drives three tabs
that the operator can flip between freely:

 - Graph: a drag-and-drop canvas. Nodes are step cards with
   port dots on their left (one per `with:` field) and a
   combined output port on the right. Pinned inputs and
   outputs pseudo-nodes sit at the left and right edges so
   every flow has a visually obvious source and sink. Pan +
   zoom via InteractiveViewer; drag a node by its body to
   reposition it (positions persisted to a sidecar JSON file
   under ~/.fai/data/flows/.layout/<name>.json — kept OUT of
   the YAML so `fai run` stays byte-stable).
 - Text: the existing YAML CodeField with expands:true so
   line 1 anchors at the top edge. YAML-aware syntax
   highlighting picks up the theme's primary / secondary /
   tertiary palette for keys / strings / numbers.
 - Run: an inputs form (text fields + file-pick), a Start
   button that calls the host's FlowRunDriver, a live step
   list driven by the driver's event stream (matches the
   `fai run` CLI rendering — ◻ pending, · running, ✔ done +
   duration, ✗ failed, ⏸ awaiting approval), and the typed
   outputs once the run resolves.

Source of truth = the YAML text. Graph edits emit fresh YAML
into the shared CodeController; text edits re-parse the
graph on a 350 ms debounce. Layout sidecar persists drag
positions only.

New public API (lib/fai_studio_flow_editor.dart):

  FlowEditorPage(
    initialFlowName: ...,
    locale: ...,
    runDriver: FlowRunDriver?,        // NEW — host bridge
    availableCapabilities: List<String>, // NEW — for the
                                          // capability picker
                                          // dialog when adding
                                          // a step
  )

The host (Studio) implements FlowRunDriver to bridge the
hub's gRPC SDK into the editor's event vocabulary. The
StepStarted/Completed/Failed/AwaitingApproval events are
shared verbatim with the CLI's run_progress renderer so
both surfaces speak the same visual language.

Files in this commit:
 - lib/src/editor_controller.dart       — shared state +
   debounced reparse loop
 - lib/src/run_driver.dart              — host bridge
   interface + event types
 - lib/src/widgets/flow_canvas.dart     — pan / zoom / drag /
   port-to-port connection drawing
 - lib/src/widgets/flow_node.dart       — node card primitive
   (module / approval / inputs / outputs variants)
 - lib/src/widgets/edge_painter.dart    — single CustomPainter
   for every edge + draft drag line, cubic bezier with
   arrow-head caps
 - lib/src/widgets/properties_panel.dart — right-side editor
   when a step is selected (rename id, change capability, add
   / remove / rename with-fields, delete step)
 - lib/src/widgets/capability_picker.dart — searchable list
   dialog used by Add-step
 - lib/src/widgets/run_tab.dart         — inputs form +
   live step progress + outputs renderer
 - lib/src/flow_editor_page.dart        — host scaffolding,
   toolbar, file list, three-tab body, keyboard shortcuts
 - lib/src/l10n.dart                    — EN + DE strings for
   every new label
 - lib/fai_studio_flow_editor.dart      — exports the new
   public types (FlowRunDriver, FlowRunEvent variants,
   FlowOutputValue variants)

flutter analyze: 0 issues. flutter test: 7/7 green.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 00:48:35 +02:00
parent dbd9a0004f
commit 870cbc29f7
12 changed files with 2769 additions and 408 deletions

View file

@ -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<Map<String, Object>> Function(String name)? onRun;
/// Bridge between editor and host's hub. When null, the
/// Run tab is read-only.
final FlowRunDriver? runDriver;
/// Capabilities the operator can drop into a new step.
/// Studio supplies the list from `HubService.listCapabilities()`.
/// Empty list = the picker shows a free-form text field.
final List<String> availableCapabilities;
const FlowEditorPage({
super.key,
this.initialFlowName,
this.locale = FlowEditorLocale.en,
this.onRun,
this.runDriver,
this.availableCapabilities = const [],
});
@override
State<FlowEditorPage> createState() => _FlowEditorPageState();
}
class _FlowEditorPageState extends State<FlowEditorPage> {
late final CodeController _code;
class _FlowEditorPageState extends State<FlowEditorPage>
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<List<_FlowFile>> _files;
Object? _runError;
Map<String, Object>? _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<FlowEditorPage> {
@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<List<_FlowFile>> _listFiles() async {
final dir = Directory(_defaultFlowsDir());
@ -120,42 +140,25 @@ class _FlowEditorPageState extends State<FlowEditorPage> {
}
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();
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<void> _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<bool?> _confirmDiscard() async {
Future<bool?> _confirmDiscard() {
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@ -176,27 +179,26 @@ class _FlowEditorPageState extends State<FlowEditorPage> {
}
Future<void> _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<FlowEditorPage> {
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<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 {
Future<void> _refreshFiles() async {
setState(() => _files = _listFiles());
}
Future<void> _addStep() async {
final picked = await CapabilityPicker.show(
context,
capabilities: widget.availableCapabilities,
strings: _l,
);
if (picked == null || !mounted) return;
final graph = _controller.graph;
// Generate a fresh id base on the capability's local
// name, deduplicating against existing step ids.
final localPart = picked.split('/').last.split('@').first;
final baseId = localPart.split('.').last;
var id = baseId;
var i = 1;
while (graph.steps.any((s) => s.id == id)) {
id = '${baseId}_$i';
i++;
}
_controller.applyGraphEdit(
graph.withStepAdded(FlowStep(id: id, use: picked, with_: const {})),
);
_controller.selectStep(id);
_tabs.animateTo(0);
}
// --- build ---
@override
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(),
};
// 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: <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(),
},
child: Actions(
actions: <Type, Action<Intent>>{
_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<String, TextStyle> _yamlStyle(ThemeData theme) {
final cs = theme.colorScheme;
final monoBase = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 13,
height: 1.45,
);
return {
'root': monoBase.copyWith(color: cs.onSurface),
// YAML key bright primary so the structure pops at a
// glance. attr is the highlight grammar's key token.
'attr': monoBase.copyWith(color: cs.primary, fontWeight: FontWeight.w600),
'string': monoBase.copyWith(color: cs.tertiary),
'number': monoBase.copyWith(color: cs.secondary),
'bullet': monoBase.copyWith(color: cs.onSurfaceVariant),
'literal': monoBase.copyWith(color: cs.secondary),
'comment': monoBase.copyWith(
color: cs.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
'meta': monoBase.copyWith(color: cs.secondary),
};
}
}
// --- shortcuts ---
class _SaveIntent extends Intent {
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<List<_FlowFile>> 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<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,
});
// --- 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<String, Object>? 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<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,
),
};
}