From 01837716893434b30fe0d00a8e5b60c815df862d Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 30 May 2026 14:36:54 +0200 Subject: [PATCH] feat(studio): swap built-in flow editor for fai_studio_flow_editor package (v0.51.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flow editor was internal to Studio (lib/pages/flow_editor.dart). Per Stefan's review feedback ("austauschbar wäre schöner"), extract it into its own Forgejo repo so the host can swap the implementation without touching Studio. New repo: https://git.flemming.ai/fai/studio-flow-editor Studio's pubspec.yaml now references the package by git URL: dependencies: fai_studio_flow_editor: git: url: https://git.flemming.ai/fai/studio-flow-editor ref: main To swap the editor: 1. Fork (or write a new) fai/studio-flow-editor. 2. Keep the FlowEditorPage(initialFlowName, locale, onRun) constructor signature — the stable host contract. 3. Point the pubspec at your fork. 4. Rebuild Studio. Adapter pattern: _FlowEditorAdapter in main.dart resolves the package's runtime dependencies (locale via Localizations, onRun via HubService) from the BuildContext, then constructs the package's FlowEditorPage. Same pattern in flows.dart for the pencil → editor route push, so a future operator-side locale switch propagates correctly. The package brings its own copies of FaiSpace tokens, minimal FaiEmptyState/FaiErrorBox widgets, and an inline EN+DE l10n table — accepting a small amount of visual drift in exchange for true package independence. flutter_code_editor + highlight move from Studio's pubspec to the package's. Deleted: lib/pages/flow_editor.dart → package's lib/src/flow_editor_page.dart test/flow_editor_test.dart → package's test/ (next commit there) Bumped: pubspec.yaml version 0.50.0 → 0.51.0 main.dart kStudioVersion 0.50.0 → 0.51.0 Signed-off-by: flemming-it --- lib/main.dart | 27 +- lib/pages/flow_editor.dart | 867 ------------------------------------- lib/pages/flows.dart | 13 +- pubspec.lock | 13 +- pubspec.yaml | 12 +- test/flow_editor_test.dart | 41 -- 6 files changed, 54 insertions(+), 919 deletions(-) delete mode 100644 lib/pages/flow_editor.dart delete mode 100644 test/flow_editor_test.dart diff --git a/lib/main.dart b/lib/main.dart index 1628b0d..6bb1d85 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,8 +5,8 @@ import 'dart:async'; +import 'package:fai_studio_flow_editor/fai_studio_flow_editor.dart'; import 'package:flutter/material.dart'; - import 'package:flutter/services.dart'; import 'data/hub.dart'; @@ -16,7 +16,6 @@ import 'l10n/app_localizations.dart'; import 'pages/approvals.dart'; import 'pages/audit.dart'; import 'pages/doctor.dart'; -import 'pages/flow_editor.dart'; import 'pages/flows.dart'; import 'pages/store.dart'; import 'pages/welcome.dart'; @@ -28,7 +27,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.50.0'; +const String kStudioVersion = '0.51.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -239,7 +238,7 @@ class _StudioShellState extends State { id: 'flow-editor', icon: Icons.code_outlined, selectedIcon: Icons.code, - page: FlowEditorPage(), + page: _FlowEditorAdapter(), ), _NavPage( id: 'audit', @@ -1062,3 +1061,23 @@ class _LanguageToggle extends StatelessWidget { ); } } + +/// Adapter that resolves the swappable FlowEditorPage's +/// runtime dependencies (locale, hub onRun callback) from +/// Studio's BuildContext. The _NavPage destination list +/// holds widgets as const-ish, so the adapter sits in +/// between to grab live data. +class _FlowEditorAdapter extends StatelessWidget { + const _FlowEditorAdapter(); + + @override + Widget build(BuildContext context) { + final lang = Localizations.localeOf(context).languageCode; + return FlowEditorPage( + locale: lang == 'de' ? FlowEditorLocale.de : FlowEditorLocale.en, + onRun: (name) async => + (await HubService.instance.runSavedFlow(name: name)) + .cast(), + ); + } +} diff --git a/lib/pages/flow_editor.dart b/lib/pages/flow_editor.dart deleted file mode 100644 index c8b42b9..0000000 --- a/lib/pages/flow_editor.dart +++ /dev/null @@ -1,867 +0,0 @@ -// 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 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(); - // 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 _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> _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, - // 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> 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, - ), - }; -} diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index 7cb39e3..a8b26ca 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -5,9 +5,10 @@ import 'package:fai_client_sdk/fai_client_sdk.dart' show FlowInputDef; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:fai_studio_flow_editor/fai_studio_flow_editor.dart'; + import '../data/format.dart'; import '../data/hub.dart'; -import 'flow_editor.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; @@ -108,9 +109,17 @@ class _FlowsPageState extends State { /// the OS's default editor only when explicitly asked (the /// secondary "open in OS" entry, not the pencil). Future _openInEditor(SavedFlow flow) async { + final lang = Localizations.localeOf(context).languageCode; await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => FlowEditorPage(initialFlowName: flow.name), + builder: (_) => FlowEditorPage( + initialFlowName: flow.name, + locale: + lang == 'de' ? FlowEditorLocale.de : FlowEditorLocale.en, + onRun: (name) async => + (await HubService.instance.runSavedFlow(name: name)) + .cast(), + ), ), ); // Refresh the list when we come back — operator may have diff --git a/pubspec.lock b/pubspec.lock index f161335..489a92a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -120,6 +120,15 @@ packages: relative: true source: path version: "0.17.1" + fai_studio_flow_editor: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: "51f9a1d2b1a292a109457ae29dd4e530397fd1c2" + url: "https://git.flemming.ai/fai/studio-flow-editor" + source: git + version: "0.1.0" fake_async: dependency: transitive description: @@ -166,7 +175,7 @@ packages: source: sdk version: "0.0.0" flutter_code_editor: - dependency: "direct main" + dependency: transitive description: name: flutter_code_editor sha256: "9af48ba8e3558b6ea4bb98b84c5eb1649702acf53e61a84d88383eeb79b239b0" @@ -261,7 +270,7 @@ packages: source: hosted version: "5.1.0" highlight: - dependency: "direct main" + dependency: transitive description: name: highlight sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" diff --git a/pubspec.yaml b/pubspec.yaml index 161b979..248658e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.50.0 +version: 0.51.0 environment: sdk: ^3.11.0-200.1.beta @@ -34,8 +34,14 @@ dependencies: # routes through the OS dialog so sandboxed Studio still # gets read access to the chosen file). file_picker: ^11.0.0 - flutter_code_editor: ^0.3.5 - highlight: ^0.7.0 + # Swappable flow editor — lives in its own repo so it can + # be replaced without touching the rest of Studio. Pin to a + # commit (or branch) here; rebuild Studio to pick up + # editor-side changes. + fai_studio_flow_editor: + git: + url: https://git.flemming.ai/fai/studio-flow-editor + ref: main dev_dependencies: flutter_test: diff --git a/test/flow_editor_test.dart b/test/flow_editor_test.dart deleted file mode 100644 index 46d006d..0000000 --- a/test/flow_editor_test.dart +++ /dev/null @@ -1,41 +0,0 @@ -// Widget smoke-test for the FlowEditorPage. -// -// Platform.environment is read-only from inside the test -// isolate, so we can't redirect HOME to a temp directory. -// Instead this test pumps the page and verifies that the -// widget tree builds without throwing — exercising the -// missing-directory empty-state path on hosts without -// ~/.fai/data/flows. - -import 'package:fai_studio/l10n/app_localizations.dart'; -import 'package:fai_studio/pages/flow_editor.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - Widget wrap() { - return MaterialApp( - locale: const Locale('en'), - supportedLocales: const [Locale('en'), Locale('de')], - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - home: const FlowEditorPage(), - ); - } - - testWidgets('builds without throwing + shows expected toolbar buttons', - (tester) async { - await tester.pumpWidget(wrap()); - await tester.pump(const Duration(milliseconds: 100)); - expect(find.text('New flow'), findsOneWidget); - expect(find.text('Save'), findsOneWidget); - expect(find.text('Run'), findsOneWidget); - // Without an open file, the empty-state title shows. - expect(find.text('No flow open'), findsAtLeastNWidgets(1)); - }); -}