feat(model): FlowGraph + LayoutStore foundation for WYSIWYG editor
First piece of the v0.2.0 rewrite. The graph view, text view,
and run view will all share a single source of truth: the
YAML text. This commit introduces the parsing layer + the
sidecar that keeps layout metadata OUT of the YAML.
FlowGraph (lib/src/model/flow_graph.dart):
- Parses + emits the F-Delta-I flow YAML shape (inputs +
steps + outputs + leading comment block).
- Recognises both inputs shorthand (`name: text`) and the
expanded form (`name: { type: text, default: ..., hint: ... }`).
- Detects edges by scanning step.with + outputs for
$source.field references; understands both the short
$x.y form (canonical) and the long ${{ x.y }} form (legacy).
- Flags system.approval@ steps via FlowStep.isApproval so
the graph can paint them with the human-gate styling.
- Immutable update helpers (withStepUpdated, withStepAdded,
withStepRemoved) used by the property panel + add-step
button.
- tryParse returns null on YAML syntax errors so the text
tab can keep the last valid graph while the operator
types.
LayoutStore + FlowLayout (lib/src/model/layout_store.dart):
- Per-flow JSON sidecar at
~/.fai/data/flows/.layout/<name>.json. Atomic write via
tmp + rename so a crash mid-save leaves the previous
copy intact.
- The YAML stays byte-identical to what `fai run` consumes
— positions never bleed into the flow file.
AutoLayout (lib/src/model/auto_layout.dart):
- Deterministic topological column layout for flows that
have never been opened in the graph view before.
- Steps with no $step refs sit in column 0; steps whose
refs all point at column-0 steps sit in column 1; etc.
- Cycles + dangling refs collapse to column 0 so nothing
is ever invisible.
Tests (test/flow_graph_test.dart): 7 cases cover canonical
parsing, edge detection across both ref forms, approval-step
flagging, leading-comment round-trip, structural round-trip,
parser robustness on broken YAML, immutability invariant.
All pass.
Version 0.1.4 -> 0.2.0 (minor bump — significant new module
graph; public FlowEditorPage constructor API unchanged).
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
daf6732893
commit
dbd9a0004f
5 changed files with 872 additions and 1 deletions
144
lib/src/model/layout_store.dart
Normal file
144
lib/src/model/layout_store.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// Layout sidecar — node positions persisted alongside a flow.
|
||||
//
|
||||
// Position metadata is intentionally kept OUT of the flow
|
||||
// YAML. Reasons:
|
||||
//
|
||||
// 1. The YAML is what `fai run` consumes, what gets
|
||||
// tamper-checked against the audit log, and what shows
|
||||
// up in pull requests. It should describe behaviour
|
||||
// only, not where a node happened to be on a screen.
|
||||
//
|
||||
// 2. Two operators viewing the same flow may legitimately
|
||||
// want different layouts. The sidecar is per-user (and
|
||||
// can be re-laid-out fresh from any host).
|
||||
//
|
||||
// 3. If the sidecar is missing or corrupt, the graph view
|
||||
// falls back to a deterministic auto-layout. The flow
|
||||
// itself never breaks.
|
||||
//
|
||||
// Storage layout:
|
||||
//
|
||||
// ~/.fai/data/flows/.layout/<flow-name>.json
|
||||
//
|
||||
// One file per flow. JSON shape:
|
||||
//
|
||||
// {
|
||||
// "version": 1,
|
||||
// "positions": {
|
||||
// "<step-id>": { "x": 120.0, "y": 80.0 }
|
||||
// }
|
||||
// }
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
class FlowLayout {
|
||||
final Map<String, NodePosition> positions;
|
||||
const FlowLayout({this.positions = const {}});
|
||||
|
||||
factory FlowLayout.empty() => const FlowLayout();
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'version': 1,
|
||||
'positions': {
|
||||
for (final entry in positions.entries)
|
||||
entry.key: {'x': entry.value.x, 'y': entry.value.y},
|
||||
},
|
||||
};
|
||||
|
||||
factory FlowLayout.fromJson(Map<String, dynamic> json) {
|
||||
final raw = json['positions'];
|
||||
if (raw is! Map) return FlowLayout.empty();
|
||||
final positions = <String, NodePosition>{};
|
||||
for (final entry in raw.entries) {
|
||||
final v = entry.value;
|
||||
if (v is! Map) continue;
|
||||
final x = (v['x'] as num?)?.toDouble();
|
||||
final y = (v['y'] as num?)?.toDouble();
|
||||
if (x == null || y == null) continue;
|
||||
positions[entry.key.toString()] = NodePosition(x, y);
|
||||
}
|
||||
return FlowLayout(positions: positions);
|
||||
}
|
||||
|
||||
FlowLayout withPosition(String stepId, NodePosition pos) {
|
||||
return FlowLayout(positions: {...positions, stepId: pos});
|
||||
}
|
||||
|
||||
FlowLayout withoutStep(String stepId) {
|
||||
return FlowLayout(
|
||||
positions: {
|
||||
for (final entry in positions.entries)
|
||||
if (entry.key != stepId) entry.key: entry.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NodePosition {
|
||||
final double x;
|
||||
final double y;
|
||||
const NodePosition(this.x, this.y);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is NodePosition && other.x == x && other.y == y;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(x, y);
|
||||
}
|
||||
|
||||
/// On-disk persistence for [FlowLayout]. Synchronous I/O is
|
||||
/// fine — these files are tiny (a few KB at most) and the
|
||||
/// graph view loads / saves them at well-defined moments
|
||||
/// (open file, drag end), not in the render loop.
|
||||
class LayoutStore {
|
||||
/// Directory `~/.fai/data/flows/.layout/`, created on first
|
||||
/// write. Hidden so it doesn't show up in `fai admin
|
||||
/// flows list` style enumerations.
|
||||
static String defaultDir() {
|
||||
final home =
|
||||
Platform.environment['HOME'] ??
|
||||
Platform.environment['USERPROFILE'] ??
|
||||
'.';
|
||||
return '$home/.fai/data/flows/.layout';
|
||||
}
|
||||
|
||||
/// Load the layout for [flowName]. Returns an empty layout
|
||||
/// if the sidecar doesn't exist yet (first time the flow is
|
||||
/// opened in the graph view), or if it fails to parse.
|
||||
static FlowLayout loadSync(String flowName) {
|
||||
try {
|
||||
final file = File('${defaultDir()}/$flowName.json');
|
||||
if (!file.existsSync()) return FlowLayout.empty();
|
||||
final raw = file.readAsStringSync();
|
||||
final json = jsonDecode(raw);
|
||||
if (json is! Map<String, dynamic>) return FlowLayout.empty();
|
||||
return FlowLayout.fromJson(json);
|
||||
} catch (_) {
|
||||
return FlowLayout.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist [layout] for [flowName]. Atomic via write-rename
|
||||
/// so a crash mid-write leaves the previous good copy
|
||||
/// intact.
|
||||
static void saveSync(String flowName, FlowLayout layout) {
|
||||
final dir = Directory(defaultDir());
|
||||
if (!dir.existsSync()) dir.createSync(recursive: true);
|
||||
final final_ = File('${dir.path}/$flowName.json');
|
||||
final tmp = File('${dir.path}/$flowName.json.tmp');
|
||||
tmp.writeAsStringSync(
|
||||
const JsonEncoder.withIndent(' ').convert(layout.toJson()),
|
||||
flush: true,
|
||||
);
|
||||
tmp.renameSync(final_.path);
|
||||
}
|
||||
|
||||
/// Remove the sidecar — call this when the operator
|
||||
/// deletes a flow so we don't leak stale layouts.
|
||||
static void deleteSync(String flowName) {
|
||||
final file = File('${defaultDir()}/$flowName.json');
|
||||
if (file.existsSync()) file.deleteSync();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue