diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ab46c3..ccc5edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,103 @@ # 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 + the page — the duplicate label read as a broken title hierarchy. + The refresh action moved next to the list filter. +- Doc comments name the current CLI (`chain install`). + +## 0.24.0 + +Usertest-panel fixes (flow list + language): + +- **Builtin-capability truth from the hub.** Removed the hardcoded + client-side builtin set (`{'debug.echo'}`): missing-module + detection now trusts the host-supplied capability list alone, + which already includes the hub's real builtins. Fixes the bundled + `hello` flow showing a play button on hubs that don't have + `debug.echo` installed. +- **Missing-modules badge redesigned.** Status ("N Module fehlen", + quiet neutral chip with an amber dot) and the install action + (own link, never truncated) are separate elements; the former + combined orange chip read as an error wall and ellipsized the + action word. +- **Flow-list filter.** Case-insensitive substring filter above the + list, with an honest "no flow matches" empty state. +- **Formal address (Sie) in all German strings** plus missing + commas/quotes in the empty-state sentences. +- Analyzer hint now names the current CLI (`chain install --link`, + was `fai install --link`). + ## 0.23.0 - Run tab file inputs accept a host-injected native file picker 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 9b63e6c..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); @@ -282,7 +329,8 @@ class _FlowEditorPageState extends State 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}' + final newLine = + '${line.substring(0, colonIdx + 1)} ${fix.replacement}' '${tail.isEmpty ? '' : ' $tail'}'; if (newLine == line) return; lines[fix.line] = newLine; @@ -291,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() @@ -306,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, ), ); @@ -331,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(); @@ -374,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, @@ -418,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 @@ -437,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()) { @@ -575,6 +634,7 @@ outputs: ? () => Navigator.of(context).maybePop() : null, onNew: _newFlow, + trailing: widget.toolbarTrailing, ), const Divider(height: 1), Expanded( @@ -587,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, @@ -643,7 +718,8 @@ outputs: errorCount: _controller.analyzerErrorCount, onAddStep: _controller.activeName != null ? _addStep : null, onSave: _controller.activeName != null ? _save : null, - onRun: _controller.activeName != null && + onRun: + _controller.activeName != null && _controller.analyzerErrorCount == 0 ? () => _tabs.animateTo(2) : null, @@ -843,9 +919,7 @@ outputs: minLines: null, maxLines: null, gutterStyle: GutterStyle( - textStyle: mono.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + textStyle: mono.copyWith(color: theme.colorScheme.onSurfaceVariant), background: theme.colorScheme.surfaceContainer, showLineNumbers: true, // Disable the built-in error column entirely — its @@ -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), @@ -1127,9 +1211,7 @@ class _TabActionStrip extends StatelessWidget { child: CircularProgressIndicator(strokeWidth: 2), ) : Icon( - errorCount > 0 - ? Icons.block - : Icons.play_arrow, + errorCount > 0 ? Icons.block : Icons.play_arrow, size: 18, ), label: Text(strings.run), @@ -1234,47 +1316,50 @@ 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'; - -/// Built-in capabilities the Hub always provides, even when -/// they aren't in the host-supplied installed list. Treated as -/// satisfied so the "needs N modules" badge never flags them. -const Set _builtinCapabilities = {'debug.echo'}; - -/// 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 = '', + }); - /// Capabilities this flow needs that are neither installed nor - /// built in. [availableNames] is the set of installed - /// capability NAMES (the part before `@`). - List missingCaps(Set availableNames) => requiredCaps - .where( - (c) => - !availableNames.contains(c) && !_builtinCapabilities.contains(c), - ) - .toList(); + static const empty = _FlowMeta(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 missingCaps(Set availableNames) => + requiredCaps.where((c) => !availableNames.contains(c)).toList(); } /// Caches [_FlowMeta] per file, keyed by path + mtime. Reading @@ -1301,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')) { @@ -1325,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 @@ -1345,7 +1432,7 @@ String _formatBytes(int bytes) { return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } -class _FileList extends StatelessWidget { +class _FileList extends StatefulWidget { final Future> filesFuture; final String? activeName; final FlowEditorStrings strings; @@ -1353,6 +1440,18 @@ class _FileList extends StatelessWidget { /// 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; @@ -1366,46 +1465,105 @@ class _FileList extends StatelessWidget { /// 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, 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 = ''; + + /// 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); + final strings = widget.strings; return Container( color: theme.colorScheme.surface, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: FaiSpace.md, - vertical: FaiSpace.xs, - ), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: theme.dividerColor)), + // 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: Text( - strings.listHeader, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 0.6, + 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: onRefresh, + onPressed: widget.onRefresh, tooltip: strings.refresh, icon: const Icon(Icons.refresh, size: 16), visualDensity: VisualDensity.compact, @@ -1425,8 +1583,9 @@ class _FileList extends StatelessWidget { } Widget _buildBody(BuildContext context, ThemeData theme) { + final strings = widget.strings; return FutureBuilder>( - future: filesFuture, + future: widget.filesFuture, builder: (context, snap) { if (snap.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); @@ -1437,26 +1596,132 @@ class _FileList extends StatelessWidget { child: ChainErrorBox(error: snap.error, isError: true), ); } - final files = snap.data ?? <_FlowFile>[]; - if (files.isEmpty) { + 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, + // 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), + ), ), ); } - return ListView.builder( + // 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, + ), + ); + } + // 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]; - final isActive = f.name == activeName; - final missing = f.meta.missingCaps(installedNames); + 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: () => onOpen(f), + onTap: () => widget.onOpen(f), child: Container( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, @@ -1523,21 +1788,30 @@ class _FileList extends StatelessWidget { 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: onInstallMissing == null + // Install only what the store + // resolves — the not-in-store + // chip explains the rest. + onInstall: + widget.onInstallMissing == null || + split.installable.isEmpty ? null - : () => onInstallMissing!(missing), + : () => widget.onInstallMissing!( + split.installable, + ), ), ], ), @@ -1550,7 +1824,7 @@ class _FileList extends StatelessWidget { // the install badge is the actionable path then. if (missing.isEmpty) IconButton( - onPressed: () => onStart(f), + onPressed: () => widget.onStart(f), tooltip: strings.listStartTooltip, icon: Icon( Icons.play_arrow_rounded, @@ -1568,10 +1842,7 @@ class _FileList extends StatelessWidget { ), ), ); - }, - ); - }, - ); + } } } @@ -1617,72 +1888,6 @@ class _ExampleBadge extends StatelessWidget { } } -/// Warning-toned "needs N modules" chip on flows whose steps -/// reference capabilities that aren't installed. Tooltip lists -/// the exact missing ids. When an install handler is wired, the -/// chip is tappable and one-click installs all missing caps via -/// the host's existing install path. -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); - // Amber warning tone consistent with the diagnostic strip's - // warning colour, readable on both light + dark surfaces. - const warn = Color(0xFFEF6C00); - final label = '${strings.flowListNeedsModules(missing.length)}' - '${onInstall != null ? ' · ${strings.flowListInstallMissing}' : ''}'; - final chip = Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: warn.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border.all(color: warn.withValues(alpha: 0.5)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.extension_off_outlined, size: 10, color: warn), - const SizedBox(width: 4), - // Flexible + ellipsis: the chip sits in the narrow flow - // list, where the full label otherwise overflows by a few - // pixels (the tooltip still carries the complete text). - Flexible( - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith( - color: warn, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.2, - ), - ), - ), - ], - ), - ); - return Tooltip( - message: strings.flowListNeedsModulesTooltip(missing.join('\n')), - child: onInstall == null - ? chip - : InkWell( - onTap: onInstall, - borderRadius: BorderRadius.circular(FaiRadius.sm), - child: chip, - ), - ); - } -} - // --- empty state --- class _EmptyState extends StatelessWidget { @@ -1940,8 +2145,10 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> { InkWell( onTap: () => setState(() => _expanded = !_expanded), child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), child: Row( children: [ Container( @@ -2135,16 +2342,10 @@ class _IssueRow extends StatelessWidget { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.auto_fix_high, size: 13), - label: Text( - fix.label, - style: _monoTextStyle(size: 11), - ), + label: Text(fix.label, style: _monoTextStyle(size: 11)), style: FilledButton.styleFrom( visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), minimumSize: const Size(0, 24), ), ), @@ -2193,7 +2394,10 @@ class _IssueHoverCard extends StatelessWidget { // 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 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, @@ -2213,9 +2417,7 @@ class _IssueHoverCard extends StatelessWidget { decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.outlineVariant, - ), + border: Border.all(color: theme.colorScheme.outlineVariant), boxShadow: const [ BoxShadow( color: Color(0x33000000), @@ -2366,11 +2568,7 @@ class _FixDialogState extends State<_FixDialog> { return AlertDialog( title: Row( children: [ - Icon( - Icons.auto_fix_high, - size: 20, - color: theme.colorScheme.primary, - ), + Icon(Icons.auto_fix_high, size: 20, color: theme.colorScheme.primary), const SizedBox(width: 8), Text(strings.diagnosticFixDialogTitle), ], @@ -2492,10 +2690,7 @@ class _FixDialogRow extends StatelessWidget { FilledButton.tonalIcon( onPressed: () => onApplyFix(fix), icon: const Icon(Icons.auto_fix_high, size: 13), - label: Text( - fix.label, - style: _monoTextStyle(size: 11), - ), + label: Text(fix.label, style: _monoTextStyle(size: 11)), ), ], ), 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 94f81bd..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( @@ -108,7 +108,7 @@ class FlowYamlCodeController extends CodeController { /// Replace the analyzer's capability provider. Called by the /// editor host when the installed-capability list changes — - /// e.g. after `fai install` while the editor is open. + /// e.g. after `chain install` while the editor is open. void setAvailableCapabilities(List Function() provider) { analyzer = FlowAnalyzer(availableCapabilities: provider); } diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 910ffd2..7eef70d 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -17,7 +17,6 @@ class FlowEditorStrings { String get save => _t('Save', 'Speichern'); String get run => _t('Run', 'Ausführen'); String get refresh => _t('Refresh file list', 'Datei-Liste neu laden'); - String get listHeader => _t('FLOWS', 'FLOWS'); String get listStartTooltip => _t('Open on the Run tab', 'Im Starten-Tab öffnen'); String get copy => _t('Copy', 'Kopieren'); @@ -28,13 +27,14 @@ class FlowEditorStrings { String get emptyTitle => _t('No flow open', 'Kein Flow geöffnet'); String get emptyBody => _t( 'Pick one from the left, or click New flow to start a fresh one.', - 'Wähle links einen aus oder klicke Neuer Flow um einen neuen anzulegen.', + 'Wählen Sie links einen Flow aus oder klicken Sie auf „Neuer Flow", ' + 'um einen neuen anzulegen.', ); String get listEmptyTitle => _t('No saved flows', 'Keine gespeicherten Flows'); String get listEmptyBody => _t( 'Click New flow to scaffold one from a template.', - 'Klicke Neuer Flow um aus einer Vorlage zu starten.', + 'Klicken Sie auf „Neuer Flow", um aus einer Vorlage zu starten.', ); String get discardTitle => _t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?'); @@ -95,14 +95,15 @@ class FlowEditorStrings { String get graphEmptyTitle => _t('No steps yet', 'Noch keine Schritte'); String get graphEmptyBody => _t( 'Click Add step to drop the first capability onto the canvas.', - 'Klicke Schritt hinzufügen um die erste Capability auf die Fläche zu setzen.', + 'Klicken Sie auf „Schritt hinzufügen", um die erste Capability auf ' + 'die Fläche zu setzen.', ); // Properties panel. String get propPanelTitle => _t('Step details', 'Schritt-Details'); String get propNoSelection => _t( 'Click a step on the canvas to edit it.', - 'Klicke einen Schritt auf der Fläche an, um ihn zu bearbeiten.', + 'Klicken Sie einen Schritt auf der Fläche an, um ihn zu bearbeiten.', ); String get propStepId => _t('ID', 'ID'); String get propCapability => _t('Capability', 'Capability'); @@ -142,11 +143,12 @@ class FlowEditorStrings { String get runStart => _t('Start run', 'Lauf starten'); String get runUnsavedBanner => _t( 'You have unsaved changes — save first to run the latest version.', - 'Du hast ungespeicherte Änderungen — bitte zuerst speichern, um die neueste Version auszuführen.', + 'Sie haben ungespeicherte Änderungen — bitte zuerst speichern, um die ' + 'neueste Version auszuführen.', ); String get runNoFlow => _t( 'Open a flow from the left to run it.', - 'Öffne links einen Flow um ihn auszuführen.', + 'Öffnen Sie links einen Flow, um ihn auszuführen.', ); String get runOutputs => _t('Outputs', 'Ausgaben'); String get runChooseFile => _t('Choose file…', 'Datei wählen…'); @@ -253,15 +255,28 @@ class FlowEditorStrings { ); String analyzerUnknownCapTypo(String cap, String suggestion) => _t( 'Unknown capability "$cap". Did you mean "$suggestion"?', - 'Unbekannte Capability "$cap". Meintest du "$suggestion"?', + 'Unbekannte Capability "$cap". Meinten Sie "$suggestion"?', ); String analyzerUnknownCapNotInStore(String cap) => _t( 'Unknown capability "$cap". ' - 'Not in the store — install locally with ' - '`fai 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 ' - '`fai 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'); @@ -279,9 +294,13 @@ 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 ? 'needs 1 module' : 'needs $n modules', - n == 1 ? 'braucht 1 Modul' : 'braucht $n Module', + n == 1 ? '1 module missing' : '$n modules missing', + n == 1 ? '1 Modul fehlt' : '$n Module fehlen', ); String flowListNeedsModulesTooltip(String caps) => _t( 'This flow needs capabilities that are not installed:\n$caps\n' @@ -290,6 +309,52 @@ 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…'); + String listFilterNoMatch(String query) => _t( + 'No flow matches "$query".', + 'Kein Flow passt zu „$query".', + ); // Quick-fix button labels. String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren'); @@ -309,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; @@ -324,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, @@ -342,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, @@ -362,6 +430,7 @@ class AnalyzerStrings { : unknownCapInStore = _enUnknownCapInStore, unknownCapTypo = _enUnknownCapTypo, unknownCapNotInStore = _enUnknownCapNotInStore, + unknownCapStoreUnknown = _enUnknownCapStoreUnknown, unknownType = _enUnknownType, inputKind = _enInputKind, outputKind = _enOutputKind, @@ -378,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 ' - '`fai 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 c23bc91..7772482 100644 --- a/lib/src/quick_fix.dart +++ b/lib/src/quick_fix.dart @@ -69,12 +69,12 @@ class InstallCapabilityFix extends QuickFix { /// Ask the host to register a new module source for an unknown /// capability — used when the capability isn't in the public /// store. The host's handler typically prompts the operator -/// for a local path (`fai install --link`) or a URL -/// (`fai install `), then installs and reanalyzes. +/// for a local path (`chain install --link`) or a URL +/// (`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 b3110fd..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.23.0 +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); + }); + }); +}