diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cfbe52..ccc5edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,74 @@ # Changelog +## 0.26.0 + +Sample flows follow the hub's wire flag (sealed-areas rework): + +- **`FlowEditorPage.sampleFlowNames`** — the host passes the set of + flow names the hub reports as bundled samples + (`FlowSummary.sample`). This is now the ONLY source of the + "Example" chip; the editor's own content-marker scan is gone (it + had silently drifted from the hub's renamed sample header and + missed every current sample). Null = no host info = no chips. +- **Examples group.** Sample flows collapse under one + "Examples (N)" row below the operator's own flows, expanded on + demand — or by default when there is nothing else to show. +- **`FlowEditorPage.onImportSamples`** — optional empty-list action + ("Import example flows"); sealed areas start without samples, so + hosts wire this as the deliberate pull. The list refreshes itself + after the import. +- `FaiEmptyState` gained an optional `action` slot. +- The editor reloads its file list when the host changes `flowsDir` + or delivers the sample set after an async fetch. + +## 0.25.0 + +Honest install badge — the flow list and analyzer only offer +"Install" for capabilities a configured store can actually resolve: + +- **Store-resolvability split in the flow list.** Missing + capabilities are classified BEFORE any click: store-resolvable + ones keep the amber chip + Install link (which now installs only + those), the rest render a quiet "not in store" chip whose tooltip + explains the three recovery paths (local install via + `chain install --link`, adding the providing store, configuring + the MCP/n8n integration). Previously the Install action covered + every missing capability and could end in the hub's + "no store entry for ''" error. +- New host contract: `_FileList` consumes the host's + `storeCapabilities` (already exposed on `FlowEditorPage`) for the + split; hosts should pass only capabilities the store can install + (installable status, native kind). +- **Analyzer not-in-store message** now names all three recovery + paths instead of only the local-install hint (EN + DE). + +Project separation in the flow list: + +- **Workspace filter.** The file list filters by the host's + `activeProject`; flows without a `project:` key count as + `general` (display semantics — the file stays the truth and is + never rewritten). A project with no flows gets its own honest + empty state naming the project and the way out. +- **New flows are stamped** with the active project's `project:` + key (`general` and "all projects" stay unstamped — no key + already means general). +- **`toolbarTrailing` slot** on `FlowEditorPage`: the host can + place its workspace switcher in the editor's single toolbar. +- **`flowsDir` injection** on `FlowEditorPage`: tests (and other + hosts) point the editor at any directory instead of the + hard-wired `~/.chain/data/flows` — the new widget tests run + against a temp dir, never the operator's flows. + +Disturbance hardening: + +- **Unknown ≠ absent.** `storeCapabilities` is nullable now + (null = store state unknown): missing capabilities then render + the plain missing chip with an honest tooltip — no install + offer, no "not in store" claim — and the analyzer message says + the store cannot be checked right now (EN + DE). Previously an + unreachable store made every missing capability claim + "not in store". + ## 0.24.1 - No ALL-CAPS "FLOWS" panel header: the page toolbar already names diff --git a/lib/src/flow_analyzer.dart b/lib/src/flow_analyzer.dart index 05dcd6e..30ca706 100644 --- a/lib/src/flow_analyzer.dart +++ b/lib/src/flow_analyzer.dart @@ -38,13 +38,14 @@ class FlowAnalyzer extends AbstractAnalyzer { /// rebuild. final List Function() availableCapabilities; - /// Returns the names of capabilities the public store knows - /// how to install. Used to decide whether an unknown-cap - /// issue should carry an Install button — clicking the - /// button on a capability the hub can't actually fetch would - /// just fail. Null = "no store available" → install offered - /// for every unknown cap (legacy behaviour). - final List Function()? storeCapabilities; + /// Returns the names of capabilities the store can actually + /// install, or null when the store state is UNKNOWN (snapshot + /// not loaded / store unreachable). Drives whether an + /// unknown-cap issue carries an Install button (in store), the + /// "not in store" recovery message (known, absent) or the + /// neutral store-unknown wording (null) — the analyzer never + /// claims "no store provides it" without a loaded snapshot. + final List? Function()? storeCapabilities; /// Quick fixes attached to the most-recent analyze() pass. /// Keyed by the same `Issue` instances that landed in @@ -111,9 +112,10 @@ class FlowAnalyzer extends AbstractAnalyzer { // as Did-you-mean candidates so the suggestion can preserve // the version constraint when the user already typed one. final installedFull = caps.toSet(); - final storeCaps = - storeCapabilities?.call() ?? const []; - final storeBare = storeCaps.map(_bareCap).toSet(); + final storeCaps = storeCapabilities?.call(); + final storeKnown = storeCaps != null; + final storeBare = + (storeCaps ?? const []).map(_bareCap).toSet(); YamlNode? doc; try { @@ -175,7 +177,9 @@ class FlowAnalyzer extends AbstractAnalyzer { ? strings.unknownCapInStore(useValue) : didYouMean != null ? strings.unknownCapTypo(useValue, didYouMean) - : strings.unknownCapNotInStore(useValue); + : storeKnown + ? strings.unknownCapNotInStore(useValue) + : strings.unknownCapStoreUnknown(useValue); final issue = Issue( line: issueLine, message: message, diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index a278e78..6363851 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -25,6 +25,7 @@ library; import 'dart:io'; import 'dart:ui'; +import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart'; @@ -41,6 +42,7 @@ 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'; @@ -94,12 +96,12 @@ class FlowEditorPage extends StatefulWidget { /// 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; + /// 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? storeCapabilities; /// Host-side native file picker for the Run tab's file inputs. /// Studio passes a real file dialog; null keeps the manual @@ -119,6 +121,32 @@ class FlowEditorPage extends StatefulWidget { /// (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; + + /// Names of the flows the HOST reports as bundled samples (the + /// hub's `FlowSummary.sample` wire flag). Null = the host has no + /// sample information (old hub / standalone use) — then no + /// example chips render at all. The hub's sample header is the + /// single source of truth; the editor no longer keeps its own + /// marker scan (it had already drifted from the hub's header). + final Set? sampleFlowNames; + + /// Import the bundled sample flows into the current hub. + /// Rendered as the action of the empty list state — sealed + /// areas start without examples, this is the deliberate pull. + /// Null hides the action. + final Future Function()? onImportSamples; + const FlowEditorPage({ super.key, this.initialFlowName, @@ -128,10 +156,14 @@ class FlowEditorPage extends StatefulWidget { this.style, this.onInstallCapability, this.onAddModuleSource, - this.storeCapabilities = const [], + this.storeCapabilities, this.activeProject = '', this.onSwitchToFileProject, this.onPickFile, + this.toolbarTrailing, + this.flowsDir, + this.sampleFlowNames, + this.onImportSamples, }); @override @@ -178,6 +210,21 @@ class _FlowEditorPageState extends State } } + @override + void didUpdateWidget(covariant FlowEditorPage old) { + super.didUpdateWidget(old); + // The host may repoint the directory (connection switch) or + // deliver the hub's sample set after an async fetch — both + // change what the list must show. + if (widget.flowsDir != old.flowsDir || + !setEquals(widget.sampleFlowNames, old.sampleFlowNames)) { + final fresh = _listFiles(); + setState(() { + _files = fresh; + }); + } + } + @override void dispose() { _controller.codeController.hoverRequest.removeListener(_onHoverChanged); @@ -292,8 +339,10 @@ class _FlowEditorPageState extends State // --- file ops --- + String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir(); + Future> _listFiles() async { - final dir = Directory(_defaultFlowsDir()); + final dir = Directory(_flowsDir); if (!dir.existsSync()) return <_FlowFile>[]; final entries = await dir .list() @@ -307,11 +356,13 @@ class _FlowEditorPageState extends State // 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); + final name = f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''); files.add( _FlowFile( - name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''), + name: name, path: f.path, sizeBytes: stat.size, + isExample: widget.sampleFlowNames?.contains(name) ?? false, meta: meta, ), ); @@ -332,7 +383,7 @@ class _FlowEditorPageState extends State } Future _openByName(String name) async { - final path = '${_defaultFlowsDir()}/$name.yaml'; + final path = '$_flowsDir/$name.yaml'; final file = File(path); if (!file.existsSync()) return; final text = await file.readAsString(); @@ -375,7 +426,7 @@ class _FlowEditorPageState extends State if (name == null) return; _controller.saving = true; try { - final file = File('${_defaultFlowsDir()}/$name.yaml'); + final file = File('$_flowsDir/$name.yaml'); await file.writeAsString( _controller.codeController.fullText, flush: true, @@ -419,11 +470,18 @@ class _FlowEditorPageState extends State 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 @@ -438,7 +496,7 @@ outputs: result: \$echo.echoed '''; try { - final dir = Directory(_defaultFlowsDir()); + final dir = Directory(_flowsDir); if (!dir.existsSync()) await dir.create(recursive: true); final file = File('${dir.path}/$name.yaml'); if (file.existsSync()) { @@ -576,6 +634,7 @@ outputs: ? () => Navigator.of(context).maybePop() : null, onNew: _newFlow, + trailing: widget.toolbarTrailing, ), const Divider(height: 1), Expanded( @@ -588,9 +647,24 @@ outputs: filesFuture: _files, activeName: _controller.activeName, strings: _l, + onImportSamples: widget.onImportSamples == null + ? null + : () async { + await widget.onImportSamples!(); + if (mounted) { + final fresh = _listFiles(); + setState(() { + _files = fresh; + }); + } + }, installedNames: _installedNames( widget.availableCapabilities, ), + storeNames: widget.storeCapabilities == null + ? null + : _installedNames(widget.storeCapabilities!), + activeProject: widget.activeProject, onOpen: _openFile, onRefresh: _refreshFiles, onStart: _startFile, @@ -944,12 +1018,18 @@ class _Toolbar extends StatelessWidget { 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 @@ -1018,6 +1098,10 @@ class _Toolbar extends StatelessWidget { ), ], const Spacer(), + if (trailing != null) ...[ + trailing!, + const SizedBox(width: FaiSpace.md), + ], FilledButton.tonalIcon( onPressed: onNew, icon: const Icon(Icons.add, size: 16), @@ -1232,32 +1316,39 @@ class _FlowFile { 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]). + /// True when the HOST reports this flow as a bundled sample + /// (hub wire flag) — never guessed from the file content. + final bool isExample; + + /// Scan result for this file — 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.isExample, 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. +/// Result of scanning a single flow file: the capability NAMES +/// (without `@version`) its steps reference. class _FlowMeta { - final bool isExample; final List requiredCaps; - const _FlowMeta({required this.isExample, required this.requiredCaps}); - static const empty = _FlowMeta(isExample: false, 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.requiredCaps, + this.project = '', + }); + + static const empty = _FlowMeta(requiredCaps: []); /// Capabilities this flow needs that the hub does not provide. /// [availableNames] is the set of capability NAMES (the part @@ -1295,12 +1386,11 @@ class _FlowMetaCache { } } -/// 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. +/// Scan raw flow YAML for 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 = {}; final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$'); for (final raw in text.split('\n')) { @@ -1319,7 +1409,10 @@ _FlowMeta _scanFlow(String text) { final name = value.split('@').first.trim(); if (name.isNotEmpty) caps.add(name); } - return _FlowMeta(isExample: isExample, requiredCaps: caps.toList()); + return _FlowMeta( + requiredCaps: caps.toList(), + project: parseFlowProject(text), + ); } /// Reduce the host-supplied installed list (entries like @@ -1347,6 +1440,18 @@ class _FileList extends StatefulWidget { /// Bare capability NAMES the host reports as installed. Used /// to compute each row's missing-module count. final Set 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? 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; @@ -1360,11 +1465,17 @@ class _FileList extends StatefulWidget { /// renders, just without the one-click action. final Future Function(List)? onInstallMissing; + /// Import the bundled samples (empty-list action); null hides it. + final Future Function()? onImportSamples; + const _FileList({ required this.filesFuture, required this.activeName, required this.strings, + this.onImportSamples, required this.installedNames, + required this.storeNames, + required this.activeProject, required this.onOpen, required this.onRefresh, required this.onStart, @@ -1381,6 +1492,23 @@ class _FileListState extends State<_FileList> { /// finding: no search over the flow list at all). String _filter = ''; + /// Whether the examples group is expanded. Null until the + /// operator toggles it — the default then follows the content + /// (expanded only when there are no own flows). + bool? _samplesExpanded; + + /// Guards the empty-state import button against double-taps. + bool _importing = false; + + Future _runImport() async { + setState(() => _importing = true); + try { + await widget.onImportSamples!(); + } finally { + if (mounted) setState(() => _importing = false); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -1476,13 +1604,47 @@ class _FileListState extends State<_FileList> { icon: Icons.folder_outlined, title: strings.listEmptyTitle, hint: strings.listEmptyBody, + // Sealed areas start without examples — offer the + // deliberate pull right where the emptiness shows. + action: widget.onImportSamples == null + ? null + : OutlinedButton.icon( + onPressed: _importing ? null : _runImport, + icon: const Icon(Icons.download_outlined, size: 16), + label: Text(strings.flowListSamplesImport), + ), + ), + ); + } + // 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 - ? all - : all.where((f) => f.name.toLowerCase().contains(needle)).toList(); + ? 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. @@ -1495,13 +1657,69 @@ class _FileListState extends State<_FileList> { ), ); } - return ListView.builder( + // Grouping: the operator's own flows first; bundled + // examples collapsed under one labelled group so samples + // are never mistaken for area work (they expand by + // default only when there is nothing else to show). + final own = files.where((f) => !f.isExample).toList(); + final samples = files.where((f) => f.isExample).toList(); + final samplesExpanded = _samplesExpanded ?? own.isEmpty; + return ListView( padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs), - itemCount: files.length, - itemBuilder: (_, i) { - final f = files[i]; + children: [ + for (final f in own) _row(theme, f), + if (samples.isNotEmpty) ...[ + InkWell( + onTap: () => + setState(() => _samplesExpanded = !samplesExpanded), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.md, + vertical: FaiSpace.sm, + ), + child: Row( + children: [ + Icon( + Icons.auto_awesome_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Text( + strings.flowListSamplesGroup(samples.length), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + samplesExpanded + ? Icons.expand_less + : Icons.expand_more, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + if (samplesExpanded) for (final f in samples) _row(theme, f), + ], + ], + ); + }, + ); + } + + Widget _row(ThemeData theme, _FlowFile f) { + final strings = widget.strings; + { 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( @@ -1570,22 +1788,30 @@ class _FileListState extends State<_FileList> { fontSize: 11, ), ), - if (f.meta.isExample || missing.isNotEmpty) ...[ + if (f.isExample || missing.isNotEmpty) ...[ const SizedBox(height: FaiSpace.xs), Wrap( spacing: FaiSpace.xs, runSpacing: FaiSpace.xs, children: [ - if (f.meta.isExample) + if (f.isExample) _ExampleBadge(strings: strings), if (missing.isNotEmpty) - _MissingModulesBadge( - missing: missing, + MissingModulesBadge( + installable: split.installable, + notInStore: split.notInStore, + unclassified: split.unclassified, strings: strings, - onInstall: widget.onInstallMissing == null + // Install only what the store + // resolves — the not-in-store + // chip explains the rest. + onInstall: + widget.onInstallMissing == null || + split.installable.isEmpty ? null - : () => - widget.onInstallMissing!(missing), + : () => widget.onInstallMissing!( + split.installable, + ), ), ], ), @@ -1616,10 +1842,7 @@ class _FileListState extends State<_FileList> { ), ), ); - }, - ); - }, - ); + } } } @@ -1665,95 +1888,6 @@ class _ExampleBadge extends StatelessWidget { } } -/// "N modules missing" status on flows whose steps reference -/// capabilities the hub doesn't provide. Status and action are -/// SEPARATE elements (usertest: the combined orange chip made -/// the whole list read like an error wall and ellipsized the -/// action word): -/// - status = quiet neutral chip with a small amber dot — a -/// note, not an alarm; warning orange stays reserved for real -/// failures. -/// - action = its own "Install" link that is never truncated; -/// both live in the row's Wrap, so tight widths wrap to a -/// second line instead of cutting text mid-word. -class _MissingModulesBadge extends StatelessWidget { - final List missing; - final FlowEditorStrings strings; - final VoidCallback? onInstall; - const _MissingModulesBadge({ - required this.missing, - required this.strings, - required this.onInstall, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - const dot = Color(0xFFEF6C00); - final fg = theme.colorScheme.onSurfaceVariant; - final chip = Tooltip( - message: strings.flowListNeedsModulesTooltip(missing.join('\n')), - 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: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: dot, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 4), - Text( - strings.flowListNeedsModules(missing.length), - style: theme.textTheme.labelSmall?.copyWith( - color: fg, - fontSize: 10, - letterSpacing: 0.2, - ), - ), - ], - ), - ), - ); - if (onInstall == null) return chip; - return Wrap( - spacing: FaiSpace.xs, - runSpacing: FaiSpace.xs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - chip, - InkWell( - onTap: onInstall, - borderRadius: BorderRadius.circular(FaiRadius.sm), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - child: Text( - strings.flowListInstallMissing, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.primary, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.2, - ), - ), - ), - ), - ], - ); - } -} - // --- empty state --- class _EmptyState extends StatelessWidget { diff --git a/lib/src/flow_project.dart b/lib/src/flow_project.dart index 74385c8..7d1eb99 100644 --- a/lib/src/flow_project.dart +++ b/lib/src/flow_project.dart @@ -24,6 +24,21 @@ String parseFlowProject(String yaml) { return ''; } +/// The project a flow file effectively belongs to: its own +/// `project:` slug, or `general` when the file declares none. +/// Display/filter semantics only — the FILE stays the truth and +/// is never rewritten to make this explicit. +String effectiveFlowProject(String fileProject) => + fileProject.isEmpty ? 'general' : fileProject; + +/// Whether a flow file belongs in the list under the active +/// workspace filter. Empty [activeProject] = "all projects"; +/// otherwise the file's effective project (no key = `general`) +/// must match. +bool flowVisibleInProject(String fileProject, String activeProject) => + activeProject.isEmpty || + effectiveFlowProject(fileProject) == activeProject; + /// Mirror of the hub's `normalize_project_slug`: lowercase, /// collapse spaces/underscores/dashes to a single `-`, drop /// everything else, trim trailing dashes. Empty in → empty out diff --git a/lib/src/flow_yaml_controller.dart b/lib/src/flow_yaml_controller.dart index 8582b78..ff5a6ed 100644 --- a/lib/src/flow_yaml_controller.dart +++ b/lib/src/flow_yaml_controller.dart @@ -30,7 +30,7 @@ import 'wire_colors.dart'; class FlowYamlCodeController extends CodeController { FlowYamlCodeController({ List Function()? availableCapabilities, - List Function()? storeCapabilities, + List? Function()? storeCapabilities, AnalyzerStrings analyzerStrings = AnalyzerStrings.english, }) : super( text: '', @@ -49,7 +49,7 @@ class FlowYamlCodeController extends CodeController { /// closure or stale strings inside an old FlowAnalyzer. void setCapabilityProviders({ required List Function() available, - List Function()? store, + List? Function()? store, AnalyzerStrings? strings, }) { analyzer = FlowAnalyzer( diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 953796d..7eef70d 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -259,11 +259,24 @@ class FlowEditorStrings { ); String analyzerUnknownCapNotInStore(String cap) => _t( 'Unknown capability "$cap". ' - 'Not in the store — install locally with ' - '`chain install --link ` or check the spelling.', + 'No configured store can install it — install a local module ' + '(`chain install --link `), add the store that provides ' + 'it, or configure the integration (MCP/n8n) that supplies it.', 'Unbekannte Capability "$cap". ' - 'Nicht im Store — lokal mit ' - '`chain install --link ` installieren oder Tippfehler prüfen.', + 'Kein eingerichteter Store kann sie installieren — lokales ' + 'Modul installieren (`chain install --link `), den ' + 'passenden Store hinzufügen oder die Anbindung (MCP/n8n) ' + 'einrichten, die sie bereitstellt.', + ); + String analyzerUnknownCapStoreUnknown(String cap) => _t( + 'Unknown capability "$cap". ' + 'The store is not reachable right now, so it may or may not ' + 'be installable — check the spelling, or add a local source ' + '(`chain install --link `).', + 'Unbekannte Capability "$cap". ' + 'Der Store ist gerade nicht erreichbar — ob sie installierbar ' + 'ist, lässt sich nicht sagen. Tippfehler prüfen oder lokale ' + 'Quelle hinzufügen (`chain install --link `).', ); String analyzerInputKind() => _t('input', 'Eingabe'); String analyzerOutputKind() => _t('output', 'Ausgabe'); @@ -281,6 +294,10 @@ class FlowEditorStrings { 'Bundled sample flow.', 'Mitgelieferter Beispiel-Flow.', ); + String flowListSamplesGroup(int n) => + _t('Examples ($n)', 'Beispiele ($n)'); + String get flowListSamplesImport => + _t('Import example flows', 'Beispiel-Flows importieren'); String flowListNeedsModules(int n) => _t( n == 1 ? '1 module missing' : '$n modules missing', n == 1 ? '1 Modul fehlt' : '$n Module fehlen', @@ -292,6 +309,45 @@ class FlowEditorStrings { 'Klicken, um die fehlenden zu installieren.', ); String get flowListInstallMissing => _t('Install', 'Installieren'); + String flowListNeedsModulesTooltipNoAction(String caps) => _t( + 'This flow needs capabilities that are not installed:\n$caps\n' + 'The store is not reachable right now — no install offer ' + 'until Studio can check it.', + 'Dieser Flow braucht nicht installierte Capabilities:\n$caps\n' + 'Der Store ist gerade nicht erreichbar — kein Install-Angebot, ' + 'bis Studio das prüfen kann.', + ); + String flowListNotInStore(int n) => _t( + n == 1 ? 'not in store' : '$n not in store', + n == 1 ? 'nicht im Store' : '$n nicht im Store', + ); + String flowListNotInStoreTooltip(String caps) => _t( + 'This flow needs capabilities no configured store can install:\n' + '$caps\n' + 'Ways to get them: install a local module ' + '(`chain install --link `), add the store that provides ' + 'them (Settings → Stores), or configure the integration ' + '(MCP/n8n) that supplies the capability.', + 'Dieser Flow braucht Capabilities, die kein eingerichteter Store ' + 'installieren kann:\n$caps\n' + 'Wege: lokales Modul installieren ' + '(`chain install --link `), den passenden Store ' + 'hinzufügen (Einstellungen → Stores) oder die Anbindung ' + '(MCP/n8n) einrichten, die die Capability bereitstellt.', + ); + + // Project-scoped empty state: flows exist, none in the active + // workspace project. + String listProjectEmpty(String project) => _t( + 'No flows in project "$project".', + 'Keine Flows im Projekt „$project".', + ); + String get listProjectEmptyHint => _t( + 'New flow puts one here, or switch the project selector to ' + '"All projects".', + '„Neuer Flow" legt hier einen an — oder wechseln Sie die ' + 'Projekt-Auswahl auf „Alle Projekte".', + ); // Flow-list filter (first iteration: plain substring match). String get listFilterHint => _t('Filter flows…', 'Flows filtern…'); @@ -318,6 +374,7 @@ class AnalyzerStrings { final String Function(String cap) unknownCapInStore; final String Function(String cap, String suggestion) unknownCapTypo; final String Function(String cap) unknownCapNotInStore; + final String Function(String cap) unknownCapStoreUnknown; final String Function(String kind, String value, String validList) unknownType; final String Function() inputKind; @@ -333,6 +390,7 @@ class AnalyzerStrings { required this.unknownCapInStore, required this.unknownCapTypo, required this.unknownCapNotInStore, + required this.unknownCapStoreUnknown, required this.unknownType, required this.inputKind, required this.outputKind, @@ -351,6 +409,7 @@ class AnalyzerStrings { unknownCapInStore: s.analyzerUnknownCapInStore, unknownCapTypo: s.analyzerUnknownCapTypo, unknownCapNotInStore: s.analyzerUnknownCapNotInStore, + unknownCapStoreUnknown: s.analyzerUnknownCapStoreUnknown, unknownType: s.analyzerUnknownType, inputKind: s.analyzerInputKind, outputKind: s.analyzerOutputKind, @@ -371,6 +430,7 @@ class AnalyzerStrings { : unknownCapInStore = _enUnknownCapInStore, unknownCapTypo = _enUnknownCapTypo, unknownCapNotInStore = _enUnknownCapNotInStore, + unknownCapStoreUnknown = _enUnknownCapStoreUnknown, unknownType = _enUnknownType, inputKind = _enInputKind, outputKind = _enOutputKind, @@ -387,8 +447,14 @@ class AnalyzerStrings { 'Unknown capability "$cap". Did you mean "$suggestion"?'; static String _enUnknownCapNotInStore(String cap) => 'Unknown capability "$cap". ' - 'Not in the store — install locally with ' - '`chain install --link ` or check the spelling.'; + 'No configured store can install it — install a local module ' + '(`chain install --link `), add the store that provides ' + 'it, or configure the integration (MCP/n8n) that supplies it.'; + static String _enUnknownCapStoreUnknown(String cap) => + 'Unknown capability "$cap". ' + 'The store is not reachable right now, so it may or may not ' + 'be installable — check the spelling, or add a local source ' + '(`chain install --link `).'; static String _enUnknownType(String kind, String value, String validList) => 'Unknown $kind type "$value". Use one of: $validList.'; static String _enInputKind() => 'input'; diff --git a/lib/src/quick_fix.dart b/lib/src/quick_fix.dart index cd4c863..7772482 100644 --- a/lib/src/quick_fix.dart +++ b/lib/src/quick_fix.dart @@ -73,8 +73,8 @@ class InstallCapabilityFix extends QuickFix { /// (`chain install `), then installs and reanalyzes. /// /// This is the recovery path for private modules: the public -/// store doesn't know about `htw.digiscout/onet-lookup`, but -/// the operator can point the hub at the local clone. +/// store doesn't know about `acme.internal/directory-lookup`, +/// but the operator can point the hub at the local clone. @immutable class AddModuleSourceFix extends QuickFix { /// The capability the operator wrote — the host uses it to diff --git a/lib/src/widgets.dart b/lib/src/widgets.dart index 89bf739..23bd68c 100644 --- a/lib/src/widgets.dart +++ b/lib/src/widgets.dart @@ -12,11 +12,17 @@ class FaiEmptyState extends StatelessWidget { final IconData icon; final String title; final String? hint; + + /// Optional action rendered under the hint (e.g. the empty flow + /// list's "import example flows" button). + final Widget? action; + const FaiEmptyState({ super.key, required this.icon, required this.title, this.hint, + this.action, }); @override @@ -46,6 +52,10 @@ class FaiEmptyState extends StatelessWidget { textAlign: TextAlign.center, ), ], + if (action != null) ...[ + const SizedBox(height: FaiSpace.md), + action!, + ], ], ), ), diff --git a/lib/src/widgets/missing_modules_badge.dart b/lib/src/widgets/missing_modules_badge.dart new file mode 100644 index 0000000..319911b --- /dev/null +++ b/lib/src/widgets/missing_modules_badge.dart @@ -0,0 +1,184 @@ +// Flow-list badge for flows whose steps reference capabilities +// the hub doesn't provide. Split out of flow_editor_page.dart so +// the resolvability states (installable from a store vs. not in +// any store) stay unit-testable. + +import 'package:flutter/material.dart'; + +import '../l10n.dart'; +import '../tokens.dart'; + +/// Split a flow's missing capabilities into the ones a configured +/// store can actually install and the ones no store resolves. +/// +/// [storeNames] is the host-supplied set of bare capability names +/// the store can install (already filtered to installable entries +/// — published/alpha, native). An empty set means "nothing is +/// store-installable" (known state); `null` means the store state +/// is UNKNOWN (snapshot not loaded / unreachable) — then every +/// missing cap lands in `unclassified` and the UI claims neither +/// "installable" nor "not in store". +({ + List installable, + List notInStore, + List unclassified, +}) splitMissingCaps( + List missing, + Set? storeNames, +) { + if (storeNames == null) { + // Store state unknown (snapshot not loaded / unreachable): + // claim neither "installable" nor "not in store". + return ( + installable: const [], + notInStore: const [], + unclassified: missing, + ); + } + final installable = []; + final notInStore = []; + for (final cap in missing) { + final bare = cap.split('@').first; + (storeNames.contains(bare) ? installable : notInStore).add(cap); + } + return ( + installable: installable, + notInStore: notInStore, + unclassified: const [], + ); +} + +/// "N modules missing" status on flows whose steps reference +/// capabilities the hub doesn't provide. Status and action are +/// SEPARATE elements (usertest: the combined orange chip made +/// the whole list read like an error wall and ellipsized the +/// action word): +/// - status = quiet neutral chip with a small amber dot — a +/// note, not an alarm; warning orange stays reserved for real +/// failures. +/// - action = its own "Install" link that is never truncated; +/// both live in the row's Wrap, so tight widths wrap to a +/// second line instead of cutting text mid-word. +/// +/// The install action only covers [installable] — capabilities a +/// configured store resolves. [notInStore] capabilities get their +/// own quiet chip whose tooltip explains the three recovery paths +/// (local install / add store / configure integration) BEFORE any +/// click, instead of an install button that would end in the +/// hub's "no store entry" error. [unclassified] capabilities +/// (store state unknown) get the missing chip with neither an +/// install offer nor a not-in-store claim. +class MissingModulesBadge extends StatelessWidget { + final List installable; + final List notInStore; + final List unclassified; + final FlowEditorStrings strings; + final VoidCallback? onInstall; + const MissingModulesBadge({ + super.key, + required this.installable, + required this.notInStore, + required this.strings, + required this.onInstall, + this.unclassified = const [], + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final children = [ + if (installable.isNotEmpty) + _chip( + theme, + dot: const Color(0xFFEF6C00), + label: strings.flowListNeedsModules(installable.length), + tooltip: strings.flowListNeedsModulesTooltip( + installable.join('\n'), + ), + ), + if (installable.isNotEmpty && onInstall != null) + InkWell( + onTap: onInstall, + borderRadius: BorderRadius.circular(FaiRadius.sm), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: Text( + strings.flowListInstallMissing, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + ), + ), + if (unclassified.isNotEmpty) + _chip( + theme, + dot: const Color(0xFFEF6C00), + label: strings.flowListNeedsModules(unclassified.length), + tooltip: strings.flowListNeedsModulesTooltipNoAction( + unclassified.join('\n'), + ), + ), + if (notInStore.isNotEmpty) + _chip( + theme, + // Muted dot: this state has no one-click fix, so it + // must not borrow the actionable chip's amber. + dot: theme.colorScheme.outline, + label: strings.flowListNotInStore(notInStore.length), + tooltip: strings.flowListNotInStoreTooltip(notInStore.join('\n')), + ), + ]; + if (children.length == 1) return children.single; + return Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + crossAxisAlignment: WrapCrossAlignment.center, + children: children, + ); + } + + Widget _chip( + ThemeData theme, { + required Color dot, + required String label, + required String tooltip, + }) { + final fg = theme.colorScheme.onSurfaceVariant; + return Tooltip( + message: tooltip, + 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: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: dot, shape: BoxShape.circle), + ), + const SizedBox(width: 4), + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: fg, + fontSize: 10, + letterSpacing: 0.2, + ), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 8380d23..3d6656e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: chain_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.24.1 +version: 0.26.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor diff --git a/test/flow_analyzer_test.dart b/test/flow_analyzer_test.dart index 9b35f4f..11ab1ab 100644 --- a/test/flow_analyzer_test.dart +++ b/test/flow_analyzer_test.dart @@ -135,13 +135,20 @@ steps: name: x steps: - id: c - use: htw.private/secret@^0.1 + use: acme.internal/secret@^0.1 ''')); expect(r.issues, hasLength(1)); final fixes = a.fixesFor(r.issues.first); expect(fixes, hasLength(1)); expect(fixes.first, isA()); - expect(r.issues.first.message, contains('Not in the store')); + // The message must explain all three recovery paths in place: + // local install, adding a store, configuring an integration. + expect( + r.issues.first.message, + contains('No configured store can install it'), + ); + expect(r.issues.first.message, contains('chain install --link')); + expect(r.issues.first.message, contains('integration')); }); test('did-you-mean wins over install/add-source when a near miss exists', diff --git a/test/flow_list_project_test.dart b/test/flow_list_project_test.dart new file mode 100644 index 0000000..a49c709 --- /dev/null +++ b/test/flow_list_project_test.dart @@ -0,0 +1,146 @@ +// Widget-level proof of the flow list's project separation: +// switching the active workspace filters the list, and a new flow +// is stamped with the active project's key. Hermetic — the editor +// gets a temp flows dir injected and never touches ~/.chain. + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart'; + +Future _pumpEditor( + WidgetTester tester, { + required String flowsDir, + required String activeProject, +}) async { + // Build + let the real file IO of the listing complete inside + // runAsync (the fake-async test zone never drives real IO). + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: FlowEditorPage( + flowsDir: flowsDir, + activeProject: activeProject, + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + await tester.pump(); +} + +void main() { + late Directory tmp; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('chain_editor_flows_test'); + File('${tmp.path}/alpha-report.yaml').writeAsStringSync( + 'name: alpha-report\nproject: client-a\nsteps: []\n', + ); + File('${tmp.path}/plain.yaml').writeAsStringSync( + 'name: plain\nsteps: []\n', + ); + }); + + tearDown(() { + tmp.deleteSync(recursive: true); + }); + + testWidgets('active project filters the flow list', (tester) async { + await _pumpEditor( + tester, + flowsDir: tmp.path, + activeProject: 'client-a', + ); + expect(find.text('alpha-report'), findsOneWidget); + expect(find.text('plain'), findsNothing); + }); + + testWidgets('general shows keyless flows only', (tester) async { + await _pumpEditor( + tester, + flowsDir: tmp.path, + activeProject: 'general', + ); + expect(find.text('plain'), findsOneWidget); + expect(find.text('alpha-report'), findsNothing); + }); + + testWidgets('all projects shows everything', (tester) async { + await _pumpEditor(tester, flowsDir: tmp.path, activeProject: ''); + expect(find.text('plain'), findsOneWidget); + expect(find.text('alpha-report'), findsOneWidget); + }); + + testWidgets('empty project state names the project and the way out', ( + tester, + ) async { + await _pumpEditor( + tester, + flowsDir: tmp.path, + activeProject: 'client-b', + ); + expect(find.textContaining('client-b'), findsOneWidget); + expect(find.text('alpha-report'), findsNothing); + expect(find.text('plain'), findsNothing); + }); + + testWidgets('new flow is stamped with the active project key', ( + tester, + ) async { + await _pumpEditor( + tester, + flowsDir: tmp.path, + activeProject: 'client-a', + ); + await tester.tap(find.text('New flow')); + await tester.pumpAndSettle(); + await tester.enterText( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ), + 'fresh-flow', + ); + await tester.tap(find.text('Create')); + await tester.runAsync(() => Future.delayed( + const Duration(milliseconds: 50), + )); + await tester.pumpAndSettle(); + + final created = File('${tmp.path}/fresh-flow.yaml'); + expect(created.existsSync(), isTrue); + expect(created.readAsStringSync(), contains('project: client-a')); + }); + + testWidgets('new flow under general stays unstamped (no key = general)', ( + tester, + ) async { + await _pumpEditor( + tester, + flowsDir: tmp.path, + activeProject: 'general', + ); + await tester.tap(find.text('New flow')); + await tester.pumpAndSettle(); + await tester.enterText( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ), + 'general-flow', + ); + await tester.tap(find.text('Create')); + await tester.runAsync(() => Future.delayed( + const Duration(milliseconds: 50), + )); + await tester.pumpAndSettle(); + + final created = File('${tmp.path}/general-flow.yaml'); + expect(created.existsSync(), isTrue); + expect(created.readAsStringSync(), isNot(contains('project:'))); + }); +} diff --git a/test/flow_list_samples_test.dart b/test/flow_list_samples_test.dart new file mode 100644 index 0000000..caedf2f --- /dev/null +++ b/test/flow_list_samples_test.dart @@ -0,0 +1,144 @@ +// Widget-level proof of the sample handling contract (persona +// review 2026-08-27 / sealed-areas rework): +// +// * The host's sample set (the hub's FlowSummary.sample wire +// flag) is the ONLY source of the "Example" chip — no host +// info, no chips. The editor's old content-marker scan had +// silently drifted from the hub's header and is gone. +// * Sample flows collapse under one "Examples (N)" group so +// they are never mistaken for the operator's own work; the +// group expands only on demand (or when nothing else exists). +// * An empty list offers "Import example flows" iff the host +// wired the action (sealed areas start empty on purpose). +// +// Hermetic — temp flows dir, never ~/.chain. + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart'; + +Future _pumpEditor( + WidgetTester tester, { + required String flowsDir, + Set? sampleFlowNames, + Future Function()? onImportSamples, +}) async { + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: FlowEditorPage( + flowsDir: flowsDir, + sampleFlowNames: sampleFlowNames, + onImportSamples: onImportSamples, + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + await tester.pump(); +} + +void main() { + late Directory tmp; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('chain_editor_samples_test'); + File('${tmp.path}/my-work.yaml').writeAsStringSync( + 'name: my-work\nsteps: []\n', + ); + File('${tmp.path}/hello.yaml').writeAsStringSync( + 'name: hello\nsteps: []\n', + ); + }); + + tearDown(() { + tmp.deleteSync(recursive: true); + }); + + testWidgets('host-reported samples collapse under the examples group', ( + tester, + ) async { + await _pumpEditor( + tester, + flowsDir: tmp.path, + sampleFlowNames: const {'hello'}, + ); + // Own flow visible, sample hidden behind the collapsed group. + expect(find.text('my-work'), findsOneWidget); + expect(find.text('hello'), findsNothing); + expect(find.text('Examples (1)'), findsOneWidget); + + await tester.tap(find.text('Examples (1)')); + await tester.pump(); + expect(find.text('hello'), findsOneWidget); + // The revealed sample row carries the chip. + expect(find.text('Example'), findsOneWidget); + }); + + testWidgets('no host sample info means no chips and no group', ( + tester, + ) async { + await _pumpEditor(tester, flowsDir: tmp.path, sampleFlowNames: null); + expect(find.text('my-work'), findsOneWidget); + expect(find.text('hello'), findsOneWidget); + expect(find.text('Example'), findsNothing); + expect(find.textContaining('Examples ('), findsNothing); + }); + + testWidgets('a samples-only list starts expanded', (tester) async { + File('${tmp.path}/my-work.yaml').deleteSync(); + await _pumpEditor( + tester, + flowsDir: tmp.path, + sampleFlowNames: const {'hello'}, + ); + expect(find.text('hello'), findsOneWidget); + expect(find.text('Examples (1)'), findsOneWidget); + }); + + testWidgets('the empty list offers the import action when wired', ( + tester, + ) async { + File('${tmp.path}/my-work.yaml').deleteSync(); + File('${tmp.path}/hello.yaml').deleteSync(); + var imported = 0; + await _pumpEditor( + tester, + flowsDir: tmp.path, + onImportSamples: () async { + imported++; + File('${tmp.path}/hello.yaml').writeAsStringSync( + 'name: hello\nsteps: []\n', + ); + }, + ); + final button = find.text('Import example flows'); + expect(button, findsOneWidget); + await tester.runAsync(() async { + await tester.tap(button); + // Let the import callback, the directory re-list (real IO) + // and the FutureBuilder's completion callback all run. + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + await tester.pump(); + expect(imported, 1); + // The imported flow appears without a manual refresh. + expect(find.text('hello'), findsOneWidget); + }); + + testWidgets('the empty list stays action-free when nothing is wired', ( + tester, + ) async { + File('${tmp.path}/my-work.yaml').deleteSync(); + File('${tmp.path}/hello.yaml').deleteSync(); + await _pumpEditor(tester, flowsDir: tmp.path); + expect(find.text('Import example flows'), findsNothing); + }); +} diff --git a/test/flow_project_test.dart b/test/flow_project_test.dart index 8888abf..ad8eb6b 100644 --- a/test/flow_project_test.dart +++ b/test/flow_project_test.dart @@ -34,6 +34,30 @@ void main() { }); }); + group('project filter semantics', () { + test('no project key counts as general (display only)', () { + expect(effectiveFlowProject(''), 'general'); + expect(effectiveFlowProject('client-a'), 'client-a'); + }); + + test('empty active workspace shows every flow', () { + expect(flowVisibleInProject('', ''), isTrue); + expect(flowVisibleInProject('client-a', ''), isTrue); + }); + + test('general shows keyless flows and explicit general ones', () { + expect(flowVisibleInProject('', 'general'), isTrue); + expect(flowVisibleInProject('general', 'general'), isTrue); + expect(flowVisibleInProject('client-a', 'general'), isFalse); + }); + + test('a project shows only its own flows', () { + expect(flowVisibleInProject('client-a', 'client-a'), isTrue); + expect(flowVisibleInProject('', 'client-a'), isFalse); + expect(flowVisibleInProject('client-b', 'client-a'), isFalse); + }); + }); + group('normalizeProjectSlug', () { test('collapses separators and trims trailing dashes', () { expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha'); diff --git a/test/missing_modules_badge_test.dart b/test/missing_modules_badge_test.dart new file mode 100644 index 0000000..9a36236 --- /dev/null +++ b/test/missing_modules_badge_test.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio_flow_editor/src/l10n.dart'; +import 'package:chain_studio_flow_editor/src/widgets/missing_modules_badge.dart'; + +void main() { + group('splitMissingCaps', () { + test('separates store-resolvable from not-in-store capabilities', () { + final split = splitMissingCaps( + ['text.extract', 'example-provider/tool.summarize'], + {'text.extract', 'debug.echo'}, + ); + expect(split.installable, ['text.extract']); + expect(split.notInStore, ['example-provider/tool.summarize']); + }); + + test('matches on the bare name when the flow pins a version', () { + final split = splitMissingCaps(['text.extract@^0'], {'text.extract'}); + expect(split.installable, ['text.extract@^0']); + expect(split.notInStore, isEmpty); + }); + + test('empty store set classifies everything as not-in-store', () { + final split = splitMissingCaps(['text.extract'], {}); + expect(split.installable, isEmpty); + expect(split.notInStore, ['text.extract']); + expect(split.unclassified, isEmpty); + }); + + test('null store set (state unknown) classifies nothing', () { + final split = splitMissingCaps(['text.extract'], null); + expect(split.installable, isEmpty); + expect(split.notInStore, isEmpty); + expect(split.unclassified, ['text.extract']); + }); + }); + + Future pumpBadge( + WidgetTester tester, { + required List installable, + required List notInStore, + VoidCallback? onInstall, + }) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MissingModulesBadge( + installable: installable, + notInStore: notInStore, + strings: const FlowEditorStrings(FlowEditorLocale.en), + onInstall: onInstall, + ), + ), + ), + ); + } + + group('MissingModulesBadge', () { + testWidgets('store-resolvable capability offers the install action', ( + tester, + ) async { + var installed = false; + await pumpBadge( + tester, + installable: ['text.extract'], + notInStore: [], + onInstall: () => installed = true, + ); + expect(find.text('1 module missing'), findsOneWidget); + expect(find.text('Install'), findsOneWidget); + expect(find.textContaining('not in store'), findsNothing); + await tester.tap(find.text('Install')); + expect(installed, isTrue); + }); + + testWidgets( + 'not-in-store capability shows the classified state, no install', + (tester) async { + await pumpBadge( + tester, + installable: [], + notInStore: ['example-provider/tool.summarize'], + onInstall: () => fail('no install action for not-in-store caps'), + ); + expect(find.text('not in store'), findsOneWidget); + expect(find.text('Install'), findsNothing); + // The recovery paths are explained in place, before any click. + final tooltip = tester.widget(find.byType(Tooltip)); + expect(tooltip.message, contains('example-provider/tool.summarize')); + expect(tooltip.message, contains('chain install --link')); + expect(tooltip.message, contains('Settings → Stores')); + expect(tooltip.message, contains('integration')); + }, + ); + + testWidgets('unknown store state offers neither install nor claims', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MissingModulesBadge( + installable: const [], + notInStore: const [], + unclassified: const ['text.extract'], + strings: const FlowEditorStrings(FlowEditorLocale.en), + onInstall: () => fail('no install offer while store unknown'), + ), + ), + ), + ); + expect(find.text('1 module missing'), findsOneWidget); + expect(find.text('Install'), findsNothing); + expect(find.textContaining('not in store'), findsNothing); + final tooltip = tester.widget(find.byType(Tooltip)); + expect(tooltip.message, contains('store is not reachable')); + }); + + testWidgets('mixed state renders both chips, install covers store caps', ( + tester, + ) async { + await pumpBadge( + tester, + installable: ['text.extract'], + notInStore: ['example-provider/tool.summarize'], + onInstall: () {}, + ); + expect(find.text('1 module missing'), findsOneWidget); + expect(find.text('Install'), findsOneWidget); + expect(find.text('not in store'), findsOneWidget); + }); + }); +}