Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36a83f9ae4 | |||
| 29294a2971 | |||
| 8da50068f3 | |||
| 2535c28fce | |||
| c4a39a3779 | |||
| ab97e5e834 | |||
| 7d6a575cae | |||
| 4e7abec24d | |||
| ae09a55146 |
18 changed files with 1302 additions and 220 deletions
106
CHANGELOG.md
106
CHANGELOG.md
|
|
@ -1,5 +1,111 @@
|
||||||
# Changelog
|
# 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.
|
All notable changes to `chain_studio_flow_editor` recorded here.
|
||||||
Studio bumps its `pubspec.yaml` git ref to a specific release
|
Studio bumps its `pubspec.yaml` git ref to a specific release
|
||||||
of this package on every editor change.
|
of this package on every editor change.
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ export 'src/run_driver.dart'
|
||||||
show
|
show
|
||||||
FlowRunDriver,
|
FlowRunDriver,
|
||||||
FlowRunEvent,
|
FlowRunEvent,
|
||||||
|
PickedFileData,
|
||||||
|
PickFileCallback,
|
||||||
StepStarted,
|
StepStarted,
|
||||||
StepCompleted,
|
StepCompleted,
|
||||||
StepFailed,
|
StepFailed,
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,14 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
||||||
/// rebuild.
|
/// rebuild.
|
||||||
final List<String> Function() availableCapabilities;
|
final List<String> Function() availableCapabilities;
|
||||||
|
|
||||||
/// Returns the names of capabilities the public store knows
|
/// Returns the names of capabilities the store can actually
|
||||||
/// how to install. Used to decide whether an unknown-cap
|
/// install, or null when the store state is UNKNOWN (snapshot
|
||||||
/// issue should carry an Install button — clicking the
|
/// not loaded / store unreachable). Drives whether an
|
||||||
/// button on a capability the hub can't actually fetch would
|
/// unknown-cap issue carries an Install button (in store), the
|
||||||
/// just fail. Null = "no store available" → install offered
|
/// "not in store" recovery message (known, absent) or the
|
||||||
/// for every unknown cap (legacy behaviour).
|
/// neutral store-unknown wording (null) — the analyzer never
|
||||||
final List<String> Function()? storeCapabilities;
|
/// claims "no store provides it" without a loaded snapshot.
|
||||||
|
final List<String>? Function()? storeCapabilities;
|
||||||
|
|
||||||
/// Quick fixes attached to the most-recent analyze() pass.
|
/// Quick fixes attached to the most-recent analyze() pass.
|
||||||
/// Keyed by the same `Issue` instances that landed in
|
/// 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
|
// as Did-you-mean candidates so the suggestion can preserve
|
||||||
// the version constraint when the user already typed one.
|
// the version constraint when the user already typed one.
|
||||||
final installedFull = caps.toSet();
|
final installedFull = caps.toSet();
|
||||||
final storeCaps =
|
final storeCaps = storeCapabilities?.call();
|
||||||
storeCapabilities?.call() ?? const <String>[];
|
final storeKnown = storeCaps != null;
|
||||||
final storeBare = storeCaps.map(_bareCap).toSet();
|
final storeBare =
|
||||||
|
(storeCaps ?? const <String>[]).map(_bareCap).toSet();
|
||||||
|
|
||||||
YamlNode? doc;
|
YamlNode? doc;
|
||||||
try {
|
try {
|
||||||
|
|
@ -175,7 +177,9 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
||||||
? strings.unknownCapInStore(useValue)
|
? strings.unknownCapInStore(useValue)
|
||||||
: didYouMean != null
|
: didYouMean != null
|
||||||
? strings.unknownCapTypo(useValue, didYouMean)
|
? strings.unknownCapTypo(useValue, didYouMean)
|
||||||
: strings.unknownCapNotInStore(useValue);
|
: storeKnown
|
||||||
|
? strings.unknownCapNotInStore(useValue)
|
||||||
|
: strings.unknownCapStoreUnknown(useValue);
|
||||||
final issue = Issue(
|
final issue = Issue(
|
||||||
line: issueLine,
|
line: issueLine,
|
||||||
message: message,
|
message: message,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ library;
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show setEquals;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||||
|
|
@ -41,6 +42,7 @@ import 'tokens.dart';
|
||||||
import 'widgets.dart';
|
import 'widgets.dart';
|
||||||
import 'widgets/capability_picker.dart';
|
import 'widgets/capability_picker.dart';
|
||||||
import 'widgets/flow_canvas.dart';
|
import 'widgets/flow_canvas.dart';
|
||||||
|
import 'widgets/missing_modules_badge.dart';
|
||||||
import 'widgets/properties_panel.dart';
|
import 'widgets/properties_panel.dart';
|
||||||
import 'widgets/run_tab.dart';
|
import 'widgets/run_tab.dart';
|
||||||
|
|
||||||
|
|
@ -94,12 +96,17 @@ class FlowEditorPage extends StatefulWidget {
|
||||||
/// then call the Hub install API.
|
/// then call the Hub install API.
|
||||||
final AddModuleSourceCallback? onAddModuleSource;
|
final AddModuleSourceCallback? onAddModuleSource;
|
||||||
|
|
||||||
/// Capabilities the public store knows how to install. The
|
/// Capabilities the store can actually install, or null when
|
||||||
/// analyzer uses this to decide whether to show "Install …"
|
/// the store state is UNKNOWN (snapshot not loaded / store
|
||||||
/// (in store) or "Add source for …" (not in store) as the
|
/// unreachable). Drives the analyzer's quick-fix choice and
|
||||||
/// quick-fix on an unknown `use:` line. Empty list = store
|
/// the flow list's badge: in store → Install; known-absent →
|
||||||
/// silent — no install button offered.
|
/// "not in store" + recovery paths; unknown → neither claim.
|
||||||
final List<String> storeCapabilities;
|
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
|
/// The host's active workspace/project slug (empty = "all
|
||||||
/// projects", no active workspace). Used only to flag a
|
/// 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).
|
/// (the mismatch note still shows — the file still wins).
|
||||||
final void Function(String fileProject)? onSwitchToFileProject;
|
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({
|
const FlowEditorPage({
|
||||||
super.key,
|
super.key,
|
||||||
this.initialFlowName,
|
this.initialFlowName,
|
||||||
|
|
@ -123,9 +156,14 @@ class FlowEditorPage extends StatefulWidget {
|
||||||
this.style,
|
this.style,
|
||||||
this.onInstallCapability,
|
this.onInstallCapability,
|
||||||
this.onAddModuleSource,
|
this.onAddModuleSource,
|
||||||
this.storeCapabilities = const [],
|
this.storeCapabilities,
|
||||||
this.activeProject = '',
|
this.activeProject = '',
|
||||||
this.onSwitchToFileProject,
|
this.onSwitchToFileProject,
|
||||||
|
this.onPickFile,
|
||||||
|
this.toolbarTrailing,
|
||||||
|
this.flowsDir,
|
||||||
|
this.sampleFlowNames,
|
||||||
|
this.onImportSamples,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.codeController.hoverRequest.removeListener(_onHoverChanged);
|
_controller.codeController.hoverRequest.removeListener(_onHoverChanged);
|
||||||
|
|
@ -276,7 +329,8 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
final commentIdx = line.indexOf('#', colonIdx + 1);
|
final commentIdx = line.indexOf('#', colonIdx + 1);
|
||||||
final rhsEnd = commentIdx < 0 ? line.length : commentIdx;
|
final rhsEnd = commentIdx < 0 ? line.length : commentIdx;
|
||||||
final tail = commentIdx < 0 ? '' : line.substring(rhsEnd);
|
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'}';
|
'${tail.isEmpty ? '' : ' $tail'}';
|
||||||
if (newLine == line) return;
|
if (newLine == line) return;
|
||||||
lines[fix.line] = newLine;
|
lines[fix.line] = newLine;
|
||||||
|
|
@ -285,8 +339,10 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
|
|
||||||
// --- file ops ---
|
// --- file ops ---
|
||||||
|
|
||||||
|
String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir();
|
||||||
|
|
||||||
Future<List<_FlowFile>> _listFiles() async {
|
Future<List<_FlowFile>> _listFiles() async {
|
||||||
final dir = Directory(_defaultFlowsDir());
|
final dir = Directory(_flowsDir);
|
||||||
if (!dir.existsSync()) return <_FlowFile>[];
|
if (!dir.existsSync()) return <_FlowFile>[];
|
||||||
final entries = await dir
|
final entries = await dir
|
||||||
.list()
|
.list()
|
||||||
|
|
@ -300,11 +356,13 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
// by path + mtime so a refresh that didn't touch a file
|
// 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.
|
// doesn't re-read it, and a paint never re-reads at all.
|
||||||
final meta = await _flowMetaCache.forFile(f, stat);
|
final meta = await _flowMetaCache.forFile(f, stat);
|
||||||
|
final name = f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), '');
|
||||||
files.add(
|
files.add(
|
||||||
_FlowFile(
|
_FlowFile(
|
||||||
name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
|
name: name,
|
||||||
path: f.path,
|
path: f.path,
|
||||||
sizeBytes: stat.size,
|
sizeBytes: stat.size,
|
||||||
|
isExample: widget.sampleFlowNames?.contains(name) ?? false,
|
||||||
meta: meta,
|
meta: meta,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -325,7 +383,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openByName(String name) async {
|
Future<void> _openByName(String name) async {
|
||||||
final path = '${_defaultFlowsDir()}/$name.yaml';
|
final path = '$_flowsDir/$name.yaml';
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
if (!file.existsSync()) return;
|
if (!file.existsSync()) return;
|
||||||
final text = await file.readAsString();
|
final text = await file.readAsString();
|
||||||
|
|
@ -368,7 +426,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
if (name == null) return;
|
if (name == null) return;
|
||||||
_controller.saving = true;
|
_controller.saving = true;
|
||||||
try {
|
try {
|
||||||
final file = File('${_defaultFlowsDir()}/$name.yaml');
|
final file = File('$_flowsDir/$name.yaml');
|
||||||
await file.writeAsString(
|
await file.writeAsString(
|
||||||
_controller.codeController.fullText,
|
_controller.codeController.fullText,
|
||||||
flush: true,
|
flush: true,
|
||||||
|
|
@ -412,11 +470,18 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
builder: (ctx) => _NewFlowDialog(strings: _l),
|
builder: (ctx) => _NewFlowDialog(strings: _l),
|
||||||
);
|
);
|
||||||
if (name == null || name.isEmpty || !mounted) return;
|
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 =
|
final template =
|
||||||
'''# ${_l.newTemplateComment(name)}
|
'''# ${_l.newTemplateComment(name)}
|
||||||
|
|
||||||
name: $name
|
name: $name
|
||||||
|
$projectLine
|
||||||
inputs:
|
inputs:
|
||||||
text:
|
text:
|
||||||
type: text
|
type: text
|
||||||
|
|
@ -431,7 +496,7 @@ outputs:
|
||||||
result: \$echo.echoed
|
result: \$echo.echoed
|
||||||
''';
|
''';
|
||||||
try {
|
try {
|
||||||
final dir = Directory(_defaultFlowsDir());
|
final dir = Directory(_flowsDir);
|
||||||
if (!dir.existsSync()) await dir.create(recursive: true);
|
if (!dir.existsSync()) await dir.create(recursive: true);
|
||||||
final file = File('${dir.path}/$name.yaml');
|
final file = File('${dir.path}/$name.yaml');
|
||||||
if (file.existsSync()) {
|
if (file.existsSync()) {
|
||||||
|
|
@ -569,6 +634,7 @@ outputs:
|
||||||
? () => Navigator.of(context).maybePop()
|
? () => Navigator.of(context).maybePop()
|
||||||
: null,
|
: null,
|
||||||
onNew: _newFlow,
|
onNew: _newFlow,
|
||||||
|
trailing: widget.toolbarTrailing,
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -581,9 +647,24 @@ outputs:
|
||||||
filesFuture: _files,
|
filesFuture: _files,
|
||||||
activeName: _controller.activeName,
|
activeName: _controller.activeName,
|
||||||
strings: _l,
|
strings: _l,
|
||||||
|
onImportSamples: widget.onImportSamples == null
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await widget.onImportSamples!();
|
||||||
|
if (mounted) {
|
||||||
|
final fresh = _listFiles();
|
||||||
|
setState(() {
|
||||||
|
_files = fresh;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
installedNames: _installedNames(
|
installedNames: _installedNames(
|
||||||
widget.availableCapabilities,
|
widget.availableCapabilities,
|
||||||
),
|
),
|
||||||
|
storeNames: widget.storeCapabilities == null
|
||||||
|
? null
|
||||||
|
: _installedNames(widget.storeCapabilities!),
|
||||||
|
activeProject: widget.activeProject,
|
||||||
onOpen: _openFile,
|
onOpen: _openFile,
|
||||||
onRefresh: _refreshFiles,
|
onRefresh: _refreshFiles,
|
||||||
onStart: _startFile,
|
onStart: _startFile,
|
||||||
|
|
@ -637,7 +718,8 @@ outputs:
|
||||||
errorCount: _controller.analyzerErrorCount,
|
errorCount: _controller.analyzerErrorCount,
|
||||||
onAddStep: _controller.activeName != null ? _addStep : null,
|
onAddStep: _controller.activeName != null ? _addStep : null,
|
||||||
onSave: _controller.activeName != null ? _save : null,
|
onSave: _controller.activeName != null ? _save : null,
|
||||||
onRun: _controller.activeName != null &&
|
onRun:
|
||||||
|
_controller.activeName != null &&
|
||||||
_controller.analyzerErrorCount == 0
|
_controller.analyzerErrorCount == 0
|
||||||
? () => _tabs.animateTo(2)
|
? () => _tabs.animateTo(2)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -658,6 +740,7 @@ outputs:
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
strings: _l,
|
strings: _l,
|
||||||
driver: widget.runDriver,
|
driver: widget.runDriver,
|
||||||
|
onPickFile: widget.onPickFile,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -836,9 +919,7 @@ outputs:
|
||||||
minLines: null,
|
minLines: null,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
gutterStyle: GutterStyle(
|
gutterStyle: GutterStyle(
|
||||||
textStyle: mono.copyWith(
|
textStyle: mono.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
background: theme.colorScheme.surfaceContainer,
|
background: theme.colorScheme.surfaceContainer,
|
||||||
showLineNumbers: true,
|
showLineNumbers: true,
|
||||||
// Disable the built-in error column entirely — its
|
// Disable the built-in error column entirely — its
|
||||||
|
|
@ -937,12 +1018,18 @@ class _Toolbar extends StatelessWidget {
|
||||||
final bool dirty;
|
final bool dirty;
|
||||||
final VoidCallback? onBack;
|
final VoidCallback? onBack;
|
||||||
final VoidCallback onNew;
|
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({
|
const _Toolbar({
|
||||||
required this.strings,
|
required this.strings,
|
||||||
required this.activeName,
|
required this.activeName,
|
||||||
required this.dirty,
|
required this.dirty,
|
||||||
required this.onBack,
|
required this.onBack,
|
||||||
required this.onNew,
|
required this.onNew,
|
||||||
|
this.trailing,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -1011,6 +1098,10 @@ class _Toolbar extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
if (trailing != null) ...[
|
||||||
|
trailing!,
|
||||||
|
const SizedBox(width: FaiSpace.md),
|
||||||
|
],
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: onNew,
|
onPressed: onNew,
|
||||||
icon: const Icon(Icons.add, size: 16),
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
|
@ -1120,9 +1211,7 @@ class _TabActionStrip extends StatelessWidget {
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: Icon(
|
: Icon(
|
||||||
errorCount > 0
|
errorCount > 0 ? Icons.block : Icons.play_arrow,
|
||||||
? Icons.block
|
|
||||||
: Icons.play_arrow,
|
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
label: Text(strings.run),
|
label: Text(strings.run),
|
||||||
|
|
@ -1227,47 +1316,50 @@ class _FlowFile {
|
||||||
final String path;
|
final String path;
|
||||||
final int sizeBytes;
|
final int sizeBytes;
|
||||||
|
|
||||||
/// Scan result for this file — whether it's a bundled example
|
/// True when the HOST reports this flow as a bundled sample
|
||||||
/// and which capabilities its steps require. Computed once at
|
/// (hub wire flag) — never guessed from the file content.
|
||||||
/// list-load time (see [_FlowMetaCache]).
|
final bool isExample;
|
||||||
|
|
||||||
|
/// Scan result for this file — which capabilities its steps
|
||||||
|
/// require. Computed once at list-load time (see
|
||||||
|
/// [_FlowMetaCache]).
|
||||||
final _FlowMeta meta;
|
final _FlowMeta meta;
|
||||||
|
|
||||||
const _FlowFile({
|
const _FlowFile({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.sizeBytes,
|
required this.sizeBytes,
|
||||||
|
required this.isExample,
|
||||||
required this.meta,
|
required this.meta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marker text every bundled sample flow carries in its
|
/// Result of scanning a single flow file: the capability NAMES
|
||||||
/// provenance comment header. A file is an example iff its raw
|
/// (without `@version`) its steps reference.
|
||||||
/// 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.
|
|
||||||
class _FlowMeta {
|
class _FlowMeta {
|
||||||
final bool isExample;
|
|
||||||
final List<String> requiredCaps;
|
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
|
static const empty = _FlowMeta(requiredCaps: []);
|
||||||
/// built in. [availableNames] is the set of installed
|
|
||||||
/// capability NAMES (the part before `@`).
|
/// Capabilities this flow needs that the hub does not provide.
|
||||||
List<String> missingCaps(Set<String> availableNames) => requiredCaps
|
/// [availableNames] is the set of capability NAMES (the part
|
||||||
.where(
|
/// before `@`) from the host's live capability list — which
|
||||||
(c) =>
|
/// already includes the hub's builtins (e.g. system.approval),
|
||||||
!availableNames.contains(c) && !_builtinCapabilities.contains(c),
|
/// so there is no client-side builtin list to drift out of
|
||||||
)
|
/// sync with the hub (the old hardcoded {'debug.echo'} made
|
||||||
.toList();
|
/// 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
|
/// 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
|
/// Scan raw flow YAML for the capability ids referenced by
|
||||||
/// ids referenced by `use:` lines. A line scan is used rather
|
/// `use:` lines. A line scan is used rather than a full YAML
|
||||||
/// than a full YAML parse: it's robust against malformed flows
|
/// parse: it's robust against malformed flows (the analyzer
|
||||||
/// (the analyzer reports those separately) and never throws.
|
/// reports those separately) and never throws.
|
||||||
_FlowMeta _scanFlow(String text) {
|
_FlowMeta _scanFlow(String text) {
|
||||||
final isExample = text.contains(_sampleFlowMarker);
|
|
||||||
final caps = <String>{};
|
final caps = <String>{};
|
||||||
final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$');
|
final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$');
|
||||||
for (final raw in text.split('\n')) {
|
for (final raw in text.split('\n')) {
|
||||||
|
|
@ -1318,7 +1409,10 @@ _FlowMeta _scanFlow(String text) {
|
||||||
final name = value.split('@').first.trim();
|
final name = value.split('@').first.trim();
|
||||||
if (name.isNotEmpty) caps.add(name);
|
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
|
/// Reduce the host-supplied installed list (entries like
|
||||||
|
|
@ -1338,7 +1432,7 @@ String _formatBytes(int bytes) {
|
||||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FileList extends StatelessWidget {
|
class _FileList extends StatefulWidget {
|
||||||
final Future<List<_FlowFile>> filesFuture;
|
final Future<List<_FlowFile>> filesFuture;
|
||||||
final String? activeName;
|
final String? activeName;
|
||||||
final FlowEditorStrings strings;
|
final FlowEditorStrings strings;
|
||||||
|
|
@ -1346,6 +1440,18 @@ class _FileList extends StatelessWidget {
|
||||||
/// Bare capability NAMES the host reports as installed. Used
|
/// Bare capability NAMES the host reports as installed. Used
|
||||||
/// to compute each row's missing-module count.
|
/// to compute each row's missing-module count.
|
||||||
final Set<String> installedNames;
|
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 void Function(_FlowFile) onOpen;
|
||||||
final VoidCallback onRefresh;
|
final VoidCallback onRefresh;
|
||||||
|
|
||||||
|
|
@ -1359,46 +1465,105 @@ class _FileList extends StatelessWidget {
|
||||||
/// renders, just without the one-click action.
|
/// renders, just without the one-click action.
|
||||||
final Future<void> Function(List<String>)? onInstallMissing;
|
final Future<void> Function(List<String>)? onInstallMissing;
|
||||||
|
|
||||||
|
/// Import the bundled samples (empty-list action); null hides it.
|
||||||
|
final Future<void> Function()? onImportSamples;
|
||||||
|
|
||||||
const _FileList({
|
const _FileList({
|
||||||
required this.filesFuture,
|
required this.filesFuture,
|
||||||
required this.activeName,
|
required this.activeName,
|
||||||
required this.strings,
|
required this.strings,
|
||||||
|
this.onImportSamples,
|
||||||
required this.installedNames,
|
required this.installedNames,
|
||||||
|
required this.storeNames,
|
||||||
|
required this.activeProject,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onRefresh,
|
required this.onRefresh,
|
||||||
required this.onStart,
|
required this.onStart,
|
||||||
required this.onInstallMissing,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final strings = widget.strings;
|
||||||
return Container(
|
return Container(
|
||||||
color: theme.colorScheme.surface,
|
color: theme.colorScheme.surface,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
// No ALL-CAPS panel header: the page toolbar already says
|
||||||
padding: const EdgeInsets.symmetric(
|
// "Flows" — the duplicate label read as a broken title
|
||||||
horizontal: FaiSpace.md,
|
// hierarchy (usertest art-director finding). The refresh
|
||||||
vertical: FaiSpace.xs,
|
// action sits next to the filter instead.
|
||||||
),
|
Padding(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
border: Border(bottom: BorderSide(color: theme.dividerColor)),
|
FaiSpace.md,
|
||||||
|
FaiSpace.xs,
|
||||||
|
FaiSpace.md,
|
||||||
|
FaiSpace.xs,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: SizedBox(
|
||||||
strings.listHeader,
|
height: 28,
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
child: TextField(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
onChanged: (v) => setState(() => _filter = v.trim()),
|
||||||
letterSpacing: 0.6,
|
style: theme.textTheme.bodySmall,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 14),
|
||||||
|
prefixIconConstraints: const BoxConstraints(
|
||||||
|
minWidth: 28,
|
||||||
|
minHeight: 28,
|
||||||
|
),
|
||||||
|
hintText: strings.listFilterHint,
|
||||||
|
hintStyle: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: theme.colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: onRefresh,
|
onPressed: widget.onRefresh,
|
||||||
tooltip: strings.refresh,
|
tooltip: strings.refresh,
|
||||||
icon: const Icon(Icons.refresh, size: 16),
|
icon: const Icon(Icons.refresh, size: 16),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
|
@ -1418,8 +1583,9 @@ class _FileList extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody(BuildContext context, ThemeData theme) {
|
Widget _buildBody(BuildContext context, ThemeData theme) {
|
||||||
|
final strings = widget.strings;
|
||||||
return FutureBuilder<List<_FlowFile>>(
|
return FutureBuilder<List<_FlowFile>>(
|
||||||
future: filesFuture,
|
future: widget.filesFuture,
|
||||||
builder: (context, snap) {
|
builder: (context, snap) {
|
||||||
if (snap.connectionState == ConnectionState.waiting) {
|
if (snap.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
|
@ -1430,26 +1596,132 @@ class _FileList extends StatelessWidget {
|
||||||
child: ChainErrorBox(error: snap.error, isError: true),
|
child: ChainErrorBox(error: snap.error, isError: true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final files = snap.data ?? <_FlowFile>[];
|
final all = snap.data ?? <_FlowFile>[];
|
||||||
if (files.isEmpty) {
|
if (all.isEmpty) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(FaiSpace.md),
|
padding: const EdgeInsets.all(FaiSpace.md),
|
||||||
child: FaiEmptyState(
|
child: FaiEmptyState(
|
||||||
icon: Icons.folder_outlined,
|
icon: Icons.folder_outlined,
|
||||||
title: strings.listEmptyTitle,
|
title: strings.listEmptyTitle,
|
||||||
hint: strings.listEmptyBody,
|
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),
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
||||||
itemCount: files.length,
|
children: [
|
||||||
itemBuilder: (_, i) {
|
for (final f in own) _row(theme, f),
|
||||||
final f = files[i];
|
if (samples.isNotEmpty) ...[
|
||||||
final isActive = f.name == activeName;
|
InkWell(
|
||||||
final missing = f.meta.missingCaps(installedNames);
|
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(
|
return InkWell(
|
||||||
onTap: () => onOpen(f),
|
onTap: () => widget.onOpen(f),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: FaiSpace.md,
|
horizontal: FaiSpace.md,
|
||||||
|
|
@ -1516,21 +1788,30 @@ class _FileList extends StatelessWidget {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (f.meta.isExample || missing.isNotEmpty) ...[
|
if (f.isExample || missing.isNotEmpty) ...[
|
||||||
const SizedBox(height: FaiSpace.xs),
|
const SizedBox(height: FaiSpace.xs),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: FaiSpace.xs,
|
spacing: FaiSpace.xs,
|
||||||
runSpacing: FaiSpace.xs,
|
runSpacing: FaiSpace.xs,
|
||||||
children: [
|
children: [
|
||||||
if (f.meta.isExample)
|
if (f.isExample)
|
||||||
_ExampleBadge(strings: strings),
|
_ExampleBadge(strings: strings),
|
||||||
if (missing.isNotEmpty)
|
if (missing.isNotEmpty)
|
||||||
_MissingModulesBadge(
|
MissingModulesBadge(
|
||||||
missing: missing,
|
installable: split.installable,
|
||||||
|
notInStore: split.notInStore,
|
||||||
|
unclassified: split.unclassified,
|
||||||
strings: strings,
|
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
|
? null
|
||||||
: () => onInstallMissing!(missing),
|
: () => widget.onInstallMissing!(
|
||||||
|
split.installable,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1543,7 +1824,7 @@ class _FileList extends StatelessWidget {
|
||||||
// the install badge is the actionable path then.
|
// the install badge is the actionable path then.
|
||||||
if (missing.isEmpty)
|
if (missing.isEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => onStart(f),
|
onPressed: () => widget.onStart(f),
|
||||||
tooltip: strings.listStartTooltip,
|
tooltip: strings.listStartTooltip,
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.play_arrow_rounded,
|
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 ---
|
// --- empty state ---
|
||||||
|
|
||||||
class _EmptyState extends StatelessWidget {
|
class _EmptyState extends StatelessWidget {
|
||||||
|
|
@ -1933,8 +2145,10 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> {
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => setState(() => _expanded = !_expanded),
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -2128,16 +2342,10 @@ class _IssueRow extends StatelessWidget {
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.auto_fix_high, size: 13),
|
: const Icon(Icons.auto_fix_high, size: 13),
|
||||||
label: Text(
|
label: Text(fix.label, style: _monoTextStyle(size: 11)),
|
||||||
fix.label,
|
|
||||||
style: _monoTextStyle(size: 11),
|
|
||||||
),
|
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
horizontal: 8,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
minimumSize: const Size(0, 24),
|
minimumSize: const Size(0, 24),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -2186,7 +2394,10 @@ class _IssueHoverCard extends StatelessWidget {
|
||||||
// to viewport so the card never spills off-screen on a
|
// to viewport so the card never spills off-screen on a
|
||||||
// narrow window.
|
// narrow window.
|
||||||
const cardWidth = 380.0;
|
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 dx = (request.globalPosition.dx + 12).clamp(8.0, maxLeft);
|
||||||
final dy = (request.globalPosition.dy + 18).clamp(
|
final dy = (request.globalPosition.dy + 18).clamp(
|
||||||
8.0,
|
8.0,
|
||||||
|
|
@ -2206,9 +2417,7 @@ class _IssueHoverCard extends StatelessWidget {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
color: theme.colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Color(0x33000000),
|
color: Color(0x33000000),
|
||||||
|
|
@ -2359,11 +2568,7 @@ class _FixDialogState extends State<_FixDialog> {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.auto_fix_high, size: 20, color: theme.colorScheme.primary),
|
||||||
Icons.auto_fix_high,
|
|
||||||
size: 20,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(strings.diagnosticFixDialogTitle),
|
Text(strings.diagnosticFixDialogTitle),
|
||||||
],
|
],
|
||||||
|
|
@ -2485,10 +2690,7 @@ class _FixDialogRow extends StatelessWidget {
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => onApplyFix(fix),
|
onPressed: () => onApplyFix(fix),
|
||||||
icon: const Icon(Icons.auto_fix_high, size: 13),
|
icon: const Icon(Icons.auto_fix_high, size: 13),
|
||||||
label: Text(
|
label: Text(fix.label, style: _monoTextStyle(size: 11)),
|
||||||
fix.label,
|
|
||||||
style: _monoTextStyle(size: 11),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,21 @@ String parseFlowProject(String yaml) {
|
||||||
return '';
|
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,
|
/// Mirror of the hub's `normalize_project_slug`: lowercase,
|
||||||
/// collapse spaces/underscores/dashes to a single `-`, drop
|
/// collapse spaces/underscores/dashes to a single `-`, drop
|
||||||
/// everything else, trim trailing dashes. Empty in → empty out
|
/// everything else, trim trailing dashes. Empty in → empty out
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import 'wire_colors.dart';
|
||||||
class FlowYamlCodeController extends CodeController {
|
class FlowYamlCodeController extends CodeController {
|
||||||
FlowYamlCodeController({
|
FlowYamlCodeController({
|
||||||
List<String> Function()? availableCapabilities,
|
List<String> Function()? availableCapabilities,
|
||||||
List<String> Function()? storeCapabilities,
|
List<String>? Function()? storeCapabilities,
|
||||||
AnalyzerStrings analyzerStrings = AnalyzerStrings.english,
|
AnalyzerStrings analyzerStrings = AnalyzerStrings.english,
|
||||||
}) : super(
|
}) : super(
|
||||||
text: '',
|
text: '',
|
||||||
|
|
@ -49,7 +49,7 @@ class FlowYamlCodeController extends CodeController {
|
||||||
/// closure or stale strings inside an old FlowAnalyzer.
|
/// closure or stale strings inside an old FlowAnalyzer.
|
||||||
void setCapabilityProviders({
|
void setCapabilityProviders({
|
||||||
required List<String> Function() available,
|
required List<String> Function() available,
|
||||||
List<String> Function()? store,
|
List<String>? Function()? store,
|
||||||
AnalyzerStrings? strings,
|
AnalyzerStrings? strings,
|
||||||
}) {
|
}) {
|
||||||
analyzer = FlowAnalyzer(
|
analyzer = FlowAnalyzer(
|
||||||
|
|
@ -108,7 +108,7 @@ class FlowYamlCodeController extends CodeController {
|
||||||
|
|
||||||
/// Replace the analyzer's capability provider. Called by the
|
/// Replace the analyzer's capability provider. Called by the
|
||||||
/// editor host when the installed-capability list changes —
|
/// 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) {
|
void setAvailableCapabilities(List<String> Function() provider) {
|
||||||
analyzer = FlowAnalyzer(availableCapabilities: provider);
|
analyzer = FlowAnalyzer(availableCapabilities: provider);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ class FlowEditorStrings {
|
||||||
String get save => _t('Save', 'Speichern');
|
String get save => _t('Save', 'Speichern');
|
||||||
String get run => _t('Run', 'Ausführen');
|
String get run => _t('Run', 'Ausführen');
|
||||||
String get refresh => _t('Refresh file list', 'Datei-Liste neu laden');
|
String get refresh => _t('Refresh file list', 'Datei-Liste neu laden');
|
||||||
String get listHeader => _t('FLOWS', 'FLOWS');
|
|
||||||
String get listStartTooltip =>
|
String get listStartTooltip =>
|
||||||
_t('Open on the Run tab', 'Im Starten-Tab öffnen');
|
_t('Open on the Run tab', 'Im Starten-Tab öffnen');
|
||||||
String get copy => _t('Copy', 'Kopieren');
|
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 emptyTitle => _t('No flow open', 'Kein Flow geöffnet');
|
||||||
String get emptyBody => _t(
|
String get emptyBody => _t(
|
||||||
'Pick one from the left, or click New flow to start a fresh one.',
|
'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 =>
|
String get listEmptyTitle =>
|
||||||
_t('No saved flows', 'Keine gespeicherten Flows');
|
_t('No saved flows', 'Keine gespeicherten Flows');
|
||||||
String get listEmptyBody => _t(
|
String get listEmptyBody => _t(
|
||||||
'Click New flow to scaffold one from a template.',
|
'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 =>
|
String get discardTitle =>
|
||||||
_t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?');
|
_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 graphEmptyTitle => _t('No steps yet', 'Noch keine Schritte');
|
||||||
String get graphEmptyBody => _t(
|
String get graphEmptyBody => _t(
|
||||||
'Click Add step to drop the first capability onto the canvas.',
|
'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.
|
// Properties panel.
|
||||||
String get propPanelTitle => _t('Step details', 'Schritt-Details');
|
String get propPanelTitle => _t('Step details', 'Schritt-Details');
|
||||||
String get propNoSelection => _t(
|
String get propNoSelection => _t(
|
||||||
'Click a step on the canvas to edit it.',
|
'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 propStepId => _t('ID', 'ID');
|
||||||
String get propCapability => _t('Capability', 'Capability');
|
String get propCapability => _t('Capability', 'Capability');
|
||||||
|
|
@ -142,11 +143,12 @@ class FlowEditorStrings {
|
||||||
String get runStart => _t('Start run', 'Lauf starten');
|
String get runStart => _t('Start run', 'Lauf starten');
|
||||||
String get runUnsavedBanner => _t(
|
String get runUnsavedBanner => _t(
|
||||||
'You have unsaved changes — save first to run the latest version.',
|
'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(
|
String get runNoFlow => _t(
|
||||||
'Open a flow from the left to run it.',
|
'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 runOutputs => _t('Outputs', 'Ausgaben');
|
||||||
String get runChooseFile => _t('Choose file…', 'Datei wählen…');
|
String get runChooseFile => _t('Choose file…', 'Datei wählen…');
|
||||||
|
|
@ -253,15 +255,28 @@ class FlowEditorStrings {
|
||||||
);
|
);
|
||||||
String analyzerUnknownCapTypo(String cap, String suggestion) => _t(
|
String analyzerUnknownCapTypo(String cap, String suggestion) => _t(
|
||||||
'Unknown capability "$cap". Did you mean "$suggestion"?',
|
'Unknown capability "$cap". Did you mean "$suggestion"?',
|
||||||
'Unbekannte Capability "$cap". Meintest du "$suggestion"?',
|
'Unbekannte Capability "$cap". Meinten Sie "$suggestion"?',
|
||||||
);
|
);
|
||||||
String analyzerUnknownCapNotInStore(String cap) => _t(
|
String analyzerUnknownCapNotInStore(String cap) => _t(
|
||||||
'Unknown capability "$cap". '
|
'Unknown capability "$cap". '
|
||||||
'Not in the store — install locally with '
|
'No configured store can install it — install a local module '
|
||||||
'`fai install --link <path>` or check the spelling.',
|
'(`chain install --link <path>`), add the store that provides '
|
||||||
|
'it, or configure the integration (MCP/n8n) that supplies it.',
|
||||||
'Unbekannte Capability "$cap". '
|
'Unbekannte Capability "$cap". '
|
||||||
'Nicht im Store — lokal mit '
|
'Kein eingerichteter Store kann sie installieren — lokales '
|
||||||
'`fai install --link <pfad>` installieren oder Tippfehler prüfen.',
|
'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 analyzerInputKind() => _t('input', 'Eingabe');
|
||||||
String analyzerOutputKind() => _t('output', 'Ausgabe');
|
String analyzerOutputKind() => _t('output', 'Ausgabe');
|
||||||
|
|
@ -279,9 +294,13 @@ class FlowEditorStrings {
|
||||||
'Bundled sample flow.',
|
'Bundled sample flow.',
|
||||||
'Mitgelieferter Beispiel-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(
|
String flowListNeedsModules(int n) => _t(
|
||||||
n == 1 ? 'needs 1 module' : 'needs $n modules',
|
n == 1 ? '1 module missing' : '$n modules missing',
|
||||||
n == 1 ? 'braucht 1 Modul' : 'braucht $n Module',
|
n == 1 ? '1 Modul fehlt' : '$n Module fehlen',
|
||||||
);
|
);
|
||||||
String flowListNeedsModulesTooltip(String caps) => _t(
|
String flowListNeedsModulesTooltip(String caps) => _t(
|
||||||
'This flow needs capabilities that are not installed:\n$caps\n'
|
'This flow needs capabilities that are not installed:\n$caps\n'
|
||||||
|
|
@ -290,6 +309,52 @@ class FlowEditorStrings {
|
||||||
'Klicken, um die fehlenden zu installieren.',
|
'Klicken, um die fehlenden zu installieren.',
|
||||||
);
|
);
|
||||||
String get flowListInstallMissing => _t('Install', '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.
|
// Quick-fix button labels.
|
||||||
String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren');
|
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) unknownCapInStore;
|
||||||
final String Function(String cap, String suggestion) unknownCapTypo;
|
final String Function(String cap, String suggestion) unknownCapTypo;
|
||||||
final String Function(String cap) unknownCapNotInStore;
|
final String Function(String cap) unknownCapNotInStore;
|
||||||
|
final String Function(String cap) unknownCapStoreUnknown;
|
||||||
final String Function(String kind, String value, String validList)
|
final String Function(String kind, String value, String validList)
|
||||||
unknownType;
|
unknownType;
|
||||||
final String Function() inputKind;
|
final String Function() inputKind;
|
||||||
|
|
@ -324,6 +390,7 @@ class AnalyzerStrings {
|
||||||
required this.unknownCapInStore,
|
required this.unknownCapInStore,
|
||||||
required this.unknownCapTypo,
|
required this.unknownCapTypo,
|
||||||
required this.unknownCapNotInStore,
|
required this.unknownCapNotInStore,
|
||||||
|
required this.unknownCapStoreUnknown,
|
||||||
required this.unknownType,
|
required this.unknownType,
|
||||||
required this.inputKind,
|
required this.inputKind,
|
||||||
required this.outputKind,
|
required this.outputKind,
|
||||||
|
|
@ -342,6 +409,7 @@ class AnalyzerStrings {
|
||||||
unknownCapInStore: s.analyzerUnknownCapInStore,
|
unknownCapInStore: s.analyzerUnknownCapInStore,
|
||||||
unknownCapTypo: s.analyzerUnknownCapTypo,
|
unknownCapTypo: s.analyzerUnknownCapTypo,
|
||||||
unknownCapNotInStore: s.analyzerUnknownCapNotInStore,
|
unknownCapNotInStore: s.analyzerUnknownCapNotInStore,
|
||||||
|
unknownCapStoreUnknown: s.analyzerUnknownCapStoreUnknown,
|
||||||
unknownType: s.analyzerUnknownType,
|
unknownType: s.analyzerUnknownType,
|
||||||
inputKind: s.analyzerInputKind,
|
inputKind: s.analyzerInputKind,
|
||||||
outputKind: s.analyzerOutputKind,
|
outputKind: s.analyzerOutputKind,
|
||||||
|
|
@ -362,6 +430,7 @@ class AnalyzerStrings {
|
||||||
: unknownCapInStore = _enUnknownCapInStore,
|
: unknownCapInStore = _enUnknownCapInStore,
|
||||||
unknownCapTypo = _enUnknownCapTypo,
|
unknownCapTypo = _enUnknownCapTypo,
|
||||||
unknownCapNotInStore = _enUnknownCapNotInStore,
|
unknownCapNotInStore = _enUnknownCapNotInStore,
|
||||||
|
unknownCapStoreUnknown = _enUnknownCapStoreUnknown,
|
||||||
unknownType = _enUnknownType,
|
unknownType = _enUnknownType,
|
||||||
inputKind = _enInputKind,
|
inputKind = _enInputKind,
|
||||||
outputKind = _enOutputKind,
|
outputKind = _enOutputKind,
|
||||||
|
|
@ -378,8 +447,14 @@ class AnalyzerStrings {
|
||||||
'Unknown capability "$cap". Did you mean "$suggestion"?';
|
'Unknown capability "$cap". Did you mean "$suggestion"?';
|
||||||
static String _enUnknownCapNotInStore(String cap) =>
|
static String _enUnknownCapNotInStore(String cap) =>
|
||||||
'Unknown capability "$cap". '
|
'Unknown capability "$cap". '
|
||||||
'Not in the store — install locally with '
|
'No configured store can install it — install a local module '
|
||||||
'`fai install --link <path>` or check the spelling.';
|
'(`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) =>
|
static String _enUnknownType(String kind, String value, String validList) =>
|
||||||
'Unknown $kind type "$value". Use one of: $validList.';
|
'Unknown $kind type "$value". Use one of: $validList.';
|
||||||
static String _enInputKind() => 'input';
|
static String _enInputKind() => 'input';
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,12 @@ class InstallCapabilityFix extends QuickFix {
|
||||||
/// Ask the host to register a new module source for an unknown
|
/// Ask the host to register a new module source for an unknown
|
||||||
/// capability — used when the capability isn't in the public
|
/// capability — used when the capability isn't in the public
|
||||||
/// store. The host's handler typically prompts the operator
|
/// store. The host's handler typically prompts the operator
|
||||||
/// for a local path (`fai install --link`) or a URL
|
/// for a local path (`chain install --link`) or a URL
|
||||||
/// (`fai install <url>`), then installs and reanalyzes.
|
/// (`chain install <url>`), then installs and reanalyzes.
|
||||||
///
|
///
|
||||||
/// This is the recovery path for private modules: the public
|
/// This is the recovery path for private modules: the public
|
||||||
/// store doesn't know about `htw.digiscout/onet-lookup`, but
|
/// store doesn't know about `acme.internal/directory-lookup`,
|
||||||
/// the operator can point the hub at the local clone.
|
/// but the operator can point the hub at the local clone.
|
||||||
@immutable
|
@immutable
|
||||||
class AddModuleSourceFix extends QuickFix {
|
class AddModuleSourceFix extends QuickFix {
|
||||||
/// The capability the operator wrote — the host uses it to
|
/// The capability the operator wrote — the host uses it to
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,19 @@
|
||||||
|
|
||||||
import 'dart:typed_data';
|
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 {
|
abstract class FlowRunDriver {
|
||||||
/// Trigger a run of [flowName] with the supplied inputs.
|
/// Trigger a run of [flowName] with the supplied inputs.
|
||||||
/// Text + file inputs come in separate maps so the host
|
/// Text + file inputs come in separate maps so the host
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,17 @@ class FaiEmptyState extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
final String? hint;
|
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({
|
const FaiEmptyState({
|
||||||
super.key,
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.hint,
|
this.hint,
|
||||||
|
this.action,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -46,6 +52,10 @@ class FaiEmptyState extends StatelessWidget {
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (action != null) ...[
|
||||||
|
const SizedBox(height: FaiSpace.md),
|
||||||
|
action!,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
184
lib/src/widgets/missing_modules_badge.dart
Normal file
184
lib/src/widgets/missing_modules_badge.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,11 +36,17 @@ class RunTab extends StatefulWidget {
|
||||||
final FlowEditorController controller;
|
final FlowEditorController controller;
|
||||||
final FlowEditorStrings strings;
|
final FlowEditorStrings strings;
|
||||||
final FlowRunDriver? driver;
|
final FlowRunDriver? driver;
|
||||||
|
|
||||||
|
/// Host-side native file picker; null keeps the manual
|
||||||
|
/// path-entry fallback dialog.
|
||||||
|
final PickFileCallback? onPickFile;
|
||||||
|
|
||||||
const RunTab({
|
const RunTab({
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.strings,
|
required this.strings,
|
||||||
this.driver,
|
this.driver,
|
||||||
|
this.onPickFile,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -469,11 +475,21 @@ class _RunTabState extends State<RunTab> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickFile(String name) async {
|
Future<void> _pickFile(String name) async {
|
||||||
// For 0.2.0 the editor relies on the host to inject a
|
// Host-injected native picker first — typing an absolute path
|
||||||
// file-picker via callback; minimal version uses
|
// by hand is a last resort, not an input method.
|
||||||
// dart:io directly for desktop. Studio uses file_picker;
|
final hostPick = widget.onPickFile;
|
||||||
// we fall back to a manual path entry dialog if no host
|
if (hostPick != null) {
|
||||||
// picker is available.
|
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 controller = TextEditingController();
|
||||||
final ctx = context;
|
final ctx = context;
|
||||||
final result = await showDialog<String>(
|
final result = await showDialog<String>(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
name: chain_studio_flow_editor
|
name: chain_studio_flow_editor
|
||||||
description: Swappable inline YAML editor for F∆I Studio flows.
|
description: Swappable inline YAML editor for F∆I Studio flows.
|
||||||
version: 0.22.0
|
version: 0.26.0
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
repository: https://git.flemming.ai/fai/studio-flow-editor
|
repository: https://git.flemming.ai/fai/studio-flow-editor
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,13 +135,20 @@ steps:
|
||||||
name: x
|
name: x
|
||||||
steps:
|
steps:
|
||||||
- id: c
|
- id: c
|
||||||
use: htw.private/secret@^0.1
|
use: acme.internal/secret@^0.1
|
||||||
'''));
|
'''));
|
||||||
expect(r.issues, hasLength(1));
|
expect(r.issues, hasLength(1));
|
||||||
final fixes = a.fixesFor(r.issues.first);
|
final fixes = a.fixesFor(r.issues.first);
|
||||||
expect(fixes, hasLength(1));
|
expect(fixes, hasLength(1));
|
||||||
expect(fixes.first, isA<AddModuleSourceFix>());
|
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',
|
test('did-you-mean wins over install/add-source when a near miss exists',
|
||||||
|
|
|
||||||
146
test/flow_list_project_test.dart
Normal file
146
test/flow_list_project_test.dart
Normal 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:')));
|
||||||
|
});
|
||||||
|
}
|
||||||
144
test/flow_list_samples_test.dart
Normal file
144
test/flow_list_samples_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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', () {
|
group('normalizeProjectSlug', () {
|
||||||
test('collapses separators and trims trailing dashes', () {
|
test('collapses separators and trims trailing dashes', () {
|
||||||
expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha');
|
expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha');
|
||||||
|
|
|
||||||
134
test/missing_modules_badge_test.dart
Normal file
134
test/missing_modules_badge_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue