A failed/unloaded store snapshot used to be indistinguishable from a known-empty store, so every missing capability was labelled 'not in store' the moment the hub or store endpoint was unreachable — a wrong claim. storeCapabilities is now nullable (null = unknown): missing caps then get the plain missing chip with an honest tooltip, no install offer and no not-in-store claim; the analyzer message says the store cannot be checked right now (EN+DE). Split and badge covered by new unit + widget tests. Signed-off-by: flemming-it <sf@flemming.it>
2579 lines
88 KiB
Dart
2579 lines
88 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_project.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/missing_modules_badge.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/.chain/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 store can actually install, or null when
|
|
/// the store state is UNKNOWN (snapshot not loaded / store
|
|
/// unreachable). Drives the analyzer's quick-fix choice and
|
|
/// the flow list's badge: in store → Install; known-absent →
|
|
/// "not in store" + recovery paths; unknown → neither claim.
|
|
final List<String>? storeCapabilities;
|
|
|
|
/// Host-side native file picker for the Run tab's file inputs.
|
|
/// Studio passes a real file dialog; null keeps the manual
|
|
/// path-entry fallback.
|
|
final PickFileCallback? onPickFile;
|
|
|
|
/// The host's active workspace/project slug (empty = "all
|
|
/// projects", no active workspace). Used only to flag a
|
|
/// mismatch when the open flow file declares a *different*
|
|
/// top-level `project:` — the file always wins for the run;
|
|
/// the mismatch banner just offers to align the workspace view.
|
|
final String activeProject;
|
|
|
|
/// Invoked when the operator accepts the "switch to the file's
|
|
/// project" action on a mismatch. The host switches its active
|
|
/// workspace to the given slug. `null` hides the switch action
|
|
/// (the mismatch note still shows — the file still wins).
|
|
final void Function(String fileProject)? onSwitchToFileProject;
|
|
|
|
/// Host-injected widget rendered in the editor toolbar, before
|
|
/// the New-flow button. Studio places its workspace (project)
|
|
/// switcher here — the editor keeps a single toolbar and stays
|
|
/// host-agnostic.
|
|
final Widget? toolbarTrailing;
|
|
|
|
/// Directory the editor lists/saves flow files in. `null` uses
|
|
/// the hub's default (`~/.chain/data/flows`). Tests inject a
|
|
/// temp dir so they never touch the operator's real flows
|
|
/// (hermetic per shared/TESTING.md).
|
|
final String? flowsDir;
|
|
|
|
const FlowEditorPage({
|
|
super.key,
|
|
this.initialFlowName,
|
|
this.locale = FlowEditorLocale.en,
|
|
this.runDriver,
|
|
this.availableCapabilities = const [],
|
|
this.style,
|
|
this.onInstallCapability,
|
|
this.onAddModuleSource,
|
|
this.storeCapabilities,
|
|
this.activeProject = '',
|
|
this.onSwitchToFileProject,
|
|
this.onPickFile,
|
|
this.toolbarTrailing,
|
|
this.flowsDir,
|
|
});
|
|
|
|
@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;
|
|
|
|
/// Per-file scan cache (example marker + required caps),
|
|
/// keyed by path + mtime. Populated during [_listFiles] so the
|
|
/// file rows never re-read from disk on a rebuild/paint.
|
|
final _FlowMetaCache _flowMetaCache = _FlowMetaCache();
|
|
|
|
/// 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(() {});
|
|
}
|
|
|
|
/// The open flow's own top-level `project:` slug, or empty when
|
|
/// the YAML declares none. Parsed live from the buffer so the
|
|
/// chip tracks edits.
|
|
String _fileProject() {
|
|
if (_controller.activeName == null) return '';
|
|
return parseFlowProject(_controller.codeController.fullText);
|
|
}
|
|
|
|
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 ---
|
|
|
|
String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir();
|
|
|
|
Future<List<_FlowFile>> _listFiles() async {
|
|
final dir = Directory(_flowsDir);
|
|
if (!dir.existsSync()) return <_FlowFile>[];
|
|
final entries = await dir
|
|
.list()
|
|
.where((e) => e is File && e.path.endsWith('.yaml'))
|
|
.cast<File>()
|
|
.toList();
|
|
final files = <_FlowFile>[];
|
|
for (final f in entries) {
|
|
final stat = f.statSync();
|
|
// Per-file scan (example marker + required caps) is cached
|
|
// by path + mtime so a refresh that didn't touch a file
|
|
// doesn't re-read it, and a paint never re-reads at all.
|
|
final meta = await _flowMetaCache.forFile(f, stat);
|
|
files.add(
|
|
_FlowFile(
|
|
name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
|
|
path: f.path,
|
|
sizeBytes: stat.size,
|
|
meta: meta,
|
|
),
|
|
);
|
|
}
|
|
files.sort((a, b) => a.name.compareTo(b.name));
|
|
return files;
|
|
}
|
|
|
|
/// Row-level start: open the flow and land directly on the Run
|
|
/// tab, where inputs + the start button live. One click from the
|
|
/// list to running a flow.
|
|
Future<void> _startFile(_FlowFile f) async {
|
|
await _openFile(f);
|
|
if (!mounted) return;
|
|
if (_controller.analyzerErrorCount == 0) {
|
|
_tabs.animateTo(2);
|
|
}
|
|
}
|
|
|
|
Future<void> _openByName(String name) async {
|
|
final path = '$_flowsDir/$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('$_flowsDir/$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;
|
|
// Stamp the active workspace project into the new file so the
|
|
// flow stays visible under the filter it was created in. The
|
|
// file wins from here on; `general` (and "all projects") stay
|
|
// unstamped — no key already means general.
|
|
final project = widget.activeProject;
|
|
final projectLine =
|
|
project.isEmpty || project == 'general' ? '' : 'project: $project\n';
|
|
final template =
|
|
'''# ${_l.newTemplateComment(name)}
|
|
|
|
name: $name
|
|
$projectLine
|
|
inputs:
|
|
text:
|
|
type: text
|
|
|
|
steps:
|
|
- id: echo
|
|
use: debug.echo@^0
|
|
with:
|
|
message: \$inputs.text
|
|
|
|
outputs:
|
|
result: \$echo.echoed
|
|
''';
|
|
try {
|
|
final dir = Directory(_flowsDir);
|
|
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());
|
|
}
|
|
|
|
/// Install the capabilities a flow row flagged as missing,
|
|
/// reusing the host's existing [FlowEditorPage.onInstallCapability]
|
|
/// path — no new install mechanism. Installs sequentially,
|
|
/// keeps the latest capability list, refreshes the analyzer
|
|
/// providers, and re-scans the file list so the row's badge
|
|
/// updates. Returns once all installs settle.
|
|
Future<void> _installMissingCaps(List<String> caps) async {
|
|
final handler = widget.onInstallCapability;
|
|
if (handler == null || caps.isEmpty) return;
|
|
List<String>? latest;
|
|
for (final cap in caps) {
|
|
try {
|
|
final result = await handler(cap);
|
|
if (result != null) latest = result;
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(e.toString())));
|
|
}
|
|
}
|
|
if (!mounted) return;
|
|
if (latest != null) {
|
|
_controller.codeController.setCapabilityProviders(
|
|
available: () => latest!,
|
|
store: () => widget.storeCapabilities,
|
|
strings: AnalyzerStrings.from(_l),
|
|
);
|
|
await _controller.codeController.reanalyze();
|
|
if (!mounted) return;
|
|
}
|
|
await _refreshFiles();
|
|
}
|
|
|
|
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,
|
|
trailing: widget.toolbarTrailing,
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SizedBox(
|
|
width: 240,
|
|
child: _FileList(
|
|
filesFuture: _files,
|
|
activeName: _controller.activeName,
|
|
strings: _l,
|
|
installedNames: _installedNames(
|
|
widget.availableCapabilities,
|
|
),
|
|
storeNames: widget.storeCapabilities == null
|
|
? null
|
|
: _installedNames(widget.storeCapabilities!),
|
|
activeProject: widget.activeProject,
|
|
onOpen: _openFile,
|
|
onRefresh: _refreshFiles,
|
|
onStart: _startFile,
|
|
onInstallMissing: widget.onInstallCapability == null
|
|
? null
|
|
: _installMissingCaps,
|
|
),
|
|
),
|
|
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,
|
|
fileProject: _fileProject(),
|
|
activeProject: widget.activeProject,
|
|
onSwitchToFileProject: widget.onSwitchToFileProject,
|
|
),
|
|
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,
|
|
onPickFile: widget.onPickFile,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// 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;
|
|
|
|
/// Host-injected widget rendered before the New-flow button —
|
|
/// Studio places its workspace (project) switcher here so the
|
|
/// editor keeps a single toolbar and stays host-agnostic.
|
|
final Widget? trailing;
|
|
const _Toolbar({
|
|
required this.strings,
|
|
required this.activeName,
|
|
required this.dirty,
|
|
required this.onBack,
|
|
required this.onNew,
|
|
this.trailing,
|
|
});
|
|
|
|
@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),
|
|
],
|
|
// With no flow open the toolbar acts as the page header, so
|
|
// it says what the page IS ("Flows"), not what it lacks —
|
|
// the state sentence stays in the centered empty state.
|
|
Text(
|
|
activeName == null ? strings.pageTitle : '${activeName!}.yaml',
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontFamily: activeName == null ? null : '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(),
|
|
if (trailing != null) ...[
|
|
trailing!,
|
|
const SizedBox(width: FaiSpace.md),
|
|
],
|
|
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;
|
|
|
|
/// The open file's own `project:` (empty when it declares none).
|
|
final String fileProject;
|
|
|
|
/// The host's active workspace slug (empty = all projects).
|
|
final String activeProject;
|
|
final void Function(String fileProject)? onSwitchToFileProject;
|
|
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,
|
|
required this.fileProject,
|
|
required this.activeProject,
|
|
required this.onSwitchToFileProject,
|
|
});
|
|
|
|
@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(),
|
|
..._projectChip(context, theme),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// The right-aligned project affordance. Nothing when the file
|
|
/// declares no `project:`. A neutral chip when the file's project
|
|
/// matches (or there is no active workspace). A mismatch pill with
|
|
/// a one-click switch when the file's project differs from the
|
|
/// active workspace — the file always wins for the run; switching
|
|
/// only aligns the workspace view.
|
|
List<Widget> _projectChip(BuildContext context, ThemeData theme) {
|
|
if (fileProject.isEmpty) return const [];
|
|
final mismatch = activeProject.isNotEmpty && activeProject != fileProject;
|
|
if (!mismatch) {
|
|
return [
|
|
Tooltip(
|
|
message: strings.projectChipTooltip,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.folder_outlined,
|
|
size: 13,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
strings.projectChip(fileProject),
|
|
style: theme.textTheme.labelSmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
// Mismatch: file wins. Amber pill + optional switch action.
|
|
final amber = theme.colorScheme.brightness == Brightness.dark
|
|
? const Color(0xFFE0A458)
|
|
: const Color(0xFFB4791F);
|
|
return [
|
|
Tooltip(
|
|
message: strings.projectMismatch(fileProject),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: amber.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
border: Border.all(color: amber.withValues(alpha: 0.4)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.info_outline, size: 13, color: amber),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
strings.projectChip(fileProject),
|
|
style: theme.textTheme.labelSmall?.copyWith(color: amber),
|
|
),
|
|
if (onSwitchToFileProject != null) ...[
|
|
const SizedBox(width: 8),
|
|
InkWell(
|
|
onTap: () => onSwitchToFileProject!(fileProject),
|
|
child: Text(
|
|
strings.projectMismatchSwitch(fileProject),
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
// --- file list ---
|
|
|
|
class _FlowFile {
|
|
final String name;
|
|
final String path;
|
|
final int sizeBytes;
|
|
|
|
/// Scan result for this file — whether it's a bundled example
|
|
/// and which capabilities its steps require. Computed once at
|
|
/// list-load time (see [_FlowMetaCache]).
|
|
final _FlowMeta meta;
|
|
|
|
const _FlowFile({
|
|
required this.name,
|
|
required this.path,
|
|
required this.sizeBytes,
|
|
required this.meta,
|
|
});
|
|
}
|
|
|
|
/// Marker text every bundled sample flow carries in its
|
|
/// provenance comment header. A file is an example iff its raw
|
|
/// content contains this exact string.
|
|
const String _sampleFlowMarker = 'F∆I sample flow';
|
|
|
|
/// Result of scanning a single flow file: provenance + the
|
|
/// capability NAMES (without `@version`) its steps reference.
|
|
class _FlowMeta {
|
|
final bool isExample;
|
|
final List<String> requiredCaps;
|
|
|
|
/// The file's own normalized `project:` slug; empty when the
|
|
/// YAML declares none (which counts as `general` for the list
|
|
/// filter — display semantics only, the file is never rewritten).
|
|
final String project;
|
|
const _FlowMeta({
|
|
required this.isExample,
|
|
required this.requiredCaps,
|
|
this.project = '',
|
|
});
|
|
|
|
static const empty = _FlowMeta(isExample: false, requiredCaps: []);
|
|
|
|
/// Capabilities this flow needs that the hub does not provide.
|
|
/// [availableNames] is the set of capability NAMES (the part
|
|
/// before `@`) from the host's live capability list — which
|
|
/// already includes the hub's builtins (e.g. system.approval),
|
|
/// so there is no client-side builtin list to drift out of
|
|
/// sync with the hub (the old hardcoded {'debug.echo'} made
|
|
/// the bundled hello flow look runnable on hubs that don't
|
|
/// have the module).
|
|
List<String> missingCaps(Set<String> availableNames) =>
|
|
requiredCaps.where((c) => !availableNames.contains(c)).toList();
|
|
}
|
|
|
|
/// Caches [_FlowMeta] per file, keyed by path + mtime. Reading
|
|
/// and scanning a flow's YAML happens here, only when the file
|
|
/// is new or changed since the last list load — never on a
|
|
/// paint.
|
|
class _FlowMetaCache {
|
|
final Map<String, ({DateTime mtime, _FlowMeta meta})> _byPath = {};
|
|
|
|
Future<_FlowMeta> forFile(File f, FileStat stat) async {
|
|
final cached = _byPath[f.path];
|
|
if (cached != null && cached.mtime == stat.modified) {
|
|
return cached.meta;
|
|
}
|
|
_FlowMeta meta;
|
|
try {
|
|
final text = await f.readAsString();
|
|
meta = _scanFlow(text);
|
|
} catch (_) {
|
|
meta = _FlowMeta.empty;
|
|
}
|
|
_byPath[f.path] = (mtime: stat.modified, meta: meta);
|
|
return meta;
|
|
}
|
|
}
|
|
|
|
/// Scan raw flow YAML for its example marker and the capability
|
|
/// ids referenced by `use:` lines. A line scan is used rather
|
|
/// than a full YAML parse: it's robust against malformed flows
|
|
/// (the analyzer reports those separately) and never throws.
|
|
_FlowMeta _scanFlow(String text) {
|
|
final isExample = text.contains(_sampleFlowMarker);
|
|
final caps = <String>{};
|
|
final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$');
|
|
for (final raw in text.split('\n')) {
|
|
// Skip comment-only lines.
|
|
final line = raw.split('#').first;
|
|
final m = useRe.firstMatch(line);
|
|
if (m == null) continue;
|
|
var value = m.group(1)!.trim();
|
|
// Strip surrounding quotes if present.
|
|
if (value.length >= 2 &&
|
|
((value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'")))) {
|
|
value = value.substring(1, value.length - 1);
|
|
}
|
|
// Drop the @<version-range> suffix, keep the capability name.
|
|
final name = value.split('@').first.trim();
|
|
if (name.isNotEmpty) caps.add(name);
|
|
}
|
|
return _FlowMeta(
|
|
isExample: isExample,
|
|
requiredCaps: caps.toList(),
|
|
project: parseFlowProject(text),
|
|
);
|
|
}
|
|
|
|
/// Reduce the host-supplied installed list (entries like
|
|
/// `text.extract@0.1.0`) to the set of bare capability NAMES so
|
|
/// satisfaction is checked name-first, version-agnostic.
|
|
Set<String> _installedNames(List<String> available) =>
|
|
available.map((c) => c.split('@').first).toSet();
|
|
|
|
/// Compact byte-count formatter used in the flow-list rows.
|
|
/// '12 B' / '4.3 KB' / '1.2 MB'. Avoids overflowing the
|
|
/// narrow sidebar with raw byte counts on long flows.
|
|
String _formatBytes(int bytes) {
|
|
if (bytes < 1024) return '$bytes B';
|
|
if (bytes < 1024 * 1024) {
|
|
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
}
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
}
|
|
|
|
class _FileList extends StatefulWidget {
|
|
final Future<List<_FlowFile>> filesFuture;
|
|
final String? activeName;
|
|
final FlowEditorStrings strings;
|
|
|
|
/// Bare capability NAMES the host reports as installed. Used
|
|
/// to compute each row's missing-module count.
|
|
final Set<String> installedNames;
|
|
|
|
/// Bare capability NAMES a configured store can install
|
|
/// (host-filtered to installable entries), or null when the
|
|
/// store state is unknown. Missing caps outside this set
|
|
/// render the "not in store" state instead of an install
|
|
/// action that the hub would refuse; with null neither claim
|
|
/// is made.
|
|
final Set<String>? storeNames;
|
|
|
|
/// Active workspace project slug; empty = all projects. Files
|
|
/// without a `project:` key count as `general`.
|
|
final String activeProject;
|
|
final void Function(_FlowFile) onOpen;
|
|
final VoidCallback onRefresh;
|
|
|
|
/// Open the flow directly on the Run tab — the per-row start
|
|
/// affordance the usertest asked for. Rows with missing modules
|
|
/// don't offer it (they can't run yet).
|
|
final void Function(_FlowFile) onStart;
|
|
|
|
/// Install the listed missing capabilities. `null` when the
|
|
/// host wired no install handler — the warning badge still
|
|
/// renders, just without the one-click action.
|
|
final Future<void> Function(List<String>)? onInstallMissing;
|
|
|
|
const _FileList({
|
|
required this.filesFuture,
|
|
required this.activeName,
|
|
required this.strings,
|
|
required this.installedNames,
|
|
required this.storeNames,
|
|
required this.activeProject,
|
|
required this.onOpen,
|
|
required this.onRefresh,
|
|
required this.onStart,
|
|
required this.onInstallMissing,
|
|
});
|
|
|
|
@override
|
|
State<_FileList> createState() => _FileListState();
|
|
}
|
|
|
|
class _FileListState extends State<_FileList> {
|
|
/// Case-insensitive substring filter over flow names. First
|
|
/// iteration of "find a flow fast" (usertest power-user
|
|
/// finding: no search over the flow list at all).
|
|
String _filter = '';
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final strings = widget.strings;
|
|
return Container(
|
|
color: theme.colorScheme.surface,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// No ALL-CAPS panel header: the page toolbar already says
|
|
// "Flows" — the duplicate label read as a broken title
|
|
// hierarchy (usertest art-director finding). The refresh
|
|
// action sits next to the filter instead.
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
FaiSpace.md,
|
|
FaiSpace.xs,
|
|
FaiSpace.md,
|
|
FaiSpace.xs,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: SizedBox(
|
|
height: 28,
|
|
child: TextField(
|
|
onChanged: (v) => setState(() => _filter = v.trim()),
|
|
style: theme.textTheme.bodySmall,
|
|
decoration: InputDecoration(
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
prefixIcon: const Icon(Icons.search, size: 14),
|
|
prefixIconConstraints: const BoxConstraints(
|
|
minWidth: 28,
|
|
minHeight: 28,
|
|
),
|
|
hintText: strings.listFilterHint,
|
|
hintStyle: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
borderSide: BorderSide(
|
|
color: theme.colorScheme.outlineVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: widget.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) {
|
|
final strings = widget.strings;
|
|
return FutureBuilder<List<_FlowFile>>(
|
|
future: widget.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: ChainErrorBox(error: snap.error, isError: true),
|
|
);
|
|
}
|
|
final all = snap.data ?? <_FlowFile>[];
|
|
if (all.isEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(FaiSpace.md),
|
|
child: FaiEmptyState(
|
|
icon: Icons.folder_outlined,
|
|
title: strings.listEmptyTitle,
|
|
hint: strings.listEmptyBody,
|
|
),
|
|
);
|
|
}
|
|
// Workspace filter first: files without a `project:` key
|
|
// count as `general` (display semantics — the file is the
|
|
// truth and never rewritten).
|
|
final inProject = all
|
|
.where(
|
|
(f) => flowVisibleInProject(
|
|
f.meta.project,
|
|
widget.activeProject,
|
|
),
|
|
)
|
|
.toList();
|
|
if (inProject.isEmpty) {
|
|
// Flows exist, just none in this project — say that and
|
|
// point at the way out (switch to all projects).
|
|
return Padding(
|
|
padding: const EdgeInsets.all(FaiSpace.md),
|
|
child: FaiEmptyState(
|
|
icon: Icons.folder_off_outlined,
|
|
title: strings.listProjectEmpty(widget.activeProject),
|
|
hint: strings.listProjectEmptyHint,
|
|
),
|
|
);
|
|
}
|
|
final needle = _filter.toLowerCase();
|
|
final files = needle.isEmpty
|
|
? inProject
|
|
: inProject
|
|
.where((f) => f.name.toLowerCase().contains(needle))
|
|
.toList();
|
|
if (files.isEmpty) {
|
|
// Flows exist, the filter just matches none — say that
|
|
// instead of pretending the directory is empty.
|
|
return Padding(
|
|
padding: const EdgeInsets.all(FaiSpace.md),
|
|
child: FaiEmptyState(
|
|
icon: Icons.search_off_outlined,
|
|
title: strings.listFilterNoMatch(_filter),
|
|
hint: null,
|
|
),
|
|
);
|
|
}
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
|
itemCount: files.length,
|
|
itemBuilder: (_, i) {
|
|
final f = files[i];
|
|
final isActive = f.name == widget.activeName;
|
|
final missing = f.meta.missingCaps(widget.installedNames);
|
|
final split = splitMissingCaps(missing, widget.storeNames);
|
|
return InkWell(
|
|
onTap: () => widget.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: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
// Subtle leading icon that distinguishes a
|
|
// flow file from any other row in the
|
|
// sidebar. Active row gets the accent
|
|
// colour, others stay muted so the active
|
|
// selection reads at a glance.
|
|
Container(
|
|
width: 28,
|
|
height: 28,
|
|
margin: const EdgeInsets.only(right: FaiSpace.sm),
|
|
decoration: BoxDecoration(
|
|
color: isActive
|
|
? theme.colorScheme.primary.withValues(alpha: 0.15)
|
|
: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
),
|
|
child: Icon(
|
|
Icons.account_tree_outlined,
|
|
size: 16,
|
|
color: isActive
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
f.name,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: isActive
|
|
? FontWeight.w600
|
|
: FontWeight.w500,
|
|
color: isActive
|
|
? theme.colorScheme.onSecondaryContainer
|
|
: null,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 1),
|
|
Text(
|
|
_formatBytes(f.sizeBytes),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
if (f.meta.isExample || missing.isNotEmpty) ...[
|
|
const SizedBox(height: FaiSpace.xs),
|
|
Wrap(
|
|
spacing: FaiSpace.xs,
|
|
runSpacing: FaiSpace.xs,
|
|
children: [
|
|
if (f.meta.isExample)
|
|
_ExampleBadge(strings: strings),
|
|
if (missing.isNotEmpty)
|
|
MissingModulesBadge(
|
|
installable: split.installable,
|
|
notInStore: split.notInStore,
|
|
unclassified: split.unclassified,
|
|
strings: strings,
|
|
// Install only what the store
|
|
// resolves — the not-in-store
|
|
// chip explains the rest.
|
|
onInstall:
|
|
widget.onInstallMissing == null ||
|
|
split.installable.isEmpty
|
|
? null
|
|
: () => widget.onInstallMissing!(
|
|
split.installable,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
// Per-row start: one click from the list to the
|
|
// Run tab. Hidden while modules are missing —
|
|
// the install badge is the actionable path then.
|
|
if (missing.isEmpty)
|
|
IconButton(
|
|
onPressed: () => widget.onStart(f),
|
|
tooltip: strings.listStartTooltip,
|
|
icon: Icon(
|
|
Icons.play_arrow_rounded,
|
|
size: 20,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(
|
|
minWidth: 32,
|
|
minHeight: 32,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Quiet "Example" / "Beispiel" chip on bundled sample flows.
|
|
/// Tinted from the surface palette so it reads as metadata, not
|
|
/// an alert — stays subtle in both light and dark themes.
|
|
class _ExampleBadge extends StatelessWidget {
|
|
final FlowEditorStrings strings;
|
|
const _ExampleBadge({required this.strings});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final fg = theme.colorScheme.onSurfaceVariant;
|
|
return Tooltip(
|
|
message: strings.flowListExampleTooltip,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
border: Border.all(
|
|
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.auto_awesome_outlined, size: 10, color: fg),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
strings.flowListExampleBadge,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: fg,
|
|
fontSize: 10,
|
|
letterSpacing: 0.2,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- 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));
|
|
}
|
|
}
|
|
|
|
/// Opens the "Fix flow issues" modal. Each issue gets a row
|
|
/// with its quick fixes; rows without a fix surface the
|
|
/// no-fix hint so the operator knows to edit YAML manually.
|
|
/// A master "apply all" runs every available fix sequentially,
|
|
/// pausing on first failure.
|
|
Future<void> _showFixDialog(List<Issue> issues, ThemeData theme) async {
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (_) => _FixDialog(
|
|
issues: issues,
|
|
controller: widget.controller,
|
|
strings: widget.strings,
|
|
toneForIssue: _toneForIssue,
|
|
onApplyFix: _runFix,
|
|
),
|
|
);
|
|
}
|
|
|
|
@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,
|
|
),
|
|
),
|
|
),
|
|
// "Beheben" — opens a modal enumerating each
|
|
// issue with its fixes plus an "apply all"
|
|
// master button. Only shows when at least one
|
|
// issue carries a quick fix; otherwise the
|
|
// strip stays minimal.
|
|
if (issues.any(
|
|
(i) => widget.controller.fixesFor(i).isNotEmpty,
|
|
))
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: FilledButton.tonalIcon(
|
|
onPressed: () => _showFixDialog(issues, theme),
|
|
icon: const Icon(Icons.auto_fix_high, size: 14),
|
|
label: Text(
|
|
widget.strings.diagnosticFixButton,
|
|
style: _monoTextStyle(size: 11),
|
|
),
|
|
style: FilledButton.styleFrom(
|
|
visualDensity: VisualDensity.compact,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 4,
|
|
),
|
|
minimumSize: const Size(0, 28),
|
|
),
|
|
),
|
|
),
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// "Fix flow issues" modal — opens from the diagnostic strip's
|
|
/// header "Beheben" button. Lists each issue with its
|
|
/// available quick fixes and an "apply every quick fix"
|
|
/// master action. Replaces the inline expand-strip workflow
|
|
/// for operators who prefer a deliberate dialog over scrolling
|
|
/// in a 200-px-tall footer.
|
|
class _FixDialog extends StatefulWidget {
|
|
final List<Issue> issues;
|
|
final FlowYamlCodeController controller;
|
|
final FlowEditorStrings strings;
|
|
final Color Function(IssueType, ThemeData) toneForIssue;
|
|
final Future<void> Function(QuickFix) onApplyFix;
|
|
|
|
const _FixDialog({
|
|
required this.issues,
|
|
required this.controller,
|
|
required this.strings,
|
|
required this.toneForIssue,
|
|
required this.onApplyFix,
|
|
});
|
|
|
|
@override
|
|
State<_FixDialog> createState() => _FixDialogState();
|
|
}
|
|
|
|
class _FixDialogState extends State<_FixDialog> {
|
|
bool _applyingAll = false;
|
|
|
|
Future<void> _applyAll() async {
|
|
setState(() => _applyingAll = true);
|
|
try {
|
|
for (final issue in widget.issues) {
|
|
final fixes = widget.controller.fixesFor(issue);
|
|
if (fixes.isEmpty) continue;
|
|
// Pick the first fix per issue — the analyzer orders
|
|
// Replace-fixes before Install/AddSource, so a typo
|
|
// suggestion wins over a remote install round-trip.
|
|
await widget.onApplyFix(fixes.first);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _applyingAll = false);
|
|
}
|
|
if (mounted) Navigator.of(context).pop();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final strings = widget.strings;
|
|
final fixCount = widget.issues
|
|
.where((i) => widget.controller.fixesFor(i).isNotEmpty)
|
|
.length;
|
|
return AlertDialog(
|
|
title: Row(
|
|
children: [
|
|
Icon(Icons.auto_fix_high, size: 20, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 8),
|
|
Text(strings.diagnosticFixDialogTitle),
|
|
],
|
|
),
|
|
content: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 480),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
strings.diagnosticFixDialogBody(widget.issues.length),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
for (final issue in widget.issues)
|
|
_FixDialogRow(
|
|
issue: issue,
|
|
fixes: widget.controller.fixesFor(issue),
|
|
tone: widget.toneForIssue(issue.type, theme),
|
|
strings: strings,
|
|
onApplyFix: (fix) async {
|
|
await widget.onApplyFix(fix);
|
|
if (mounted) setState(() {});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
if (fixCount > 1)
|
|
FilledButton.tonalIcon(
|
|
onPressed: _applyingAll ? null : _applyAll,
|
|
icon: _applyingAll
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.auto_fix_high, size: 16),
|
|
label: Text(strings.diagnosticFixApplyAll(fixCount)),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(strings.diagnosticFixDialogClose),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FixDialogRow extends StatelessWidget {
|
|
final Issue issue;
|
|
final List<QuickFix> fixes;
|
|
final Color tone;
|
|
final FlowEditorStrings strings;
|
|
final Future<void> Function(QuickFix) onApplyFix;
|
|
|
|
const _FixDialogRow({
|
|
required this.issue,
|
|
required this.fixes,
|
|
required this.tone,
|
|
required this.strings,
|
|
required this.onApplyFix,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border(left: BorderSide(color: tone, width: 3)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
strings.diagnosticLinePrefix(issue.line + 1),
|
|
style: _monoTextStyle(
|
|
size: 11,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: SelectableText(
|
|
issue.message,
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (fixes.isEmpty)
|
|
Text(
|
|
strings.diagnosticNoFixesAvailable,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
fontStyle: FontStyle.italic,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
)
|
|
else
|
|
Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: [
|
|
for (final fix in fixes)
|
|
FilledButton.tonalIcon(
|
|
onPressed: () => onApplyFix(fix),
|
|
icon: const Icon(Icons.auto_fix_high, size: 13),
|
|
label: Text(fix.label, style: _monoTextStyle(size: 11)),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|