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
|
|
@ -33,6 +33,7 @@
|
|||
// InteractiveViewer.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_code_editor/flutter_code_editor.dart' show IssueType;
|
||||
|
||||
import '../editor_controller.dart';
|
||||
import '../editor_style.dart';
|
||||
|
|
@ -875,6 +876,13 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
outputLabels.length,
|
||||
);
|
||||
final cardWidth = _stepWidth(step);
|
||||
// Analyzer severity for this step — if its YAML carries an
|
||||
// unknown-capability error or a typed-input warning, we
|
||||
// tag the node so FlowNode renders a pulsing halo. The
|
||||
// running pulse takes priority when both are active; the
|
||||
// node never combines run-glow + error-halo on the same
|
||||
// surface (would mush the colour).
|
||||
final issueSeverity = _issueSeverityFor(step.id);
|
||||
return Positioned(
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
|
|
@ -891,14 +899,12 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
selected: selected,
|
||||
status: status,
|
||||
elevated: _style.nodeShadows,
|
||||
// Breathing-pulse on running steps when the active
|
||||
// style allows flow animation. The canvas's existing
|
||||
// _flowAnim ticks 0..1 during runs and is gated on
|
||||
// the same accessibility / style preferences, so we
|
||||
// can route it straight through.
|
||||
pulse: _style.flowAnimation && status == FlowNodeStatus.running
|
||||
? _flowAnim
|
||||
: null,
|
||||
issueSeverity: status == FlowNodeStatus.running
|
||||
? null
|
||||
: issueSeverity,
|
||||
onTap: () => widget.controller.selectStep(step.id),
|
||||
onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight),
|
||||
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
|
||||
|
|
@ -930,10 +936,11 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
portSide: portSide,
|
||||
inputPortLabels: labels,
|
||||
selected: selected,
|
||||
// Endpoints are selectable too — selecting opens
|
||||
// the inputs / outputs editor in the properties
|
||||
// panel so the operator can rename, retype, or add
|
||||
// entries graphically instead of editing YAML.
|
||||
// Endpoints carry the same issue-halo treatment as
|
||||
// step nodes — the analyzer uses `__inputs__` /
|
||||
// `__outputs__` sentinel ids so an `Unknown input type`
|
||||
// warning lights up the matching endpoint card.
|
||||
issueSeverity: _issueSeverityFor(nodeId),
|
||||
onTap: () => widget.controller.selectStep(nodeId),
|
||||
onDrag: (delta) => _applyDrag(
|
||||
nodeId,
|
||||
|
|
@ -945,6 +952,18 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
);
|
||||
}
|
||||
|
||||
/// Map the controller's per-step issue severity (from the
|
||||
/// YAML analyzer) onto the FlowNode's enum. Returns null
|
||||
/// when the step has no issues so the node renders normally.
|
||||
FlowIssueSeverity? _issueSeverityFor(String stepId) {
|
||||
final raw = widget.controller.stepIssueSeverity[stepId];
|
||||
return switch (raw) {
|
||||
IssueType.error => FlowIssueSeverity.error,
|
||||
IssueType.warning => FlowIssueSeverity.warning,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Single drag entry point used by every node on the
|
||||
/// canvas. Converts a screen-space delta to canvas-space
|
||||
/// (via the current InteractiveViewer scale), clamps the
|
||||
|
|
|
|||
|
|
@ -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