Some checks are pending
Security / Security check (push) Waiting to run
Store snapshot is now nullable: until the first successful store search (or after a sealed-area connection switch) the editor gets null and claims neither 'installable' nor 'not in store'. The snapshot reloads after installs and on connection switches — a sealed switch previously kept the other hub's capability offers alive on the mounted Flows page. Editor pin 2535c28. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
371 lines
14 KiB
Dart
371 lines
14 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 'dart:async' show unawaited;
|
|
|
|
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';
|
|
import '../widgets/chain_workspace_switcher.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; null while the store state is UNKNOWN (not yet
|
|
/// loaded / search failed) so the editor claims neither
|
|
/// "installable" nor "not in store". Refreshed at open, after
|
|
/// an install and on a connection switch.
|
|
List<String>? _storeCaps;
|
|
|
|
/// Which hub connection the snapshots were loaded against —
|
|
/// the sealed-area slug, or empty for the shared hub. A sealed
|
|
/// switch keeps this page mounted, so without the guard the
|
|
/// editor would keep offering the OTHER hub's capabilities.
|
|
String _connKey = Workspace.instance.activeSealed?.slug ?? '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_driver = StudioFlowRunDriver();
|
|
_capabilities = _loadCapabilities();
|
|
_loadStoreCapabilities();
|
|
Workspace.instance.addListener(_onWorkspaceChanged);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
Workspace.instance.removeListener(_onWorkspaceChanged);
|
|
super.dispose();
|
|
}
|
|
|
|
/// Reload both capability snapshots when the CONNECTION target
|
|
/// changes (entering/leaving a sealed area) — a plain project
|
|
/// filter change stays cheap and reuses the loaded lists.
|
|
void _onWorkspaceChanged() {
|
|
final key = Workspace.instance.activeSealed?.slug ?? '';
|
|
if (key == _connKey) return;
|
|
_connKey = key;
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_storeCaps = null; // other hub — snapshot unknown again
|
|
_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: keep the last snapshot for THIS connection if
|
|
// one exists; otherwise stay in the honest "unknown" state
|
|
// (no install offers, no not-in-store claims). Add-source
|
|
// remains available either way.
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
// The store snapshot may have moved too (an install can pull
|
|
// in new entries / the operator refreshed a store) — keep the
|
|
// badge's truth in step with the capability list.
|
|
unawaited(_loadStoreCapabilities());
|
|
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,
|
|
// Same switcher as Audit/Approvals/Runs, hosted in the
|
|
// editor's toolbar (the page's single header): it
|
|
// filters the flow list and is the project a new flow
|
|
// gets stamped with.
|
|
toolbarTrailing: const ChainWorkspaceSwitcher(),
|
|
// 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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|