feat(editor): type-token coloring + analyzer diagnostics

The text tab now colours `type: text|json|bytes|file|number`
values in the same hues the graph canvas uses for the wire of
that type — a glance at the YAML confirms what a glance at the
graph shows. Promoted the wire-colour palette to a single
source of truth in `wire_colors.dart` so the graph, the
properties panel and the text tab can't drift.

Adds `FlowAnalyzer` (extends `AbstractAnalyzer`) so the gutter
shows error pins and broken lines get wavy red underlines for:

  - YAML parse errors (source-span pinned to the offending line)
  - `use:` referencing a capability the operator hasn't installed
    (bare provider/name match — `text.echo@^1` and `text.echo`
    compare equal)

Editor host passes a closure into `setAvailableCapabilities` so
the analyser always sees the current installed list without
re-creating the controller on every Studio rebuild. Bumps the
package version to 0.15.0.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-04 00:46:51 +02:00
parent 73736769a0
commit 885d2db4e1
8 changed files with 400 additions and 52 deletions

View file

@ -0,0 +1,242 @@
// FlowYamlCodeController CodeController subclass that adds
// FI-specific colouring on top of the stock YAML highlighter.
//
// The base highlighter colours generic YAML structure (keys,
// strings, numbers, comments, anchors). On top of that, this
// subclass walks the rendered span tree once per build to find
// `type: text|json|bytes|file|number|integer` declarations and
// recolours just the type-value token with the same hue the
// graph canvas uses for that wire so a glance at the YAML
// confirms what a glance at the graph shows.
//
// The lookup runs every keystroke (because TextField rebuilds
// the span on each change), but each pass is O(N) over the
// visible text fast enough for the flow files we expect
// (hundreds of lines, not megabytes).
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:highlight/languages/yaml.dart';
import 'flow_analyzer.dart';
import 'wire_colors.dart';
class FlowYamlCodeController extends CodeController {
FlowYamlCodeController({List<String> Function()? availableCapabilities})
: super(
text: '',
language: yaml,
analyzer: FlowAnalyzer(
availableCapabilities:
availableCapabilities ?? (() => const <String>[]),
),
);
/// Replace the analyzer's capability provider. Called by the
/// editor host when the installed-capability list changes
/// e.g. after `fai install` while the editor is open.
void setAvailableCapabilities(List<String> Function() provider) {
analyzer = FlowAnalyzer(availableCapabilities: provider);
}
// Matches `type:` followed by (optional) quote + one of our
// known type names + (optional) closing quote. The captured
// group is the type name only we use the parent match span
// to locate it inside the document so we can recolour just
// the name (not the `type:` key itself).
static final RegExp _typeAssignment = RegExp(
r'''\btype\s*:\s*(?:['"])?([A-Za-z_][\w-]*)(?:['"])?''',
);
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
bool? withComposing,
}) {
final base = super.buildTextSpan(
context: context,
style: style,
withComposing: withComposing,
);
final theme = Theme.of(context);
final overrides = <_Override>[];
overrides.addAll(_collectOverrides(text, theme));
overrides.addAll(_collectIssueOverrides(theme));
if (overrides.isEmpty) return base;
overrides.sort((a, b) => a.start.compareTo(b.start));
return _applyOverrides(base, overrides);
}
/// Build wavy-underline overrides for every analyzer-reported
/// issue. The underline spans from the first non-whitespace
/// character to the end of the affected line, so a clean line
/// stays clean and the diagnostic visually attaches to the
/// content the operator wrote (not the indent).
List<_Override> _collectIssueOverrides(ThemeData theme) {
final issues = analysisResult.issues;
if (issues.isEmpty) return const <_Override>[];
final lines = code.lines.lines;
final out = <_Override>[];
for (final issue in issues) {
if (issue.line < 0 || issue.line >= lines.length) continue;
final line = lines[issue.line];
final text = line.text;
// Skip trailing newline if any so the underline doesn't
// extend past the visual line end.
var endOffset = line.textRange.end;
if (text.endsWith('\n')) endOffset -= 1;
var startOffset = line.textRange.start;
// Walk past leading whitespace so indentation isn't
// underlined looks cleaner and keeps focus on the
// actual broken token.
for (var i = 0; i < text.length; i++) {
final c = text.codeUnitAt(i);
if (c == 0x20 || c == 0x09) {
startOffset++;
} else {
break;
}
}
if (startOffset >= endOffset) continue;
final color = switch (issue.type) {
IssueType.error => theme.colorScheme.error,
IssueType.warning => const Color(0xFFEF6C00),
IssueType.info => theme.colorScheme.primary,
};
out.add(
_Override(
startOffset,
endOffset,
TextStyle(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.wavy,
decorationColor: color,
decorationThickness: 1.5,
),
),
);
}
return out;
}
/// Scan [source] and return ascending, non-overlapping ranges
/// whose [_Override.style] should override whatever the
/// highlighter chose.
List<_Override> _collectOverrides(String source, ThemeData theme) {
final out = <_Override>[];
for (final m in _typeAssignment.allMatches(source)) {
final typeName = m.group(1);
if (typeName == null || !kKnownTypes.contains(typeName)) continue;
final whole = m.group(0)!;
final nameStart = m.start + whole.indexOf(typeName);
final nameEnd = nameStart + typeName.length;
out.add(
_Override(
nameStart,
nameEnd,
TextStyle(
color: wireColor(typeName, theme),
fontWeight: FontWeight.w600,
),
),
);
}
return out;
}
/// Flatten + rebuild [span] so every character in an
/// [_Override] range carries the override style. Outside
/// ranges, the original highlighter style is preserved.
TextSpan _applyOverrides(InlineSpan span, List<_Override> overrides) {
final leaves = <_Leaf>[];
_flatten(span, null, leaves);
final children = <InlineSpan>[];
var globalCursor = 0;
var ovIdx = 0;
for (final leaf in leaves) {
final localText = leaf.text;
final leafStart = globalCursor;
final leafEnd = leafStart + localText.length;
var localCursor = 0;
while (ovIdx < overrides.length && overrides[ovIdx].end <= leafStart) {
ovIdx++;
}
var probe = ovIdx;
while (probe < overrides.length && overrides[probe].start < leafEnd) {
final ov = overrides[probe];
final ovStart = ov.start < leafStart ? leafStart : ov.start;
final ovEnd = ov.end > leafEnd ? leafEnd : ov.end;
final before = ovStart - leafStart;
if (before > localCursor) {
children.add(
TextSpan(
text: localText.substring(localCursor, before),
style: leaf.style,
),
);
}
final overlap = ovEnd - leafStart;
children.add(
TextSpan(
text: localText.substring(before, overlap),
style: (leaf.style ?? const TextStyle()).merge(ov.style),
),
);
localCursor = overlap;
probe++;
}
if (localCursor < localText.length) {
children.add(
TextSpan(
text: localText.substring(localCursor),
style: leaf.style,
),
);
}
globalCursor = leafEnd;
}
final rootStyle = (span is TextSpan) ? span.style : null;
return TextSpan(style: rootStyle, children: children);
}
/// Depth-first walk of [span], emitting one [_Leaf] per text
/// chunk in document order. The highlighter's SpanBuilder
/// produces TextSpans that carry BOTH text AND children; we
/// emit the parent's text first, then descend into its
/// children, so concatenating all leaves reproduces [text].
void _flatten(InlineSpan span, TextStyle? inherited, List<_Leaf> out) {
if (span is! TextSpan) return;
final merged = inherited == null
? span.style
: (span.style == null ? inherited : inherited.merge(span.style));
final t = span.text;
if (t != null && t.isNotEmpty) {
out.add(_Leaf(t, merged));
}
final children = span.children;
if (children != null) {
for (final c in children) {
_flatten(c, merged, out);
}
}
}
}
class _Override {
final int start;
final int end;
final TextStyle style;
const _Override(this.start, this.end, this.style);
}
class _Leaf {
final String text;
final TextStyle? style;
const _Leaf(this.text, this.style);
}