Three connection-truth fixes on the flow surface: * The editor now lists the CONNECTED hub's flows: inside a sealed area Studio passes the instance's own flows dir (~/.chain/sealed/<slug>/data/flows) — previously the editor kept showing the shared hub's files whatever the connection, so a sealed area's list was simply wrong (and runSavedFlow hit the other hub's namespace). * A connection switch replaces the editor state entirely (keyed by the sealed slug): an open buffer from one context never survives into the other — same privacy class as the switch race. * Sample truth comes from the hub: listFlows' FlowSummary.sample (regenerated Dart SDK stubs) feeds the editor's sampleFlowNames; unknown (old hub / fetch failed) means no chips. Inside a sealed area the empty list offers the deliberate 'import example flows' action via chain flows import-samples against the instance's own dirs; the shared hub gets no such offer (it seeds samples itself, and a deliberate deletion is respected). Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
441 lines
16 KiB
Dart
441 lines
16 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 'dart:io' show Platform;
|
|
|
|
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/system_actions.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; 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 ?? '';
|
|
|
|
/// Flow names the hub reports as bundled samples
|
|
/// (FlowSummary.sample). Null while unknown (fetch pending /
|
|
/// failed / hub too old) — the editor then shows no sample
|
|
/// chips rather than guessing.
|
|
Set<String>? _sampleNames;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_driver = StudioFlowRunDriver();
|
|
_capabilities = _loadCapabilities();
|
|
_loadStoreCapabilities();
|
|
_loadSampleNames();
|
|
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
|
|
_sampleNames = null;
|
|
_capabilities = _loadCapabilities();
|
|
});
|
|
_loadStoreCapabilities();
|
|
_loadSampleNames();
|
|
}
|
|
|
|
/// Pull the hub's sample marking for the current connection.
|
|
/// Soft-fails to "unknown" — no chips on guesswork.
|
|
Future<void> _loadSampleNames() async {
|
|
final key = _connKey;
|
|
try {
|
|
final flows = await HubService.instance.listFlows();
|
|
if (!mounted || key != _connKey) return;
|
|
setState(() {
|
|
_sampleNames = {
|
|
for (final f in flows)
|
|
if (f.sample) f.name,
|
|
};
|
|
});
|
|
} catch (_) {
|
|
// Unknown stays unknown.
|
|
}
|
|
}
|
|
|
|
/// Flows directory of the CURRENT connection: a sealed area's
|
|
/// own instance dir, or null for the editor's shared-hub
|
|
/// default. Without this the editor kept listing the shared
|
|
/// hub's files while Studio was connected to a sealed area.
|
|
String? _flowsDirForConnection() {
|
|
final sealed = Workspace.instance.activeSealed;
|
|
if (sealed == null) return null;
|
|
final home =
|
|
Platform.environment['HOME'] ??
|
|
Platform.environment['USERPROFILE'] ??
|
|
'.';
|
|
return '$home/.chain/sealed/${sealed.slug}/data/flows';
|
|
}
|
|
|
|
/// Deliberate sample pull for a sealed area's empty hub (the
|
|
/// shared hub seeds samples itself; a deliberate deletion there
|
|
/// must not be second-guessed with an import offer).
|
|
Future<void> _importSamples() async {
|
|
final sealed = Workspace.instance.activeSealed;
|
|
if (sealed == null) return;
|
|
final r = await SystemActions.chainFlowsImportSamples(
|
|
sealedSlug: sealed.slug,
|
|
);
|
|
if (!mounted) return;
|
|
if (!r.ok) {
|
|
showChainErrorSnack(
|
|
context,
|
|
'flows.importSamples',
|
|
(r.stderr.isEmpty ? r.stdout : r.stderr).trim(),
|
|
);
|
|
return;
|
|
}
|
|
await _loadSampleNames();
|
|
}
|
|
|
|
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>[];
|
|
final sealed = Workspace.instance.activeSealed;
|
|
return FlowEditorPage(
|
|
// A connection switch replaces the whole editor state:
|
|
// an open buffer from one context must never survive
|
|
// into the other (same privacy class as the switch
|
|
// race), and the file list re-roots at the new dir.
|
|
key: ValueKey('flow-editor-${sealed?.slug ?? ''}'),
|
|
flowsDir: _flowsDirForConnection(),
|
|
sampleFlowNames: _sampleNames,
|
|
onImportSamples: sealed == null ? null : _importSamples,
|
|
initialFlowName: widget.initialFlowName,
|
|
locale: editorLocale,
|
|
runDriver: _driver,
|
|
availableCapabilities: caps,
|
|
storeCapabilities: _storeCaps,
|
|
onInstallCapability: _onInstallCapability,
|
|
onAddModuleSource: _onAddModuleSource,
|
|
activeProject: Workspace.instance.activeSlug,
|
|
// The workspace switcher lives ONCE in the shell sidebar
|
|
// (global anchor) — the editor toolbar no longer hosts
|
|
// its own copy.
|
|
// 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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|