feat(studio): clickable install pills + open-in-editor on flow cards (v0.43.0)

When a saved flow is missing modules, each capability pill in the
"Needs:" row is now clickable: tap one and a per-item progress
dialog walks the bare capability name through installModule, then
the flows page refreshes. When more than one capability is missing,
an extra "Install all (N)" button runs the same dialog over the
whole list sequentially so a fresh operator can take an unrunnable
flow and one click later have its dependencies resolved.

Also adds a pencil icon next to Run that hands the YAML path to
the OS via SystemActions.openInOs — the operator's default editor
for .yaml decides what opens. Makes the path next to the flow name
actionable instead of decorative.

The install dialog renders one row per spec with a status icon
(pending circle / spinner / check / error), shows the installed
version on success, and surfaces the full error in a copyable
FaiErrorBox on failure so the operator can paste it back.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-09 11:58:24 +02:00
parent 2002486828
commit c389876a2f
9 changed files with 662 additions and 59 deletions

View file

@ -6,6 +6,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
@ -77,6 +78,31 @@ class _FlowsPageState extends State<FlowsPage> {
);
}
/// Walk through one or more capability specs (e.g.
/// `text.extract@^0`) sequentially, showing per-item progress.
/// Refreshes the flows page on dismiss so the row updates if
/// any install actually changed the installed-set.
Future<void> _installDeps(List<String> specs) async {
final changed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => _InstallDependenciesDialog(specs: specs),
);
if (changed == true && mounted) _refresh();
}
/// Hand the YAML file to the OS `open` on macOS, `xdg-open`
/// on Linux, `start` on Windows. The operator's default editor
/// for `.yaml` decides what actually opens.
Future<void> _openInEditor(SavedFlow flow) async {
final r = await SystemActions.openInOs(flow.path);
if (!mounted || r.ok) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.flowsOpenInEditorFailed(r.stderr))),
);
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
@ -131,6 +157,11 @@ class _FlowsPageState extends State<FlowsPage> {
flow: flow,
missingCapabilities: missing,
onRun: missing.isEmpty ? () => _runFlow(flow) : null,
onInstallSingle: (spec) => _installDeps([spec]),
onInstallAll: missing.length > 1
? () => _installDeps(missing)
: null,
onOpenInEditor: () => _openInEditor(flow),
);
},
);
@ -164,11 +195,24 @@ class _FlowCard extends StatelessWidget {
/// Run button greys out + a tooltip points the operator
/// at the install path. Non-null otherwise.
final VoidCallback? onRun;
/// Install a single capability spec (verbatim `use:` string,
/// version constraint included the dialog strips the `@`
/// suffix before calling installModule).
final ValueChanged<String> onInstallSingle;
/// Install every missing capability in sequence. Null when
/// only one is missing (the single-install pill is enough).
final VoidCallback? onInstallAll;
/// Hand the flow YAML to the OS so the operator's default
/// editor opens it.
final VoidCallback onOpenInEditor;
const _FlowCard({
required this.flow,
required this.missingCapabilities,
required this.onRun,
required this.onInstallSingle,
required this.onInstallAll,
required this.onOpenInEditor,
});
@override
@ -177,73 +221,142 @@ class _FlowCard extends StatelessWidget {
final l = AppLocalizations.of(context)!;
final isRunnable = missingCapabilities.isEmpty;
return FaiCard(
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.account_tree_outlined,
size: 20,
color: theme.colorScheme.primary,
Row(
children: [
Icon(
Icons.account_tree_outlined,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: FaiSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
flow.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
flow.path,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
FaiPill(
label: '${flow.sizeBytes} B',
tone: FaiPillTone.neutral,
monospace: true,
),
const SizedBox(width: FaiSpace.sm),
IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
tooltip: l.flowsOpenInEditorTooltip,
onPressed: onOpenInEditor,
),
const SizedBox(width: FaiSpace.xs),
Tooltip(
message: isRunnable ? '' : l.flowsRunDisabledTooltip,
child: FilledButton.icon(
onPressed: onRun,
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(l.flowsRunButton),
),
),
],
),
const SizedBox(width: FaiSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
flow.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
if (!isRunnable) ...[
const SizedBox(height: FaiSpace.sm),
Padding(
padding: const EdgeInsets.only(left: 32),
child: Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
l.flowsMissingModulesLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.error,
letterSpacing: 0.4,
),
),
),
const SizedBox(height: 2),
Text(
flow.path,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (!isRunnable) ...[
const SizedBox(height: FaiSpace.sm),
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
l.flowsMissingModulesLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.error,
letterSpacing: 0.4,
for (final cap in missingCapabilities)
_ClickablePill(
label: cap,
tooltip: l.flowsInstallPillTooltip,
onTap: () => onInstallSingle(cap),
),
if (onInstallAll != null)
Padding(
padding: const EdgeInsets.only(left: FaiSpace.sm),
child: FilledButton.tonalIcon(
onPressed: onInstallAll,
icon: const Icon(Icons.download_outlined, size: 14),
label: Text(
l.flowsInstallAllButton(missingCapabilities.length),
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: 0,
),
minimumSize: const Size(0, 28),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
for (final cap in missingCapabilities)
FaiPill(
label: cap,
tone: FaiPillTone.danger,
monospace: true,
),
],
),
),
],
],
),
),
),
FaiPill(
label: '${flow.sizeBytes} B',
tone: FaiPillTone.neutral,
],
],
),
);
}
}
/// Tappable variant of `FaiPill` used for the missing-capability
/// row. Wraps the pill in `InkWell` + `Tooltip` so the operator
/// gets a click affordance and a "Click to install" hint.
class _ClickablePill extends StatelessWidget {
final String label;
final String tooltip;
final VoidCallback onTap;
const _ClickablePill({
required this.label,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: InkWell(
borderRadius: BorderRadius.circular(FaiRadius.sm),
onTap: onTap,
child: FaiPill(
label: label,
tone: FaiPillTone.danger,
icon: Icons.download_outlined,
monospace: true,
),
const SizedBox(width: FaiSpace.md),
Tooltip(
message: isRunnable ? '' : l.flowsRunDisabledTooltip,
child: FilledButton.icon(
onPressed: onRun,
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(l.flowsRunButton),
),
),
],
),
),
);
}
@ -731,3 +844,256 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
);
}
}
/// Per-spec install state for the dependencies dialog. The
/// dialog walks the list sequentially, advancing each row from
/// `pending` `installing` `done` | `failed` so the operator
/// sees one row light up at a time.
enum _InstallStatus { pending, installing, done, failed }
class _InstallItemState {
/// Verbatim spec from the flow's `use:` line, e.g.
/// `text.extract@^0`. Shown as-is so the operator recognises
/// the same string they saw on the flow card.
final String spec;
/// Bare capability name (everything left of `@`). What the
/// hub's `installModule` actually resolves through the store
/// index the version constraint is dropped here because
/// the index already picks a matching version.
final String bareName;
_InstallStatus status;
String? installedVersion;
String? error;
_InstallItemState(this.spec)
: bareName = _stripVersion(spec),
status = _InstallStatus.pending;
static String _stripVersion(String spec) {
final at = spec.indexOf('@');
return at >= 0 ? spec.substring(0, at) : spec;
}
}
/// Sequential installer for a list of capability specs. Pops
/// with `true` if at least one install completed successfully
/// (so the parent knows to refresh its installed-modules view),
/// `false` otherwise.
class _InstallDependenciesDialog extends StatefulWidget {
final List<String> specs;
const _InstallDependenciesDialog({required this.specs});
@override
State<_InstallDependenciesDialog> createState() =>
_InstallDependenciesDialogState();
}
class _InstallDependenciesDialogState
extends State<_InstallDependenciesDialog> {
late final List<_InstallItemState> _items;
bool _running = true;
@override
void initState() {
super.initState();
_items = widget.specs.map(_InstallItemState.new).toList();
// Kick off the install sequence after first frame so any
// initState-time setState calls inside the chain hit a
// mounted widget.
WidgetsBinding.instance.addPostFrameCallback((_) => _runAll());
}
Future<void> _runAll() async {
for (final item in _items) {
if (!mounted) return;
setState(() => item.status = _InstallStatus.installing);
try {
final r = await HubService.instance.installModule(
source: item.bareName,
);
if (!mounted) return;
setState(() {
item.status = _InstallStatus.done;
item.installedVersion = r.version;
});
} catch (e) {
if (!mounted) return;
setState(() {
item.status = _InstallStatus.failed;
item.error = e.toString();
});
}
}
if (!mounted) return;
setState(() => _running = false);
}
bool get _anySucceeded =>
_items.any((i) => i.status == _InstallStatus.done);
int get _doneCount =>
_items.where((i) => i.status == _InstallStatus.done).length;
int get _failedCount =>
_items.where((i) => i.status == _InstallStatus.failed).length;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.flowsInstallDepsTitle),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 480),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.flowsInstallDepsBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
for (final item in _items) ...[
_InstallRow(item: item),
const SizedBox(height: FaiSpace.sm),
],
if (!_running) ...[
const SizedBox(height: FaiSpace.xs),
Text(
_failedCount == 0
? l.flowsInstallDepsAllDone
: l.flowsInstallDepsAnyFailed(_doneCount, _failedCount),
style: theme.textTheme.bodySmall?.copyWith(
color: _failedCount == 0
? null
: theme.colorScheme.error,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
actions: [
TextButton(
onPressed: _running
? null
: () => Navigator.pop(context, _anySucceeded),
child: Text(l.buttonClose),
),
],
);
}
}
/// One row in the install-dependencies dialog. Status icon +
/// capability name + per-state detail line (version on success,
/// truncated error on failure).
class _InstallRow extends StatelessWidget {
final _InstallItemState item;
const _InstallRow({required this.item});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 20,
height: 20,
child: Center(child: _statusIcon(theme)),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.spec,
style: FaiTheme.mono(
size: 12,
weight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
_detailLine(l),
style: theme.textTheme.bodySmall?.copyWith(
color: item.status == _InstallStatus.failed
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
),
if (item.status == _InstallStatus.failed &&
item.error != null) ...[
const SizedBox(height: FaiSpace.xs),
FaiErrorBox(
text: item.error!,
isError: true,
maxHeight: 120,
),
],
],
),
),
],
);
}
Widget _statusIcon(ThemeData theme) {
switch (item.status) {
case _InstallStatus.pending:
return Icon(
Icons.radio_button_unchecked,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
);
case _InstallStatus.installing:
return const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
);
case _InstallStatus.done:
return Icon(
Icons.check_circle,
size: 16,
color: theme.colorScheme.primary,
);
case _InstallStatus.failed:
return Icon(
Icons.error_outline,
size: 16,
color: theme.colorScheme.error,
);
}
}
String _detailLine(AppLocalizations l) {
switch (item.status) {
case _InstallStatus.pending:
return l.flowsInstallStatusPending;
case _InstallStatus.installing:
return l.flowsInstallStatusInstalling;
case _InstallStatus.done:
return l.flowsInstallStatusDone(item.installedVersion ?? '?');
case _InstallStatus.failed:
return l.flowsInstallStatusFailed;
}
}
}