Some checks are pending
Security / Security check (push) Waiting to run
The capability set behind the flow editor's Install quick-fix and the flow list's install badge ingested every store entry's requiresCapabilities (dependencies, not provided capabilities) and ignored entry status/kind. Clicking Install on such a capability ended in the hub's "no store entry for '<name>'" error. The set now mirrors the hub's install resolver (entry names only, no planned or federated entries) via installableStoreCapabilities() with unit tests for both classification states. Unresolvable capabilities render the editor's 'not in store' state, which explains the three recovery paths before any click; the editor pin moves to 0.25.0 (commit-pinned until its tag exists) and the dialog harness captures the badge states light+dark. Studio 0.78.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
328 lines
12 KiB
Dart
328 lines
12 KiB
Dart
// FlowsPage — Studio's flow surface. Thin host wrapper around
|
|
// the swappable chain_studio_flow_editor package: loads the
|
|
// hub's installed capabilities, hands them + a Studio-side
|
|
// FlowRunDriver to the editor, and lets the editor own
|
|
// everything else (file list, three-tab body, properties
|
|
// panel, run progress, …).
|
|
//
|
|
// Pre-0.52 versions split file-listing and run-dialog state
|
|
// across this page. v0.52 moves all of that into the editor
|
|
// package so a swap is one pubspec change — Studio doesn't
|
|
// need to know what's inside the editor any more.
|
|
|
|
import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../data/error_presentation.dart';
|
|
import '../data/flow_run_driver.dart';
|
|
import '../data/hub.dart';
|
|
import '../data/store_caps.dart';
|
|
import '../data/workspace.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../widgets/chain_install_confirm.dart';
|
|
|
|
class FlowsPage extends StatefulWidget {
|
|
/// Pre-load this flow when the editor first builds. Studio
|
|
/// keeps the parameter so the Cmd+K palette + welcome page
|
|
/// can deep-link straight into a specific flow.
|
|
final String? initialFlowName;
|
|
|
|
const FlowsPage({super.key, this.initialFlowName});
|
|
|
|
@override
|
|
State<FlowsPage> createState() => _FlowsPageState();
|
|
}
|
|
|
|
class _FlowsPageState extends State<FlowsPage> {
|
|
late Future<List<String>> _capabilities;
|
|
late final StudioFlowRunDriver _driver;
|
|
|
|
/// Snapshot of bare capability names the public store can
|
|
/// install. Refreshed once at open so the editor's analyzer
|
|
/// can decide between "Install" (in store) and "Add source"
|
|
/// (not in store) without a per-keystroke network call.
|
|
List<String> _storeCaps = const [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_driver = StudioFlowRunDriver();
|
|
_capabilities = _loadCapabilities();
|
|
_loadStoreCapabilities();
|
|
}
|
|
|
|
Future<void> _loadStoreCapabilities() async {
|
|
try {
|
|
final items = await HubService.instance.searchStore(limit: 500);
|
|
if (!mounted) return;
|
|
// Only capabilities the store can actually install — the
|
|
// editor's Install quick-fix and list badge promise exactly
|
|
// what the hub's install resolver accepts. (This set used to
|
|
// ingest requiresCapabilities too, which offered installs
|
|
// that ended in "no store entry for '<name>'".)
|
|
setState(
|
|
() => _storeCaps = installableStoreCapabilities(items).toList()..sort(),
|
|
);
|
|
} catch (_) {
|
|
// Soft-fail: empty list disables the Install button on
|
|
// unknown-cap fixes, but Add-source remains available.
|
|
}
|
|
}
|
|
|
|
/// Pulls the capability identifiers the picker dialog
|
|
/// offers when adding a step. `<capability>@<version>` so
|
|
/// the operator can see exactly what they'll be wiring in,
|
|
/// matching the format the `use:` field expects. Soft-fails
|
|
/// to an empty list if the hub is unreachable — the editor
|
|
/// falls back to a free-form text field in that case.
|
|
Future<List<String>> _loadCapabilities() async {
|
|
try {
|
|
final caps = await HubService.instance.allCapabilities();
|
|
final entries =
|
|
caps.map((c) => '${c.capability}@${c.version}').toSet().toList()
|
|
..sort();
|
|
return entries;
|
|
} catch (_) {
|
|
return const <String>[];
|
|
}
|
|
}
|
|
|
|
/// Quick-fix handler — invoked when the operator clicks
|
|
/// `Install <capability>` on an analyzer issue. Parses the
|
|
/// editor-supplied `provider/name@version` spec into the
|
|
/// Hub's install API and returns the refreshed capability
|
|
/// list so the editor can re-analyze without waiting for the
|
|
/// parent widget rebuild.
|
|
/// Native picker for a flow file input. `withData` loads the
|
|
/// bytes directly so the editor never touches the path itself
|
|
/// (sandbox-friendly: the picker grants access to exactly the
|
|
/// chosen file).
|
|
Future<PickedFileData?> _pickFlowInputFile() async {
|
|
final picked = await FilePicker.pickFiles(withData: true);
|
|
final f = picked?.files.single;
|
|
if (f == null || f.bytes == null) return null;
|
|
return PickedFileData(fileName: f.name, bytes: f.bytes!);
|
|
}
|
|
|
|
Future<List<String>?> _onInstallCapability(String capability) async {
|
|
// The analyzer hands us the full `use:` value (e.g. `debug.echo@^0`).
|
|
// The hub resolves install sources by bare capability name; the
|
|
// `@<constraint>` suffix would make the store lookup miss ("no store
|
|
// entry for 'debug.echo@^0'"). Strip it — same as the Store page.
|
|
final at = capability.indexOf('@');
|
|
final bare = at < 0 ? capability : capability.substring(0, at);
|
|
// The quick-fix install goes through the same trust dialog as
|
|
// the Store page — no quieter direct path.
|
|
final confirmed =
|
|
await ChainInstallConfirmDialog.showForCapability(context, bare);
|
|
if (!confirmed) return null;
|
|
return _runInstall(source: bare);
|
|
}
|
|
|
|
/// `Add source for <cap>…` handler. Prompts the operator
|
|
/// for a local path or URL, then routes through the same
|
|
/// install RPC. Returns the refreshed capability list on
|
|
/// success, null on cancel / failure.
|
|
Future<List<String>?> _onAddModuleSource(String capability) async {
|
|
final source = await _AddModuleSourceDialog.show(context, capability);
|
|
if (source == null || source.isEmpty) return null;
|
|
return _runInstall(source: source);
|
|
}
|
|
|
|
Future<List<String>?> _runInstall({required String source}) async {
|
|
try {
|
|
await HubService.instance.installModule(source: source);
|
|
final caps = await HubService.instance.allCapabilities();
|
|
final updated = caps
|
|
.map((c) => '${c.capability}@${c.version}')
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
if (mounted) {
|
|
setState(() => _capabilities = Future.value(updated));
|
|
}
|
|
return updated;
|
|
} catch (e) {
|
|
if (mounted) {
|
|
final l = AppLocalizations.of(context);
|
|
// Copyable error (was a plain SnackBar) — install failures
|
|
// carry the real cause (network, signature, store lookup).
|
|
showChainErrorSnack(
|
|
context,
|
|
'flows.install',
|
|
e,
|
|
title: l != null ? l.installFailed('') : 'Install failed',
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final locale = Localizations.localeOf(context);
|
|
final editorLocale = locale.languageCode == 'de'
|
|
? FlowEditorLocale.de
|
|
: FlowEditorLocale.en;
|
|
// Rebuild the editor's project chip when the workspace changes,
|
|
// so a mismatch banner appears/clears live.
|
|
return ListenableBuilder(
|
|
listenable: Workspace.instance,
|
|
builder: (context, _) => FutureBuilder<List<String>>(
|
|
future: _capabilities,
|
|
builder: (context, snap) {
|
|
final caps = snap.data ?? const <String>[];
|
|
return FlowEditorPage(
|
|
initialFlowName: widget.initialFlowName,
|
|
locale: editorLocale,
|
|
runDriver: _driver,
|
|
availableCapabilities: caps,
|
|
storeCapabilities: _storeCaps,
|
|
onInstallCapability: _onInstallCapability,
|
|
onAddModuleSource: _onAddModuleSource,
|
|
activeProject: Workspace.instance.activeSlug,
|
|
// Native file dialog for the Run tab's file inputs —
|
|
// nobody should have to type an absolute path by hand.
|
|
onPickFile: _pickFlowInputFile,
|
|
// The file wins for the run; accepting the switch only
|
|
// aligns the workspace view to the file's project.
|
|
onSwitchToFileProject: (slug) =>
|
|
Workspace.instance.setActive(slug),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Modal asking the operator where a missing capability can be
|
|
/// installed from — either a local module directory (the
|
|
/// `--link` install path) or a remote URL the hub can resolve.
|
|
/// Returns the typed source string on submit, null on cancel.
|
|
class _AddModuleSourceDialog extends StatefulWidget {
|
|
final String capability;
|
|
const _AddModuleSourceDialog({required this.capability});
|
|
|
|
static Future<String?> show(BuildContext context, String capability) {
|
|
return showDialog<String>(
|
|
context: context,
|
|
builder: (_) => _AddModuleSourceDialog(capability: capability),
|
|
);
|
|
}
|
|
|
|
@override
|
|
State<_AddModuleSourceDialog> createState() => _AddModuleSourceDialogState();
|
|
}
|
|
|
|
class _AddModuleSourceDialogState extends State<_AddModuleSourceDialog> {
|
|
final TextEditingController _ctrl = TextEditingController();
|
|
String _source = '';
|
|
|
|
@override
|
|
void dispose() {
|
|
_ctrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return AlertDialog(
|
|
title: Text(l.addSourceTitle),
|
|
content: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 520),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.addSourceIntro(widget.capability),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _ctrl,
|
|
autofocus: true,
|
|
onChanged: (v) => setState(() => _source = v.trim()),
|
|
decoration: InputDecoration(
|
|
labelText: l.addSourceField,
|
|
hintText: l.addSourceHint,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.addSourceHowItWorksTitle,
|
|
style: theme.textTheme.labelMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
l.addSourceHowItWorksBody,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surface,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: SelectableText(
|
|
l.addSourceCliExample,
|
|
style: TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontFamilyFallback: const [
|
|
'Menlo',
|
|
'Consolas',
|
|
'Courier New',
|
|
'monospace',
|
|
],
|
|
fontSize: 11,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(l.addSourceCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: _source.isEmpty
|
|
? null
|
|
: () => Navigator.of(context).pop(_source),
|
|
child: Text(l.addSourceInstallButton),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|