chain-studio/lib/pages/flows.dart
flemming-it d69277d29d feat(flows): sealed-aware flow list — hub sample flag, own dir, sample import
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>
2026-08-28 00:11:55 +02:00

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),
),
],
);
}
}