From 4e7abec24d6859aee3af05794de4c59a7e880ece Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 17 Jul 2026 23:49:17 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20usertest=20fixes=20=E2=80=94=20hub-trut?= =?UTF-8?q?h=20builtins,=20calm=20badge=20+=20separate=20install=20action,?= =?UTF-8?q?=20list=20filter,=20formal=20address?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop the hardcoded builtin set {'debug.echo'}; missing-module detection trusts the host capability list (includes real hub builtins) — fixes hello's false play button - split the orange combined chip into a neutral status chip and a never-truncated install link - add a substring filter over the flow list with an honest no-match state - German strings use the formal address throughout; grammar fixes - analyzer hint says 'chain install --link' (was 'fai …') - 0.24.0 Signed-off-by: flemming-it --- CHANGELOG.md | 22 ++++ lib/src/flow_editor_page.dart | 221 +++++++++++++++++++++++----------- lib/src/l10n.dart | 34 ++++-- pubspec.yaml | 2 +- 4 files changed, 199 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ab46c3..a4856da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 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_editor_page.dart b/lib/src/flow_editor_page.dart index 9b63e6c..3040dcd 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -1252,11 +1252,6 @@ class _FlowFile { /// 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 { @@ -1266,15 +1261,16 @@ class _FlowMeta { static const empty = _FlowMeta(isExample: false, requiredCaps: []); - /// 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(); + /// 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 @@ -1345,7 +1341,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; @@ -1377,9 +1373,20 @@ class _FileList extends StatelessWidget { required this.onInstallMissing, }); + @override + State<_FileList> createState() => _FileListState(); +} + +class _FileListState extends State<_FileList> { + /// Case-insensitive substring filter over flow names. First + /// iteration of "find a flow fast" (usertest power-user + /// finding: no search over the flow list at all). + String _filter = ''; + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final strings = widget.strings; return Container( color: theme.colorScheme.surface, child: Column( @@ -1405,7 +1412,7 @@ class _FileList extends StatelessWidget { ), ), IconButton( - onPressed: onRefresh, + onPressed: widget.onRefresh, tooltip: strings.refresh, icon: const Icon(Icons.refresh, size: 16), visualDensity: VisualDensity.compact, @@ -1418,6 +1425,43 @@ class _FileList extends StatelessWidget { ], ), ), + Padding( + padding: const EdgeInsets.fromLTRB( + FaiSpace.md, + FaiSpace.xs, + FaiSpace.md, + FaiSpace.xs, + ), + 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, + ), + ), + ), + ), + ), + ), Expanded(child: _buildBody(context, theme)), ], ), @@ -1425,8 +1469,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,8 +1482,8 @@ 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( @@ -1448,15 +1493,33 @@ class _FileList extends StatelessWidget { ), ); } + final needle = _filter.toLowerCase(); + final files = needle.isEmpty + ? all + : all + .where((f) => f.name.toLowerCase().contains(needle)) + .toList(); + if (files.isEmpty) { + // Flows exist, the filter just matches none — say that + // instead of pretending the directory is empty. + return Padding( + padding: const EdgeInsets.all(FaiSpace.md), + child: FaiEmptyState( + icon: Icons.search_off_outlined, + title: strings.listFilterNoMatch(_filter), + hint: null, + ), + ); + } return ListView.builder( padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs), itemCount: files.length, itemBuilder: (_, i) { final f = files[i]; - final isActive = f.name == activeName; - final missing = f.meta.missingCaps(installedNames); + final isActive = f.name == widget.activeName; + final missing = f.meta.missingCaps(widget.installedNames); return InkWell( - onTap: () => onOpen(f), + onTap: () => widget.onOpen(f), child: Container( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, @@ -1535,9 +1598,10 @@ class _FileList extends StatelessWidget { _MissingModulesBadge( missing: missing, strings: strings, - onInstall: onInstallMissing == null + onInstall: widget.onInstallMissing == null ? null - : () => onInstallMissing!(missing), + : () => + widget.onInstallMissing!(missing), ), ], ), @@ -1550,7 +1614,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, @@ -1617,11 +1681,17 @@ 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. +/// "N modules missing" status on flows whose steps reference +/// capabilities the hub doesn't provide. Status and action are +/// SEPARATE elements (usertest: the combined orange chip made +/// the whole list read like an error wall and ellipsized the +/// action word): +/// - status = quiet neutral chip with a small amber dot — a +/// note, not an alarm; warning orange stays reserved for real +/// failures. +/// - action = its own "Install" link that is never truncated; +/// both live in the row's Wrap, so tight widths wrap to a +/// second line instead of cutting text mid-word. class _MissingModulesBadge extends StatelessWidget { final List missing; final FlowEditorStrings strings; @@ -1635,50 +1705,67 @@ class _MissingModulesBadge extends StatelessWidget { @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, + const dot = Color(0xFFEF6C00); + final fg = theme.colorScheme.onSurfaceVariant; + final chip = Tooltip( + message: strings.flowListNeedsModulesTooltip(missing.join('\n')), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: dot, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + strings.flowListNeedsModules(missing.length), style: theme.textTheme.labelSmall?.copyWith( - color: warn, + color: fg, + fontSize: 10, + letterSpacing: 0.2, + ), + ), + ], + ), + ), + ); + if (onInstall == null) return chip; + return Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + chip, + InkWell( + onTap: onInstall, + borderRadius: BorderRadius.circular(FaiRadius.sm), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: Text( + strings.flowListInstallMissing, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, fontSize: 10, fontWeight: FontWeight.w600, letterSpacing: 0.2, ), ), ), - ], - ), - ); - return Tooltip( - message: strings.flowListNeedsModulesTooltip(missing.join('\n')), - child: onInstall == null - ? chip - : InkWell( - onTap: onInstall, - borderRadius: BorderRadius.circular(FaiRadius.sm), - child: chip, - ), + ), + ], ); } } diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 910ffd2..3c0c46b 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -28,13 +28,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 +96,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 +144,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 +256,15 @@ 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.', + '`chain install --link ` or check the spelling.', 'Unbekannte Capability "$cap". ' 'Nicht im Store — lokal mit ' - '`fai install --link ` installieren oder Tippfehler prüfen.', + '`chain install --link ` installieren oder Tippfehler prüfen.', ); String analyzerInputKind() => _t('input', 'Eingabe'); String analyzerOutputKind() => _t('output', 'Ausgabe'); @@ -280,8 +283,8 @@ class FlowEditorStrings { 'Mitgelieferter Beispiel-Flow.', ); 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' @@ -291,6 +294,13 @@ class FlowEditorStrings { ); String get flowListInstallMissing => _t('Install', 'Installieren'); + // 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'); String fixAddSource(String cap) => @@ -379,7 +389,7 @@ class AnalyzerStrings { static String _enUnknownCapNotInStore(String cap) => 'Unknown capability "$cap". ' 'Not in the store — install locally with ' - '`fai install --link ` or check the spelling.'; + '`chain 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/pubspec.yaml b/pubspec.yaml index b3110fd..60d1f9d 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.24.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor