diff --git a/CHANGELOG.md b/CHANGELOG.md index ccc5edd..5ab46c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,103 +1,5 @@ # 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 30ca706..05dcd6e 100644 --- a/lib/src/flow_analyzer.dart +++ b/lib/src/flow_analyzer.dart @@ -38,14 +38,13 @@ class FlowAnalyzer extends AbstractAnalyzer { /// rebuild. final List Function() availableCapabilities; - /// 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; + /// 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; /// Quick fixes attached to the most-recent analyze() pass. /// Keyed by the same `Issue` instances that landed in @@ -112,10 +111,9 @@ 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(); - final storeKnown = storeCaps != null; - final storeBare = - (storeCaps ?? const []).map(_bareCap).toSet(); + final storeCaps = + storeCapabilities?.call() ?? const []; + final storeBare = storeCaps.map(_bareCap).toSet(); YamlNode? doc; try { @@ -177,9 +175,7 @@ class FlowAnalyzer extends AbstractAnalyzer { ? strings.unknownCapInStore(useValue) : didYouMean != null ? strings.unknownCapTypo(useValue, didYouMean) - : storeKnown - ? strings.unknownCapNotInStore(useValue) - : strings.unknownCapStoreUnknown(useValue); + : strings.unknownCapNotInStore(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 6363851..9b63e6c 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -25,7 +25,6 @@ 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'; @@ -42,7 +41,6 @@ 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'; @@ -96,12 +94,12 @@ class FlowEditorPage extends StatefulWidget { /// then call the Hub install API. final AddModuleSourceCallback? onAddModuleSource; - /// Capabilities the store can actually install, or null when - /// the store state is UNKNOWN (snapshot not loaded / store - /// unreachable). Drives the analyzer's quick-fix choice and - /// the flow list's badge: in store → Install; known-absent → - /// "not in store" + recovery paths; unknown → neither claim. - final List? storeCapabilities; + /// 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; /// Host-side native file picker for the Run tab's file inputs. /// Studio passes a real file dialog; null keeps the manual @@ -121,32 +119,6 @@ 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, @@ -156,14 +128,10 @@ class FlowEditorPage extends StatefulWidget { this.style, this.onInstallCapability, this.onAddModuleSource, - this.storeCapabilities, + this.storeCapabilities = const [], this.activeProject = '', this.onSwitchToFileProject, this.onPickFile, - this.toolbarTrailing, - this.flowsDir, - this.sampleFlowNames, - this.onImportSamples, }); @override @@ -210,21 +178,6 @@ 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); @@ -329,8 +282,7 @@ 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; @@ -339,10 +291,8 @@ class _FlowEditorPageState extends State // --- file ops --- - String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir(); - Future> _listFiles() async { - final dir = Directory(_flowsDir); + final dir = Directory(_defaultFlowsDir()); if (!dir.existsSync()) return <_FlowFile>[]; final entries = await dir .list() @@ -356,13 +306,11 @@ 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: name, + name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''), path: f.path, sizeBytes: stat.size, - isExample: widget.sampleFlowNames?.contains(name) ?? false, meta: meta, ), ); @@ -383,7 +331,7 @@ class _FlowEditorPageState extends State } Future _openByName(String name) async { - final path = '$_flowsDir/$name.yaml'; + final path = '${_defaultFlowsDir()}/$name.yaml'; final file = File(path); if (!file.existsSync()) return; final text = await file.readAsString(); @@ -426,7 +374,7 @@ class _FlowEditorPageState extends State if (name == null) return; _controller.saving = true; try { - final file = File('$_flowsDir/$name.yaml'); + final file = File('${_defaultFlowsDir()}/$name.yaml'); await file.writeAsString( _controller.codeController.fullText, flush: true, @@ -470,18 +418,11 @@ 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 @@ -496,7 +437,7 @@ outputs: result: \$echo.echoed '''; try { - final dir = Directory(_flowsDir); + final dir = Directory(_defaultFlowsDir()); if (!dir.existsSync()) await dir.create(recursive: true); final file = File('${dir.path}/$name.yaml'); if (file.existsSync()) { @@ -634,7 +575,6 @@ outputs: ? () => Navigator.of(context).maybePop() : null, onNew: _newFlow, - trailing: widget.toolbarTrailing, ), const Divider(height: 1), Expanded( @@ -647,24 +587,9 @@ 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, @@ -718,8 +643,7 @@ 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, @@ -919,7 +843,9 @@ 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 @@ -1018,18 +944,12 @@ 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 @@ -1098,10 +1018,6 @@ 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), @@ -1211,7 +1127,9 @@ 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), @@ -1316,50 +1234,47 @@ class _FlowFile { final String path; final int sizeBytes; - /// 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]). + /// Scan result for this file — whether it's a bundled example + /// and which capabilities its steps require. Computed once at + /// list-load time (see [_FlowMetaCache]). final _FlowMeta meta; const _FlowFile({ required this.name, required this.path, required this.sizeBytes, - required this.isExample, required this.meta, }); } -/// Result of scanning a single flow file: the capability NAMES -/// (without `@version`) its steps reference. +/// 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. class _FlowMeta { + final bool isExample; final List requiredCaps; + const _FlowMeta({required this.isExample, required this.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(isExample: false, requiredCaps: []); - 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(); + /// 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(); } /// Caches [_FlowMeta] per file, keyed by path + mtime. Reading @@ -1386,11 +1301,12 @@ class _FlowMetaCache { } } -/// 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. +/// Scan raw flow YAML for its example marker and the capability +/// ids referenced by `use:` lines. A line scan is used rather +/// than a full YAML parse: it's robust against malformed flows +/// (the analyzer reports those separately) and never throws. _FlowMeta _scanFlow(String text) { + final isExample = text.contains(_sampleFlowMarker); final caps = {}; final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$'); for (final raw in text.split('\n')) { @@ -1409,10 +1325,7 @@ _FlowMeta _scanFlow(String text) { final name = value.split('@').first.trim(); if (name.isNotEmpty) caps.add(name); } - return _FlowMeta( - requiredCaps: caps.toList(), - project: parseFlowProject(text), - ); + return _FlowMeta(isExample: isExample, requiredCaps: caps.toList()); } /// Reduce the host-supplied installed list (entries like @@ -1432,7 +1345,7 @@ String _formatBytes(int bytes) { return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } -class _FileList extends StatefulWidget { +class _FileList extends StatelessWidget { final Future> filesFuture; final String? activeName; final FlowEditorStrings strings; @@ -1440,18 +1353,6 @@ 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; @@ -1465,105 +1366,46 @@ 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, 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: [ - // 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, + Container( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.md, + vertical: FaiSpace.xs, + ), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: theme.dividerColor)), ), child: Row( children: [ Expanded( - child: 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, - ), - ), - ), + child: Text( + strings.listHeader, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, ), ), ), IconButton( - onPressed: widget.onRefresh, + onPressed: onRefresh, tooltip: strings.refresh, icon: const Icon(Icons.refresh, size: 16), visualDensity: VisualDensity.compact, @@ -1583,9 +1425,8 @@ class _FileListState extends State<_FileList> { } Widget _buildBody(BuildContext context, ThemeData theme) { - final strings = widget.strings; return FutureBuilder>( - future: widget.filesFuture, + future: filesFuture, builder: (context, snap) { if (snap.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); @@ -1596,132 +1437,26 @@ class _FileListState extends State<_FileList> { child: ChainErrorBox(error: snap.error, isError: true), ); } - final all = snap.data ?? <_FlowFile>[]; - if (all.isEmpty) { + final files = snap.data ?? <_FlowFile>[]; + if (files.isEmpty) { return Padding( padding: const EdgeInsets.all(FaiSpace.md), child: FaiEmptyState( icon: Icons.folder_outlined, title: strings.listEmptyTitle, hint: strings.listEmptyBody, - // 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 - ? 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( + return ListView.builder( padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs), - 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); + itemCount: files.length, + itemBuilder: (_, i) { + final f = files[i]; + final isActive = f.name == activeName; + final missing = f.meta.missingCaps(installedNames); return InkWell( - onTap: () => widget.onOpen(f), + onTap: () => onOpen(f), child: Container( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, @@ -1788,30 +1523,21 @@ class _FileListState extends State<_FileList> { fontSize: 11, ), ), - if (f.isExample || missing.isNotEmpty) ...[ + if (f.meta.isExample || missing.isNotEmpty) ...[ const SizedBox(height: FaiSpace.xs), Wrap( spacing: FaiSpace.xs, runSpacing: FaiSpace.xs, children: [ - if (f.isExample) + if (f.meta.isExample) _ExampleBadge(strings: strings), if (missing.isNotEmpty) - MissingModulesBadge( - installable: split.installable, - notInStore: split.notInStore, - unclassified: split.unclassified, + _MissingModulesBadge( + missing: missing, strings: strings, - // Install only what the store - // resolves — the not-in-store - // chip explains the rest. - onInstall: - widget.onInstallMissing == null || - split.installable.isEmpty + onInstall: onInstallMissing == null ? null - : () => widget.onInstallMissing!( - split.installable, - ), + : () => onInstallMissing!(missing), ), ], ), @@ -1824,7 +1550,7 @@ class _FileListState extends State<_FileList> { // the install badge is the actionable path then. if (missing.isEmpty) IconButton( - onPressed: () => widget.onStart(f), + onPressed: () => onStart(f), tooltip: strings.listStartTooltip, icon: Icon( Icons.play_arrow_rounded, @@ -1842,7 +1568,10 @@ class _FileListState extends State<_FileList> { ), ), ); - } + }, + ); + }, + ); } } @@ -1888,6 +1617,72 @@ 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 { @@ -2145,10 +1940,8 @@ 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( @@ -2342,10 +2135,16 @@ 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), ), ), @@ -2394,10 +2193,7 @@ 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, @@ -2417,7 +2213,9 @@ 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), @@ -2568,7 +2366,11 @@ 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), ], @@ -2690,7 +2492,10 @@ 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 7d1eb99..74385c8 100644 --- a/lib/src/flow_project.dart +++ b/lib/src/flow_project.dart @@ -24,21 +24,6 @@ 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 ff5a6ed..94f81bd 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 `chain install` while the editor is open. + /// e.g. after `fai 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 7eef70d..910ffd2 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -17,6 +17,7 @@ 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'); @@ -27,14 +28,13 @@ 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ählen Sie links einen Flow aus oder klicken Sie auf „Neuer Flow", ' - 'um einen neuen anzulegen.', + 'Wähle links einen aus oder klicke 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.', - 'Klicken Sie auf „Neuer Flow", um aus einer Vorlage zu starten.', + 'Klicke Neuer Flow um aus einer Vorlage zu starten.', ); String get discardTitle => _t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?'); @@ -95,15 +95,14 @@ 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.', - 'Klicken Sie auf „Schritt hinzufügen", um die erste Capability auf ' - 'die Fläche zu setzen.', + 'Klicke 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.', - 'Klicken Sie einen Schritt auf der Fläche an, um ihn zu bearbeiten.', + 'Klicke einen Schritt auf der Fläche an, um ihn zu bearbeiten.', ); String get propStepId => _t('ID', 'ID'); String get propCapability => _t('Capability', 'Capability'); @@ -143,12 +142,11 @@ 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.', - 'Sie haben ungespeicherte Änderungen — bitte zuerst speichern, um die ' - 'neueste Version auszuführen.', + 'Du hast ungespeicherte Änderungen — bitte zuerst speichern, um die neueste Version auszuführen.', ); String get runNoFlow => _t( 'Open a flow from the left to run it.', - 'Öffnen Sie links einen Flow, um ihn auszuführen.', + 'Öffne links einen Flow um ihn auszuführen.', ); String get runOutputs => _t('Outputs', 'Ausgaben'); String get runChooseFile => _t('Choose file…', 'Datei wählen…'); @@ -255,28 +253,15 @@ class FlowEditorStrings { ); String analyzerUnknownCapTypo(String cap, String suggestion) => _t( 'Unknown capability "$cap". Did you mean "$suggestion"?', - 'Unbekannte Capability "$cap". Meinten Sie "$suggestion"?', + 'Unbekannte Capability "$cap". Meintest du "$suggestion"?', ); String analyzerUnknownCapNotInStore(String cap) => _t( 'Unknown capability "$cap". ' - '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.', + 'Not in the store — install locally with ' + '`fai install --link ` or check the spelling.', 'Unbekannte Capability "$cap". ' - '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 `).', + 'Nicht im Store — lokal mit ' + '`fai install --link ` installieren oder Tippfehler prüfen.', ); String analyzerInputKind() => _t('input', 'Eingabe'); String analyzerOutputKind() => _t('output', 'Ausgabe'); @@ -294,13 +279,9 @@ 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', + n == 1 ? 'needs 1 module' : 'needs $n modules', + n == 1 ? 'braucht 1 Modul' : 'braucht $n Module', ); String flowListNeedsModulesTooltip(String caps) => _t( 'This flow needs capabilities that are not installed:\n$caps\n' @@ -309,52 +290,6 @@ 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'); @@ -374,7 +309,6 @@ 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; @@ -390,7 +324,6 @@ class AnalyzerStrings { required this.unknownCapInStore, required this.unknownCapTypo, required this.unknownCapNotInStore, - required this.unknownCapStoreUnknown, required this.unknownType, required this.inputKind, required this.outputKind, @@ -409,7 +342,6 @@ class AnalyzerStrings { unknownCapInStore: s.analyzerUnknownCapInStore, unknownCapTypo: s.analyzerUnknownCapTypo, unknownCapNotInStore: s.analyzerUnknownCapNotInStore, - unknownCapStoreUnknown: s.analyzerUnknownCapStoreUnknown, unknownType: s.analyzerUnknownType, inputKind: s.analyzerInputKind, outputKind: s.analyzerOutputKind, @@ -430,7 +362,6 @@ class AnalyzerStrings { : unknownCapInStore = _enUnknownCapInStore, unknownCapTypo = _enUnknownCapTypo, unknownCapNotInStore = _enUnknownCapNotInStore, - unknownCapStoreUnknown = _enUnknownCapStoreUnknown, unknownType = _enUnknownType, inputKind = _enInputKind, outputKind = _enOutputKind, @@ -447,14 +378,8 @@ class AnalyzerStrings { 'Unknown capability "$cap". Did you mean "$suggestion"?'; static String _enUnknownCapNotInStore(String cap) => 'Unknown capability "$cap". ' - '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 `).'; + 'Not in the store — install locally with ' + '`fai install --link ` or check the spelling.'; 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 7772482..c23bc91 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 (`chain install --link`) or a URL -/// (`chain install `), then installs and reanalyzes. +/// for a local path (`fai install --link`) or a URL +/// (`fai install `), then installs and reanalyzes. /// /// This is the recovery path for private modules: the public -/// store doesn't know about `acme.internal/directory-lookup`, -/// but the operator can point the hub at the local clone. +/// store doesn't know about `htw.digiscout/onet-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 23bd68c..89bf739 100644 --- a/lib/src/widgets.dart +++ b/lib/src/widgets.dart @@ -12,17 +12,11 @@ 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 @@ -52,10 +46,6 @@ 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 deleted file mode 100644 index 319911b..0000000 --- a/lib/src/widgets/missing_modules_badge.dart +++ /dev/null @@ -1,184 +0,0 @@ -// 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 3d6656e..b3110fd 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.26.0 +version: 0.23.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 11ab1ab..9b35f4f 100644 --- a/test/flow_analyzer_test.dart +++ b/test/flow_analyzer_test.dart @@ -135,20 +135,13 @@ steps: name: x steps: - id: c - use: acme.internal/secret@^0.1 + use: htw.private/secret@^0.1 ''')); expect(r.issues, hasLength(1)); final fixes = a.fixesFor(r.issues.first); expect(fixes, hasLength(1)); expect(fixes.first, isA()); - // 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')); + expect(r.issues.first.message, contains('Not in the store')); }); 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 deleted file mode 100644 index a49c709..0000000 --- a/test/flow_list_project_test.dart +++ /dev/null @@ -1,146 +0,0 @@ -// 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 deleted file mode 100644 index caedf2f..0000000 --- a/test/flow_list_samples_test.dart +++ /dev/null @@ -1,144 +0,0 @@ -// 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 ad8eb6b..8888abf 100644 --- a/test/flow_project_test.dart +++ b/test/flow_project_test.dart @@ -34,30 +34,6 @@ 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 deleted file mode 100644 index 9a36236..0000000 --- a/test/missing_modules_badge_test.dart +++ /dev/null @@ -1,134 +0,0 @@ -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); - }); - }); -}