Compare commits
No commits in common. "main" and "v0.24.1" have entirely different histories.
15 changed files with 162 additions and 1099 deletions
69
CHANGELOG.md
69
CHANGELOG.md
|
|
@ -1,74 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
## 0.26.0
|
||||
|
||||
Sample flows follow the hub's wire flag (sealed-areas rework):
|
||||
|
||||
- **`FlowEditorPage.sampleFlowNames`** — the host passes the set of
|
||||
flow names the hub reports as bundled samples
|
||||
(`FlowSummary.sample`). This is now the ONLY source of the
|
||||
"Example" chip; the editor's own content-marker scan is gone (it
|
||||
had silently drifted from the hub's renamed sample header and
|
||||
missed every current sample). Null = no host info = no chips.
|
||||
- **Examples group.** Sample flows collapse under one
|
||||
"Examples (N)" row below the operator's own flows, expanded on
|
||||
demand — or by default when there is nothing else to show.
|
||||
- **`FlowEditorPage.onImportSamples`** — optional empty-list action
|
||||
("Import example flows"); sealed areas start without samples, so
|
||||
hosts wire this as the deliberate pull. The list refreshes itself
|
||||
after the import.
|
||||
- `FaiEmptyState` gained an optional `action` slot.
|
||||
- The editor reloads its file list when the host changes `flowsDir`
|
||||
or delivers the sample set after an async fetch.
|
||||
|
||||
## 0.25.0
|
||||
|
||||
Honest install badge — the flow list and analyzer only offer
|
||||
"Install" for capabilities a configured store can actually resolve:
|
||||
|
||||
- **Store-resolvability split in the flow list.** Missing
|
||||
capabilities are classified BEFORE any click: store-resolvable
|
||||
ones keep the amber chip + Install link (which now installs only
|
||||
those), the rest render a quiet "not in store" chip whose tooltip
|
||||
explains the three recovery paths (local install via
|
||||
`chain install --link`, adding the providing store, configuring
|
||||
the MCP/n8n integration). Previously the Install action covered
|
||||
every missing capability and could end in the hub's
|
||||
"no store entry for '<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
|
||||
|
|
|
|||
|
|
@ -38,14 +38,13 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
|||
/// rebuild.
|
||||
final List<String> Function() availableCapabilities;
|
||||
|
||||
/// Returns the names of capabilities the store can actually
|
||||
/// install, or null when the store state is UNKNOWN (snapshot
|
||||
/// not loaded / store unreachable). Drives whether an
|
||||
/// unknown-cap issue carries an Install button (in store), the
|
||||
/// "not in store" recovery message (known, absent) or the
|
||||
/// neutral store-unknown wording (null) — the analyzer never
|
||||
/// claims "no store provides it" without a loaded snapshot.
|
||||
final List<String>? Function()? storeCapabilities;
|
||||
/// Returns the names of capabilities the public store knows
|
||||
/// how to install. Used to decide whether an unknown-cap
|
||||
/// issue should carry an Install button — clicking the
|
||||
/// button on a capability the hub can't actually fetch would
|
||||
/// just fail. Null = "no store available" → install offered
|
||||
/// for every unknown cap (legacy behaviour).
|
||||
final List<String> Function()? storeCapabilities;
|
||||
|
||||
/// Quick fixes attached to the most-recent analyze() pass.
|
||||
/// Keyed by the same `Issue` instances that landed in
|
||||
|
|
@ -112,10 +111,9 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
|||
// as Did-you-mean candidates so the suggestion can preserve
|
||||
// the version constraint when the user already typed one.
|
||||
final installedFull = caps.toSet();
|
||||
final storeCaps = storeCapabilities?.call();
|
||||
final storeKnown = storeCaps != null;
|
||||
final storeBare =
|
||||
(storeCaps ?? const <String>[]).map(_bareCap).toSet();
|
||||
final storeCaps =
|
||||
storeCapabilities?.call() ?? const <String>[];
|
||||
final storeBare = storeCaps.map(_bareCap).toSet();
|
||||
|
||||
YamlNode? doc;
|
||||
try {
|
||||
|
|
@ -177,9 +175,7 @@ class FlowAnalyzer extends AbstractAnalyzer {
|
|||
? strings.unknownCapInStore(useValue)
|
||||
: didYouMean != null
|
||||
? strings.unknownCapTypo(useValue, didYouMean)
|
||||
: storeKnown
|
||||
? strings.unknownCapNotInStore(useValue)
|
||||
: strings.unknownCapStoreUnknown(useValue);
|
||||
: strings.unknownCapNotInStore(useValue);
|
||||
final issue = Issue(
|
||||
line: issueLine,
|
||||
message: message,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ library;
|
|||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart' show setEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||
|
|
@ -42,7 +41,6 @@ import 'tokens.dart';
|
|||
import 'widgets.dart';
|
||||
import 'widgets/capability_picker.dart';
|
||||
import 'widgets/flow_canvas.dart';
|
||||
import 'widgets/missing_modules_badge.dart';
|
||||
import 'widgets/properties_panel.dart';
|
||||
import 'widgets/run_tab.dart';
|
||||
|
||||
|
|
@ -96,12 +94,12 @@ class FlowEditorPage extends StatefulWidget {
|
|||
/// then call the Hub install API.
|
||||
final AddModuleSourceCallback? onAddModuleSource;
|
||||
|
||||
/// Capabilities the store can actually install, or null when
|
||||
/// the store state is UNKNOWN (snapshot not loaded / store
|
||||
/// unreachable). Drives the analyzer's quick-fix choice and
|
||||
/// the flow list's badge: in store → Install; known-absent →
|
||||
/// "not in store" + recovery paths; unknown → neither claim.
|
||||
final List<String>? storeCapabilities;
|
||||
/// Capabilities the public store knows how to install. The
|
||||
/// analyzer uses this to decide whether to show "Install …"
|
||||
/// (in store) or "Add source for …" (not in store) as the
|
||||
/// quick-fix on an unknown `use:` line. Empty list = store
|
||||
/// silent — no install button offered.
|
||||
final List<String> storeCapabilities;
|
||||
|
||||
/// Host-side native file picker for the Run tab's file inputs.
|
||||
/// Studio passes a real file dialog; null keeps the manual
|
||||
|
|
@ -121,32 +119,6 @@ class FlowEditorPage extends StatefulWidget {
|
|||
/// (the mismatch note still shows — the file still wins).
|
||||
final void Function(String fileProject)? onSwitchToFileProject;
|
||||
|
||||
/// Host-injected widget rendered in the editor toolbar, before
|
||||
/// the New-flow button. Studio places its workspace (project)
|
||||
/// switcher here — the editor keeps a single toolbar and stays
|
||||
/// host-agnostic.
|
||||
final Widget? toolbarTrailing;
|
||||
|
||||
/// Directory the editor lists/saves flow files in. `null` uses
|
||||
/// the hub's default (`~/.chain/data/flows`). Tests inject a
|
||||
/// temp dir so they never touch the operator's real flows
|
||||
/// (hermetic per shared/TESTING.md).
|
||||
final String? flowsDir;
|
||||
|
||||
/// Names of the flows the HOST reports as bundled samples (the
|
||||
/// hub's `FlowSummary.sample` wire flag). Null = the host has no
|
||||
/// sample information (old hub / standalone use) — then no
|
||||
/// example chips render at all. The hub's sample header is the
|
||||
/// single source of truth; the editor no longer keeps its own
|
||||
/// marker scan (it had already drifted from the hub's header).
|
||||
final Set<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,
|
||||
|
|
@ -156,14 +128,10 @@ class FlowEditorPage extends StatefulWidget {
|
|||
this.style,
|
||||
this.onInstallCapability,
|
||||
this.onAddModuleSource,
|
||||
this.storeCapabilities,
|
||||
this.storeCapabilities = const [],
|
||||
this.activeProject = '',
|
||||
this.onSwitchToFileProject,
|
||||
this.onPickFile,
|
||||
this.toolbarTrailing,
|
||||
this.flowsDir,
|
||||
this.sampleFlowNames,
|
||||
this.onImportSamples,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -210,21 +178,6 @@ class _FlowEditorPageState extends State<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);
|
||||
|
|
@ -339,10 +292,8 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
|
||||
// --- file ops ---
|
||||
|
||||
String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir();
|
||||
|
||||
Future<List<_FlowFile>> _listFiles() async {
|
||||
final dir = Directory(_flowsDir);
|
||||
final dir = Directory(_defaultFlowsDir());
|
||||
if (!dir.existsSync()) return <_FlowFile>[];
|
||||
final entries = await dir
|
||||
.list()
|
||||
|
|
@ -356,13 +307,11 @@ 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: name,
|
||||
name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
|
||||
path: f.path,
|
||||
sizeBytes: stat.size,
|
||||
isExample: widget.sampleFlowNames?.contains(name) ?? false,
|
||||
meta: meta,
|
||||
),
|
||||
);
|
||||
|
|
@ -383,7 +332,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
}
|
||||
|
||||
Future<void> _openByName(String name) async {
|
||||
final path = '$_flowsDir/$name.yaml';
|
||||
final path = '${_defaultFlowsDir()}/$name.yaml';
|
||||
final file = File(path);
|
||||
if (!file.existsSync()) return;
|
||||
final text = await file.readAsString();
|
||||
|
|
@ -426,7 +375,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
if (name == null) return;
|
||||
_controller.saving = true;
|
||||
try {
|
||||
final file = File('$_flowsDir/$name.yaml');
|
||||
final file = File('${_defaultFlowsDir()}/$name.yaml');
|
||||
await file.writeAsString(
|
||||
_controller.codeController.fullText,
|
||||
flush: true,
|
||||
|
|
@ -470,18 +419,11 @@ 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
|
||||
|
|
@ -496,7 +438,7 @@ outputs:
|
|||
result: \$echo.echoed
|
||||
''';
|
||||
try {
|
||||
final dir = Directory(_flowsDir);
|
||||
final dir = Directory(_defaultFlowsDir());
|
||||
if (!dir.existsSync()) await dir.create(recursive: true);
|
||||
final file = File('${dir.path}/$name.yaml');
|
||||
if (file.existsSync()) {
|
||||
|
|
@ -634,7 +576,6 @@ outputs:
|
|||
? () => Navigator.of(context).maybePop()
|
||||
: null,
|
||||
onNew: _newFlow,
|
||||
trailing: widget.toolbarTrailing,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
|
|
@ -647,24 +588,9 @@ outputs:
|
|||
filesFuture: _files,
|
||||
activeName: _controller.activeName,
|
||||
strings: _l,
|
||||
onImportSamples: widget.onImportSamples == null
|
||||
? null
|
||||
: () async {
|
||||
await widget.onImportSamples!();
|
||||
if (mounted) {
|
||||
final fresh = _listFiles();
|
||||
setState(() {
|
||||
_files = fresh;
|
||||
});
|
||||
}
|
||||
},
|
||||
installedNames: _installedNames(
|
||||
widget.availableCapabilities,
|
||||
),
|
||||
storeNames: widget.storeCapabilities == null
|
||||
? null
|
||||
: _installedNames(widget.storeCapabilities!),
|
||||
activeProject: widget.activeProject,
|
||||
onOpen: _openFile,
|
||||
onRefresh: _refreshFiles,
|
||||
onStart: _startFile,
|
||||
|
|
@ -1018,18 +944,12 @@ class _Toolbar extends StatelessWidget {
|
|||
final bool dirty;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback onNew;
|
||||
|
||||
/// Host-injected widget rendered before the New-flow button —
|
||||
/// Studio places its workspace (project) switcher here so the
|
||||
/// editor keeps a single toolbar and stays host-agnostic.
|
||||
final Widget? trailing;
|
||||
const _Toolbar({
|
||||
required this.strings,
|
||||
required this.activeName,
|
||||
required this.dirty,
|
||||
required this.onBack,
|
||||
required this.onNew,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -1098,10 +1018,6 @@ class _Toolbar extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (trailing != null) ...[
|
||||
trailing!,
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
],
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onNew,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
|
|
@ -1316,39 +1232,32 @@ class _FlowFile {
|
|||
final String path;
|
||||
final int sizeBytes;
|
||||
|
||||
/// True when the HOST reports this flow as a bundled sample
|
||||
/// (hub wire flag) — never guessed from the file content.
|
||||
final bool isExample;
|
||||
|
||||
/// Scan result for this file — which capabilities its steps
|
||||
/// require. Computed once at list-load time (see
|
||||
/// [_FlowMetaCache]).
|
||||
/// Scan result for this file — whether it's a bundled example
|
||||
/// and which capabilities its steps require. Computed once at
|
||||
/// list-load time (see [_FlowMetaCache]).
|
||||
final _FlowMeta meta;
|
||||
|
||||
const _FlowFile({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.sizeBytes,
|
||||
required this.isExample,
|
||||
required this.meta,
|
||||
});
|
||||
}
|
||||
|
||||
/// Result of scanning a single flow file: the capability NAMES
|
||||
/// (without `@version`) its steps reference.
|
||||
/// Marker text every bundled sample flow carries in its
|
||||
/// provenance comment header. A file is an example iff its raw
|
||||
/// content contains this exact string.
|
||||
const String _sampleFlowMarker = 'F∆I sample flow';
|
||||
|
||||
/// Result of scanning a single flow file: provenance + 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});
|
||||
|
||||
/// The file's own normalized `project:` slug; empty when the
|
||||
/// YAML declares none (which counts as `general` for the list
|
||||
/// filter — display semantics only, the file is never rewritten).
|
||||
final String project;
|
||||
const _FlowMeta({
|
||||
required this.requiredCaps,
|
||||
this.project = '',
|
||||
});
|
||||
|
||||
static const empty = _FlowMeta(requiredCaps: []);
|
||||
static const empty = _FlowMeta(isExample: false, requiredCaps: []);
|
||||
|
||||
/// Capabilities this flow needs that the hub does not provide.
|
||||
/// [availableNames] is the set of capability NAMES (the part
|
||||
|
|
@ -1386,11 +1295,12 @@ class _FlowMetaCache {
|
|||
}
|
||||
}
|
||||
|
||||
/// Scan raw flow YAML for the capability ids referenced by
|
||||
/// `use:` lines. A line scan is used rather than a full YAML
|
||||
/// parse: it's robust against malformed flows (the analyzer
|
||||
/// reports those separately) and never throws.
|
||||
/// Scan raw flow YAML for its example marker and the capability
|
||||
/// ids referenced by `use:` lines. A line scan is used rather
|
||||
/// than a full YAML parse: it's robust against malformed flows
|
||||
/// (the analyzer reports those separately) and never throws.
|
||||
_FlowMeta _scanFlow(String text) {
|
||||
final isExample = text.contains(_sampleFlowMarker);
|
||||
final caps = <String>{};
|
||||
final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$');
|
||||
for (final raw in text.split('\n')) {
|
||||
|
|
@ -1409,10 +1319,7 @@ _FlowMeta _scanFlow(String text) {
|
|||
final name = value.split('@').first.trim();
|
||||
if (name.isNotEmpty) caps.add(name);
|
||||
}
|
||||
return _FlowMeta(
|
||||
requiredCaps: caps.toList(),
|
||||
project: parseFlowProject(text),
|
||||
);
|
||||
return _FlowMeta(isExample: isExample, requiredCaps: caps.toList());
|
||||
}
|
||||
|
||||
/// Reduce the host-supplied installed list (entries like
|
||||
|
|
@ -1440,18 +1347,6 @@ class _FileList extends StatefulWidget {
|
|||
/// 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;
|
||||
|
||||
|
|
@ -1465,17 +1360,11 @@ class _FileList extends StatefulWidget {
|
|||
/// 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,
|
||||
|
|
@ -1492,23 +1381,6 @@ class _FileListState extends State<_FileList> {
|
|||
/// 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);
|
||||
|
|
@ -1604,47 +1476,13 @@ class _FileListState extends State<_FileList> {
|
|||
icon: Icons.folder_outlined,
|
||||
title: strings.listEmptyTitle,
|
||||
hint: strings.listEmptyBody,
|
||||
// Sealed areas start without examples — offer the
|
||||
// deliberate pull right where the emptiness shows.
|
||||
action: widget.onImportSamples == null
|
||||
? null
|
||||
: OutlinedButton.icon(
|
||||
onPressed: _importing ? null : _runImport,
|
||||
icon: const Icon(Icons.download_outlined, size: 16),
|
||||
label: Text(strings.flowListSamplesImport),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Workspace filter first: files without a `project:` key
|
||||
// count as `general` (display semantics — the file is the
|
||||
// truth and never rewritten).
|
||||
final inProject = all
|
||||
.where(
|
||||
(f) => flowVisibleInProject(
|
||||
f.meta.project,
|
||||
widget.activeProject,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
if (inProject.isEmpty) {
|
||||
// Flows exist, just none in this project — say that and
|
||||
// point at the way out (switch to all projects).
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
child: FaiEmptyState(
|
||||
icon: Icons.folder_off_outlined,
|
||||
title: strings.listProjectEmpty(widget.activeProject),
|
||||
hint: strings.listProjectEmptyHint,
|
||||
),
|
||||
);
|
||||
}
|
||||
final needle = _filter.toLowerCase();
|
||||
final files = needle.isEmpty
|
||||
? inProject
|
||||
: inProject
|
||||
.where((f) => f.name.toLowerCase().contains(needle))
|
||||
.toList();
|
||||
? all
|
||||
: all.where((f) => f.name.toLowerCase().contains(needle)).toList();
|
||||
if (files.isEmpty) {
|
||||
// Flows exist, the filter just matches none — say that
|
||||
// instead of pretending the directory is empty.
|
||||
|
|
@ -1657,69 +1495,13 @@ class _FileListState extends State<_FileList> {
|
|||
),
|
||||
);
|
||||
}
|
||||
// Grouping: the operator's own flows first; bundled
|
||||
// examples collapsed under one labelled group so samples
|
||||
// are never mistaken for area work (they expand by
|
||||
// default only when there is nothing else to show).
|
||||
final own = files.where((f) => !f.isExample).toList();
|
||||
final samples = files.where((f) => f.isExample).toList();
|
||||
final samplesExpanded = _samplesExpanded ?? own.isEmpty;
|
||||
return ListView(
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
||||
children: [
|
||||
for (final f in own) _row(theme, f),
|
||||
if (samples.isNotEmpty) ...[
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
setState(() => _samplesExpanded = !samplesExpanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.md,
|
||||
vertical: FaiSpace.sm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
strings.flowListSamplesGroup(samples.length),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
samplesExpanded
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (samplesExpanded) for (final f in samples) _row(theme, f),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(ThemeData theme, _FlowFile f) {
|
||||
final strings = widget.strings;
|
||||
{
|
||||
itemCount: files.length,
|
||||
itemBuilder: (_, i) {
|
||||
final f = files[i];
|
||||
final isActive = f.name == widget.activeName;
|
||||
final missing = f.meta.missingCaps(widget.installedNames);
|
||||
final split = splitMissingCaps(missing, widget.storeNames);
|
||||
return InkWell(
|
||||
onTap: () => widget.onOpen(f),
|
||||
child: Container(
|
||||
|
|
@ -1788,30 +1570,22 @@ class _FileListState extends State<_FileList> {
|
|||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
if (f.isExample || missing.isNotEmpty) ...[
|
||||
if (f.meta.isExample || missing.isNotEmpty) ...[
|
||||
const SizedBox(height: FaiSpace.xs),
|
||||
Wrap(
|
||||
spacing: FaiSpace.xs,
|
||||
runSpacing: FaiSpace.xs,
|
||||
children: [
|
||||
if (f.isExample)
|
||||
if (f.meta.isExample)
|
||||
_ExampleBadge(strings: strings),
|
||||
if (missing.isNotEmpty)
|
||||
MissingModulesBadge(
|
||||
installable: split.installable,
|
||||
notInStore: split.notInStore,
|
||||
unclassified: split.unclassified,
|
||||
_MissingModulesBadge(
|
||||
missing: missing,
|
||||
strings: strings,
|
||||
// Install only what the store
|
||||
// resolves — the not-in-store
|
||||
// chip explains the rest.
|
||||
onInstall:
|
||||
widget.onInstallMissing == null ||
|
||||
split.installable.isEmpty
|
||||
onInstall: widget.onInstallMissing == null
|
||||
? null
|
||||
: () => widget.onInstallMissing!(
|
||||
split.installable,
|
||||
),
|
||||
: () =>
|
||||
widget.onInstallMissing!(missing),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -1842,7 +1616,10 @@ class _FileListState extends State<_FileList> {
|
|||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1888,6 +1665,95 @@ class _ExampleBadge extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// "N modules missing" status on flows whose steps reference
|
||||
/// capabilities the hub doesn't provide. Status and action are
|
||||
/// SEPARATE elements (usertest: the combined orange chip made
|
||||
/// the whole list read like an error wall and ellipsized the
|
||||
/// action word):
|
||||
/// - status = quiet neutral chip with a small amber dot — a
|
||||
/// note, not an alarm; warning orange stays reserved for real
|
||||
/// failures.
|
||||
/// - action = its own "Install" link that is never truncated;
|
||||
/// both live in the row's Wrap, so tight widths wrap to a
|
||||
/// second line instead of cutting text mid-word.
|
||||
class _MissingModulesBadge extends StatelessWidget {
|
||||
final List<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);
|
||||
const dot = Color(0xFFEF6C00);
|
||||
final fg = theme.colorScheme.onSurfaceVariant;
|
||||
final chip = Tooltip(
|
||||
message: strings.flowListNeedsModulesTooltip(missing.join('\n')),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: dot,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
strings.flowListNeedsModules(missing.length),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: fg,
|
||||
fontSize: 10,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (onInstall == null) return chip;
|
||||
return Wrap(
|
||||
spacing: FaiSpace.xs,
|
||||
runSpacing: FaiSpace.xs,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
chip,
|
||||
InkWell(
|
||||
onTap: onInstall,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
child: Text(
|
||||
strings.flowListInstallMissing,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- empty state ---
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -24,21 +24,6 @@ String parseFlowProject(String yaml) {
|
|||
return '';
|
||||
}
|
||||
|
||||
/// The project a flow file effectively belongs to: its own
|
||||
/// `project:` slug, or `general` when the file declares none.
|
||||
/// Display/filter semantics only — the FILE stays the truth and
|
||||
/// is never rewritten to make this explicit.
|
||||
String effectiveFlowProject(String fileProject) =>
|
||||
fileProject.isEmpty ? 'general' : fileProject;
|
||||
|
||||
/// Whether a flow file belongs in the list under the active
|
||||
/// workspace filter. Empty [activeProject] = "all projects";
|
||||
/// otherwise the file's effective project (no key = `general`)
|
||||
/// must match.
|
||||
bool flowVisibleInProject(String fileProject, String activeProject) =>
|
||||
activeProject.isEmpty ||
|
||||
effectiveFlowProject(fileProject) == activeProject;
|
||||
|
||||
/// Mirror of the hub's `normalize_project_slug`: lowercase,
|
||||
/// collapse spaces/underscores/dashes to a single `-`, drop
|
||||
/// everything else, trim trailing dashes. Empty in → empty out
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -259,24 +259,11 @@ class FlowEditorStrings {
|
|||
);
|
||||
String analyzerUnknownCapNotInStore(String cap) => _t(
|
||||
'Unknown capability "$cap". '
|
||||
'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.',
|
||||
'Not in the store — install locally with '
|
||||
'`chain install --link <path>` or check the spelling.',
|
||||
'Unbekannte Capability "$cap". '
|
||||
'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>`).',
|
||||
'Nicht im Store — lokal mit '
|
||||
'`chain install --link <pfad>` installieren oder Tippfehler prüfen.',
|
||||
);
|
||||
String analyzerInputKind() => _t('input', 'Eingabe');
|
||||
String analyzerOutputKind() => _t('output', 'Ausgabe');
|
||||
|
|
@ -294,10 +281,6 @@ class FlowEditorStrings {
|
|||
'Bundled sample flow.',
|
||||
'Mitgelieferter Beispiel-Flow.',
|
||||
);
|
||||
String flowListSamplesGroup(int n) =>
|
||||
_t('Examples ($n)', 'Beispiele ($n)');
|
||||
String get flowListSamplesImport =>
|
||||
_t('Import example flows', 'Beispiel-Flows importieren');
|
||||
String flowListNeedsModules(int n) => _t(
|
||||
n == 1 ? '1 module missing' : '$n modules missing',
|
||||
n == 1 ? '1 Modul fehlt' : '$n Module fehlen',
|
||||
|
|
@ -309,45 +292,6 @@ class FlowEditorStrings {
|
|||
'Klicken, um die fehlenden zu installieren.',
|
||||
);
|
||||
String get flowListInstallMissing => _t('Install', 'Installieren');
|
||||
String flowListNeedsModulesTooltipNoAction(String caps) => _t(
|
||||
'This flow needs capabilities that are not installed:\n$caps\n'
|
||||
'The store is not reachable right now — no install offer '
|
||||
'until Studio can check it.',
|
||||
'Dieser Flow braucht nicht installierte Capabilities:\n$caps\n'
|
||||
'Der Store ist gerade nicht erreichbar — kein Install-Angebot, '
|
||||
'bis Studio das prüfen kann.',
|
||||
);
|
||||
String flowListNotInStore(int n) => _t(
|
||||
n == 1 ? 'not in store' : '$n not in store',
|
||||
n == 1 ? 'nicht im Store' : '$n nicht im Store',
|
||||
);
|
||||
String flowListNotInStoreTooltip(String caps) => _t(
|
||||
'This flow needs capabilities no configured store can install:\n'
|
||||
'$caps\n'
|
||||
'Ways to get them: install a local module '
|
||||
'(`chain install --link <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…');
|
||||
|
|
@ -374,7 +318,6 @@ class AnalyzerStrings {
|
|||
final String Function(String cap) unknownCapInStore;
|
||||
final String Function(String cap, String suggestion) unknownCapTypo;
|
||||
final String Function(String cap) unknownCapNotInStore;
|
||||
final String Function(String cap) unknownCapStoreUnknown;
|
||||
final String Function(String kind, String value, String validList)
|
||||
unknownType;
|
||||
final String Function() inputKind;
|
||||
|
|
@ -390,7 +333,6 @@ class AnalyzerStrings {
|
|||
required this.unknownCapInStore,
|
||||
required this.unknownCapTypo,
|
||||
required this.unknownCapNotInStore,
|
||||
required this.unknownCapStoreUnknown,
|
||||
required this.unknownType,
|
||||
required this.inputKind,
|
||||
required this.outputKind,
|
||||
|
|
@ -409,7 +351,6 @@ class AnalyzerStrings {
|
|||
unknownCapInStore: s.analyzerUnknownCapInStore,
|
||||
unknownCapTypo: s.analyzerUnknownCapTypo,
|
||||
unknownCapNotInStore: s.analyzerUnknownCapNotInStore,
|
||||
unknownCapStoreUnknown: s.analyzerUnknownCapStoreUnknown,
|
||||
unknownType: s.analyzerUnknownType,
|
||||
inputKind: s.analyzerInputKind,
|
||||
outputKind: s.analyzerOutputKind,
|
||||
|
|
@ -430,7 +371,6 @@ class AnalyzerStrings {
|
|||
: unknownCapInStore = _enUnknownCapInStore,
|
||||
unknownCapTypo = _enUnknownCapTypo,
|
||||
unknownCapNotInStore = _enUnknownCapNotInStore,
|
||||
unknownCapStoreUnknown = _enUnknownCapStoreUnknown,
|
||||
unknownType = _enUnknownType,
|
||||
inputKind = _enInputKind,
|
||||
outputKind = _enOutputKind,
|
||||
|
|
@ -447,14 +387,8 @@ class AnalyzerStrings {
|
|||
'Unknown capability "$cap". Did you mean "$suggestion"?';
|
||||
static String _enUnknownCapNotInStore(String cap) =>
|
||||
'Unknown capability "$cap". '
|
||||
'No configured store can install it — install a local module '
|
||||
'(`chain install --link <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>`).';
|
||||
'Not in the store — install locally with '
|
||||
'`chain install --link <path>` or check the spelling.';
|
||||
static String _enUnknownType(String kind, String value, String validList) =>
|
||||
'Unknown $kind type "$value". Use one of: $validList.';
|
||||
static String _enInputKind() => 'input';
|
||||
|
|
|
|||
|
|
@ -73,8 +73,8 @@ class InstallCapabilityFix extends QuickFix {
|
|||
/// (`chain install <url>`), then installs and reanalyzes.
|
||||
///
|
||||
/// This is the recovery path for private modules: the public
|
||||
/// store doesn't know about `acme.internal/directory-lookup`,
|
||||
/// but the operator can point the hub at the local clone.
|
||||
/// store doesn't know about `htw.digiscout/onet-lookup`, but
|
||||
/// the operator can point the hub at the local clone.
|
||||
@immutable
|
||||
class AddModuleSourceFix extends QuickFix {
|
||||
/// The capability the operator wrote — the host uses it to
|
||||
|
|
|
|||
|
|
@ -12,17 +12,11 @@ class FaiEmptyState extends StatelessWidget {
|
|||
final IconData icon;
|
||||
final String title;
|
||||
final String? hint;
|
||||
|
||||
/// Optional action rendered under the hint (e.g. the empty flow
|
||||
/// list's "import example flows" button).
|
||||
final Widget? action;
|
||||
|
||||
const FaiEmptyState({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.hint,
|
||||
this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -52,10 +46,6 @@ class FaiEmptyState extends StatelessWidget {
|
|||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,184 +0,0 @@
|
|||
// Flow-list badge for flows whose steps reference capabilities
|
||||
// the hub doesn't provide. Split out of flow_editor_page.dart so
|
||||
// the resolvability states (installable from a store vs. not in
|
||||
// any store) stay unit-testable.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n.dart';
|
||||
import '../tokens.dart';
|
||||
|
||||
/// Split a flow's missing capabilities into the ones a configured
|
||||
/// store can actually install and the ones no store resolves.
|
||||
///
|
||||
/// [storeNames] is the host-supplied set of bare capability names
|
||||
/// the store can install (already filtered to installable entries
|
||||
/// — published/alpha, native). An empty set means "nothing is
|
||||
/// store-installable" (known state); `null` means the store state
|
||||
/// is UNKNOWN (snapshot not loaded / unreachable) — then every
|
||||
/// missing cap lands in `unclassified` and the UI claims neither
|
||||
/// "installable" nor "not in store".
|
||||
({
|
||||
List<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.26.0
|
||||
version: 0.24.1
|
||||
publish_to: 'none'
|
||||
repository: https://git.flemming.ai/fai/studio-flow-editor
|
||||
|
||||
|
|
|
|||
|
|
@ -135,20 +135,13 @@ steps:
|
|||
name: x
|
||||
steps:
|
||||
- id: c
|
||||
use: acme.internal/secret@^0.1
|
||||
use: htw.private/secret@^0.1
|
||||
'''));
|
||||
expect(r.issues, hasLength(1));
|
||||
final fixes = a.fixesFor(r.issues.first);
|
||||
expect(fixes, hasLength(1));
|
||||
expect(fixes.first, isA<AddModuleSourceFix>());
|
||||
// The message must explain all three recovery paths in place:
|
||||
// local install, adding a store, configuring an integration.
|
||||
expect(
|
||||
r.issues.first.message,
|
||||
contains('No configured store can install it'),
|
||||
);
|
||||
expect(r.issues.first.message, contains('chain install --link'));
|
||||
expect(r.issues.first.message, contains('integration'));
|
||||
expect(r.issues.first.message, contains('Not in the store'));
|
||||
});
|
||||
|
||||
test('did-you-mean wins over install/add-source when a near miss exists',
|
||||
|
|
|
|||
|
|
@ -1,146 +0,0 @@
|
|||
// Widget-level proof of the flow list's project separation:
|
||||
// switching the active workspace filters the list, and a new flow
|
||||
// is stamped with the active project's key. Hermetic — the editor
|
||||
// gets a temp flows dir injected and never touches ~/.chain.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart';
|
||||
|
||||
Future<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:')));
|
||||
});
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
// Widget-level proof of the sample handling contract (persona
|
||||
// review 2026-08-27 / sealed-areas rework):
|
||||
//
|
||||
// * The host's sample set (the hub's FlowSummary.sample wire
|
||||
// flag) is the ONLY source of the "Example" chip — no host
|
||||
// info, no chips. The editor's old content-marker scan had
|
||||
// silently drifted from the hub's header and is gone.
|
||||
// * Sample flows collapse under one "Examples (N)" group so
|
||||
// they are never mistaken for the operator's own work; the
|
||||
// group expands only on demand (or when nothing else exists).
|
||||
// * An empty list offers "Import example flows" iff the host
|
||||
// wired the action (sealed areas start empty on purpose).
|
||||
//
|
||||
// Hermetic — temp flows dir, never ~/.chain.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart';
|
||||
|
||||
Future<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,30 +34,6 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('project filter semantics', () {
|
||||
test('no project key counts as general (display only)', () {
|
||||
expect(effectiveFlowProject(''), 'general');
|
||||
expect(effectiveFlowProject('client-a'), 'client-a');
|
||||
});
|
||||
|
||||
test('empty active workspace shows every flow', () {
|
||||
expect(flowVisibleInProject('', ''), isTrue);
|
||||
expect(flowVisibleInProject('client-a', ''), isTrue);
|
||||
});
|
||||
|
||||
test('general shows keyless flows and explicit general ones', () {
|
||||
expect(flowVisibleInProject('', 'general'), isTrue);
|
||||
expect(flowVisibleInProject('general', 'general'), isTrue);
|
||||
expect(flowVisibleInProject('client-a', 'general'), isFalse);
|
||||
});
|
||||
|
||||
test('a project shows only its own flows', () {
|
||||
expect(flowVisibleInProject('client-a', 'client-a'), isTrue);
|
||||
expect(flowVisibleInProject('', 'client-a'), isFalse);
|
||||
expect(flowVisibleInProject('client-b', 'client-a'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('normalizeProjectSlug', () {
|
||||
test('collapses separators and trims trailing dashes', () {
|
||||
expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha');
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chain_studio_flow_editor/src/l10n.dart';
|
||||
import 'package:chain_studio_flow_editor/src/widgets/missing_modules_badge.dart';
|
||||
|
||||
void main() {
|
||||
group('splitMissingCaps', () {
|
||||
test('separates store-resolvable from not-in-store capabilities', () {
|
||||
final split = splitMissingCaps(
|
||||
['text.extract', 'example-provider/tool.summarize'],
|
||||
{'text.extract', 'debug.echo'},
|
||||
);
|
||||
expect(split.installable, ['text.extract']);
|
||||
expect(split.notInStore, ['example-provider/tool.summarize']);
|
||||
});
|
||||
|
||||
test('matches on the bare name when the flow pins a version', () {
|
||||
final split = splitMissingCaps(['text.extract@^0'], {'text.extract'});
|
||||
expect(split.installable, ['text.extract@^0']);
|
||||
expect(split.notInStore, isEmpty);
|
||||
});
|
||||
|
||||
test('empty store set classifies everything as not-in-store', () {
|
||||
final split = splitMissingCaps(['text.extract'], {});
|
||||
expect(split.installable, isEmpty);
|
||||
expect(split.notInStore, ['text.extract']);
|
||||
expect(split.unclassified, isEmpty);
|
||||
});
|
||||
|
||||
test('null store set (state unknown) classifies nothing', () {
|
||||
final split = splitMissingCaps(['text.extract'], null);
|
||||
expect(split.installable, isEmpty);
|
||||
expect(split.notInStore, isEmpty);
|
||||
expect(split.unclassified, ['text.extract']);
|
||||
});
|
||||
});
|
||||
|
||||
Future<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