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:
parent
9196251e00
commit
9e73036d0e
6 changed files with 253 additions and 1 deletions
|
|
@ -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 ---
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue