chain-studio-flow-editor/lib/src/flow_editor_page.dart
flemming-it 307eaccc3e feat(editor): success snackbar after save
Brief 1.5 s green-check snackbar appears at the bottom of
the editor after a successful save. Confirmation matters
here because Cmd+S is the operator's most common keystroke
in any editor — without visible feedback, they'd need to
hunt for the dirty dot to verify the save landed.

The error path's existing snackbar (which has no duration
cap by design — operators must read errors) stays
unchanged.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-01 01:57:52 +02:00

900 lines
27 KiB
Dart

// 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<String> availableCapabilities;
const FlowEditorPage({
super.key,
this.initialFlowName,
this.locale = FlowEditorLocale.en,
this.runDriver,
this.availableCapabilities = const [],
});
@override
State<FlowEditorPage> createState() => _FlowEditorPageState();
}
class _FlowEditorPageState extends State<FlowEditorPage>
with TickerProviderStateMixin {
late final FlowEditorController _controller;
late final FlowEditorStrings _l;
late Future<List<_FlowFile>> _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<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 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<void> _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<bool?> _confirmDiscard() {
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 = _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(() {});
// Brief positive feedback. Confirmation matters more
// here than on most save buttons because the operator
// can also save via Cmd+S without watching the dirty
// dot — a tiny green snackbar tells them the
// keystroke landed.
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(milliseconds: 1500),
behavior: SnackBarBehavior.floating,
content: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle_outline, size: 16),
const SizedBox(width: FaiSpace.sm),
Text('$name.yaml ${_l.save.toLowerCase()}'),
],
),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
} finally {
_controller.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)}
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<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) {
// 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: <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 (_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. When the flow has no steps at all,
// overlay a call-to-action so the operator's first
// instinct is the right action rather than staring at an
// empty grid.
final hasSteps = _controller.graph.steps.isNotEmpty;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
children: [
FlowCanvas(controller: _controller),
if (!hasSteps)
Positioned.fill(
child: Container(
color: theme.colorScheme.surface.withValues(alpha: 0.92),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.account_tree_outlined,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: FaiSpace.md),
Text(
_l.graphEmptyTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: FaiSpace.sm),
Text(
_l.graphEmptyBody,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
FilledButton.icon(
onPressed: _addStep,
icon: const Icon(Icons.add_box_outlined, size: 16),
label: Text(_l.addStep),
),
],
),
),
),
),
],
),
),
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();
}
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<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: 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<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,
),
),
],
),
),
);
},
);
},
);
}
}
// --- 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),
),
],
);
}
}