// 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 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 " 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 …" 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 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 createState() => _FlowEditorPageState(); } class _FlowEditorPageState extends State with TickerProviderStateMixin { late final FlowEditorController _controller; late final FlowEditorStrings _l; late Future> _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 _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> _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() .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 _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 _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 _confirmDiscard() { return showDialog( 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 _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 _newFlow() async { final name = await showDialog( 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 _refreshFiles() async { setState(() => _files = _listFiles()); } Future _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: { 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: >{ _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 _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> 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>( 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 " 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 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 _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 issues) { final buf = StringBuffer(); for (final i in issues) { buf.writeln( '${widget.strings.diagnosticLinePrefix(i.line + 1)}: ${i.message}', ); } return buf.toString().trimRight(); } Future _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 = []; 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 fixes; final Color tone; final FlowEditorStrings strings; final Map busy; final Future 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 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), ), ), ], ), ], ], ), ), ), ), ); } }