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