feat(editor): three-tab WYSIWYG editor — graph / text / run
Full rewrite of the editor surface, layered on top of the
FlowGraph foundation. One in-memory flow drives three tabs
that the operator can flip between freely:
- Graph: a drag-and-drop canvas. Nodes are step cards with
port dots on their left (one per `with:` field) and a
combined output port on the right. Pinned inputs and
outputs pseudo-nodes sit at the left and right edges so
every flow has a visually obvious source and sink. Pan +
zoom via InteractiveViewer; drag a node by its body to
reposition it (positions persisted to a sidecar JSON file
under ~/.fai/data/flows/.layout/<name>.json — kept OUT of
the YAML so `fai run` stays byte-stable).
- Text: the existing YAML CodeField with expands:true so
line 1 anchors at the top edge. YAML-aware syntax
highlighting picks up the theme's primary / secondary /
tertiary palette for keys / strings / numbers.
- Run: an inputs form (text fields + file-pick), a Start
button that calls the host's FlowRunDriver, a live step
list driven by the driver's event stream (matches the
`fai run` CLI rendering — ◻ pending, · running, ✔ done +
duration, ✗ failed, ⏸ awaiting approval), and the typed
outputs once the run resolves.
Source of truth = the YAML text. Graph edits emit fresh YAML
into the shared CodeController; text edits re-parse the
graph on a 350 ms debounce. Layout sidecar persists drag
positions only.
New public API (lib/fai_studio_flow_editor.dart):
FlowEditorPage(
initialFlowName: ...,
locale: ...,
runDriver: FlowRunDriver?, // NEW — host bridge
availableCapabilities: List<String>, // NEW — for the
// capability picker
// dialog when adding
// a step
)
The host (Studio) implements FlowRunDriver to bridge the
hub's gRPC SDK into the editor's event vocabulary. The
StepStarted/Completed/Failed/AwaitingApproval events are
shared verbatim with the CLI's run_progress renderer so
both surfaces speak the same visual language.
Files in this commit:
- lib/src/editor_controller.dart — shared state +
debounced reparse loop
- lib/src/run_driver.dart — host bridge
interface + event types
- lib/src/widgets/flow_canvas.dart — pan / zoom / drag /
port-to-port connection drawing
- lib/src/widgets/flow_node.dart — node card primitive
(module / approval / inputs / outputs variants)
- lib/src/widgets/edge_painter.dart — single CustomPainter
for every edge + draft drag line, cubic bezier with
arrow-head caps
- lib/src/widgets/properties_panel.dart — right-side editor
when a step is selected (rename id, change capability, add
/ remove / rename with-fields, delete step)
- lib/src/widgets/capability_picker.dart — searchable list
dialog used by Add-step
- lib/src/widgets/run_tab.dart — inputs form +
live step progress + outputs renderer
- lib/src/flow_editor_page.dart — host scaffolding,
toolbar, file list, three-tab body, keyboard shortcuts
- lib/src/l10n.dart — EN + DE strings for
every new label
- lib/fai_studio_flow_editor.dart — exports the new
public types (FlowRunDriver, FlowRunEvent variants,
FlowOutputValue variants)
flutter analyze: 0 issues. flutter test: 7/7 green.
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
dbd9a0004f
commit
870cbc29f7
12 changed files with 2769 additions and 408 deletions
404
lib/src/widgets/properties_panel.dart
Normal file
404
lib/src/widgets/properties_panel.dart
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
// PropertiesPanel — side drawer showing the currently
|
||||
// selected step's details.
|
||||
//
|
||||
// Lets the operator edit:
|
||||
// - step `id` (with collision warning before applying)
|
||||
// - step `use` (capability spec)
|
||||
// - each `with:` key/value pair (rename key, edit value,
|
||||
// add new key, remove key)
|
||||
// - delete the entire step
|
||||
//
|
||||
// The panel emits FlowGraph mutations through the controller,
|
||||
// which re-emits the YAML buffer + updates the canvas in one
|
||||
// pass.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../editor_controller.dart';
|
||||
import '../l10n.dart';
|
||||
import '../model/flow_graph.dart';
|
||||
import '../tokens.dart';
|
||||
|
||||
class PropertiesPanel extends StatefulWidget {
|
||||
final FlowEditorController controller;
|
||||
final FlowEditorStrings strings;
|
||||
final List<String> availableCapabilities;
|
||||
const PropertiesPanel({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.strings,
|
||||
this.availableCapabilities = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<PropertiesPanel> createState() => _PropertiesPanelState();
|
||||
}
|
||||
|
||||
class _PropertiesPanelState extends State<PropertiesPanel> {
|
||||
// Local edit buffers — committed back to the controller
|
||||
// on focus loss / explicit save so each keystroke doesn't
|
||||
// round-trip through YAML emission.
|
||||
final _idCtrl = TextEditingController();
|
||||
final _useCtrl = TextEditingController();
|
||||
// Per-with-field controllers, keyed by current field name.
|
||||
final Map<String, TextEditingController> _withCtrls = {};
|
||||
String? _trackedStepId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_sync);
|
||||
_sync();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_sync);
|
||||
_idCtrl.dispose();
|
||||
_useCtrl.dispose();
|
||||
for (final c in _withCtrls.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sync() {
|
||||
final selected = widget.controller.selectedStepId;
|
||||
if (selected != _trackedStepId) {
|
||||
_trackedStepId = selected;
|
||||
for (final c in _withCtrls.values) {
|
||||
c.dispose();
|
||||
}
|
||||
_withCtrls.clear();
|
||||
if (selected != null) {
|
||||
final step = widget.controller.graph.steps.firstWhere(
|
||||
(s) => s.id == selected,
|
||||
orElse: () => _empty,
|
||||
);
|
||||
_idCtrl.text = step.id;
|
||||
_useCtrl.text = step.use;
|
||||
for (final entry in step.with_.entries) {
|
||||
_withCtrls[entry.key] = TextEditingController(
|
||||
text: entry.value.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
static const _empty = FlowStep(id: '', use: '');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final controller = widget.controller;
|
||||
final selected = controller.selectedStepId;
|
||||
if (selected == null) return _emptyHint(theme);
|
||||
final step = controller.graph.steps.firstWhere(
|
||||
(s) => s.id == selected,
|
||||
orElse: () => _empty,
|
||||
);
|
||||
if (step.id.isEmpty) return _emptyHint(theme);
|
||||
return Container(
|
||||
color: theme.colorScheme.surface,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_header(theme, step),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_section(theme, widget.strings.propStepId),
|
||||
_idField(theme),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
_section(theme, widget.strings.propCapability),
|
||||
_useField(theme),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
_section(theme, widget.strings.propInputs),
|
||||
..._withFields(theme),
|
||||
TextButton.icon(
|
||||
onPressed: _addWithField,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(widget.strings.propAddInput),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _deleteStep(step.id),
|
||||
icon: const Icon(Icons.delete_outline, size: 16),
|
||||
label: Text(widget.strings.propDeleteStep),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyHint(ThemeData theme) {
|
||||
return Container(
|
||||
color: theme.colorScheme.surface,
|
||||
padding: const EdgeInsets.all(FaiSpace.lg),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.strings.propNoSelection,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(ThemeData theme, FlowStep step) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.md,
|
||||
vertical: FaiSpace.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
border: Border(bottom: BorderSide(color: theme.dividerColor)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
step.isApproval ? Icons.pan_tool_outlined : Icons.widgets_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.xs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.strings.propPanelTitle,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _section(ThemeData theme, String label) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _idField(ThemeData theme) {
|
||||
return TextField(
|
||||
controller: _idCtrl,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _commitId(),
|
||||
onTapOutside: (_) => _commitId(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _useField(ThemeData theme) {
|
||||
final caps = widget.availableCapabilities;
|
||||
if (caps.isEmpty) {
|
||||
return TextField(
|
||||
controller: _useCtrl,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
hintText: 'e.g. text.extract@^0',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _commitUse(),
|
||||
onTapOutside: (_) => _commitUse(),
|
||||
);
|
||||
}
|
||||
return Autocomplete<String>(
|
||||
initialValue: TextEditingValue(text: _useCtrl.text),
|
||||
optionsBuilder: (input) {
|
||||
final query = input.text.toLowerCase();
|
||||
if (query.isEmpty) return caps;
|
||||
return caps.where((c) => c.toLowerCase().contains(query));
|
||||
},
|
||||
onSelected: (sel) {
|
||||
_useCtrl.text = sel;
|
||||
_commitUse();
|
||||
},
|
||||
fieldViewBuilder: (ctx, fieldCtrl, focus, onSubmit) {
|
||||
fieldCtrl.text = _useCtrl.text;
|
||||
return TextField(
|
||||
controller: fieldCtrl,
|
||||
focusNode: focus,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
hintText: 'e.g. text.extract@^0',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (val) {
|
||||
_useCtrl.text = val;
|
||||
_commitUse();
|
||||
},
|
||||
onTapOutside: (_) {
|
||||
_useCtrl.text = fieldCtrl.text;
|
||||
_commitUse();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _withFields(ThemeData theme) {
|
||||
final entries = _withCtrls.entries.toList();
|
||||
return [
|
||||
for (final entry in entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: FaiSpace.sm),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: TextField(
|
||||
controller: TextEditingController(text: entry.key),
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (newKey) => _renameWithField(entry.key, newKey),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.xs),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: entry.value,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _commitWithFields(),
|
||||
onTapOutside: (_) => _commitWithFields(),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _removeWithField(entry.key),
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
tooltip: widget.strings.propRemoveInput,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _commitId() {
|
||||
final selected = widget.controller.selectedStepId;
|
||||
if (selected == null) return;
|
||||
final newId = _idCtrl.text.trim();
|
||||
if (newId.isEmpty || newId == selected) return;
|
||||
final graph = widget.controller.graph;
|
||||
// Reject collisions silently — UI should ideally show a
|
||||
// warning. For now: refuse to apply, snap controller back.
|
||||
if (graph.steps.any((s) => s.id == newId)) {
|
||||
_idCtrl.text = selected;
|
||||
return;
|
||||
}
|
||||
final step = graph.steps.firstWhere((s) => s.id == selected);
|
||||
widget.controller.applyGraphEdit(
|
||||
graph.withStepUpdated(selected, step.copyWith(id: newId)),
|
||||
);
|
||||
widget.controller.selectStep(newId);
|
||||
}
|
||||
|
||||
void _commitUse() {
|
||||
final selected = widget.controller.selectedStepId;
|
||||
if (selected == null) return;
|
||||
final graph = widget.controller.graph;
|
||||
final step = graph.steps.firstWhere((s) => s.id == selected);
|
||||
if (step.use == _useCtrl.text) return;
|
||||
widget.controller.applyGraphEdit(
|
||||
graph.withStepUpdated(selected, step.copyWith(use: _useCtrl.text)),
|
||||
);
|
||||
}
|
||||
|
||||
void _commitWithFields() {
|
||||
final selected = widget.controller.selectedStepId;
|
||||
if (selected == null) return;
|
||||
final graph = widget.controller.graph;
|
||||
final step = graph.steps.firstWhere((s) => s.id == selected);
|
||||
final newWith = <String, dynamic>{
|
||||
for (final entry in _withCtrls.entries) entry.key: entry.value.text,
|
||||
};
|
||||
if (_mapsEqual(step.with_, newWith)) return;
|
||||
widget.controller.applyGraphEdit(
|
||||
graph.withStepUpdated(selected, step.copyWith(with_: newWith)),
|
||||
);
|
||||
}
|
||||
|
||||
void _renameWithField(String oldKey, String newKey) {
|
||||
final selected = widget.controller.selectedStepId;
|
||||
if (selected == null) return;
|
||||
if (newKey.isEmpty || newKey == oldKey) return;
|
||||
if (_withCtrls.containsKey(newKey)) return; // collision
|
||||
final ctrl = _withCtrls.remove(oldKey);
|
||||
if (ctrl != null) _withCtrls[newKey] = ctrl;
|
||||
_commitWithFields();
|
||||
}
|
||||
|
||||
void _addWithField() {
|
||||
final base = 'value';
|
||||
String name = base;
|
||||
var i = 1;
|
||||
while (_withCtrls.containsKey(name)) {
|
||||
name = '${base}_$i';
|
||||
i++;
|
||||
}
|
||||
setState(() {
|
||||
_withCtrls[name] = TextEditingController(text: '');
|
||||
});
|
||||
_commitWithFields();
|
||||
}
|
||||
|
||||
void _removeWithField(String key) {
|
||||
final ctrl = _withCtrls.remove(key);
|
||||
ctrl?.dispose();
|
||||
setState(() {});
|
||||
_commitWithFields();
|
||||
}
|
||||
|
||||
void _deleteStep(String id) {
|
||||
widget.controller.applyGraphEdit(
|
||||
widget.controller.graph.withStepRemoved(id),
|
||||
);
|
||||
widget.controller.selectStep(null);
|
||||
}
|
||||
|
||||
bool _mapsEqual(Map<String, dynamic> a, Map<String, dynamic> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final entry in a.entries) {
|
||||
if (b[entry.key].toString() != entry.value.toString()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue