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>
This commit is contained in:
flemming-it 2026-08-28 00:11:55 +02:00
parent 64c2a77dc9
commit d69277d29d
4 changed files with 107 additions and 1 deletions

View file

@ -1002,6 +1002,7 @@ class HubService {
sizeBytes: f.sizeBytes.toInt(), sizeBytes: f.sizeBytes.toInt(),
requiredCapabilities: List<String>.from(f.requiredCapabilities), requiredCapabilities: List<String>.from(f.requiredCapabilities),
project: f.project, project: f.project,
sample: f.sample,
), ),
) )
.toList() .toList()
@ -1593,12 +1594,19 @@ class SavedFlow {
/// project, then `general`). /// project, then `general`).
final String project; final String project;
/// True when the file still carries the bundled-sample header
/// it arrived via the hub's sample import, not from an operator.
/// Old hubs without the wire field report false, so no chip is
/// ever shown on guesswork.
final bool sample;
const SavedFlow({ const SavedFlow({
required this.name, required this.name,
required this.path, required this.path,
required this.sizeBytes, required this.sizeBytes,
required this.requiredCapabilities, required this.requiredCapabilities,
this.project = '', this.project = '',
this.sample = false,
}); });
} }

View file

@ -239,6 +239,31 @@ class SystemActions {
return _runFai(['project', 'start', slug]); return _runFai(['project', 'start', slug]);
} }
/// Import the bundled sample flows (`chain flows import-samples`;
/// existing files are kept). With [sealedSlug] the import targets
/// that sealed instance's own data/modules dirs — sealed areas
/// start without samples, this is the deliberate pull.
static Future<({bool ok, String stdout, String stderr})>
chainFlowsImportSamples({String? sealedSlug}) async {
if (sealedSlug == null || sealedSlug.isEmpty) {
return _runFai(['flows', 'import-samples']);
}
final home =
Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
'';
final sep = Platform.pathSeparator;
final root = '$home$sep.chain${sep}sealed$sep$sealedSlug';
return _runFai([
'flows',
'import-samples',
'--data-dir',
'$root${sep}data',
'--modules-dir',
'$root${sep}modules',
]);
}
static Future<({bool ok, String stdout, String stderr})> _runFai( static Future<({bool ok, String stdout, String stderr})> _runFai(
List<String> args, List<String> args,
) async { ) async {

View file

@ -11,6 +11,7 @@
// need to know what's inside the editor any more. // need to know what's inside the editor any more.
import 'dart:async' show unawaited; import 'dart:async' show unawaited;
import 'dart:io' show Platform;
import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart'; import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
@ -20,6 +21,7 @@ import '../data/error_presentation.dart';
import '../data/flow_run_driver.dart'; import '../data/flow_run_driver.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../data/store_caps.dart'; import '../data/store_caps.dart';
import '../data/system_actions.dart';
import '../data/workspace.dart'; import '../data/workspace.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../widgets/chain_install_confirm.dart'; import '../widgets/chain_install_confirm.dart';
@ -53,12 +55,19 @@ class _FlowsPageState extends State<FlowsPage> {
/// editor would keep offering the OTHER hub's capabilities. /// editor would keep offering the OTHER hub's capabilities.
String _connKey = Workspace.instance.activeSealed?.slug ?? ''; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
_driver = StudioFlowRunDriver(); _driver = StudioFlowRunDriver();
_capabilities = _loadCapabilities(); _capabilities = _loadCapabilities();
_loadStoreCapabilities(); _loadStoreCapabilities();
_loadSampleNames();
Workspace.instance.addListener(_onWorkspaceChanged); Workspace.instance.addListener(_onWorkspaceChanged);
} }
@ -78,9 +87,64 @@ class _FlowsPageState extends State<FlowsPage> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_storeCaps = null; // other hub snapshot unknown again _storeCaps = null; // other hub snapshot unknown again
_sampleNames = null;
_capabilities = _loadCapabilities(); _capabilities = _loadCapabilities();
}); });
_loadStoreCapabilities(); _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 { Future<void> _loadStoreCapabilities() async {
@ -210,7 +274,16 @@ class _FlowsPageState extends State<FlowsPage> {
future: _capabilities, future: _capabilities,
builder: (context, snap) { builder: (context, snap) {
final caps = snap.data ?? const <String>[]; final caps = snap.data ?? const <String>[];
final sealed = Workspace.instance.activeSealed;
return FlowEditorPage( 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, initialFlowName: widget.initialFlowName,
locale: editorLocale, locale: editorLocale,
runDriver: _driver, runDriver: _driver,

View file

@ -46,7 +46,7 @@ packages:
path: "../fai_chain_studio_flow_editor" path: "../fai_chain_studio_flow_editor"
relative: true relative: true
source: path source: path
version: "0.25.0" version: "0.26.0"
characters: characters:
dependency: transitive dependency: transitive
description: description: