feat(studio): inline flow editor — YAML highlighting + Run (v0.50.0)
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
New top-level destination "Editor" (Cmd+5) ships as Studio's
fifth surface. The editor reads + writes flow YAML directly
under ~/.fai/data/flows/ via dart:io — the hub picks up the
changes on the next listFlows / runSavedFlow call.
Layout: two-pane shell. Left (240 px) is the file list; right
flexes to the code pane and an optional results column when
Run produces output. Top toolbar exposes:
* filename + dirty-mark
* New flow (scaffolds from a debug.echo template)
* Save (writes the active file to disk)
* Run (saves first if dirty, calls
HubService.runSavedFlow, surfaces typed FlowOutputs in
a side panel)
* Refresh
YAML highlighting via flutter_code_editor + the highlight
package's yaml language. Lightweight style map mapping the
five token classes that actually appear in flow YAML
(attr / string / number / comment / subst for the
${{ ... }} template syntax) to FaiTheme colors — keeps the
editor visually consistent with the rest of Studio.
New-flow naming uses a FilteringTextInputFormatter that
restricts the name to [a-z0-9_-]. A "name already exists"
SnackBar surfaces the conflict instead of silently
overwriting.
Bilingual strings shipped (en.arb + de.arb) for every
operator-facing string: toolbar buttons, dialogs, empty
states, file-exists error, run-output header.
New deps:
* flutter_code_editor ^0.3.5
* highlight (transitive — pinned as direct so the
yaml-language import has its declared dependency).
Smoke-test (test/flow_editor_test.dart) pumps the page and
asserts the empty-state + toolbar render without throwing
on hosts that don't have ~/.fai/data/flows yet. The full
file-list + open-on-tap flow needs a writable HOME override
which dart:io's read-only Platform.environment doesn't allow
inside a test isolate — that path lives in the integration
suite as a follow-up.
Version bumps:
* pubspec.yaml: 0.49.1 → 0.50.0
* main.dart kStudioVersion: 0.42.0 → 0.50.0 (had drifted
behind pubspec; brought back into sync as part of this
bump)
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
570c38b238
commit
bb793e64a9
15 changed files with 1291 additions and 5 deletions
|
|
@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
@ -234,6 +235,12 @@ class _StudioShellState extends State<StudioShell> {
|
|||
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':
|
||||
|
|
|
|||
739
lib/pages/flow_editor.dart
Normal file
739
lib/pages/flow_editor.dart
Normal file
|
|
@ -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<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();
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
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<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)
|
||||
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<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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
154
pubspec.lock
154
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
41
test/flow_editor_test.dart
Normal file
41
test/flow_editor_test.dart
Normal file
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -6,6 +6,9 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue