feat(editor): graph-pulse on errors + store-aware install / add-source
Three connected improvements to the analyzer-driven diagnostics:
- **Pulsing halo on broken graph nodes**. Every step / pseudo-
node (inputs / outputs) carrying an analyzer issue now
breathes a red (error) or amber (warning) halo on the graph
tab — operator sees the problem on the canvas without
flipping to text. Honours prefers-reduced-motion: reduce-
motion users get a static halo at the same intensity. The
canvas reads severity via a new `stepIssueSeverity` map on
FlowEditorController; pseudo-nodes use `__inputs__` /
`__outputs__` sentinel ids.
- **Store-aware install button**. The analyzer now takes a
second closure, `storeCapabilities`, listing what the public
store can install. Unknown-capability issues only carry the
"Install …" quick-fix when the bare cap is in that list;
otherwise the issue carries an "Add source for …" fix
instead. Resolves the asymmetry the operator reported: the
Store didn't show `htw.digiscout/onet.lookup` but the editor
happily offered to install it (and would have failed). The
install path no longer lies about itself.
- **Did-you-mean suggestion**. When the unknown cap is within
edit-distance two of an installed or store cap (different
spelling — distance-0 stays hidden because that's an install
case, not a typo), the analyzer emits a `ReplaceLineValueFix`
suggesting the closest match. Preserves the version
constraint by reusing the installed spec when present
(e.g. `text.echi@^0.1` → `text.echo@^0.1`).
New public surface:
- `AddModuleSourceFix` + `AddModuleSourceCallback`
- `FlowEditorPage.storeCapabilities` + `onAddModuleSource`
- `FlowAnalyzer.stepSeverity` + the `kInputsNodeId` /
`kOutputsNodeId` sentinels
- `FlowIssueSeverity` enum + `FlowNode.issueSeverity`
Tests:
- Install fix only when in store
- AddModuleSourceFix as fallback when not in store
- Did-you-mean replaces install when a near miss exists
- stepSeverity populated for both step ids and pseudo-nodes
All 36 editor tests green. Bumped to 0.18.0.
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
f43c1ac6cf
commit
efdfa7dd79
10 changed files with 497 additions and 45 deletions
|
|
@ -154,6 +154,12 @@ enum NodeVisualKind { module, approval, inputs, outputs }
|
|||
/// endpoint OUT to downstream steps).
|
||||
enum NodePortSide { left, right }
|
||||
|
||||
/// Severity tag passed to [FlowNode.issueSeverity]. Mirrors
|
||||
/// flutter_code_editor's `IssueType` but lives in the editor
|
||||
/// package so flow_node.dart doesn't pull the code-editor
|
||||
/// transitively into widget tests that don't need it.
|
||||
enum FlowIssueSeverity { error, warning }
|
||||
|
||||
class FlowNode extends StatelessWidget {
|
||||
final String id;
|
||||
final String title;
|
||||
|
|
@ -224,6 +230,14 @@ class FlowNode extends StatelessWidget {
|
|||
/// no pulse is rendered.
|
||||
final Listenable? pulse;
|
||||
|
||||
/// When non-null, the node renders a permanently pulsing
|
||||
/// halo in the appropriate severity colour — red for an
|
||||
/// analyzer-flagged error, amber for a warning — so the
|
||||
/// operator scanning the canvas immediately sees which step
|
||||
/// the YAML linter is unhappy about, without switching to
|
||||
/// the text tab.
|
||||
final FlowIssueSeverity? issueSeverity;
|
||||
|
||||
const FlowNode({
|
||||
super.key,
|
||||
required this.id,
|
||||
|
|
@ -244,6 +258,7 @@ class FlowNode extends StatelessWidget {
|
|||
this.onContextMenu,
|
||||
this.elevated = true,
|
||||
this.pulse,
|
||||
this.issueSeverity,
|
||||
this.width = NodeGeometry.width,
|
||||
});
|
||||
|
||||
|
|
@ -268,10 +283,6 @@ class FlowNode extends StatelessWidget {
|
|||
child: AnimatedBuilder(
|
||||
animation: pulse!,
|
||||
builder: (context, child) {
|
||||
// Derive a 0..1 breath value from the controller.
|
||||
// Use a sine wave so the glow eases in + out
|
||||
// rather than ramping linearly — reads as
|
||||
// "breathing" instead of "flickering".
|
||||
final t = (pulse! is Animation<double>)
|
||||
? (pulse! as Animation<double>).value
|
||||
: 0.0;
|
||||
|
|
@ -294,6 +305,19 @@ class FlowNode extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
if (issueSeverity != null) {
|
||||
// Always-on breathing halo in the severity colour. Owns
|
||||
// its own AnimationController so it ticks even when no
|
||||
// step is running (no shared pulse listenable upstream).
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: _IssueHalo(
|
||||
severity: issueSeverity!,
|
||||
child: cardWidget,
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(width: width, height: height, child: cardWidget);
|
||||
}
|
||||
|
||||
|
|
@ -592,3 +616,80 @@ NodeVisualKind kindForStep(FlowStep step) {
|
|||
if (step.isApproval) return NodeVisualKind.approval;
|
||||
return NodeVisualKind.module;
|
||||
}
|
||||
|
||||
/// Permanently breathing halo painted around a node carrying
|
||||
/// an analyzer issue. Owns its own AnimationController so it
|
||||
/// ticks regardless of the canvas's run-state. Halo colour
|
||||
/// matches severity — error red or warning amber. Honours
|
||||
/// `MediaQuery.disableAnimations` so reduce-motion operators
|
||||
/// still get a static halo without the breathing oscillation.
|
||||
class _IssueHalo extends StatefulWidget {
|
||||
final FlowIssueSeverity severity;
|
||||
final Widget child;
|
||||
const _IssueHalo({required this.severity, required this.child});
|
||||
|
||||
@override
|
||||
State<_IssueHalo> createState() => _IssueHaloState();
|
||||
}
|
||||
|
||||
class _IssueHaloState extends State<_IssueHalo>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1600),
|
||||
)..repeat();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Color _tone(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return widget.severity == FlowIssueSeverity.error
|
||||
? theme.colorScheme.error
|
||||
: const Color(0xFFEF6C00);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tone = _tone(context);
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
if (reduceMotion) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: tone.withValues(alpha: 0.35),
|
||||
blurRadius: 18,
|
||||
spreadRadius: 1.5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
final breath = 0.5 + 0.5 * math.sin(_controller.value * 2 * math.pi);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: tone.withValues(alpha: 0.22 + 0.30 * breath),
|
||||
blurRadius: 16 + 8 * breath,
|
||||
spreadRadius: 0.5 + 2.0 * breath,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue