Compare commits

..

9 commits

Author SHA1 Message Date
36a83f9ae4 fix: setState callback must not return the reload future
Signed-off-by: flemming-it <sf@flemming.it>
2026-08-28 00:11:55 +02:00
29294a2971 feat: sample flows follow the hub's wire flag; examples group + import action
The Example chip used to come from a client-side content-marker
scan that had drifted from the hub's renamed sample header — no
current sample matched it. The host now passes the hub-reported
sample set (FlowSummary.sample) and the editor renders truth only:
no host info, no chips.

Samples collapse under one 'Examples (N)' group below the
operator's own flows (expanded only when nothing else exists), and
an optional onImportSamples action on the empty list is the
deliberate way to pull the bundled examples into a sealed area's
empty hub. Guard: flow_list_samples_test pins chips-from-host-only,
the grouping, and the import round trip.

Signed-off-by: flemming-it <sf@flemming.it>
2026-08-28 00:08:51 +02:00
8da50068f3 docs: changelog — unknown-store hardening under 0.25.0
Signed-off-by: flemming-it <sf@flemming.it>
2026-07-22 14:13:17 +02:00
2535c28fce fix: never claim 'not in store' while the store state is unknown
A failed/unloaded store snapshot used to be indistinguishable from
a known-empty store, so every missing capability was labelled 'not
in store' the moment the hub or store endpoint was unreachable — a
wrong claim. storeCapabilities is now nullable (null = unknown):
missing caps then get the plain missing chip with an honest
tooltip, no install offer and no not-in-store claim; the analyzer
message says the store cannot be checked right now (EN+DE). Split
and badge covered by new unit + widget tests.

Signed-off-by: flemming-it <sf@flemming.it>
2026-07-22 14:02:28 +02:00
c4a39a3779 feat: project separation in the flow list (0.25.0)
Filter the file list by the host's active project — flows without
a project: key count as 'general', display-only, the file is never
rewritten. New flows are stamped with the active project's key
(general and all-projects stay unstamped). Adds a toolbarTrailing
slot so the host can mount its workspace switcher in the editor's
single toolbar, and a flowsDir injection point so widget tests run
against a temp dir instead of the operator's ~/.chain flows.
Covered by pure filter-semantics tests plus hermetic widget tests
for filtering, the project-empty state, and new-flow stamping.

Signed-off-by: flemming-it <sf@flemming.it>
2026-07-22 13:56:29 +02:00
ab97e5e834 feat: honest install badge — classify store resolvability before the click
Split each flow's missing capabilities into store-resolvable ones
(amber chip + Install link, which now installs only those) and a
quiet 'not in store' chip whose tooltip explains the three recovery
paths in place: local install via chain install --link, adding the
providing store, or configuring the MCP/n8n integration. The
analyzer's not-in-store message names the same three paths (EN+DE).
Previously the list's Install action covered every missing
capability and could end in the hub's 'no store entry' error.

Also replace a private-looking capability example name in a doc
comment and test with a neutral placeholder. (0.25.0)

Signed-off-by: flemming-it <sf@flemming.it>
2026-07-22 13:42:11 +02:00
7d6a575cae fix: drop the duplicate FLOWS panel header, current CLI name in docs (0.24.1)
Signed-off-by: flemming-it <sf@flemming.it>
2026-07-18 00:27:24 +02:00
4e7abec24d feat: usertest fixes — hub-truth builtins, calm badge + separate install action, list filter, formal address
- 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 <sf@flemming.it>
2026-07-17 23:49:17 +02:00
ae09a55146 feat(run): host-injected native file picker for flow file inputs
The Run tab asked the operator to type an absolute path into a text
field — with no file dialog anywhere. FlowEditorPage now accepts an
onPickFile callback (PickedFileData: name + bytes) that hosts wire
to their native picker; the path-entry dialog remains only as the
no-host fallback. 0.23.0.

Signed-off-by: flemming-it <sf@flemming.it>
2026-07-17 01:04:40 +02:00
18 changed files with 1302 additions and 220 deletions

View file

@ -1,5 +1,111 @@
# 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 '<name>'" 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
(`FlowEditorPage.onPickFile`, new `PickedFileData` /
`PickFileCallback` contract). The manual path-entry dialog remains
only as the no-host fallback — typing an absolute path by hand is
a last resort, not an input method.
All notable changes to `chain_studio_flow_editor` recorded here.
Studio bumps its `pubspec.yaml` git ref to a specific release
of this package on every editor change.

View file

@ -40,6 +40,8 @@ export 'src/run_driver.dart'
show
FlowRunDriver,
FlowRunEvent,
PickedFileData,
PickFileCallback,
StepStarted,
StepCompleted,
StepFailed,

View file

@ -38,13 +38,14 @@ class FlowAnalyzer extends AbstractAnalyzer {
/// rebuild.
final List<String> 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<String> 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<String>? 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 <String>[];
final storeBare = storeCaps.map(_bareCap).toSet();
final storeCaps = storeCapabilities?.call();
final storeKnown = storeCaps != null;
final storeBare =
(storeCaps ?? const <String>[]).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,

View file

@ -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,17 @@ 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<String> 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<String>? storeCapabilities;
/// Host-side native file picker for the Run tab's file inputs.
/// Studio passes a real file dialog; null keeps the manual
/// path-entry fallback.
final PickFileCallback? onPickFile;
/// The host's active workspace/project slug (empty = "all
/// projects", no active workspace). Used only to flag a
@ -114,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<String>? 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<void> Function()? onImportSamples;
const FlowEditorPage({
super.key,
this.initialFlowName,
@ -123,9 +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
@ -172,6 +210,21 @@ class _FlowEditorPageState extends State<FlowEditorPage>
}
}
@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);
@ -276,7 +329,8 @@ class _FlowEditorPageState extends State<FlowEditorPage>
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;
@ -285,8 +339,10 @@ class _FlowEditorPageState extends State<FlowEditorPage>
// --- file ops ---
String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir();
Future<List<_FlowFile>> _listFiles() async {
final dir = Directory(_defaultFlowsDir());
final dir = Directory(_flowsDir);
if (!dir.existsSync()) return <_FlowFile>[];
final entries = await dir
.list()
@ -300,11 +356,13 @@ class _FlowEditorPageState extends State<FlowEditorPage>
// 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,
),
);
@ -325,7 +383,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
}
Future<void> _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();
@ -368,7 +426,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
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,
@ -412,11 +470,18 @@ class _FlowEditorPageState extends State<FlowEditorPage>
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
@ -431,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()) {
@ -569,6 +634,7 @@ outputs:
? () => Navigator.of(context).maybePop()
: null,
onNew: _newFlow,
trailing: widget.toolbarTrailing,
),
const Divider(height: 1),
Expanded(
@ -581,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,
@ -637,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,
@ -658,6 +740,7 @@ outputs:
controller: _controller,
strings: _l,
driver: widget.runDriver,
onPickFile: widget.onPickFile,
),
],
),
@ -836,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
@ -937,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
@ -1011,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),
@ -1120,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),
@ -1227,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<String> _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<String> 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<String> missingCaps(Set<String> 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<String> missingCaps(Set<String> availableNames) =>
requiredCaps.where((c) => !availableNames.contains(c)).toList();
}
/// Caches [_FlowMeta] per file, keyed by path + mtime. Reading
@ -1294,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 = <String>{};
final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$');
for (final raw in text.split('\n')) {
@ -1318,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
@ -1338,7 +1432,7 @@ String _formatBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
class _FileList extends StatelessWidget {
class _FileList extends StatefulWidget {
final Future<List<_FlowFile>> filesFuture;
final String? activeName;
final FlowEditorStrings strings;
@ -1346,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<String> installedNames;
/// Bare capability NAMES a configured store can install
/// (host-filtered to installable entries), or null when the
/// store state is unknown. Missing caps outside this set
/// render the "not in store" state instead of an install
/// action that the hub would refuse; with null neither claim
/// is made.
final Set<String>? storeNames;
/// Active workspace project slug; empty = all projects. Files
/// without a `project:` key count as `general`.
final String activeProject;
final void Function(_FlowFile) onOpen;
final VoidCallback onRefresh;
@ -1359,46 +1465,105 @@ class _FileList extends StatelessWidget {
/// renders, just without the one-click action.
final Future<void> Function(List<String>)? onInstallMissing;
/// Import the bundled samples (empty-list action); null hides it.
final Future<void> 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<void> _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(
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,
letterSpacing: 0.6,
),
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,
@ -1418,8 +1583,9 @@ class _FileList extends StatelessWidget {
}
Widget _buildBody(BuildContext context, ThemeData theme) {
final strings = widget.strings;
return FutureBuilder<List<_FlowFile>>(
future: filesFuture,
future: widget.filesFuture,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
@ -1430,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,
@ -1516,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,
),
),
],
),
@ -1543,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,
@ -1561,10 +1842,7 @@ class _FileList extends StatelessWidget {
),
),
);
},
);
},
);
}
}
}
@ -1610,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<String> 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 {
@ -1933,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(
@ -2128,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),
),
),
@ -2186,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,
@ -2206,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),
@ -2359,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),
],
@ -2485,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)),
),
],
),

View file

@ -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

View file

@ -30,7 +30,7 @@ import 'wire_colors.dart';
class FlowYamlCodeController extends CodeController {
FlowYamlCodeController({
List<String> Function()? availableCapabilities,
List<String> Function()? storeCapabilities,
List<String>? 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<String> Function() available,
List<String> Function()? store,
List<String>? 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<String> Function() provider) {
analyzer = FlowAnalyzer(availableCapabilities: provider);
}

View file

@ -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 <path>` or check the spelling.',
'No configured store can install it — install a local module '
'(`chain install --link <path>`), 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 <pfad>` installieren oder Tippfehler prüfen.',
'Kein eingerichteter Store kann sie installieren — lokales '
'Modul installieren (`chain install --link <pfad>`), 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 <path>`).',
'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 <pfad>`).',
);
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 <path>`), 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 <pfad>`), 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 <path>` or check the spelling.';
'No configured store can install it — install a local module '
'(`chain install --link <path>`), 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 <path>`).';
static String _enUnknownType(String kind, String value, String validList) =>
'Unknown $kind type "$value". Use one of: $validList.';
static String _enInputKind() => 'input';

View file

@ -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 <url>`), then installs and reanalyzes.
/// for a local path (`chain install --link`) or a URL
/// (`chain install <url>`), 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

View file

@ -19,6 +19,19 @@
import 'dart:typed_data';
/// A file chosen by the host's native picker for a flow file input.
class PickedFileData {
final String fileName;
final Uint8List bytes;
const PickedFileData({required this.fileName, required this.bytes});
}
/// Host-side native file picker. Returns null when the operator
/// cancels. When a host does not supply one, the Run tab falls back
/// to its manual path-entry dialog.
typedef PickFileCallback = Future<PickedFileData?> Function();
abstract class FlowRunDriver {
/// Trigger a run of [flowName] with the supplied inputs.
/// Text + file inputs come in separate maps so the host

View file

@ -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!,
],
],
),
),

View file

@ -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<String> installable,
List<String> notInStore,
List<String> unclassified,
}) splitMissingCaps(
List<String> missing,
Set<String>? storeNames,
) {
if (storeNames == null) {
// Store state unknown (snapshot not loaded / unreachable):
// claim neither "installable" nor "not in store".
return (
installable: const <String>[],
notInStore: const <String>[],
unclassified: missing,
);
}
final installable = <String>[];
final notInStore = <String>[];
for (final cap in missing) {
final bare = cap.split('@').first;
(storeNames.contains(bare) ? installable : notInStore).add(cap);
}
return (
installable: installable,
notInStore: notInStore,
unclassified: const <String>[],
);
}
/// "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<String> installable;
final List<String> notInStore;
final List<String> 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 = <Widget>[
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,
),
),
],
),
),
);
}
}

View file

@ -36,11 +36,17 @@ class RunTab extends StatefulWidget {
final FlowEditorController controller;
final FlowEditorStrings strings;
final FlowRunDriver? driver;
/// Host-side native file picker; null keeps the manual
/// path-entry fallback dialog.
final PickFileCallback? onPickFile;
const RunTab({
super.key,
required this.controller,
required this.strings,
this.driver,
this.onPickFile,
});
@override
@ -469,11 +475,21 @@ class _RunTabState extends State<RunTab> {
}
Future<void> _pickFile(String name) async {
// For 0.2.0 the editor relies on the host to inject a
// file-picker via callback; minimal version uses
// dart:io directly for desktop. Studio uses file_picker;
// we fall back to a manual path entry dialog if no host
// picker is available.
// Host-injected native picker first typing an absolute path
// by hand is a last resort, not an input method.
final hostPick = widget.onPickFile;
if (hostPick != null) {
final picked = await hostPick();
if (picked == null || !mounted) return;
setState(() {
_fileInputs[name] = _FilePick(
fileName: picked.fileName,
bytes: picked.bytes,
);
});
return;
}
// Fallback without a host picker: manual path entry.
final controller = TextEditingController();
final ctx = context;
final result = await showDialog<String>(

View file

@ -1,6 +1,6 @@
name: chain_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.22.0
version: 0.26.0
publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor

View file

@ -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<AddModuleSourceFix>());
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',

View file

@ -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<void> _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<void>.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<void>.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<void>.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:')));
});
}

View file

@ -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<void> _pumpEditor(
WidgetTester tester, {
required String flowsDir,
Set<String>? sampleFlowNames,
Future<void> Function()? onImportSamples,
}) async {
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
home: FlowEditorPage(
flowsDir: flowsDir,
sampleFlowNames: sampleFlowNames,
onImportSamples: onImportSamples,
),
),
);
await Future<void>.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<void>.delayed(const Duration(milliseconds: 200));
await tester.pump();
await Future<void>.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);
});
}

View file

@ -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');

View file

@ -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<void> pumpBadge(
WidgetTester tester, {
required List<String> installable,
required List<String> 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<Tooltip>(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<Tooltip>(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);
});
});
}