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
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -4,6 +4,20 @@ All notable changes to `chain_studio_flow_editor` recorded here.
|
||||||
Studio bumps its `pubspec.yaml` git ref to a specific release
|
Studio bumps its `pubspec.yaml` git ref to a specific release
|
||||||
of this package on every editor change.
|
of this package on every editor change.
|
||||||
|
|
||||||
|
## 0.22.0 — 2026-07-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Project chip (multi-project stage ②).** When the open flow file
|
||||||
|
declares its own top-level `project:`, the editor shows it as a chip
|
||||||
|
in the tab action strip. If the file's project differs from the host's
|
||||||
|
active workspace, the chip becomes an amber "file wins" pill with a
|
||||||
|
one-click *Switch to <project>* action — the file always wins for the
|
||||||
|
run; switching only aligns the workspace view. New optional
|
||||||
|
`FlowEditorPage.activeProject` + `onSwitchToFileProject`; the parse +
|
||||||
|
normalize logic (mirrors the hub's `normalize_project_slug`) lives in
|
||||||
|
`flow_project.dart` and is unit-tested.
|
||||||
|
|
||||||
## 0.21.2 — 2026-07-11
|
## 0.21.2 — 2026-07-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||||
|
|
||||||
import 'editor_controller.dart';
|
import 'editor_controller.dart';
|
||||||
import 'editor_style.dart';
|
import 'editor_style.dart';
|
||||||
|
import 'flow_project.dart';
|
||||||
import 'flow_yaml_controller.dart';
|
import 'flow_yaml_controller.dart';
|
||||||
import 'l10n.dart';
|
import 'l10n.dart';
|
||||||
import 'model/flow_graph.dart';
|
import 'model/flow_graph.dart';
|
||||||
|
|
@ -100,6 +101,19 @@ class FlowEditorPage extends StatefulWidget {
|
||||||
/// silent — no install button offered.
|
/// silent — no install button offered.
|
||||||
final List<String> storeCapabilities;
|
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({
|
const FlowEditorPage({
|
||||||
super.key,
|
super.key,
|
||||||
this.initialFlowName,
|
this.initialFlowName,
|
||||||
|
|
@ -110,6 +124,8 @@ class FlowEditorPage extends StatefulWidget {
|
||||||
this.onInstallCapability,
|
this.onInstallCapability,
|
||||||
this.onAddModuleSource,
|
this.onAddModuleSource,
|
||||||
this.storeCapabilities = const [],
|
this.storeCapabilities = const [],
|
||||||
|
this.activeProject = '',
|
||||||
|
this.onSwitchToFileProject,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -170,6 +186,14 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
if (mounted) setState(() {});
|
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() {
|
void _onHoverChanged() {
|
||||||
final req = _controller.codeController.hoverRequest.value;
|
final req = _controller.codeController.hoverRequest.value;
|
||||||
if (req == null) {
|
if (req == null) {
|
||||||
|
|
@ -617,6 +641,9 @@ outputs:
|
||||||
_controller.analyzerErrorCount == 0
|
_controller.analyzerErrorCount == 0
|
||||||
? () => _tabs.animateTo(2)
|
? () => _tabs.animateTo(2)
|
||||||
: null,
|
: null,
|
||||||
|
fileProject: _fileProject(),
|
||||||
|
activeProject: widget.activeProject,
|
||||||
|
onSwitchToFileProject: widget.onSwitchToFileProject,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
|
|
@ -1014,6 +1041,13 @@ class _TabActionStrip extends StatelessWidget {
|
||||||
final VoidCallback? onAddStep;
|
final VoidCallback? onAddStep;
|
||||||
final VoidCallback? onSave;
|
final VoidCallback? onSave;
|
||||||
final VoidCallback? onRun;
|
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({
|
const _TabActionStrip({
|
||||||
required this.strings,
|
required this.strings,
|
||||||
required this.tabs,
|
required this.tabs,
|
||||||
|
|
@ -1023,6 +1057,9 @@ class _TabActionStrip extends StatelessWidget {
|
||||||
required this.onAddStep,
|
required this.onAddStep,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
required this.onRun,
|
required this.onRun,
|
||||||
|
required this.fileProject,
|
||||||
|
required this.activeProject,
|
||||||
|
required this.onSwitchToFileProject,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -1093,12 +1130,94 @@ class _TabActionStrip extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const Spacer(),
|
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 ---
|
// --- file list ---
|
||||||
|
|
|
||||||
50
lib/src/flow_project.dart
Normal file
50
lib/src/flow_project.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,28 @@ class FlowEditorStrings {
|
||||||
);
|
);
|
||||||
String get discardKeep => _t('Keep editing', 'Weiterbearbeiten');
|
String get discardKeep => _t('Keep editing', 'Weiterbearbeiten');
|
||||||
String get discardThrow => _t('Throw away', 'Verwerfen');
|
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 newDialogTitle => _t('New flow', 'Neuer Flow');
|
||||||
String get newDialogLabel => _t('Flow name', 'Flow-Name');
|
String get newDialogLabel => _t('Flow name', 'Flow-Name');
|
||||||
String get newDialogHelper => _t(
|
String get newDialogHelper => _t(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
name: chain_studio_flow_editor
|
name: chain_studio_flow_editor
|
||||||
description: Swappable inline YAML editor for F∆I Studio flows.
|
description: Swappable inline YAML editor for F∆I Studio flows.
|
||||||
version: 0.21.2
|
version: 0.22.0
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
repository: https://git.flemming.ai/fai/studio-flow-editor
|
repository: https://git.flemming.ai/fai/studio-flow-editor
|
||||||
|
|
||||||
|
|
|
||||||
47
test/flow_project_test.dart
Normal file
47
test/flow_project_test.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chain_studio_flow_editor/src/flow_project.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('parseFlowProject', () {
|
||||||
|
test('reads a bare top-level project key', () {
|
||||||
|
const yaml = 'name: demo\nproject: client-a\nsteps: []\n';
|
||||||
|
expect(parseFlowProject(yaml), 'client-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips quotes and normalizes casing/spaces', () {
|
||||||
|
expect(parseFlowProject('project: "Client A"'), 'client-a');
|
||||||
|
// Non-[a-z0-9] that isn't a separator is dropped without a
|
||||||
|
// dash — matches the hub's normalize_project_slug.
|
||||||
|
expect(parseFlowProject("project: 'Recl∆Im'"), 'reclim');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty when no top-level project is declared', () {
|
||||||
|
const yaml = 'name: demo\ninputs:\n topic: text\nsteps: []\n';
|
||||||
|
expect(parseFlowProject(yaml), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores an indented project inside a step with-block', () {
|
||||||
|
const yaml =
|
||||||
|
'name: demo\nsteps:\n - id: s\n use: x@^0\n with:\n project: not-this\n';
|
||||||
|
expect(parseFlowProject(yaml), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a top-level project still wins over a later nested one', () {
|
||||||
|
const yaml =
|
||||||
|
'project: top\nsteps:\n - id: s\n with:\n project: nested\n';
|
||||||
|
expect(parseFlowProject(yaml), 'top');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('normalizeProjectSlug', () {
|
||||||
|
test('collapses separators and trims trailing dashes', () {
|
||||||
|
expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha');
|
||||||
|
expect(normalizeProjectSlug('a - b - c'), 'a-b-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty stays empty', () {
|
||||||
|
expect(normalizeProjectSlug(' '), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue