chain-studio/lib/pages/flow_editor.dart
flemming-it e522267ec1
Some checks failed
Security / Security check (push) Failing after 2s
feat(studio): pencil-into-editor + back button + collapsible rail
Three operator-visible improvements per Stefan's review of
the v0.50.0 editor.

1. Pencil icon on Flows page now opens Studio's own flow
   editor (route push) preloaded with the selected flow,
   replacing the previous "open in OS default editor"
   (SystemActions.openInOs) behaviour. The editor's AppBar
   gains a back arrow when reached via route push; reaching
   the editor via the nav rail leaves it bare. Tooltip
   string updated in EN + DE.

2. New FlowEditorPage `initialFlowName` parameter. When set,
   the page loads that flow on first frame (via post-frame
   callback so the BuildContext is mounted before
   _openByName runs). When null (the nav-rail path), the
   editor opens to its empty state as before.

3. Sidebar (_Sidebar) is now collapsed-by-default: shows
   just icons in a 72px-wide rail with per-destination
   tooltips. Hovering the rail expands it to 220px (the
   old width) with brand-mark + labels + the full footer
   row (theme toggle, language toggle, clock, settings).
   Collapsed footer shows the settings icon only. Brand-
   mark (FaiDeltaMark) stays visible in both states so the
   live/idle status dot is always glanceable.

Side-effect: SystemActions import in flows.dart is no
longer needed (the pencil no longer shells out) — removed.

widget_test.dart: dropped the per-destination Text-presence
asserts since labels are now Tooltips when collapsed.
Hover-expand testing triggers RenderFlex-overflow mid-
animation in widget tests (the AnimatedContainer's width
transitions through a constraint slimmer than the Row's
intrinsic min). The booting-without-throwing assertion
remains; per-destination presence stays in the per-page
test suites.

Both arb files updated with the new strings (navFlowEditor
already existed; added flowEditorBackTooltip + retouched
flowsOpenInEditorTooltip).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-30 12:04:05 +02:00

867 lines
26 KiB
Dart

// 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.
///
/// [initialFlowName] preloads a flow when the page is pushed
/// from elsewhere (e.g. the pencil icon on the Flows page). It
/// also flips on the AppBar back arrow so the operator can
/// return to where they came from. When the page is reached
/// via the nav rail, [initialFlowName] is null and the AppBar
/// stays bare.
class FlowEditorPage extends StatefulWidget {
final String? initialFlowName;
const FlowEditorPage({super.key, this.initialFlowName});
@override
State<FlowEditorPage> createState() => _FlowEditorPageState();
}
class _FlowEditorPageState extends State<FlowEditorPage> {
late final CodeController _code;
String? _activeName;
String _loadedText = '';
late Future<List<_FlowFile>> _files;
Object? _runError;
Map<String, FlowOutput>? _runOutputs;
bool _running = false;
bool _saving = false;
@override
void initState() {
super.initState();
_code = CodeController(text: '', language: yaml);
_code.addListener(_onCodeChange);
_files = _listFiles();
// Preload caller-supplied flow after first frame so the
// page's BuildContext is mounted in the widget tree (the
// _openFile path eventually touches Localizations).
final initial = widget.initialFlowName;
if (initial != null && initial.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _openByName(initial);
});
}
}
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;
});
}
@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> _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 {
final l = AppLocalizations.of(context)!;
return showDialog<bool>(
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<void> _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<void> _newFlow() async {
final l = AppLocalizations.of(context)!;
final name = await showDialog<String>(
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<void> _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<void> _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 = <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) _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,
// Show the back arrow only when the page was
// pushed onto a route (Navigator.canPop is true).
// Reaching the editor via the nav rail leaves
// canPop false → no back arrow → consistent with
// every other top-level destination.
onBack: Navigator.of(context).canPop()
? () => Navigator.of(context).maybePop()
: null,
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? onBack;
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.onBack,
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: [
if (onBack != null) ...[
IconButton(
icon: const Icon(Icons.arrow_back, size: 18),
tooltip: l.flowEditorBackTooltip,
onPressed: onBack,
),
const SizedBox(width: FaiSpace.xs),
],
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<List<_FlowFile>> 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<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: 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<String, FlowOutput>? 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<String, FlowOutput>? 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<String, TextStyle> _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,
),
};
}