feat(studio): swap built-in flow editor for fai_studio_flow_editor package (v0.51.0)
Some checks failed
Security / Security check (push) Failing after 2s
Some checks failed
Security / Security check (push) Failing after 2s
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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
e522267ec1
commit
0183771689
6 changed files with 54 additions and 919 deletions
|
|
@ -5,8 +5,8 @@
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fai_studio_flow_editor/fai_studio_flow_editor.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'data/hub.dart';
|
import 'data/hub.dart';
|
||||||
|
|
@ -16,7 +16,6 @@ import 'l10n/app_localizations.dart';
|
||||||
import 'pages/approvals.dart';
|
import 'pages/approvals.dart';
|
||||||
import 'pages/audit.dart';
|
import 'pages/audit.dart';
|
||||||
import 'pages/doctor.dart';
|
import 'pages/doctor.dart';
|
||||||
import 'pages/flow_editor.dart';
|
|
||||||
import 'pages/flows.dart';
|
import 'pages/flows.dart';
|
||||||
import 'pages/store.dart';
|
import 'pages/store.dart';
|
||||||
import 'pages/welcome.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
|
/// Studio's own build version. Bump on every UI commit so the
|
||||||
/// running app self-identifies — visible in the sidebar header
|
/// running app self-identifies — visible in the sidebar header
|
||||||
/// and quick-glance proof that you're seeing the current build.
|
/// and quick-glance proof that you're seeing the current build.
|
||||||
const String kStudioVersion = '0.50.0';
|
const String kStudioVersion = '0.51.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -239,7 +238,7 @@ class _StudioShellState extends State<StudioShell> {
|
||||||
id: 'flow-editor',
|
id: 'flow-editor',
|
||||||
icon: Icons.code_outlined,
|
icon: Icons.code_outlined,
|
||||||
selectedIcon: Icons.code,
|
selectedIcon: Icons.code,
|
||||||
page: FlowEditorPage(),
|
page: _FlowEditorAdapter(),
|
||||||
),
|
),
|
||||||
_NavPage(
|
_NavPage(
|
||||||
id: 'audit',
|
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<String, Object>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<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,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -5,9 +5,10 @@ import 'package:fai_client_sdk/fai_client_sdk.dart' show FlowInputDef;
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:fai_studio_flow_editor/fai_studio_flow_editor.dart';
|
||||||
|
|
||||||
import '../data/format.dart';
|
import '../data/format.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import 'flow_editor.dart';
|
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
|
@ -108,9 +109,17 @@ class _FlowsPageState extends State<FlowsPage> {
|
||||||
/// the OS's default editor only when explicitly asked (the
|
/// the OS's default editor only when explicitly asked (the
|
||||||
/// secondary "open in OS" entry, not the pencil).
|
/// secondary "open in OS" entry, not the pencil).
|
||||||
Future<void> _openInEditor(SavedFlow flow) async {
|
Future<void> _openInEditor(SavedFlow flow) async {
|
||||||
|
final lang = Localizations.localeOf(context).languageCode;
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
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<String, Object>(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Refresh the list when we come back — operator may have
|
// Refresh the list when we come back — operator may have
|
||||||
|
|
|
||||||
13
pubspec.lock
13
pubspec.lock
|
|
@ -120,6 +120,15 @@ packages:
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.17.1"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -166,7 +175,7 @@ packages:
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_code_editor:
|
flutter_code_editor:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_code_editor
|
name: flutter_code_editor
|
||||||
sha256: "9af48ba8e3558b6ea4bb98b84c5eb1649702acf53e61a84d88383eeb79b239b0"
|
sha256: "9af48ba8e3558b6ea4bb98b84c5eb1649702acf53e61a84d88383eeb79b239b0"
|
||||||
|
|
@ -261,7 +270,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.0"
|
version: "5.1.0"
|
||||||
highlight:
|
highlight:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: highlight
|
name: highlight
|
||||||
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
|
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
|
||||||
|
|
|
||||||
12
pubspec.yaml
12
pubspec.yaml
|
|
@ -1,7 +1,7 @@
|
||||||
name: fai_studio
|
name: fai_studio
|
||||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.50.0
|
version: 0.51.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
@ -34,8 +34,14 @@ dependencies:
|
||||||
# routes through the OS dialog so sandboxed Studio still
|
# routes through the OS dialog so sandboxed Studio still
|
||||||
# gets read access to the chosen file).
|
# gets read access to the chosen file).
|
||||||
file_picker: ^11.0.0
|
file_picker: ^11.0.0
|
||||||
flutter_code_editor: ^0.3.5
|
# Swappable flow editor — lives in its own repo so it can
|
||||||
highlight: ^0.7.0
|
# 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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue