feat: project chip + file-wins guard (multi-project stage 2)

When the open flow declares its own top-level project:, show it as a
chip in the tab action strip. On mismatch with the host's active
workspace, an amber file-wins pill offers a one-click Switch to
<project> — the file always wins for the run; switching only aligns
the workspace view.

New optional FlowEditorPage.activeProject + onSwitchToFileProject.
Parse + normalize (mirrors the hub's normalize_project_slug) lives in
flow_project.dart, unit-tested. flutter analyze clean, 57 tests green.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-07-12 14:20:15 +02:00
parent 9196251e00
commit 9e73036d0e
6 changed files with 253 additions and 1 deletions

View file

@ -31,6 +31,7 @@ import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'editor_controller.dart';
import 'editor_style.dart';
import 'flow_project.dart';
import 'flow_yaml_controller.dart';
import 'l10n.dart';
import 'model/flow_graph.dart';
@ -100,6 +101,19 @@ class FlowEditorPage extends StatefulWidget {
/// silent no install button offered.
final List<String> storeCapabilities;
/// The host's active workspace/project slug (empty = "all
/// projects", no active workspace). Used only to flag a
/// mismatch when the open flow file declares a *different*
/// top-level `project:` the file always wins for the run;
/// the mismatch banner just offers to align the workspace view.
final String activeProject;
/// Invoked when the operator accepts the "switch to the file's
/// project" action on a mismatch. The host switches its active
/// workspace to the given slug. `null` hides the switch action
/// (the mismatch note still shows the file still wins).
final void Function(String fileProject)? onSwitchToFileProject;
const FlowEditorPage({
super.key,
this.initialFlowName,
@ -110,6 +124,8 @@ class FlowEditorPage extends StatefulWidget {
this.onInstallCapability,
this.onAddModuleSource,
this.storeCapabilities = const [],
this.activeProject = '',
this.onSwitchToFileProject,
});
@override
@ -170,6 +186,14 @@ class _FlowEditorPageState extends State<FlowEditorPage>
if (mounted) setState(() {});
}
/// The open flow's own top-level `project:` slug, or empty when
/// the YAML declares none. Parsed live from the buffer so the
/// chip tracks edits.
String _fileProject() {
if (_controller.activeName == null) return '';
return parseFlowProject(_controller.codeController.fullText);
}
void _onHoverChanged() {
final req = _controller.codeController.hoverRequest.value;
if (req == null) {
@ -617,6 +641,9 @@ outputs:
_controller.analyzerErrorCount == 0
? () => _tabs.animateTo(2)
: null,
fileProject: _fileProject(),
activeProject: widget.activeProject,
onSwitchToFileProject: widget.onSwitchToFileProject,
),
Expanded(
child: TabBarView(
@ -1014,6 +1041,13 @@ class _TabActionStrip extends StatelessWidget {
final VoidCallback? onAddStep;
final VoidCallback? onSave;
final VoidCallback? onRun;
/// The open file's own `project:` (empty when it declares none).
final String fileProject;
/// The host's active workspace slug (empty = all projects).
final String activeProject;
final void Function(String fileProject)? onSwitchToFileProject;
const _TabActionStrip({
required this.strings,
required this.tabs,
@ -1023,6 +1057,9 @@ class _TabActionStrip extends StatelessWidget {
required this.onAddStep,
required this.onSave,
required this.onRun,
required this.fileProject,
required this.activeProject,
required this.onSwitchToFileProject,
});
@override
@ -1093,12 +1130,94 @@ class _TabActionStrip extends StatelessWidget {
),
],
const Spacer(),
..._projectChip(context, theme),
],
),
);
},
);
}
/// The right-aligned project affordance. Nothing when the file
/// declares no `project:`. A neutral chip when the file's project
/// matches (or there is no active workspace). A mismatch pill with
/// a one-click switch when the file's project differs from the
/// active workspace the file always wins for the run; switching
/// only aligns the workspace view.
List<Widget> _projectChip(BuildContext context, ThemeData theme) {
if (fileProject.isEmpty) return const [];
final mismatch = activeProject.isNotEmpty && activeProject != fileProject;
if (!mismatch) {
return [
Tooltip(
message: strings.projectChipTooltip,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder_outlined,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
strings.projectChip(fileProject),
style: theme.textTheme.labelSmall,
),
],
),
),
),
];
}
// Mismatch: file wins. Amber pill + optional switch action.
final amber = theme.colorScheme.brightness == Brightness.dark
? const Color(0xFFE0A458)
: const Color(0xFFB4791F);
return [
Tooltip(
message: strings.projectMismatch(fileProject),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: amber.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: amber.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.info_outline, size: 13, color: amber),
const SizedBox(width: 6),
Text(
strings.projectChip(fileProject),
style: theme.textTheme.labelSmall?.copyWith(color: amber),
),
if (onSwitchToFileProject != null) ...[
const SizedBox(width: 8),
InkWell(
onTap: () => onSwitchToFileProject!(fileProject),
child: Text(
strings.projectMismatchSwitch(fileProject),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
),
),
];
}
}
// --- file list ---

50
lib/src/flow_project.dart Normal file
View file

@ -0,0 +1,50 @@
/// Parsing + normalization for a flow file's own top-level
/// `project:` key. Kept separate from the page so the "file wins"
/// semantics are unit-testable without pumping the whole editor.
///
/// Mirrors the hub's `resolve_project` (file's `project:` is the
/// run's project unless the caller overrides it) and
/// `normalize_project_slug`, so the chip the editor shows compares
/// equal to what the hub actually stamps.
library;
/// The flow's own top-level `project:` slug (normalized), or empty
/// when the YAML declares none. Only a *non-indented* `project:`
/// line counts a nested `project:` inside a step's `with:` block
/// is ignored.
String parseFlowProject(String yaml) {
for (final raw in yaml.split('\n')) {
if (raw.startsWith(' ') || raw.startsWith('\t')) continue;
final m = RegExp(r'^project:\s*(.+?)\s*$').firstMatch(raw);
if (m != null) {
final value = m.group(1)!.replaceAll(RegExp('''^["']|["']\$'''), '');
return normalizeProjectSlug(value);
}
}
return '';
}
/// Mirror of the hub's `normalize_project_slug`: lowercase,
/// collapse spaces/underscores/dashes to a single `-`, drop
/// everything else, trim trailing dashes. Empty in empty out
/// (the editor treats "no project" and "blank project" alike).
String normalizeProjectSlug(String raw) {
final buf = StringBuffer();
var prevDash = false;
for (final ch in raw.trim().toLowerCase().split('')) {
if (RegExp(r'[a-z0-9]').hasMatch(ch)) {
buf.write(ch);
prevDash = false;
} else if ((ch == ' ' || ch == '_' || ch == '-') &&
!prevDash &&
buf.isNotEmpty) {
buf.write('-');
prevDash = true;
}
}
var out = buf.toString();
while (out.endsWith('-')) {
out = out.substring(0, out.length - 1);
}
return out;
}

View file

@ -44,6 +44,28 @@ class FlowEditorStrings {
);
String get discardKeep => _t('Keep editing', 'Weiterbearbeiten');
String get discardThrow => _t('Throw away', 'Verwerfen');
/// Chip shown when the open flow file declares its own
/// top-level `project:`. The file always wins over the active
/// workspace this label states which project the run lands in.
String projectChip(String project) =>
_t('Project: $project', 'Projekt: $project');
String get projectChipTooltip => _t(
'This flow file declares its own project — the run lands there, '
'regardless of the active workspace (the file wins).',
'Diese Flow-Datei deklariert ihr eigenes Projekt — der Lauf landet '
'dort, unabhängig vom aktiven Arbeitsbereich (die Datei gewinnt).',
);
/// Mismatch banner: the file's project differs from the active
/// workspace. Offers a one-click switch (the file still wins for
/// the run; switching just aligns the workspace view).
String projectMismatch(String fileProject) => _t(
'This flow belongs to "$fileProject", not the active workspace.',
'Dieser Flow gehört zu „$fileProject", nicht zum aktiven Arbeitsbereich.',
);
String projectMismatchSwitch(String fileProject) =>
_t('Switch to $fileProject', 'Zu $fileProject wechseln');
String get newDialogTitle => _t('New flow', 'Neuer Flow');
String get newDialogLabel => _t('Flow name', 'Flow-Name');
String get newDialogHelper => _t(