Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36a83f9ae4 | |||
| 29294a2971 | |||
| 8da50068f3 | |||
| 2535c28fce | |||
| c4a39a3779 | |||
| ab97e5e834 | |||
| 7d6a575cae | |||
| 4e7abec24d |
15 changed files with 1251 additions and 215 deletions
98
CHANGELOG.md
98
CHANGELOG.md
|
|
@ -1,5 +1,103 @@
|
|||
# Changelog
|
||||
|
||||
## 0.26.0
|
||||
|
||||
Sample flows follow the hub's wire flag (sealed-areas rework):
|
||||
|
||||
- **`FlowEditorPage.sampleFlowNames`** — the host passes the set of
|
||||
flow names the hub reports as bundled samples
|
||||
(`FlowSummary.sample`). This is now the ONLY source of the
|
||||
"Example" chip; the editor's own content-marker scan is gone (it
|
||||
had silently drifted from the hub's renamed sample header and
|
||||
missed every current sample). Null = no host info = no chips.
|
||||
- **Examples group.** Sample flows collapse under one
|
||||
"Examples (N)" row below the operator's own flows, expanded on
|
||||
demand — or by default when there is nothing else to show.
|
||||
- **`FlowEditorPage.onImportSamples`** — optional empty-list action
|
||||
("Import example flows"); sealed areas start without samples, so
|
||||
hosts wire this as the deliberate pull. The list refreshes itself
|
||||
after the import.
|
||||
- `FaiEmptyState` gained an optional `action` slot.
|
||||
- The editor reloads its file list when the host changes `flowsDir`
|
||||
or delivers the sample set after an async fetch.
|
||||
|
||||
## 0.25.0
|
||||
|
||||
Honest install badge — the flow list and analyzer only offer
|
||||
"Install" for capabilities a configured store can actually resolve:
|
||||
|
||||
- **Store-resolvability split in the flow list.** Missing
|
||||
capabilities are classified BEFORE any click: store-resolvable
|
||||
ones keep the amber chip + Install link (which now installs only
|
||||
those), the rest render a quiet "not in store" chip whose tooltip
|
||||
explains the three recovery paths (local install via
|
||||
`chain install --link`, adding the providing store, configuring
|
||||
the MCP/n8n integration). Previously the Install action covered
|
||||
every missing capability and could end in the hub's
|
||||
"no store entry for '<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
|
||||
|
|
|
|||
|
|
@ -38,13 +38,14 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
|||
/// rebuild.
|
||||
final List<String> Function() availableCapabilities;
|
||||
|
||||
/// Returns the names of capabilities the public store knows
|
||||
/// how to install. Used to decide whether an unknown-cap
|
||||
/// issue should carry an Install button — clicking the
|
||||
/// button on a capability the hub can't actually fetch would
|
||||
/// just fail. Null = "no store available" → install offered
|
||||
/// for every unknown cap (legacy behaviour).
|
||||
final List<String> Function()? storeCapabilities;
|
||||
/// Returns the names of capabilities the store can actually
|
||||
/// install, or null when the store state is UNKNOWN (snapshot
|
||||
/// not loaded / store unreachable). Drives whether an
|
||||
/// unknown-cap issue carries an Install button (in store), the
|
||||
/// "not in store" recovery message (known, absent) or the
|
||||
/// neutral store-unknown wording (null) — the analyzer never
|
||||
/// claims "no store provides it" without a loaded snapshot.
|
||||
final List<String>? Function()? storeCapabilities;
|
||||
|
||||
/// Quick fixes attached to the most-recent analyze() pass.
|
||||
/// Keyed by the same `Issue` instances that landed in
|
||||
|
|
@ -111,9 +112,10 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
|||
// as Did-you-mean candidates so the suggestion can preserve
|
||||
// the version constraint when the user already typed one.
|
||||
final installedFull = caps.toSet();
|
||||
final storeCaps =
|
||||
storeCapabilities?.call() ?? const <String>[];
|
||||
final storeBare = storeCaps.map(_bareCap).toSet();
|
||||
final storeCaps = storeCapabilities?.call();
|
||||
final storeKnown = storeCaps != null;
|
||||
final storeBare =
|
||||
(storeCaps ?? const <String>[]).map(_bareCap).toSet();
|
||||
|
||||
YamlNode? doc;
|
||||
try {
|
||||
|
|
@ -175,7 +177,9 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
|||
? strings.unknownCapInStore(useValue)
|
||||
: didYouMean != null
|
||||
? strings.unknownCapTypo(useValue, didYouMean)
|
||||
: strings.unknownCapNotInStore(useValue);
|
||||
: storeKnown
|
||||
? strings.unknownCapNotInStore(useValue)
|
||||
: strings.unknownCapStoreUnknown(useValue);
|
||||
final issue = Issue(
|
||||
line: issueLine,
|
||||
message: message,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ library;
|
|||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart' show setEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||
|
|
@ -41,6 +42,7 @@ import 'tokens.dart';
|
|||
import 'widgets.dart';
|
||||
import 'widgets/capability_picker.dart';
|
||||
import 'widgets/flow_canvas.dart';
|
||||
import 'widgets/missing_modules_badge.dart';
|
||||
import 'widgets/properties_panel.dart';
|
||||
import 'widgets/run_tab.dart';
|
||||
|
||||
|
|
@ -94,12 +96,12 @@ class FlowEditorPage extends StatefulWidget {
|
|||
/// then call the Hub install API.
|
||||
final AddModuleSourceCallback? onAddModuleSource;
|
||||
|
||||
/// Capabilities the public store knows how to install. The
|
||||
/// analyzer uses this to decide whether to show "Install …"
|
||||
/// (in store) or "Add source for …" (not in store) as the
|
||||
/// quick-fix on an unknown `use:` line. Empty list = store
|
||||
/// silent — no install button offered.
|
||||
final List<String> storeCapabilities;
|
||||
/// Capabilities the store can actually install, or null when
|
||||
/// the store state is UNKNOWN (snapshot not loaded / store
|
||||
/// unreachable). Drives the analyzer's quick-fix choice and
|
||||
/// the flow list's badge: in store → Install; known-absent →
|
||||
/// "not in store" + recovery paths; unknown → neither claim.
|
||||
final List<String>? storeCapabilities;
|
||||
|
||||
/// Host-side native file picker for the Run tab's file inputs.
|
||||
/// Studio passes a real file dialog; null keeps the manual
|
||||
|
|
@ -119,6 +121,32 @@ class FlowEditorPage extends StatefulWidget {
|
|||
/// (the mismatch note still shows — the file still wins).
|
||||
final void Function(String fileProject)? onSwitchToFileProject;
|
||||
|
||||
/// Host-injected widget rendered in the editor toolbar, before
|
||||
/// the New-flow button. Studio places its workspace (project)
|
||||
/// switcher here — the editor keeps a single toolbar and stays
|
||||
/// host-agnostic.
|
||||
final Widget? toolbarTrailing;
|
||||
|
||||
/// Directory the editor lists/saves flow files in. `null` uses
|
||||
/// the hub's default (`~/.chain/data/flows`). Tests inject a
|
||||
/// temp dir so they never touch the operator's real flows
|
||||
/// (hermetic per shared/TESTING.md).
|
||||
final String? flowsDir;
|
||||
|
||||
/// Names of the flows the HOST reports as bundled samples (the
|
||||
/// hub's `FlowSummary.sample` wire flag). Null = the host has no
|
||||
/// sample information (old hub / standalone use) — then no
|
||||
/// example chips render at all. The hub's sample header is the
|
||||
/// single source of truth; the editor no longer keeps its own
|
||||
/// marker scan (it had already drifted from the hub's header).
|
||||
final Set<String>? sampleFlowNames;
|
||||
|
||||
/// Import the bundled sample flows into the current hub.
|
||||
/// Rendered as the action of the empty list state — sealed
|
||||
/// areas start without examples, this is the deliberate pull.
|
||||
/// Null hides the action.
|
||||
final Future<void> Function()? onImportSamples;
|
||||
|
||||
const FlowEditorPage({
|
||||
super.key,
|
||||
this.initialFlowName,
|
||||
|
|
@ -128,10 +156,14 @@ class FlowEditorPage extends StatefulWidget {
|
|||
this.style,
|
||||
this.onInstallCapability,
|
||||
this.onAddModuleSource,
|
||||
this.storeCapabilities = const [],
|
||||
this.storeCapabilities,
|
||||
this.activeProject = '',
|
||||
this.onSwitchToFileProject,
|
||||
this.onPickFile,
|
||||
this.toolbarTrailing,
|
||||
this.flowsDir,
|
||||
this.sampleFlowNames,
|
||||
this.onImportSamples,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -178,6 +210,21 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FlowEditorPage old) {
|
||||
super.didUpdateWidget(old);
|
||||
// The host may repoint the directory (connection switch) or
|
||||
// deliver the hub's sample set after an async fetch — both
|
||||
// change what the list must show.
|
||||
if (widget.flowsDir != old.flowsDir ||
|
||||
!setEquals(widget.sampleFlowNames, old.sampleFlowNames)) {
|
||||
final fresh = _listFiles();
|
||||
setState(() {
|
||||
_files = fresh;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.codeController.hoverRequest.removeListener(_onHoverChanged);
|
||||
|
|
@ -282,7 +329,8 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
final commentIdx = line.indexOf('#', colonIdx + 1);
|
||||
final rhsEnd = commentIdx < 0 ? line.length : commentIdx;
|
||||
final tail = commentIdx < 0 ? '' : line.substring(rhsEnd);
|
||||
final newLine = '${line.substring(0, colonIdx + 1)} ${fix.replacement}'
|
||||
final newLine =
|
||||
'${line.substring(0, colonIdx + 1)} ${fix.replacement}'
|
||||
'${tail.isEmpty ? '' : ' $tail'}';
|
||||
if (newLine == line) return;
|
||||
lines[fix.line] = newLine;
|
||||
|
|
@ -291,8 +339,10 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
|
||||
// --- file ops ---
|
||||
|
||||
String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir();
|
||||
|
||||
Future<List<_FlowFile>> _listFiles() async {
|
||||
final dir = Directory(_defaultFlowsDir());
|
||||
final dir = Directory(_flowsDir);
|
||||
if (!dir.existsSync()) return <_FlowFile>[];
|
||||
final entries = await dir
|
||||
.list()
|
||||
|
|
@ -306,11 +356,13 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
// by path + mtime so a refresh that didn't touch a file
|
||||
// doesn't re-read it, and a paint never re-reads at all.
|
||||
final meta = await _flowMetaCache.forFile(f, stat);
|
||||
final name = f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), '');
|
||||
files.add(
|
||||
_FlowFile(
|
||||
name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
|
||||
name: name,
|
||||
path: f.path,
|
||||
sizeBytes: stat.size,
|
||||
isExample: widget.sampleFlowNames?.contains(name) ?? false,
|
||||
meta: meta,
|
||||
),
|
||||
);
|
||||
|
|
@ -331,7 +383,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
}
|
||||
|
||||
Future<void> _openByName(String name) async {
|
||||
final path = '${_defaultFlowsDir()}/$name.yaml';
|
||||
final path = '$_flowsDir/$name.yaml';
|
||||
final file = File(path);
|
||||
if (!file.existsSync()) return;
|
||||
final text = await file.readAsString();
|
||||
|
|
@ -374,7 +426,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
if (name == null) return;
|
||||
_controller.saving = true;
|
||||
try {
|
||||
final file = File('${_defaultFlowsDir()}/$name.yaml');
|
||||
final file = File('$_flowsDir/$name.yaml');
|
||||
await file.writeAsString(
|
||||
_controller.codeController.fullText,
|
||||
flush: true,
|
||||
|
|
@ -418,11 +470,18 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
builder: (ctx) => _NewFlowDialog(strings: _l),
|
||||
);
|
||||
if (name == null || name.isEmpty || !mounted) return;
|
||||
// Stamp the active workspace project into the new file so the
|
||||
// flow stays visible under the filter it was created in. The
|
||||
// file wins from here on; `general` (and "all projects") stay
|
||||
// unstamped — no key already means general.
|
||||
final project = widget.activeProject;
|
||||
final projectLine =
|
||||
project.isEmpty || project == 'general' ? '' : 'project: $project\n';
|
||||
final template =
|
||||
'''# ${_l.newTemplateComment(name)}
|
||||
|
||||
name: $name
|
||||
|
||||
$projectLine
|
||||
inputs:
|
||||
text:
|
||||
type: text
|
||||
|
|
@ -437,7 +496,7 @@ outputs:
|
|||
result: \$echo.echoed
|
||||
''';
|
||||
try {
|
||||
final dir = Directory(_defaultFlowsDir());
|
||||
final dir = Directory(_flowsDir);
|
||||
if (!dir.existsSync()) await dir.create(recursive: true);
|
||||
final file = File('${dir.path}/$name.yaml');
|
||||
if (file.existsSync()) {
|
||||
|
|
@ -575,6 +634,7 @@ outputs:
|
|||
? () => Navigator.of(context).maybePop()
|
||||
: null,
|
||||
onNew: _newFlow,
|
||||
trailing: widget.toolbarTrailing,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
|
|
@ -587,9 +647,24 @@ outputs:
|
|||
filesFuture: _files,
|
||||
activeName: _controller.activeName,
|
||||
strings: _l,
|
||||
onImportSamples: widget.onImportSamples == null
|
||||
? null
|
||||
: () async {
|
||||
await widget.onImportSamples!();
|
||||
if (mounted) {
|
||||
final fresh = _listFiles();
|
||||
setState(() {
|
||||
_files = fresh;
|
||||
});
|
||||
}
|
||||
},
|
||||
installedNames: _installedNames(
|
||||
widget.availableCapabilities,
|
||||
),
|
||||
storeNames: widget.storeCapabilities == null
|
||||
? null
|
||||
: _installedNames(widget.storeCapabilities!),
|
||||
activeProject: widget.activeProject,
|
||||
onOpen: _openFile,
|
||||
onRefresh: _refreshFiles,
|
||||
onStart: _startFile,
|
||||
|
|
@ -643,7 +718,8 @@ outputs:
|
|||
errorCount: _controller.analyzerErrorCount,
|
||||
onAddStep: _controller.activeName != null ? _addStep : null,
|
||||
onSave: _controller.activeName != null ? _save : null,
|
||||
onRun: _controller.activeName != null &&
|
||||
onRun:
|
||||
_controller.activeName != null &&
|
||||
_controller.analyzerErrorCount == 0
|
||||
? () => _tabs.animateTo(2)
|
||||
: null,
|
||||
|
|
@ -843,9 +919,7 @@ outputs:
|
|||
minLines: null,
|
||||
maxLines: null,
|
||||
gutterStyle: GutterStyle(
|
||||
textStyle: mono.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textStyle: mono.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
background: theme.colorScheme.surfaceContainer,
|
||||
showLineNumbers: true,
|
||||
// Disable the built-in error column entirely — its
|
||||
|
|
@ -944,12 +1018,18 @@ class _Toolbar extends StatelessWidget {
|
|||
final bool dirty;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback onNew;
|
||||
|
||||
/// Host-injected widget rendered before the New-flow button —
|
||||
/// Studio places its workspace (project) switcher here so the
|
||||
/// editor keeps a single toolbar and stays host-agnostic.
|
||||
final Widget? trailing;
|
||||
const _Toolbar({
|
||||
required this.strings,
|
||||
required this.activeName,
|
||||
required this.dirty,
|
||||
required this.onBack,
|
||||
required this.onNew,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -1018,6 +1098,10 @@ class _Toolbar extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (trailing != null) ...[
|
||||
trailing!,
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
],
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onNew,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
|
|
@ -1127,9 +1211,7 @@ class _TabActionStrip extends StatelessWidget {
|
|||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
errorCount > 0
|
||||
? Icons.block
|
||||
: Icons.play_arrow,
|
||||
errorCount > 0 ? Icons.block : Icons.play_arrow,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(strings.run),
|
||||
|
|
@ -1234,47 +1316,50 @@ class _FlowFile {
|
|||
final String path;
|
||||
final int sizeBytes;
|
||||
|
||||
/// Scan result for this file — whether it's a bundled example
|
||||
/// and which capabilities its steps require. Computed once at
|
||||
/// list-load time (see [_FlowMetaCache]).
|
||||
/// True when the HOST reports this flow as a bundled sample
|
||||
/// (hub wire flag) — never guessed from the file content.
|
||||
final bool isExample;
|
||||
|
||||
/// Scan result for this file — which capabilities its steps
|
||||
/// require. Computed once at list-load time (see
|
||||
/// [_FlowMetaCache]).
|
||||
final _FlowMeta meta;
|
||||
|
||||
const _FlowFile({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.sizeBytes,
|
||||
required this.isExample,
|
||||
required this.meta,
|
||||
});
|
||||
}
|
||||
|
||||
/// Marker text every bundled sample flow carries in its
|
||||
/// provenance comment header. A file is an example iff its raw
|
||||
/// content contains this exact string.
|
||||
const String _sampleFlowMarker = 'F∆I sample flow';
|
||||
|
||||
/// Built-in capabilities the Hub always provides, even when
|
||||
/// they aren't in the host-supplied installed list. Treated as
|
||||
/// satisfied so the "needs N modules" badge never flags them.
|
||||
const Set<String> _builtinCapabilities = {'debug.echo'};
|
||||
|
||||
/// Result of scanning a single flow file: provenance + the
|
||||
/// capability NAMES (without `@version`) its steps reference.
|
||||
/// Result of scanning a single flow file: the capability NAMES
|
||||
/// (without `@version`) its steps reference.
|
||||
class _FlowMeta {
|
||||
final bool isExample;
|
||||
final List<String> requiredCaps;
|
||||
const _FlowMeta({required this.isExample, required this.requiredCaps});
|
||||
|
||||
static const empty = _FlowMeta(isExample: false, requiredCaps: []);
|
||||
/// The file's own normalized `project:` slug; empty when the
|
||||
/// YAML declares none (which counts as `general` for the list
|
||||
/// filter — display semantics only, the file is never rewritten).
|
||||
final String project;
|
||||
const _FlowMeta({
|
||||
required this.requiredCaps,
|
||||
this.project = '',
|
||||
});
|
||||
|
||||
/// Capabilities this flow needs that are neither installed nor
|
||||
/// built in. [availableNames] is the set of installed
|
||||
/// capability NAMES (the part before `@`).
|
||||
List<String> missingCaps(Set<String> availableNames) => requiredCaps
|
||||
.where(
|
||||
(c) =>
|
||||
!availableNames.contains(c) && !_builtinCapabilities.contains(c),
|
||||
)
|
||||
.toList();
|
||||
static const empty = _FlowMeta(requiredCaps: []);
|
||||
|
||||
/// Capabilities this flow needs that the hub does not provide.
|
||||
/// [availableNames] is the set of capability NAMES (the part
|
||||
/// before `@`) from the host's live capability list — which
|
||||
/// already includes the hub's builtins (e.g. system.approval),
|
||||
/// so there is no client-side builtin list to drift out of
|
||||
/// sync with the hub (the old hardcoded {'debug.echo'} made
|
||||
/// the bundled hello flow look runnable on hubs that don't
|
||||
/// have the module).
|
||||
List<String> missingCaps(Set<String> availableNames) =>
|
||||
requiredCaps.where((c) => !availableNames.contains(c)).toList();
|
||||
}
|
||||
|
||||
/// Caches [_FlowMeta] per file, keyed by path + mtime. Reading
|
||||
|
|
@ -1301,12 +1386,11 @@ class _FlowMetaCache {
|
|||
}
|
||||
}
|
||||
|
||||
/// Scan raw flow YAML for its example marker and the capability
|
||||
/// ids referenced by `use:` lines. A line scan is used rather
|
||||
/// than a full YAML parse: it's robust against malformed flows
|
||||
/// (the analyzer reports those separately) and never throws.
|
||||
/// Scan raw flow YAML for the capability ids referenced by
|
||||
/// `use:` lines. A line scan is used rather than a full YAML
|
||||
/// parse: it's robust against malformed flows (the analyzer
|
||||
/// reports those separately) and never throws.
|
||||
_FlowMeta _scanFlow(String text) {
|
||||
final isExample = text.contains(_sampleFlowMarker);
|
||||
final caps = <String>{};
|
||||
final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$');
|
||||
for (final raw in text.split('\n')) {
|
||||
|
|
@ -1325,7 +1409,10 @@ _FlowMeta _scanFlow(String text) {
|
|||
final name = value.split('@').first.trim();
|
||||
if (name.isNotEmpty) caps.add(name);
|
||||
}
|
||||
return _FlowMeta(isExample: isExample, requiredCaps: caps.toList());
|
||||
return _FlowMeta(
|
||||
requiredCaps: caps.toList(),
|
||||
project: parseFlowProject(text),
|
||||
);
|
||||
}
|
||||
|
||||
/// Reduce the host-supplied installed list (entries like
|
||||
|
|
@ -1345,7 +1432,7 @@ String _formatBytes(int bytes) {
|
|||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
class _FileList extends StatelessWidget {
|
||||
class _FileList extends StatefulWidget {
|
||||
final Future<List<_FlowFile>> filesFuture;
|
||||
final String? activeName;
|
||||
final FlowEditorStrings strings;
|
||||
|
|
@ -1353,6 +1440,18 @@ class _FileList extends StatelessWidget {
|
|||
/// Bare capability NAMES the host reports as installed. Used
|
||||
/// to compute each row's missing-module count.
|
||||
final Set<String> installedNames;
|
||||
|
||||
/// Bare capability NAMES a configured store can install
|
||||
/// (host-filtered to installable entries), or null when the
|
||||
/// store state is unknown. Missing caps outside this set
|
||||
/// render the "not in store" state instead of an install
|
||||
/// action that the hub would refuse; with null neither claim
|
||||
/// is made.
|
||||
final Set<String>? storeNames;
|
||||
|
||||
/// Active workspace project slug; empty = all projects. Files
|
||||
/// without a `project:` key count as `general`.
|
||||
final String activeProject;
|
||||
final void Function(_FlowFile) onOpen;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
|
|
@ -1366,46 +1465,105 @@ class _FileList extends StatelessWidget {
|
|||
/// renders, just without the one-click action.
|
||||
final Future<void> Function(List<String>)? onInstallMissing;
|
||||
|
||||
/// Import the bundled samples (empty-list action); null hides it.
|
||||
final Future<void> Function()? onImportSamples;
|
||||
|
||||
const _FileList({
|
||||
required this.filesFuture,
|
||||
required this.activeName,
|
||||
required this.strings,
|
||||
this.onImportSamples,
|
||||
required this.installedNames,
|
||||
required this.storeNames,
|
||||
required this.activeProject,
|
||||
required this.onOpen,
|
||||
required this.onRefresh,
|
||||
required this.onStart,
|
||||
required this.onInstallMissing,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_FileList> createState() => _FileListState();
|
||||
}
|
||||
|
||||
class _FileListState extends State<_FileList> {
|
||||
/// Case-insensitive substring filter over flow names. First
|
||||
/// iteration of "find a flow fast" (usertest power-user
|
||||
/// finding: no search over the flow list at all).
|
||||
String _filter = '';
|
||||
|
||||
/// Whether the examples group is expanded. Null until the
|
||||
/// operator toggles it — the default then follows the content
|
||||
/// (expanded only when there are no own flows).
|
||||
bool? _samplesExpanded;
|
||||
|
||||
/// Guards the empty-state import button against double-taps.
|
||||
bool _importing = false;
|
||||
|
||||
Future<void> _runImport() async {
|
||||
setState(() => _importing = true);
|
||||
try {
|
||||
await widget.onImportSamples!();
|
||||
} finally {
|
||||
if (mounted) setState(() => _importing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final strings = widget.strings;
|
||||
return Container(
|
||||
color: theme.colorScheme.surface,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.md,
|
||||
vertical: FaiSpace.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: theme.dividerColor)),
|
||||
// No ALL-CAPS panel header: the page toolbar already says
|
||||
// "Flows" — the duplicate label read as a broken title
|
||||
// hierarchy (usertest art-director finding). The refresh
|
||||
// action sits next to the filter instead.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
FaiSpace.md,
|
||||
FaiSpace.xs,
|
||||
FaiSpace.md,
|
||||
FaiSpace.xs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
strings.listHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
child: SizedBox(
|
||||
height: 28,
|
||||
child: TextField(
|
||||
onChanged: (v) => setState(() => _filter = v.trim()),
|
||||
style: theme.textTheme.bodySmall,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
prefixIcon: const Icon(Icons.search, size: 14),
|
||||
prefixIconConstraints: const BoxConstraints(
|
||||
minWidth: 28,
|
||||
minHeight: 28,
|
||||
),
|
||||
hintText: strings.listFilterHint,
|
||||
hintStyle: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onRefresh,
|
||||
onPressed: widget.onRefresh,
|
||||
tooltip: strings.refresh,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
visualDensity: VisualDensity.compact,
|
||||
|
|
@ -1425,8 +1583,9 @@ class _FileList extends StatelessWidget {
|
|||
}
|
||||
|
||||
Widget _buildBody(BuildContext context, ThemeData theme) {
|
||||
final strings = widget.strings;
|
||||
return FutureBuilder<List<_FlowFile>>(
|
||||
future: filesFuture,
|
||||
future: widget.filesFuture,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
|
@ -1437,26 +1596,132 @@ class _FileList extends StatelessWidget {
|
|||
child: ChainErrorBox(error: snap.error, isError: true),
|
||||
);
|
||||
}
|
||||
final files = snap.data ?? <_FlowFile>[];
|
||||
if (files.isEmpty) {
|
||||
final all = snap.data ?? <_FlowFile>[];
|
||||
if (all.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
child: FaiEmptyState(
|
||||
icon: Icons.folder_outlined,
|
||||
title: strings.listEmptyTitle,
|
||||
hint: strings.listEmptyBody,
|
||||
// Sealed areas start without examples — offer the
|
||||
// deliberate pull right where the emptiness shows.
|
||||
action: widget.onImportSamples == null
|
||||
? null
|
||||
: OutlinedButton.icon(
|
||||
onPressed: _importing ? null : _runImport,
|
||||
icon: const Icon(Icons.download_outlined, size: 16),
|
||||
label: Text(strings.flowListSamplesImport),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
// Workspace filter first: files without a `project:` key
|
||||
// count as `general` (display semantics — the file is the
|
||||
// truth and never rewritten).
|
||||
final inProject = all
|
||||
.where(
|
||||
(f) => flowVisibleInProject(
|
||||
f.meta.project,
|
||||
widget.activeProject,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
if (inProject.isEmpty) {
|
||||
// Flows exist, just none in this project — say that and
|
||||
// point at the way out (switch to all projects).
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
child: FaiEmptyState(
|
||||
icon: Icons.folder_off_outlined,
|
||||
title: strings.listProjectEmpty(widget.activeProject),
|
||||
hint: strings.listProjectEmptyHint,
|
||||
),
|
||||
);
|
||||
}
|
||||
final needle = _filter.toLowerCase();
|
||||
final files = needle.isEmpty
|
||||
? inProject
|
||||
: inProject
|
||||
.where((f) => f.name.toLowerCase().contains(needle))
|
||||
.toList();
|
||||
if (files.isEmpty) {
|
||||
// Flows exist, the filter just matches none — say that
|
||||
// instead of pretending the directory is empty.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
child: FaiEmptyState(
|
||||
icon: Icons.search_off_outlined,
|
||||
title: strings.listFilterNoMatch(_filter),
|
||||
hint: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Grouping: the operator's own flows first; bundled
|
||||
// examples collapsed under one labelled group so samples
|
||||
// are never mistaken for area work (they expand by
|
||||
// default only when there is nothing else to show).
|
||||
final own = files.where((f) => !f.isExample).toList();
|
||||
final samples = files.where((f) => f.isExample).toList();
|
||||
final samplesExpanded = _samplesExpanded ?? own.isEmpty;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
||||
itemCount: files.length,
|
||||
itemBuilder: (_, i) {
|
||||
final f = files[i];
|
||||
final isActive = f.name == activeName;
|
||||
final missing = f.meta.missingCaps(installedNames);
|
||||
children: [
|
||||
for (final f in own) _row(theme, f),
|
||||
if (samples.isNotEmpty) ...[
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
setState(() => _samplesExpanded = !samplesExpanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.md,
|
||||
vertical: FaiSpace.sm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
strings.flowListSamplesGroup(samples.length),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
samplesExpanded
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (samplesExpanded) for (final f in samples) _row(theme, f),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(ThemeData theme, _FlowFile f) {
|
||||
final strings = widget.strings;
|
||||
{
|
||||
final isActive = f.name == widget.activeName;
|
||||
final missing = f.meta.missingCaps(widget.installedNames);
|
||||
final split = splitMissingCaps(missing, widget.storeNames);
|
||||
return InkWell(
|
||||
onTap: () => onOpen(f),
|
||||
onTap: () => widget.onOpen(f),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.md,
|
||||
|
|
@ -1523,21 +1788,30 @@ class _FileList extends StatelessWidget {
|
|||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
if (f.meta.isExample || missing.isNotEmpty) ...[
|
||||
if (f.isExample || missing.isNotEmpty) ...[
|
||||
const SizedBox(height: FaiSpace.xs),
|
||||
Wrap(
|
||||
spacing: FaiSpace.xs,
|
||||
runSpacing: FaiSpace.xs,
|
||||
children: [
|
||||
if (f.meta.isExample)
|
||||
if (f.isExample)
|
||||
_ExampleBadge(strings: strings),
|
||||
if (missing.isNotEmpty)
|
||||
_MissingModulesBadge(
|
||||
missing: missing,
|
||||
MissingModulesBadge(
|
||||
installable: split.installable,
|
||||
notInStore: split.notInStore,
|
||||
unclassified: split.unclassified,
|
||||
strings: strings,
|
||||
onInstall: onInstallMissing == null
|
||||
// Install only what the store
|
||||
// resolves — the not-in-store
|
||||
// chip explains the rest.
|
||||
onInstall:
|
||||
widget.onInstallMissing == null ||
|
||||
split.installable.isEmpty
|
||||
? null
|
||||
: () => onInstallMissing!(missing),
|
||||
: () => widget.onInstallMissing!(
|
||||
split.installable,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -1550,7 +1824,7 @@ class _FileList extends StatelessWidget {
|
|||
// the install badge is the actionable path then.
|
||||
if (missing.isEmpty)
|
||||
IconButton(
|
||||
onPressed: () => onStart(f),
|
||||
onPressed: () => widget.onStart(f),
|
||||
tooltip: strings.listStartTooltip,
|
||||
icon: Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
|
|
@ -1568,10 +1842,7 @@ class _FileList extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1617,72 +1888,6 @@ class _ExampleBadge extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Warning-toned "needs N modules" chip on flows whose steps
|
||||
/// reference capabilities that aren't installed. Tooltip lists
|
||||
/// the exact missing ids. When an install handler is wired, the
|
||||
/// chip is tappable and one-click installs all missing caps via
|
||||
/// the host's existing install path.
|
||||
class _MissingModulesBadge extends StatelessWidget {
|
||||
final List<String> missing;
|
||||
final FlowEditorStrings strings;
|
||||
final VoidCallback? onInstall;
|
||||
const _MissingModulesBadge({
|
||||
required this.missing,
|
||||
required this.strings,
|
||||
required this.onInstall,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Amber warning tone consistent with the diagnostic strip's
|
||||
// warning colour, readable on both light + dark surfaces.
|
||||
const warn = Color(0xFFEF6C00);
|
||||
final label = '${strings.flowListNeedsModules(missing.length)}'
|
||||
'${onInstall != null ? ' · ${strings.flowListInstallMissing}' : ''}';
|
||||
final chip = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: warn.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(color: warn.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.extension_off_outlined, size: 10, color: warn),
|
||||
const SizedBox(width: 4),
|
||||
// Flexible + ellipsis: the chip sits in the narrow flow
|
||||
// list, where the full label otherwise overflows by a few
|
||||
// pixels (the tooltip still carries the complete text).
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: warn,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return Tooltip(
|
||||
message: strings.flowListNeedsModulesTooltip(missing.join('\n')),
|
||||
child: onInstall == null
|
||||
? chip
|
||||
: InkWell(
|
||||
onTap: onInstall,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
child: chip,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- empty state ---
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
|
|
@ -1940,8 +2145,10 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> {
|
|||
InkWell(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
|
|
@ -2135,16 +2342,10 @@ class _IssueRow extends StatelessWidget {
|
|||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.auto_fix_high, size: 13),
|
||||
label: Text(
|
||||
fix.label,
|
||||
style: _monoTextStyle(size: 11),
|
||||
),
|
||||
label: Text(fix.label, style: _monoTextStyle(size: 11)),
|
||||
style: FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
minimumSize: const Size(0, 24),
|
||||
),
|
||||
),
|
||||
|
|
@ -2193,7 +2394,10 @@ class _IssueHoverCard extends StatelessWidget {
|
|||
// to viewport so the card never spills off-screen on a
|
||||
// narrow window.
|
||||
const cardWidth = 380.0;
|
||||
final maxLeft = (media.size.width - cardWidth - 12).clamp(8.0, double.infinity);
|
||||
final maxLeft = (media.size.width - cardWidth - 12).clamp(
|
||||
8.0,
|
||||
double.infinity,
|
||||
);
|
||||
final dx = (request.globalPosition.dx + 12).clamp(8.0, maxLeft);
|
||||
final dy = (request.globalPosition.dy + 18).clamp(
|
||||
8.0,
|
||||
|
|
@ -2213,9 +2417,7 @@ class _IssueHoverCard extends StatelessWidget {
|
|||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x33000000),
|
||||
|
|
@ -2366,11 +2568,7 @@ class _FixDialogState extends State<_FixDialog> {
|
|||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_fix_high,
|
||||
size: 20,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
Icon(Icons.auto_fix_high, size: 20, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(strings.diagnosticFixDialogTitle),
|
||||
],
|
||||
|
|
@ -2492,10 +2690,7 @@ class _FixDialogRow extends StatelessWidget {
|
|||
FilledButton.tonalIcon(
|
||||
onPressed: () => onApplyFix(fix),
|
||||
icon: const Icon(Icons.auto_fix_high, size: 13),
|
||||
label: Text(
|
||||
fix.label,
|
||||
style: _monoTextStyle(size: 11),
|
||||
),
|
||||
label: Text(fix.label, style: _monoTextStyle(size: 11)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,21 @@ String parseFlowProject(String yaml) {
|
|||
return '';
|
||||
}
|
||||
|
||||
/// The project a flow file effectively belongs to: its own
|
||||
/// `project:` slug, or `general` when the file declares none.
|
||||
/// Display/filter semantics only — the FILE stays the truth and
|
||||
/// is never rewritten to make this explicit.
|
||||
String effectiveFlowProject(String fileProject) =>
|
||||
fileProject.isEmpty ? 'general' : fileProject;
|
||||
|
||||
/// Whether a flow file belongs in the list under the active
|
||||
/// workspace filter. Empty [activeProject] = "all projects";
|
||||
/// otherwise the file's effective project (no key = `general`)
|
||||
/// must match.
|
||||
bool flowVisibleInProject(String fileProject, String activeProject) =>
|
||||
activeProject.isEmpty ||
|
||||
effectiveFlowProject(fileProject) == activeProject;
|
||||
|
||||
/// Mirror of the hub's `normalize_project_slug`: lowercase,
|
||||
/// collapse spaces/underscores/dashes to a single `-`, drop
|
||||
/// everything else, trim trailing dashes. Empty in → empty out
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import 'wire_colors.dart';
|
|||
class FlowYamlCodeController extends CodeController {
|
||||
FlowYamlCodeController({
|
||||
List<String> Function()? availableCapabilities,
|
||||
List<String> Function()? storeCapabilities,
|
||||
List<String>? Function()? storeCapabilities,
|
||||
AnalyzerStrings analyzerStrings = AnalyzerStrings.english,
|
||||
}) : super(
|
||||
text: '',
|
||||
|
|
@ -49,7 +49,7 @@ class FlowYamlCodeController extends CodeController {
|
|||
/// closure or stale strings inside an old FlowAnalyzer.
|
||||
void setCapabilityProviders({
|
||||
required List<String> Function() available,
|
||||
List<String> Function()? store,
|
||||
List<String>? Function()? store,
|
||||
AnalyzerStrings? strings,
|
||||
}) {
|
||||
analyzer = FlowAnalyzer(
|
||||
|
|
@ -108,7 +108,7 @@ class FlowYamlCodeController extends CodeController {
|
|||
|
||||
/// Replace the analyzer's capability provider. Called by the
|
||||
/// editor host when the installed-capability list changes —
|
||||
/// e.g. after `fai install` while the editor is open.
|
||||
/// e.g. after `chain install` while the editor is open.
|
||||
void setAvailableCapabilities(List<String> Function() provider) {
|
||||
analyzer = FlowAnalyzer(availableCapabilities: provider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ class FlowEditorStrings {
|
|||
String get save => _t('Save', 'Speichern');
|
||||
String get run => _t('Run', 'Ausführen');
|
||||
String get refresh => _t('Refresh file list', 'Datei-Liste neu laden');
|
||||
String get listHeader => _t('FLOWS', 'FLOWS');
|
||||
String get listStartTooltip =>
|
||||
_t('Open on the Run tab', 'Im Starten-Tab öffnen');
|
||||
String get copy => _t('Copy', 'Kopieren');
|
||||
|
|
@ -28,13 +27,14 @@ class FlowEditorStrings {
|
|||
String get emptyTitle => _t('No flow open', 'Kein Flow geöffnet');
|
||||
String get emptyBody => _t(
|
||||
'Pick one from the left, or click New flow to start a fresh one.',
|
||||
'Wähle links einen aus oder klicke Neuer Flow um einen neuen anzulegen.',
|
||||
'Wählen Sie links einen Flow aus oder klicken Sie auf „Neuer Flow", '
|
||||
'um einen neuen anzulegen.',
|
||||
);
|
||||
String get listEmptyTitle =>
|
||||
_t('No saved flows', 'Keine gespeicherten Flows');
|
||||
String get listEmptyBody => _t(
|
||||
'Click New flow to scaffold one from a template.',
|
||||
'Klicke Neuer Flow um aus einer Vorlage zu starten.',
|
||||
'Klicken Sie auf „Neuer Flow", um aus einer Vorlage zu starten.',
|
||||
);
|
||||
String get discardTitle =>
|
||||
_t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?');
|
||||
|
|
@ -95,14 +95,15 @@ class FlowEditorStrings {
|
|||
String get graphEmptyTitle => _t('No steps yet', 'Noch keine Schritte');
|
||||
String get graphEmptyBody => _t(
|
||||
'Click Add step to drop the first capability onto the canvas.',
|
||||
'Klicke Schritt hinzufügen um die erste Capability auf die Fläche zu setzen.',
|
||||
'Klicken Sie auf „Schritt hinzufügen", um die erste Capability auf '
|
||||
'die Fläche zu setzen.',
|
||||
);
|
||||
|
||||
// Properties panel.
|
||||
String get propPanelTitle => _t('Step details', 'Schritt-Details');
|
||||
String get propNoSelection => _t(
|
||||
'Click a step on the canvas to edit it.',
|
||||
'Klicke einen Schritt auf der Fläche an, um ihn zu bearbeiten.',
|
||||
'Klicken Sie einen Schritt auf der Fläche an, um ihn zu bearbeiten.',
|
||||
);
|
||||
String get propStepId => _t('ID', 'ID');
|
||||
String get propCapability => _t('Capability', 'Capability');
|
||||
|
|
@ -142,11 +143,12 @@ class FlowEditorStrings {
|
|||
String get runStart => _t('Start run', 'Lauf starten');
|
||||
String get runUnsavedBanner => _t(
|
||||
'You have unsaved changes — save first to run the latest version.',
|
||||
'Du hast ungespeicherte Änderungen — bitte zuerst speichern, um die neueste Version auszuführen.',
|
||||
'Sie haben ungespeicherte Änderungen — bitte zuerst speichern, um die '
|
||||
'neueste Version auszuführen.',
|
||||
);
|
||||
String get runNoFlow => _t(
|
||||
'Open a flow from the left to run it.',
|
||||
'Öffne links einen Flow um ihn auszuführen.',
|
||||
'Öffnen Sie links einen Flow, um ihn auszuführen.',
|
||||
);
|
||||
String get runOutputs => _t('Outputs', 'Ausgaben');
|
||||
String get runChooseFile => _t('Choose file…', 'Datei wählen…');
|
||||
|
|
@ -253,15 +255,28 @@ class FlowEditorStrings {
|
|||
);
|
||||
String analyzerUnknownCapTypo(String cap, String suggestion) => _t(
|
||||
'Unknown capability "$cap". Did you mean "$suggestion"?',
|
||||
'Unbekannte Capability "$cap". Meintest du "$suggestion"?',
|
||||
'Unbekannte Capability "$cap". Meinten Sie "$suggestion"?',
|
||||
);
|
||||
String analyzerUnknownCapNotInStore(String cap) => _t(
|
||||
'Unknown capability "$cap". '
|
||||
'Not in the store — install locally with '
|
||||
'`fai install --link <path>` or check the spelling.',
|
||||
'No configured store can install it — install a local module '
|
||||
'(`chain install --link <path>`), add the store that provides '
|
||||
'it, or configure the integration (MCP/n8n) that supplies it.',
|
||||
'Unbekannte Capability "$cap". '
|
||||
'Nicht im Store — lokal mit '
|
||||
'`fai install --link <pfad>` installieren oder Tippfehler prüfen.',
|
||||
'Kein eingerichteter Store kann sie installieren — lokales '
|
||||
'Modul installieren (`chain install --link <pfad>`), den '
|
||||
'passenden Store hinzufügen oder die Anbindung (MCP/n8n) '
|
||||
'einrichten, die sie bereitstellt.',
|
||||
);
|
||||
String analyzerUnknownCapStoreUnknown(String cap) => _t(
|
||||
'Unknown capability "$cap". '
|
||||
'The store is not reachable right now, so it may or may not '
|
||||
'be installable — check the spelling, or add a local source '
|
||||
'(`chain install --link <path>`).',
|
||||
'Unbekannte Capability "$cap". '
|
||||
'Der Store ist gerade nicht erreichbar — ob sie installierbar '
|
||||
'ist, lässt sich nicht sagen. Tippfehler prüfen oder lokale '
|
||||
'Quelle hinzufügen (`chain install --link <pfad>`).',
|
||||
);
|
||||
String analyzerInputKind() => _t('input', 'Eingabe');
|
||||
String analyzerOutputKind() => _t('output', 'Ausgabe');
|
||||
|
|
@ -279,9 +294,13 @@ class FlowEditorStrings {
|
|||
'Bundled sample flow.',
|
||||
'Mitgelieferter Beispiel-Flow.',
|
||||
);
|
||||
String flowListSamplesGroup(int n) =>
|
||||
_t('Examples ($n)', 'Beispiele ($n)');
|
||||
String get flowListSamplesImport =>
|
||||
_t('Import example flows', 'Beispiel-Flows importieren');
|
||||
String flowListNeedsModules(int n) => _t(
|
||||
n == 1 ? 'needs 1 module' : 'needs $n modules',
|
||||
n == 1 ? 'braucht 1 Modul' : 'braucht $n Module',
|
||||
n == 1 ? '1 module missing' : '$n modules missing',
|
||||
n == 1 ? '1 Modul fehlt' : '$n Module fehlen',
|
||||
);
|
||||
String flowListNeedsModulesTooltip(String caps) => _t(
|
||||
'This flow needs capabilities that are not installed:\n$caps\n'
|
||||
|
|
@ -290,6 +309,52 @@ class FlowEditorStrings {
|
|||
'Klicken, um die fehlenden zu installieren.',
|
||||
);
|
||||
String get flowListInstallMissing => _t('Install', 'Installieren');
|
||||
String flowListNeedsModulesTooltipNoAction(String caps) => _t(
|
||||
'This flow needs capabilities that are not installed:\n$caps\n'
|
||||
'The store is not reachable right now — no install offer '
|
||||
'until Studio can check it.',
|
||||
'Dieser Flow braucht nicht installierte Capabilities:\n$caps\n'
|
||||
'Der Store ist gerade nicht erreichbar — kein Install-Angebot, '
|
||||
'bis Studio das prüfen kann.',
|
||||
);
|
||||
String flowListNotInStore(int n) => _t(
|
||||
n == 1 ? 'not in store' : '$n not in store',
|
||||
n == 1 ? 'nicht im Store' : '$n nicht im Store',
|
||||
);
|
||||
String flowListNotInStoreTooltip(String caps) => _t(
|
||||
'This flow needs capabilities no configured store can install:\n'
|
||||
'$caps\n'
|
||||
'Ways to get them: install a local module '
|
||||
'(`chain install --link <path>`), add the store that provides '
|
||||
'them (Settings → Stores), or configure the integration '
|
||||
'(MCP/n8n) that supplies the capability.',
|
||||
'Dieser Flow braucht Capabilities, die kein eingerichteter Store '
|
||||
'installieren kann:\n$caps\n'
|
||||
'Wege: lokales Modul installieren '
|
||||
'(`chain install --link <pfad>`), den passenden Store '
|
||||
'hinzufügen (Einstellungen → Stores) oder die Anbindung '
|
||||
'(MCP/n8n) einrichten, die die Capability bereitstellt.',
|
||||
);
|
||||
|
||||
// Project-scoped empty state: flows exist, none in the active
|
||||
// workspace project.
|
||||
String listProjectEmpty(String project) => _t(
|
||||
'No flows in project "$project".',
|
||||
'Keine Flows im Projekt „$project".',
|
||||
);
|
||||
String get listProjectEmptyHint => _t(
|
||||
'New flow puts one here, or switch the project selector to '
|
||||
'"All projects".',
|
||||
'„Neuer Flow" legt hier einen an — oder wechseln Sie die '
|
||||
'Projekt-Auswahl auf „Alle Projekte".',
|
||||
);
|
||||
|
||||
// Flow-list filter (first iteration: plain substring match).
|
||||
String get listFilterHint => _t('Filter flows…', 'Flows filtern…');
|
||||
String listFilterNoMatch(String query) => _t(
|
||||
'No flow matches "$query".',
|
||||
'Kein Flow passt zu „$query".',
|
||||
);
|
||||
|
||||
// Quick-fix button labels.
|
||||
String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren');
|
||||
|
|
@ -309,6 +374,7 @@ class AnalyzerStrings {
|
|||
final String Function(String cap) unknownCapInStore;
|
||||
final String Function(String cap, String suggestion) unknownCapTypo;
|
||||
final String Function(String cap) unknownCapNotInStore;
|
||||
final String Function(String cap) unknownCapStoreUnknown;
|
||||
final String Function(String kind, String value, String validList)
|
||||
unknownType;
|
||||
final String Function() inputKind;
|
||||
|
|
@ -324,6 +390,7 @@ class AnalyzerStrings {
|
|||
required this.unknownCapInStore,
|
||||
required this.unknownCapTypo,
|
||||
required this.unknownCapNotInStore,
|
||||
required this.unknownCapStoreUnknown,
|
||||
required this.unknownType,
|
||||
required this.inputKind,
|
||||
required this.outputKind,
|
||||
|
|
@ -342,6 +409,7 @@ class AnalyzerStrings {
|
|||
unknownCapInStore: s.analyzerUnknownCapInStore,
|
||||
unknownCapTypo: s.analyzerUnknownCapTypo,
|
||||
unknownCapNotInStore: s.analyzerUnknownCapNotInStore,
|
||||
unknownCapStoreUnknown: s.analyzerUnknownCapStoreUnknown,
|
||||
unknownType: s.analyzerUnknownType,
|
||||
inputKind: s.analyzerInputKind,
|
||||
outputKind: s.analyzerOutputKind,
|
||||
|
|
@ -362,6 +430,7 @@ class AnalyzerStrings {
|
|||
: unknownCapInStore = _enUnknownCapInStore,
|
||||
unknownCapTypo = _enUnknownCapTypo,
|
||||
unknownCapNotInStore = _enUnknownCapNotInStore,
|
||||
unknownCapStoreUnknown = _enUnknownCapStoreUnknown,
|
||||
unknownType = _enUnknownType,
|
||||
inputKind = _enInputKind,
|
||||
outputKind = _enOutputKind,
|
||||
|
|
@ -378,8 +447,14 @@ class AnalyzerStrings {
|
|||
'Unknown capability "$cap". Did you mean "$suggestion"?';
|
||||
static String _enUnknownCapNotInStore(String cap) =>
|
||||
'Unknown capability "$cap". '
|
||||
'Not in the store — install locally with '
|
||||
'`fai install --link <path>` or check the spelling.';
|
||||
'No configured store can install it — install a local module '
|
||||
'(`chain install --link <path>`), add the store that provides '
|
||||
'it, or configure the integration (MCP/n8n) that supplies it.';
|
||||
static String _enUnknownCapStoreUnknown(String cap) =>
|
||||
'Unknown capability "$cap". '
|
||||
'The store is not reachable right now, so it may or may not '
|
||||
'be installable — check the spelling, or add a local source '
|
||||
'(`chain install --link <path>`).';
|
||||
static String _enUnknownType(String kind, String value, String validList) =>
|
||||
'Unknown $kind type "$value". Use one of: $validList.';
|
||||
static String _enInputKind() => 'input';
|
||||
|
|
|
|||
|
|
@ -69,12 +69,12 @@ class InstallCapabilityFix extends QuickFix {
|
|||
/// Ask the host to register a new module source for an unknown
|
||||
/// capability — used when the capability isn't in the public
|
||||
/// store. The host's handler typically prompts the operator
|
||||
/// for a local path (`fai install --link`) or a URL
|
||||
/// (`fai install <url>`), then installs and reanalyzes.
|
||||
/// for a local path (`chain install --link`) or a URL
|
||||
/// (`chain install <url>`), then installs and reanalyzes.
|
||||
///
|
||||
/// This is the recovery path for private modules: the public
|
||||
/// store doesn't know about `htw.digiscout/onet-lookup`, but
|
||||
/// the operator can point the hub at the local clone.
|
||||
/// store doesn't know about `acme.internal/directory-lookup`,
|
||||
/// but the operator can point the hub at the local clone.
|
||||
@immutable
|
||||
class AddModuleSourceFix extends QuickFix {
|
||||
/// The capability the operator wrote — the host uses it to
|
||||
|
|
|
|||
|
|
@ -12,11 +12,17 @@ class FaiEmptyState extends StatelessWidget {
|
|||
final IconData icon;
|
||||
final String title;
|
||||
final String? hint;
|
||||
|
||||
/// Optional action rendered under the hint (e.g. the empty flow
|
||||
/// list's "import example flows" button).
|
||||
final Widget? action;
|
||||
|
||||
const FaiEmptyState({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.hint,
|
||||
this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -46,6 +52,10 @@ class FaiEmptyState extends StatelessWidget {
|
|||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
name: chain_studio_flow_editor
|
||||
description: Swappable inline YAML editor for F∆I Studio flows.
|
||||
version: 0.23.0
|
||||
version: 0.26.0
|
||||
publish_to: 'none'
|
||||
repository: https://git.flemming.ai/fai/studio-flow-editor
|
||||
|
||||
|
|
|
|||
|
|
@ -135,13 +135,20 @@ steps:
|
|||
name: x
|
||||
steps:
|
||||
- id: c
|
||||
use: htw.private/secret@^0.1
|
||||
use: acme.internal/secret@^0.1
|
||||
'''));
|
||||
expect(r.issues, hasLength(1));
|
||||
final fixes = a.fixesFor(r.issues.first);
|
||||
expect(fixes, hasLength(1));
|
||||
expect(fixes.first, isA<AddModuleSourceFix>());
|
||||
expect(r.issues.first.message, contains('Not in the store'));
|
||||
// The message must explain all three recovery paths in place:
|
||||
// local install, adding a store, configuring an integration.
|
||||
expect(
|
||||
r.issues.first.message,
|
||||
contains('No configured store can install it'),
|
||||
);
|
||||
expect(r.issues.first.message, contains('chain install --link'));
|
||||
expect(r.issues.first.message, contains('integration'));
|
||||
});
|
||||
|
||||
test('did-you-mean wins over install/add-source when a near miss exists',
|
||||
|
|
|
|||
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', () {
|
||||
test('collapses separators and trims trailing dashes', () {
|
||||
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