diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b011112..8ff5333 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1381,5 +1381,37 @@ } } }, - "storeSourceTemporal": "Temporal" + "storeSourceTemporal": "Temporal", + + "navFlowEditor": "Editor", + "flowEditorNew": "Neuer Flow", + "flowEditorSave": "Speichern", + "flowEditorRun": "Ausführen", + "flowEditorRefresh": "Datei-Liste neu laden", + "flowEditorRunOutput": "LAUF-ERGEBNIS", + "flowEditorEmptyTitle": "Kein Flow geöffnet", + "flowEditorEmptyBody": "Wähle links einen aus oder klicke Neuer Flow um einen neuen anzulegen.", + "flowEditorListEmptyTitle": "Keine gespeicherten Flows", + "flowEditorListEmptyBody": "Klicke Neuer Flow um aus einer Vorlage zu starten.", + "flowEditorDiscardTitle": "Ungespeicherte Änderungen verwerfen?", + "flowEditorDiscardBody": "Beim Wechsel der Datei gehen alle noch nicht gespeicherten Änderungen verloren.", + "flowEditorDiscardKeep": "Weiterbearbeiten", + "flowEditorDiscardThrow": "Verwerfen", + "flowEditorNewDialogTitle": "Neuer Flow", + "flowEditorNewDialogLabel": "Flow-Name", + "flowEditorNewDialogHelper": "Kleinbuchstaben, Ziffern, Bindestrich, Unterstrich", + "flowEditorNewDialogCancel": "Abbrechen", + "flowEditorNewDialogCreate": "Anlegen", + "flowEditorNewTemplateComment": "Von Studios Flow-Editor erstellt. Bearbeiten, speichern, ausführen.", + "@flowEditorNewTemplateComment": { + "placeholders": { + "name": {"type": "String"} + } + }, + "flowEditorAlreadyExists": "Ein Flow namens \"{name}\" existiert bereits.", + "@flowEditorAlreadyExists": { + "placeholders": { + "name": {"type": "String"} + } + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 273cb68..f2bd6b5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1384,5 +1384,37 @@ } } }, - "storeSourceTemporal": "Temporal" + "storeSourceTemporal": "Temporal", + + "navFlowEditor": "Editor", + "flowEditorNew": "New flow", + "flowEditorSave": "Save", + "flowEditorRun": "Run", + "flowEditorRefresh": "Refresh file list", + "flowEditorRunOutput": "RUN OUTPUT", + "flowEditorEmptyTitle": "No flow open", + "flowEditorEmptyBody": "Pick one from the left, or click New flow to start a fresh one.", + "flowEditorListEmptyTitle": "No saved flows", + "flowEditorListEmptyBody": "Click New flow to scaffold one from a template.", + "flowEditorDiscardTitle": "Discard unsaved changes?", + "flowEditorDiscardBody": "Switching files will throw away the edits you haven't saved.", + "flowEditorDiscardKeep": "Keep editing", + "flowEditorDiscardThrow": "Throw away", + "flowEditorNewDialogTitle": "New flow", + "flowEditorNewDialogLabel": "Flow name", + "flowEditorNewDialogHelper": "lowercase letters, digits, hyphen, underscore", + "flowEditorNewDialogCancel": "Cancel", + "flowEditorNewDialogCreate": "Create", + "flowEditorNewTemplateComment": "Generated by Studio's Flow editor. Edit, save, run.", + "@flowEditorNewTemplateComment": { + "placeholders": { + "name": {"type": "String"} + } + }, + "flowEditorAlreadyExists": "A flow named \"{name}\" already exists.", + "@flowEditorAlreadyExists": { + "placeholders": { + "name": {"type": "String"} + } + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6f0922f..c8c9967 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3727,6 +3727,132 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Temporal'** String get storeSourceTemporal; + + /// No description provided for @navFlowEditor. + /// + /// In en, this message translates to: + /// **'Editor'** + String get navFlowEditor; + + /// No description provided for @flowEditorNew. + /// + /// In en, this message translates to: + /// **'New flow'** + String get flowEditorNew; + + /// No description provided for @flowEditorSave. + /// + /// In en, this message translates to: + /// **'Save'** + String get flowEditorSave; + + /// No description provided for @flowEditorRun. + /// + /// In en, this message translates to: + /// **'Run'** + String get flowEditorRun; + + /// No description provided for @flowEditorRefresh. + /// + /// In en, this message translates to: + /// **'Refresh file list'** + String get flowEditorRefresh; + + /// No description provided for @flowEditorRunOutput. + /// + /// In en, this message translates to: + /// **'RUN OUTPUT'** + String get flowEditorRunOutput; + + /// No description provided for @flowEditorEmptyTitle. + /// + /// In en, this message translates to: + /// **'No flow open'** + String get flowEditorEmptyTitle; + + /// No description provided for @flowEditorEmptyBody. + /// + /// In en, this message translates to: + /// **'Pick one from the left, or click New flow to start a fresh one.'** + String get flowEditorEmptyBody; + + /// No description provided for @flowEditorListEmptyTitle. + /// + /// In en, this message translates to: + /// **'No saved flows'** + String get flowEditorListEmptyTitle; + + /// No description provided for @flowEditorListEmptyBody. + /// + /// In en, this message translates to: + /// **'Click New flow to scaffold one from a template.'** + String get flowEditorListEmptyBody; + + /// No description provided for @flowEditorDiscardTitle. + /// + /// In en, this message translates to: + /// **'Discard unsaved changes?'** + String get flowEditorDiscardTitle; + + /// No description provided for @flowEditorDiscardBody. + /// + /// In en, this message translates to: + /// **'Switching files will throw away the edits you haven\'t saved.'** + String get flowEditorDiscardBody; + + /// No description provided for @flowEditorDiscardKeep. + /// + /// In en, this message translates to: + /// **'Keep editing'** + String get flowEditorDiscardKeep; + + /// No description provided for @flowEditorDiscardThrow. + /// + /// In en, this message translates to: + /// **'Throw away'** + String get flowEditorDiscardThrow; + + /// No description provided for @flowEditorNewDialogTitle. + /// + /// In en, this message translates to: + /// **'New flow'** + String get flowEditorNewDialogTitle; + + /// No description provided for @flowEditorNewDialogLabel. + /// + /// In en, this message translates to: + /// **'Flow name'** + String get flowEditorNewDialogLabel; + + /// No description provided for @flowEditorNewDialogHelper. + /// + /// In en, this message translates to: + /// **'lowercase letters, digits, hyphen, underscore'** + String get flowEditorNewDialogHelper; + + /// No description provided for @flowEditorNewDialogCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get flowEditorNewDialogCancel; + + /// No description provided for @flowEditorNewDialogCreate. + /// + /// In en, this message translates to: + /// **'Create'** + String get flowEditorNewDialogCreate; + + /// No description provided for @flowEditorNewTemplateComment. + /// + /// In en, this message translates to: + /// **'Generated by Studio\'s Flow editor. Edit, save, run.'** + String flowEditorNewTemplateComment(String name); + + /// No description provided for @flowEditorAlreadyExists. + /// + /// In en, this message translates to: + /// **'A flow named \"{name}\" already exists.'** + String flowEditorAlreadyExists(String name); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 4765ce5..2566e11 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2180,4 +2180,75 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storeSourceTemporal => 'Temporal'; + + @override + String get navFlowEditor => 'Editor'; + + @override + String get flowEditorNew => 'Neuer Flow'; + + @override + String get flowEditorSave => 'Speichern'; + + @override + String get flowEditorRun => 'Ausführen'; + + @override + String get flowEditorRefresh => 'Datei-Liste neu laden'; + + @override + String get flowEditorRunOutput => 'LAUF-ERGEBNIS'; + + @override + String get flowEditorEmptyTitle => 'Kein Flow geöffnet'; + + @override + String get flowEditorEmptyBody => + 'Wähle links einen aus oder klicke Neuer Flow um einen neuen anzulegen.'; + + @override + String get flowEditorListEmptyTitle => 'Keine gespeicherten Flows'; + + @override + String get flowEditorListEmptyBody => + 'Klicke Neuer Flow um aus einer Vorlage zu starten.'; + + @override + String get flowEditorDiscardTitle => 'Ungespeicherte Änderungen verwerfen?'; + + @override + String get flowEditorDiscardBody => + 'Beim Wechsel der Datei gehen alle noch nicht gespeicherten Änderungen verloren.'; + + @override + String get flowEditorDiscardKeep => 'Weiterbearbeiten'; + + @override + String get flowEditorDiscardThrow => 'Verwerfen'; + + @override + String get flowEditorNewDialogTitle => 'Neuer Flow'; + + @override + String get flowEditorNewDialogLabel => 'Flow-Name'; + + @override + String get flowEditorNewDialogHelper => + 'Kleinbuchstaben, Ziffern, Bindestrich, Unterstrich'; + + @override + String get flowEditorNewDialogCancel => 'Abbrechen'; + + @override + String get flowEditorNewDialogCreate => 'Anlegen'; + + @override + String flowEditorNewTemplateComment(String name) { + return 'Von Studios Flow-Editor erstellt. Bearbeiten, speichern, ausführen.'; + } + + @override + String flowEditorAlreadyExists(String name) { + return 'Ein Flow namens \"$name\" existiert bereits.'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 18a2417..3737d88 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2185,4 +2185,75 @@ class AppLocalizationsEn extends AppLocalizations { @override String get storeSourceTemporal => 'Temporal'; + + @override + String get navFlowEditor => 'Editor'; + + @override + String get flowEditorNew => 'New flow'; + + @override + String get flowEditorSave => 'Save'; + + @override + String get flowEditorRun => 'Run'; + + @override + String get flowEditorRefresh => 'Refresh file list'; + + @override + String get flowEditorRunOutput => 'RUN OUTPUT'; + + @override + String get flowEditorEmptyTitle => 'No flow open'; + + @override + String get flowEditorEmptyBody => + 'Pick one from the left, or click New flow to start a fresh one.'; + + @override + String get flowEditorListEmptyTitle => 'No saved flows'; + + @override + String get flowEditorListEmptyBody => + 'Click New flow to scaffold one from a template.'; + + @override + String get flowEditorDiscardTitle => 'Discard unsaved changes?'; + + @override + String get flowEditorDiscardBody => + 'Switching files will throw away the edits you haven\'t saved.'; + + @override + String get flowEditorDiscardKeep => 'Keep editing'; + + @override + String get flowEditorDiscardThrow => 'Throw away'; + + @override + String get flowEditorNewDialogTitle => 'New flow'; + + @override + String get flowEditorNewDialogLabel => 'Flow name'; + + @override + String get flowEditorNewDialogHelper => + 'lowercase letters, digits, hyphen, underscore'; + + @override + String get flowEditorNewDialogCancel => 'Cancel'; + + @override + String get flowEditorNewDialogCreate => 'Create'; + + @override + String flowEditorNewTemplateComment(String name) { + return 'Generated by Studio\'s Flow editor. Edit, save, run.'; + } + + @override + String flowEditorAlreadyExists(String name) { + return 'A flow named \"$name\" already exists.'; + } } diff --git a/lib/main.dart b/lib/main.dart index e9629be..2f38e3d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ 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'; @@ -27,7 +28,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.42.0'; +const String kStudioVersion = '0.50.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -234,6 +235,12 @@ class _StudioShellState extends State { selectedIcon: Icons.account_tree, page: FlowsPage(), ), + _NavPage( + id: 'flow-editor', + icon: Icons.code_outlined, + selectedIcon: Icons.code, + page: FlowEditorPage(), + ), _NavPage( id: 'audit', icon: Icons.timeline_outlined, @@ -834,6 +841,8 @@ class _NavPage { return l.navStore; case 'flows': return l.navFlows; + case 'flow-editor': + return l.navFlowEditor; case 'audit': return l.navAudit; case 'approvals': diff --git a/lib/pages/flow_editor.dart b/lib/pages/flow_editor.dart new file mode 100644 index 0000000..5f92c12 --- /dev/null +++ b/lib/pages/flow_editor.dart @@ -0,0 +1,739 @@ +// 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. +class FlowEditorPage extends StatefulWidget { + const FlowEditorPage({super.key}); + + @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(); + } + + @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); + + return Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Toolbar( + activeName: _activeName, + dirty: _dirty, + saving: _saving, + running: _running, + 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 _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? 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.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: [ + 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) + 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: 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/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index be1ee3e..df8d2f7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 438e7e5..ef89af8 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,10 @@ import Foundation import file_picker import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 2ebf945..f161335 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + autotrie: + dependency: transitive + description: + name: autotrie + sha256: "55da6faefb53cfcb0abb2f2ca8636123fb40e35286bb57440d2cf467568188f8" + url: "https://pub.dev" + source: hosted + version: "2.0.0" boolean_selector: dependency: transitive description: @@ -33,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" clock: dependency: transitive description: @@ -89,13 +105,21 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.13" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" fai_client_sdk: dependency: "direct main" description: path: "../fai_client_sdk_dart" relative: true source: path - version: "0.17.0" + version: "0.17.1" fake_async: dependency: transitive description: @@ -141,6 +165,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_code_editor: + dependency: "direct main" + description: + name: flutter_code_editor + sha256: "9af48ba8e3558b6ea4bb98b84c5eb1649702acf53e61a84d88383eeb79b239b0" + url: "https://pub.dev" + source: hosted + version: "0.3.5" + flutter_highlight: + dependency: transitive + description: + name: flutter_highlight + sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_lints: dependency: "direct dev" description: @@ -220,6 +260,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.0" + highlight: + dependency: "direct main" + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + hive: + dependency: transitive + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" hooks: dependency: transitive description: @@ -300,6 +356,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + linked_scroll_controller: + dependency: transitive + description: + name: linked_scroll_controller + sha256: e6020062bcf4ffc907ee7fd090fa971e65d8dfaac3c62baf601a3ced0b37986a + url: "https://pub.dev" + source: hosted + version: "0.2.0" lints: dependency: transitive description: @@ -348,6 +412,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" objective_c: dependency: transitive description: @@ -468,6 +540,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + scrollable_positioned_list: + dependency: transitive + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" shared_preferences: dependency: "direct main" description: @@ -577,6 +657,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" typed_data: dependency: transitive description: @@ -585,6 +673,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d3f5f43..161b979 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.49.1 +version: 0.50.0 environment: sdk: ^3.11.0-200.1.beta @@ -34,6 +34,8 @@ 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 dev_dependencies: flutter_test: diff --git a/test/flow_editor_test.dart b/test/flow_editor_test.dart new file mode 100644 index 0000000..46d006d --- /dev/null +++ b/test/flow_editor_test.dart @@ -0,0 +1,41 @@ +// 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)); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..4f78848 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 3ad69c6..a962892 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST