chain-studio-flow-editor/lib/src/flow_editor_page.dart
flemming-it 5cd2745db5 feat(editor): localised analyzer/strip/run messages + monospace font fallback
Closes operator-reported issues around the text-tab font and
the English-only run + diagnostic surfaces.

  - **Real monospace everywhere**. The package never bundled
    JetBrains Mono as an asset (it relied on Studio's
    google_fonts pre-load), so a host that doesn't ship the
    font saw the YAML editor render in the system proportional
    default. New _monoTextStyle() pins fontFamilyFallback to a
    cross-platform monospace chain (Menlo / Consolas / Courier
    New / monospace) so the editor stays a grid in every host.
    Applied to the code field, gutter, diagnostic strip,
    issue rows, hover card, fix buttons, and the run tab's
    error box.

  - **Locale-aware analyzer messages**. New AnalyzerStrings
    adapter holds every string the analyzer emits, with an
    .english default + an .from(FlowEditorStrings) factory.
    FlowYamlCodeController.setCapabilityProviders takes the
    strings; FlowEditorPage wires them from the active locale.
    The analyzer's 'Unknown capability', 'Did you mean',
    'Not in the store — install locally with fai install
    --link', 'Unknown input/output type', YAML parse errors,
    and every quick-fix label (Install / Add source / Use /
    Change to) now flip to DE when the editor's locale is DE.

  - **Localised run + diagnostic chrome**. The bottom strip's
    'No issues', '2 errors · 1 warning', 'Copy all'; the
    issue row's L-prefix + Copy tooltip; the hover card's
    L-prefix + Copy tooltip; the run-block tooltip + inline
    message ('2 Fehler verhindern den Lauf · siehe
    Diagnose-Leiste unten'); the step-row 'awaiting approval'
    suffix; the CopyableErrorBox's Copy / Copied tooltip —
    all now flow through FlowEditorStrings.

Bumped to 0.20.0.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-09 01:36:39 +02:00

1772 lines
58 KiB
Dart

// FlowEditorPage — the public-facing widget Studio (and any
// other host) embeds as its flow surface.
//
// Layout:
//
// ┌─ Toolbar ─────────────────────────────────────────────┐
// │ [back] file.yaml ● [+ Step] [Save] [Run] │
// ├──────┬────────────────────────────────────────────────┤
// │ FLOWS│ [Graph][Text][Run] │
// │ ⟲ │ │
// │ │ (active tab content) │
// │ list │ │
// │ │ │
// └──────┴────────────────────────────────────────────────┘
// └ Properties (overlay)
//
// All three tabs read state from a single [FlowEditorController].
// The graph tab and text tab keep YAML in lockstep — graph
// edits emit fresh YAML; text edits re-parse the graph on
// debounce. The run tab reads the saved-on-disk version
// because that's what the hub's runSavedFlow consumes.
library;
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'editor_controller.dart';
import 'editor_style.dart';
import 'flow_yaml_controller.dart';
import 'l10n.dart';
import 'model/flow_graph.dart';
import 'quick_fix.dart';
import 'run_driver.dart';
import 'tokens.dart';
import 'widgets.dart';
import 'widgets/capability_picker.dart';
import 'widgets/flow_canvas.dart';
import 'widgets/properties_panel.dart';
import 'widgets/run_tab.dart';
/// Compute the flows directory under the operator's home.
String _defaultFlowsDir() {
final home =
Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
'.';
return '$home/.fai/data/flows';
}
class FlowEditorPage extends StatefulWidget {
/// Pre-load this flow on open. When null, the editor starts
/// empty and the operator picks from the file list.
final String? initialFlowName;
/// Language for inline strings.
final FlowEditorLocale locale;
/// Bridge between editor and host's hub. When null, the
/// Run tab is read-only.
final FlowRunDriver? runDriver;
/// Capabilities the operator can drop into a new step.
/// Studio supplies the list from `HubService.listCapabilities()`.
/// Empty list = the picker shows a free-form text field.
final List<String> availableCapabilities;
/// Visual-effect overrides — frosted glass on / off, canvas
/// backdrop style, flow animation on / off. When null the
/// package's [FaiEditorStyle.modern] preset is used. A
/// theme-plugin host can pass its own to flip the editor
/// between Studio's active style modes without touching
/// the editor's source.
final FaiEditorStyle? style;
/// Host-side install handler invoked when the operator clicks
/// the "Install <capability>" quick-fix on an unknown-capability
/// issue. Studio's implementation calls the Hub and returns
/// the post-install capability list; the editor refreshes its
/// analyzer and dismisses the issue. `null` hides the install
/// action (the diagnostic remains visible without it).
final InstallCapabilityCallback? onInstallCapability;
/// Host-side "register a new module source" handler. Invoked
/// when the operator clicks "Add source for <capability>…" on
/// an unknown-capability issue that isn't in the public store.
/// Same callback contract as [onInstallCapability]; the host
/// is expected to prompt the operator for a path / URL and
/// then call the Hub install API.
final AddModuleSourceCallback? onAddModuleSource;
/// Capabilities the public store knows how to install. The
/// analyzer uses this to decide whether to show "Install …"
/// (in store) or "Add source for …" (not in store) as the
/// quick-fix on an unknown `use:` line. Empty list = store
/// silent — no install button offered.
final List<String> storeCapabilities;
const FlowEditorPage({
super.key,
this.initialFlowName,
this.locale = FlowEditorLocale.en,
this.runDriver,
this.availableCapabilities = const [],
this.style,
this.onInstallCapability,
this.onAddModuleSource,
this.storeCapabilities = const [],
});
@override
State<FlowEditorPage> createState() => _FlowEditorPageState();
}
class _FlowEditorPageState extends State<FlowEditorPage>
with TickerProviderStateMixin {
late final FlowEditorController _controller;
late final FlowEditorStrings _l;
late Future<List<_FlowFile>> _files;
late final TabController _tabs;
/// Live hover overlay — inserted when the pointer enters an
/// underlined issue range, removed when it leaves both the
/// range and the tooltip card. Kept on State so we can clear
/// it on dispose without leaking.
OverlayEntry? _hoverEntry;
@override
void initState() {
super.initState();
_l = FlowEditorStrings(widget.locale);
_controller = FlowEditorController();
_controller.codeController.setCapabilityProviders(
available: () => widget.availableCapabilities,
store: () => widget.storeCapabilities,
strings: AnalyzerStrings.from(_l),
);
_controller.addListener(_onCtrlChanged);
_controller.codeController.hoverRequest.addListener(_onHoverChanged);
_tabs = TabController(length: 3, vsync: this);
_files = _listFiles();
final initial = widget.initialFlowName;
if (initial != null && initial.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _openByName(initial);
});
}
}
@override
void dispose() {
_controller.codeController.hoverRequest.removeListener(_onHoverChanged);
_removeHoverEntry();
_controller.removeListener(_onCtrlChanged);
_controller.dispose();
_tabs.dispose();
super.dispose();
}
void _onCtrlChanged() {
if (mounted) setState(() {});
}
void _onHoverChanged() {
final req = _controller.codeController.hoverRequest.value;
if (req == null) {
_removeHoverEntry();
return;
}
_removeHoverEntry();
final overlay = Overlay.of(context, rootOverlay: true);
_hoverEntry = OverlayEntry(
builder: (ctx) => _IssueHoverCard(
request: req,
strings: _l,
onApplyFix: _applyQuickFix,
onEnter: _controller.codeController.cancelHoverDismiss,
onExit: _controller.codeController.scheduleHoverDismiss,
),
);
overlay.insert(_hoverEntry!);
}
void _removeHoverEntry() {
_hoverEntry?.remove();
_hoverEntry = null;
}
/// Apply a quick fix returned by the analyzer. Returns once
/// the fix has been applied; the controller reanalyzes on its
/// own.
Future<void> _applyQuickFix(QuickFix fix) async {
switch (fix) {
case ReplaceLineValueFix():
_applyReplaceLineValue(fix);
await _controller.codeController.reanalyze();
break;
case InstallCapabilityFix(:final capability):
final handler = widget.onInstallCapability;
if (handler == null) return;
final newCaps = await handler(capability);
if (newCaps != null && mounted) {
_controller.codeController.setCapabilityProviders(
available: () => newCaps,
store: () => widget.storeCapabilities,
strings: AnalyzerStrings.from(_l),
);
await _controller.codeController.reanalyze();
}
break;
case AddModuleSourceFix(:final capability):
final handler = widget.onAddModuleSource;
if (handler == null) return;
final newCaps = await handler(capability);
if (newCaps != null && mounted) {
_controller.codeController.setCapabilityProviders(
available: () => newCaps,
store: () => widget.storeCapabilities,
strings: AnalyzerStrings.from(_l),
);
await _controller.codeController.reanalyze();
}
break;
}
_controller.codeController.clearHover();
}
/// Substitute the colon-value of [fix.line] with
/// [fix.replacement]. Preserves indentation + the key, only
/// touches what's right of the first ":". Idempotent — if the
/// value already matches, the call is a no-op.
void _applyReplaceLineValue(ReplaceLineValueFix fix) {
final controller = _controller.codeController;
final fullText = controller.fullText;
final lines = fullText.split('\n');
if (fix.line < 0 || fix.line >= lines.length) return;
final line = lines[fix.line];
final colonIdx = line.indexOf(':');
if (colonIdx < 0) return;
// Find first non-whitespace after the colon — preserve any
// single leading space, drop everything past it up to the
// end of the line (modulo a trailing comment).
final commentIdx = line.indexOf('#', colonIdx + 1);
final rhsEnd = commentIdx < 0 ? line.length : commentIdx;
final tail = commentIdx < 0 ? '' : line.substring(rhsEnd);
final newLine = '${line.substring(0, colonIdx + 1)} ${fix.replacement}'
'${tail.isEmpty ? '' : ' $tail'}';
if (newLine == line) return;
lines[fix.line] = newLine;
controller.fullText = lines.join('\n');
}
// --- file ops ---
Future<List<_FlowFile>> _listFiles() async {
final dir = Directory(_defaultFlowsDir());
if (!dir.existsSync()) return <_FlowFile>[];
final entries = await dir
.list()
.where((e) => e is File && e.path.endsWith('.yaml'))
.cast<File>()
.toList();
final files =
entries
.map(
(f) => _FlowFile(
name: f.uri.pathSegments.last.replaceAll(
RegExp(r'\.yaml$'),
'',
),
path: f.path,
sizeBytes: f.statSync().size,
),
)
.toList()
..sort((a, b) => a.name.compareTo(b.name));
return files;
}
Future<void> _openByName(String name) async {
final path = '${_defaultFlowsDir()}/$name.yaml';
final file = File(path);
if (!file.existsSync()) return;
final text = await file.readAsString();
if (!mounted) return;
_controller.openFlow(name, text);
}
Future<void> _openFile(_FlowFile f) async {
if (_controller.isDirty) {
final keep = await _confirmDiscard();
if (keep == false || !mounted) return;
}
final text = await File(f.path).readAsString();
if (!mounted) return;
_controller.openFlow(f.name, text);
}
Future<bool?> _confirmDiscard() {
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(_l.discardTitle),
content: Text(_l.discardBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(_l.discardKeep),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(_l.discardThrow),
),
],
),
);
}
Future<void> _save() async {
final name = _controller.activeName;
if (name == null) return;
_controller.saving = true;
try {
final file = File('${_defaultFlowsDir()}/$name.yaml');
await file.writeAsString(
_controller.codeController.fullText,
flush: true,
);
if (!mounted) return;
_controller.markSaved();
_files = _listFiles();
setState(() {});
// Brief positive feedback. Confirmation matters more
// here than on most save buttons because the operator
// can also save via Cmd+S without watching the dirty
// dot — a tiny green snackbar tells them the
// keystroke landed.
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(milliseconds: 1500),
behavior: SnackBarBehavior.floating,
content: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle_outline, size: 16),
const SizedBox(width: FaiSpace.sm),
Text('$name.yaml ${_l.save.toLowerCase()}'),
],
),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
} finally {
_controller.saving = false;
}
}
Future<void> _newFlow() async {
final name = await showDialog<String>(
context: context,
builder: (ctx) => _NewFlowDialog(strings: _l),
);
if (name == null || name.isEmpty || !mounted) return;
final template =
'''# ${_l.newTemplateComment(name)}
name: $name
inputs:
text:
type: text
steps:
- id: echo
use: debug.echo@^0
with:
message: \$inputs.text
outputs:
result: \$echo.echoed
''';
try {
final dir = Directory(_defaultFlowsDir());
if (!dir.existsSync()) await dir.create(recursive: true);
final file = File('${dir.path}/$name.yaml');
if (file.existsSync()) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(_l.alreadyExists(name))));
return;
}
await file.writeAsString(template, flush: true);
if (!mounted) return;
_controller.openFlow(name, template);
_files = _listFiles();
setState(() {});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
Future<void> _refreshFiles() async {
setState(() => _files = _listFiles());
}
Future<void> _addStep() async {
final picked = await CapabilityPicker.show(
context,
capabilities: widget.availableCapabilities,
strings: _l,
);
if (picked == null || !mounted) return;
final graph = _controller.graph;
// Generate a fresh id — base on the capability's local
// name, deduplicating against existing step ids.
final localPart = picked.split('/').last.split('@').first;
final baseId = localPart.split('.').last;
var id = baseId;
var i = 1;
while (graph.steps.any((s) => s.id == id)) {
id = '${baseId}_$i';
i++;
}
_controller.applyGraphEdit(
graph.withStepAdded(FlowStep(id: id, use: picked, with_: const {})),
);
_controller.selectStep(id);
_tabs.animateTo(0);
}
// --- build ---
@override
Widget build(BuildContext context) {
// Keyboard shortcuts: Cmd/Ctrl+S to save, Cmd/Ctrl+Enter
// to start a run. The text tab also keeps these but the
// global wrapper covers the graph + run tabs too.
return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyS):
const _SaveIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
const _SaveIntent(),
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.enter):
const _RunIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter):
const _RunIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
_SaveIntent: CallbackAction<_SaveIntent>(
onInvoke: (_) {
if (_controller.activeName != null) _save();
return null;
},
),
_RunIntent: CallbackAction<_RunIntent>(
onInvoke: (_) {
if (_controller.activeName != null) _tabs.animateTo(2);
return null;
},
),
},
child: Focus(autofocus: true, child: _buildScaffold()),
),
);
}
Widget _buildScaffold() {
final theme = Theme.of(context);
return Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Toolbar(
strings: _l,
activeName: _controller.activeName,
dirty: _controller.isDirty,
onBack: Navigator.of(context).canPop()
? () => Navigator.of(context).maybePop()
: null,
onNew: _newFlow,
),
const Divider(height: 1),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 240,
child: _FileList(
filesFuture: _files,
activeName: _controller.activeName,
strings: _l,
onOpen: _openFile,
onRefresh: _refreshFiles,
),
),
const VerticalDivider(width: 1),
Expanded(
child: _controller.activeName == null
? _EmptyState(strings: _l)
: _tabbedBody(theme),
),
],
),
),
],
),
);
}
Widget _tabbedBody(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
color: theme.colorScheme.surfaceContainerLowest,
child: TabBar(
controller: _tabs,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: [
Tab(
text: _l.tabGraph,
icon: const Icon(Icons.account_tree, size: 16),
),
Tab(text: _l.tabText, icon: const Icon(Icons.notes, size: 16)),
Tab(
text: _l.tabRun,
icon: const Icon(Icons.play_arrow, size: 16),
),
],
),
),
_TabActionStrip(
strings: _l,
tabs: _tabs,
saving: _controller.isSaving,
running: _controller.isRunning,
errorCount: _controller.analyzerErrorCount,
onAddStep: _controller.activeName != null ? _addStep : null,
onSave: _controller.activeName != null ? _save : null,
onRun: _controller.activeName != null &&
_controller.analyzerErrorCount == 0
? () => _tabs.animateTo(2)
: null,
),
Expanded(
child: TabBarView(
controller: _tabs,
// Disable the lateral swipe gesture so it doesn't
// race with the canvas's pan handlers.
physics: const NeverScrollableScrollPhysics(),
children: [
_graphTab(theme),
_textTab(theme),
RunTab(
controller: _controller,
strings: _l,
driver: widget.runDriver,
),
],
),
),
// Persistent diagnostic strip — lives below the tab bar
// so the same issue list is visible on Graph + Text +
// Run. The Graph tab no longer needs a separate
// per-node tooltip because the strip is right there with
// the message + quick-fix buttons.
_DiagnosticStrip(
controller: _controller.codeController,
strings: _l,
onApplyFix: _applyQuickFix,
),
],
);
}
Widget _graphTab(ThemeData theme) {
// When a step is selected, show the properties panel as a
// floating sidebar on the right. In glass-style mode the
// panel overlaps the canvas so the BackdropFilter has
// canvas content to blur. In solid-style mode the panel
// sits flush against the canvas with a divider — no
// overlap needed.
//
// When the flow has no steps at all, overlay a CTA so the
// operator's first instinct is the right action rather
// than staring at an empty grid.
final hasSteps = _controller.graph.steps.isNotEmpty;
// Honor the OS reduce-motion preference for the
// properties-panel glass effect — same clamp the canvas
// applies to its own surfaces.
final style = (widget.style ?? FaiEditorStyle.modern).clampedForA11y(
disableAnimations: MediaQuery.disableAnimationsOf(context),
);
final glass = style.panelStyle == EditorPanelStyle.glass;
final hasSelection = _controller.selectedStepId != null;
Widget emptyOverlay() => Positioned.fill(
child: Container(
color: theme.colorScheme.surface.withValues(alpha: 0.92),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.account_tree_outlined,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: FaiSpace.md),
Text(_l.graphEmptyTitle, style: theme.textTheme.titleMedium),
const SizedBox(height: FaiSpace.sm),
Text(
_l.graphEmptyBody,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
FilledButton.icon(
onPressed: _addStep,
icon: const Icon(Icons.add_box_outlined, size: 16),
label: Text(_l.addStep),
),
],
),
),
),
);
Widget panel() => PropertiesPanel(
controller: _controller,
strings: _l,
availableCapabilities: widget.availableCapabilities,
);
if (glass) {
// Stack layout so the frosted panel can overlap canvas.
return Stack(
children: [
Positioned.fill(
child: FlowCanvas(
controller: _controller,
style: style,
driver: widget.runDriver,
locale: widget.locale,
),
),
if (!hasSteps) emptyOverlay(),
if (hasSelection)
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 320,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: style.panelBlurSigma,
sigmaY: style.panelBlurSigma,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(
alpha: style.panelBackgroundAlpha,
),
border: Border(
left: BorderSide(
color: theme.colorScheme.outlineVariant.withValues(
alpha: 0.5,
),
width: 1,
),
),
),
child: panel(),
),
),
),
),
],
);
}
// Solid-style: flush sidebar, divider, no blur.
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
children: [
FlowCanvas(
controller: _controller,
style: style,
driver: widget.runDriver,
locale: widget.locale,
),
if (!hasSteps) emptyOverlay(),
],
),
),
if (hasSelection) ...[
const VerticalDivider(width: 1),
SizedBox(width: 320, child: panel()),
],
],
);
}
Widget _textTab(ThemeData theme) {
// The editor itself doesn't bundle JetBrains Mono as an
// asset (Studio preloads it via google_fonts). When the
// host isn't Studio, Flutter would fall back to a
// proportional default and the code would line up like
// ransom-note prose. _monoTextStyle's fontFamilyFallback
// pins the chain to the system monospace stack so a YAML
// grid stays a grid in every host.
final mono = _monoTextStyle(
size: 13,
height: 1.45,
color: theme.colorScheme.onSurface,
);
// The bottom diagnostic strip now lives at the page level
// (one persistent strip across all three tabs) instead of
// being duplicated per-tab — see _tabbedBody. That keeps the
// graph + run tabs informed too, not just text.
return CodeTheme(
data: CodeThemeData(styles: _yamlStyle(theme)),
child: CodeField(
controller: _controller.codeController,
textStyle: mono,
expands: true,
minLines: null,
maxLines: null,
gutterStyle: GutterStyle(
textStyle: mono.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
background: theme.colorScheme.surfaceContainer,
showLineNumbers: true,
// Disable the built-in error column entirely — its
// hard-positioned popup overlaps the code area.
// Issues are surfaced via the wavy underline + the
// page-level diagnostic strip.
showErrors: false,
),
background: theme.colorScheme.surface,
),
);
}
Map<String, TextStyle> _yamlStyle(ThemeData theme) {
final cs = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final monoBase = _monoTextStyle(size: 13, height: 1.45);
// Pick clearly-distinguished hues so keys, strings,
// numbers, anchors, and comments don't blur into each
// other. We derive accents from the active ColorScheme
// so theme plugins (sunflower / sunset / space / glass)
// tint the highlight as expected without per-theme
// overrides. Falls back to a balanced light/dark palette
// when the scheme tints don't read well as code colours.
final keyAccent = cs.primary;
final stringAccent = isDark
? const Color(0xFFA5D6A7)
: const Color(0xFF2E7D32);
final numberAccent = isDark
? const Color(0xFFFFCC80)
: const Color(0xFFE65100);
final symbolAccent = isDark
? const Color(0xFFB39DDB)
: const Color(0xFF6A1B9A);
final commentAccent = cs.onSurfaceVariant.withValues(alpha: 0.7);
final metaAccent = isDark
? const Color(0xFF80DEEA)
: const Color(0xFF00838F);
return {
'root': monoBase.copyWith(color: cs.onSurface),
// YAML keys — bold + primary accent so the structure
// reads top-down at a glance.
'attr': monoBase.copyWith(color: keyAccent, fontWeight: FontWeight.w600),
// Quoted + unquoted strings. The highlight grammar
// tags both as 'string'.
'string': monoBase.copyWith(color: stringAccent),
// Numbers (integers, floats, durations).
'number': monoBase.copyWith(color: numberAccent),
// Sequence dashes — kept muted so list bullets don't
// shout. Operators read them as structure, not content.
'bullet': monoBase.copyWith(
color: cs.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
// Booleans, null, named scalars.
'literal': monoBase.copyWith(
color: symbolAccent,
fontWeight: FontWeight.w600,
),
// YAML comments — softened + italicised so reading the
// structure ignores them, while a deliberate scan
// still picks them out.
'comment': monoBase.copyWith(
color: commentAccent,
fontStyle: FontStyle.italic,
),
// Document markers, directives, tags (--- !!str etc.).
'meta': monoBase.copyWith(color: metaAccent),
// Anchor / alias names (& and *).
'symbol': monoBase.copyWith(color: symbolAccent),
// Templated values — the highlight grammar tags some
// braced expressions as 'tag'; pick them out so F∆I's
// $step.field references stand out via the surrounding
// string colour.
'tag': monoBase.copyWith(color: metaAccent),
'type': monoBase.copyWith(color: symbolAccent),
};
}
}
// --- shortcuts ---
class _SaveIntent extends Intent {
const _SaveIntent();
}
class _RunIntent extends Intent {
const _RunIntent();
}
// --- toolbar ---
class _Toolbar extends StatelessWidget {
final FlowEditorStrings strings;
final String? activeName;
final bool dirty;
final VoidCallback? onBack;
final VoidCallback onNew;
const _Toolbar({
required this.strings,
required this.activeName,
required this.dirty,
required this.onBack,
required this.onNew,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.sm,
),
color: theme.colorScheme.surfaceContainer,
child: Row(
children: [
if (onBack != null) ...[
IconButton(
icon: const Icon(Icons.arrow_back, size: 18),
tooltip: strings.backTooltip,
onPressed: onBack,
),
const SizedBox(width: FaiSpace.xs),
],
Text(
activeName == null ? strings.emptyTitle : '${activeName!}.yaml',
style: theme.textTheme.titleSmall?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
// Dirty file name renders in the primary accent
// so the operator sees "this file has unsaved
// changes" at a glance even when their eye is
// elsewhere in the UI.
color: dirty ? theme.colorScheme.primary : null,
),
),
if (dirty) ...[
const SizedBox(width: FaiSpace.xs),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.fiber_manual_record,
size: 8,
color: theme.colorScheme.primary,
),
const SizedBox(width: 4),
Text(
strings.unsaved,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
const Spacer(),
FilledButton.tonalIcon(
onPressed: onNew,
icon: const Icon(Icons.add, size: 16),
label: Text(strings.newFlow),
),
],
),
);
}
}
// Tab-aware action strip — lives directly under the TabBar so
// the operator's eye flows from TabBar → context buttons →
// canvas. Each tab exposes a different set of actions:
//
// Graph: [+ Step] [Save] [Run]
// Text: [Save] [Run]
// Run: (no buttons — Run tab has its own start button)
//
// Rebuilds when the TabController index changes via the
// passed-in animation.
class _TabActionStrip extends StatelessWidget {
final FlowEditorStrings strings;
final TabController tabs;
final bool saving;
final bool running;
final int errorCount;
final VoidCallback? onAddStep;
final VoidCallback? onSave;
final VoidCallback? onRun;
const _TabActionStrip({
required this.strings,
required this.tabs,
required this.saving,
required this.running,
required this.errorCount,
required this.onAddStep,
required this.onSave,
required this.onRun,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedBuilder(
animation: tabs.animation ?? tabs,
builder: (context, _) {
final idx = tabs.index;
final showAddStep = idx == 0;
final showSaveRun = idx == 0 || idx == 1;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
),
),
),
child: Row(
children: [
if (showAddStep) ...[
FilledButton.tonalIcon(
onPressed: onAddStep,
icon: const Icon(Icons.add_box_outlined, size: 16),
label: Text(strings.addStep),
),
const SizedBox(width: FaiSpace.sm),
],
if (showSaveRun) ...[
FilledButton.tonalIcon(
onPressed: saving ? null : onSave,
icon: saving
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined, size: 16),
label: Text(strings.save),
),
const SizedBox(width: FaiSpace.sm),
Tooltip(
message: errorCount > 0
? strings.runErrorsBlockTooltip(errorCount)
: '',
child: FilledButton.icon(
onPressed: onRun,
icon: running
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
errorCount > 0
? Icons.block
: Icons.play_arrow,
size: 18,
),
label: Text(strings.run),
),
),
],
const Spacer(),
],
),
);
},
);
}
}
// --- file list ---
class _FlowFile {
final String name;
final String path;
final int sizeBytes;
const _FlowFile({
required this.name,
required this.path,
required this.sizeBytes,
});
}
class _FileList extends StatelessWidget {
final Future<List<_FlowFile>> filesFuture;
final String? activeName;
final FlowEditorStrings strings;
final void Function(_FlowFile) onOpen;
final VoidCallback onRefresh;
const _FileList({
required this.filesFuture,
required this.activeName,
required this.strings,
required this.onOpen,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
color: theme.colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.xs,
),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: theme.dividerColor)),
),
child: Row(
children: [
Expanded(
child: Text(
strings.listHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
),
IconButton(
onPressed: onRefresh,
tooltip: strings.refresh,
icon: const Icon(Icons.refresh, size: 16),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 28,
minHeight: 28,
),
),
],
),
),
Expanded(child: _buildBody(context, theme)),
],
),
);
}
Widget _buildBody(BuildContext context, ThemeData theme) {
return FutureBuilder<List<_FlowFile>>(
future: filesFuture,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: FaiErrorBox(error: snap.error, isError: true),
);
}
final files = snap.data ?? <_FlowFile>[];
if (files.isEmpty) {
return Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: FaiEmptyState(
icon: Icons.folder_outlined,
title: strings.listEmptyTitle,
hint: strings.listEmptyBody,
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
itemCount: files.length,
itemBuilder: (_, i) {
final f = files[i];
final isActive = f.name == activeName;
return InkWell(
onTap: () => onOpen(f),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.sm,
),
decoration: BoxDecoration(
color: isActive ? theme.colorScheme.secondaryContainer : null,
border: Border(
left: BorderSide(
width: 3,
color: isActive
? theme.colorScheme.primary
: Colors.transparent,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
f.name,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
Text(
'${f.sizeBytes} B',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
},
);
},
);
}
}
// --- empty state ---
class _EmptyState extends StatelessWidget {
final FlowEditorStrings strings;
const _EmptyState({required this.strings});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
color: theme.colorScheme.surface,
child: Center(
child: Padding(
padding: const EdgeInsets.all(FaiSpace.xl),
child: FaiEmptyState(
icon: Icons.description_outlined,
title: strings.emptyTitle,
hint: strings.emptyBody,
),
),
),
);
}
}
// --- new flow dialog ---
class _NewFlowDialog extends StatefulWidget {
final FlowEditorStrings strings;
const _NewFlowDialog({required this.strings});
@override
State<_NewFlowDialog> createState() => _NewFlowDialogState();
}
class _NewFlowDialogState extends State<_NewFlowDialog> {
final _controller = TextEditingController();
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final name = _controller.text.trim();
if (!RegExp(r'^[a-z0-9_-]+$').hasMatch(name)) {
setState(() => _error = widget.strings.newDialogHelper);
return;
}
Navigator.pop(context, name);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.strings.newDialogTitle),
content: TextField(
autofocus: true,
controller: _controller,
decoration: InputDecoration(
labelText: widget.strings.newDialogLabel,
helperText: widget.strings.newDialogHelper,
errorText: _error,
border: const OutlineInputBorder(),
),
onSubmitted: (_) => _submit(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(widget.strings.newDialogCancel),
),
FilledButton(
onPressed: _submit,
child: Text(widget.strings.newDialogCreate),
),
],
);
}
}
/// Bottom diagnostic strip — single-line summary that opens
/// into a per-issue list on tap. Replaces the gutter's hard-
/// positioned error pin so the popup never paints over the
/// code area, and gives the operator a stable surface to scan
/// + copy from.
///
/// Each row's message is rendered as `SelectableText` so the
/// operator can copy with the system shortcut. A dedicated
/// "Copy" button per row + a "Copy all" header button cover
/// the trackpad-only operator. When the analyzer attaches a
/// `QuickFix` to an issue, the row also renders an action
/// button (e.g. "Install <cap>" or "Change to \"bytes\"").
/// Build a TextStyle that prefers `JetBrains Mono` (Studio
/// preloads it via `google_fonts`) but falls back to the
/// system's generic `monospace` family when the bundled font
/// isn't registered — which is what happens when this package
/// is hosted by something other than Studio. Without the
/// fallback the field rendered with the default proportional
/// font and code lined up like ransom-note prose.
TextStyle _monoTextStyle({
required double size,
Color? color,
FontWeight? weight,
double? letterSpacing,
double? height,
}) {
return TextStyle(
fontFamily: 'JetBrains Mono',
fontFamilyFallback: const [
'JetBrainsMono Nerd Font',
'Menlo',
'Consolas',
'Courier New',
'monospace',
],
fontSize: size,
color: color,
fontWeight: weight,
letterSpacing: letterSpacing,
height: height,
);
}
class _DiagnosticStrip extends StatefulWidget {
final FlowYamlCodeController controller;
final FlowEditorStrings strings;
final Future<void> Function(QuickFix) onApplyFix;
const _DiagnosticStrip({
required this.controller,
required this.strings,
required this.onApplyFix,
});
@override
State<_DiagnosticStrip> createState() => _DiagnosticStripState();
}
class _DiagnosticStripState extends State<_DiagnosticStrip> {
bool _expanded = false;
final Map<QuickFix, bool> _busy = {};
@override
void initState() {
super.initState();
widget.controller.addListener(_onChange);
}
@override
void dispose() {
widget.controller.removeListener(_onChange);
super.dispose();
}
void _onChange() {
if (mounted) setState(() {});
}
Color _toneForIssue(IssueType type, ThemeData theme) {
return switch (type) {
IssueType.error => theme.colorScheme.error,
IssueType.warning => const Color(0xFFEF6C00),
IssueType.info => theme.colorScheme.primary,
};
}
void _copy(String text) {
Clipboard.setData(ClipboardData(text: text));
}
String _formatAll(List<Issue> issues) {
final buf = StringBuffer();
for (final i in issues) {
buf.writeln(
'${widget.strings.diagnosticLinePrefix(i.line + 1)}: ${i.message}',
);
}
return buf.toString().trimRight();
}
Future<void> _runFix(QuickFix fix) async {
setState(() => _busy[fix] = true);
try {
await widget.onApplyFix(fix);
} finally {
if (mounted) setState(() => _busy.remove(fix));
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final issues = widget.controller.analysisResult.issues;
if (issues.isEmpty) {
return Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
border: Border(
top: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Text(
widget.strings.diagnosticNoIssues,
style: _monoTextStyle(
size: 10,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
letterSpacing: 0.4,
),
),
);
}
final errorCount = issues.where((i) => i.type == IssueType.error).length;
final warnCount = issues.where((i) => i.type == IssueType.warning).length;
final tone = errorCount > 0
? theme.colorScheme.error
: const Color(0xFFEF6C00);
return Material(
color: theme.colorScheme.surfaceContainer,
child: Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// --- Header row (clickable to expand/collapse) ---
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: tone,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
_summary(errorCount, warnCount),
style: _monoTextStyle(
size: 11,
weight: FontWeight.w600,
color: tone,
),
),
const SizedBox(width: 8),
Expanded(
child: SelectableText(
'${widget.strings.diagnosticLinePrefix(issues.first.line + 1)}: ${issues.first.message}',
maxLines: 1,
style: _monoTextStyle(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
IconButton(
tooltip: widget.strings.diagnosticCopyAll,
icon: const Icon(Icons.content_copy, size: 14),
visualDensity: VisualDensity.compact,
onPressed: () => _copy(_formatAll(issues)),
),
Icon(
_expanded ? Icons.expand_more : Icons.expand_less,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
if (_expanded) ...[
Divider(height: 1, color: theme.colorScheme.outlineVariant),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final issue in issues)
_IssueRow(
issue: issue,
fixes: widget.controller.fixesFor(issue),
tone: _toneForIssue(issue.type, theme),
strings: widget.strings,
busy: _busy,
onApplyFix: _runFix,
onCopy: () => _copy(
'${widget.strings.diagnosticLinePrefix(issue.line + 1)}: ${issue.message}',
),
),
],
),
),
),
],
],
),
),
);
}
String _summary(int errors, int warnings) {
final parts = <String>[];
if (errors > 0) parts.add(widget.strings.diagnosticErrors(errors));
if (warnings > 0) {
parts.add(widget.strings.diagnosticWarnings(warnings));
}
return parts.join(' · ');
}
}
/// One row in the expanded diagnostic list. Lays out as
/// `[dot] [L7] [message] [Copy] [Fix...]` — keeping the
/// quick-fix action buttons aligned right so the operator's
/// eye finds them consistently across rows.
class _IssueRow extends StatelessWidget {
final Issue issue;
final List<QuickFix> fixes;
final Color tone;
final FlowEditorStrings strings;
final Map<QuickFix, bool> busy;
final Future<void> Function(QuickFix) onApplyFix;
final VoidCallback onCopy;
const _IssueRow({
required this.issue,
required this.fixes,
required this.tone,
required this.strings,
required this.busy,
required this.onApplyFix,
required this.onCopy,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(top: 6, right: 8),
decoration: BoxDecoration(color: tone, shape: BoxShape.circle),
),
SizedBox(
width: 42,
child: Text(
strings.diagnosticLinePrefix(issue.line + 1),
style: _monoTextStyle(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: SelectableText(
issue.message,
style: _monoTextStyle(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
const SizedBox(width: 8),
IconButton(
tooltip: strings.runCopy,
icon: const Icon(Icons.content_copy, size: 13),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
onPressed: onCopy,
),
for (final fix in fixes) ...[
const SizedBox(width: 6),
FilledButton.tonalIcon(
onPressed: busy[fix] == true ? null : () => onApplyFix(fix),
icon: busy[fix] == true
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_fix_high, size: 13),
label: Text(
fix.label,
style: _monoTextStyle(size: 11),
),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
minimumSize: const Size(0, 24),
),
),
],
],
),
);
}
}
/// Floating tooltip rendered next to the cursor when it enters
/// a wavy-underlined issue range. Carries the same message +
/// quick-fix actions as the strip, so the operator can fix
/// without leaving the underline. Self-contained MouseRegion
/// cancels the dismiss timer when the pointer slides INTO the
/// tooltip so action buttons stay clickable.
class _IssueHoverCard extends StatelessWidget {
final IssueHoverRequest request;
final FlowEditorStrings strings;
final Future<void> Function(QuickFix) onApplyFix;
final VoidCallback onEnter;
final VoidCallback onExit;
const _IssueHoverCard({
required this.request,
required this.strings,
required this.onApplyFix,
required this.onEnter,
required this.onExit,
});
Color _tone(BuildContext context) {
final theme = Theme.of(context);
return switch (request.severity) {
IssueHoverSeverity.error => theme.colorScheme.error,
IssueHoverSeverity.warning => const Color(0xFFEF6C00),
IssueHoverSeverity.info => theme.colorScheme.primary,
};
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final media = MediaQuery.of(context);
// Anchor below + slightly right of the cursor, but clamp
// to viewport so the card never spills off-screen on a
// narrow window.
const cardWidth = 380.0;
final maxLeft = (media.size.width - cardWidth - 12).clamp(8.0, double.infinity);
final dx = (request.globalPosition.dx + 12).clamp(8.0, maxLeft);
final dy = (request.globalPosition.dy + 18).clamp(
8.0,
media.size.height - 200,
);
final tone = _tone(context);
return Positioned(
left: dx.toDouble(),
top: dy.toDouble(),
child: MouseRegion(
onEnter: (_) => onEnter(),
onExit: (_) => onExit(),
child: Material(
color: Colors.transparent,
child: Container(
width: cardWidth,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 18,
offset: Offset(0, 6),
),
],
),
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 5),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: tone,
shape: BoxShape.circle,
),
),
),
const SizedBox(width: 8),
Text(
strings.diagnosticLinePrefix(request.line + 1),
style: _monoTextStyle(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
Expanded(
child: SelectableText(
request.message,
style: _monoTextStyle(
size: 12,
color: theme.colorScheme.onSurface,
),
),
),
IconButton(
tooltip: strings.runCopy,
icon: const Icon(Icons.content_copy, size: 14),
visualDensity: VisualDensity.compact,
onPressed: () {
Clipboard.setData(
ClipboardData(
text:
'${strings.diagnosticLinePrefix(request.line + 1)}: ${request.message}',
),
);
},
),
],
),
if (request.fixes.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final fix in request.fixes)
FilledButton.tonalIcon(
onPressed: () => onApplyFix(fix),
icon: const Icon(Icons.auto_fix_high, size: 13),
label: Text(
fix.label,
style: _monoTextStyle(size: 11),
),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
minimumSize: const Size(0, 28),
),
),
],
),
],
],
),
),
),
),
);
}
}