feat(workspace): one global switcher anchor in the shell sidebar
The switcher used to be embedded per page (Flows/Runs/Audit/
Approvals) — invisible on the other five pages and sitting in a
different corner depending on the page (persona review 2026-08-27,
consensus finding). It now lives ONCE in the sidebar, above the
destinations: active project/area always visible, opens the same
menu everywhere, Cmd+P from anywhere. The shell listens to the
workspace, so the sidebar endpoint label can no longer lag a
sealed switch until the next health tick.
Also in this rebuild:
* Stopped sealed areas ask before starting ("Start area X?") —
a context switch must never boot a hub daemon as a click
side-effect; running areas keep switching with one click.
* The switcher tooltip told a wrong scope ("filters this view") —
it now says the choice applies everywhere and stamps new runs.
* The aggregated sealed row explains itself in place (names can
reveal client identities) and links to the Settings toggle
(Settings dialog gained an initialCategory jump).
* The active entry carries a checkmark in the menu.
* The Cmd+K palette knows projects and areas, ranked by recent
use; sealed names honour the privacy setting — while hidden,
the palette offers the guarded picker instead of the names.
* The runs empty state names the active project filter as the
cause ("No runs in project X" + show-all action) instead of
claiming the feature is off.
Tests updated to the anchor and made hermetic (scriptable
projects on the fake hub, sealed-area fake); new coverage for the
checkmark, the why-line, and the start confirmation.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
afe782e826
commit
64c2a77dc9
16 changed files with 988 additions and 324 deletions
|
|
@ -183,9 +183,14 @@ class FakeHubService extends Fake implements HubService {
|
|||
@override
|
||||
Future<List<SavedFlow>> listFlows() => _async('listFlows', () => const []);
|
||||
|
||||
/// Registry projects [listProjects] answers with — scriptable so
|
||||
/// workspace suites can keep their seed across the switcher's
|
||||
/// open-menu refresh.
|
||||
List<ProjectRef> projects = const [];
|
||||
|
||||
@override
|
||||
Future<List<ProjectRef>> listProjects() =>
|
||||
_async('listProjects', () => const []);
|
||||
_async('listProjects', () => projects);
|
||||
|
||||
@override
|
||||
Stream<AuditEvent> streamEvents({
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
// Workspace switcher — stage-1 contract: the control renders the
|
||||
// active selection, lists "All projects" plus every registry
|
||||
// project (colour dot, shield for protected), and switching
|
||||
// updates the shared Workspace notifier.
|
||||
// Workspace anchor — contract: the sidebar's ONE switcher control
|
||||
// renders the active selection, lists "All projects" plus every
|
||||
// registry project (colour dot, shield for protected, checkmark on
|
||||
// the active entry), keeps sealed-area names aggregated until
|
||||
// deliberately revealed, asks before starting a stopped area, and
|
||||
// switching updates the shared Workspace notifier.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:chain_studio/data/hub.dart';
|
||||
import 'package:chain_studio/data/sealed_areas.dart';
|
||||
|
|
@ -13,16 +16,25 @@ import 'package:chain_studio/data/workspace_prefs.dart';
|
|||
import 'package:chain_studio/l10n/app_localizations.dart';
|
||||
import 'package:chain_studio/widgets/chain_workspace_switcher.dart';
|
||||
|
||||
import 'support/fake_hub.dart';
|
||||
|
||||
Widget _host() {
|
||||
return const MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: Size.fromHeight(kToolbarHeight),
|
||||
child: Material(child: ChainWorkspaceSwitcher()),
|
||||
body: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
width: 220,
|
||||
height: 44,
|
||||
child: ChainWorkspaceAnchor(
|
||||
t: 1,
|
||||
labelsInteractive: true,
|
||||
iconColumnWidth: 72,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SizedBox(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -36,26 +48,46 @@ const _clientA = ProjectRef(
|
|||
);
|
||||
|
||||
void main() {
|
||||
late FakeHubService fakeHub;
|
||||
|
||||
// Seed the workspace singleton AND script both service fakes to
|
||||
// match: opening the menu triggers Workspace.refresh, which must
|
||||
// answer from the fakes — never from a live hub or the real
|
||||
// ~/.chain (hermeticity, shared/TESTING.md).
|
||||
void seed({
|
||||
List<ProjectRef> projects = const [_general, _clientA],
|
||||
String active = '',
|
||||
List<SealedArea> sealed = const [],
|
||||
SealedArea? activeSealed,
|
||||
}) {
|
||||
fakeHub.projects = projects;
|
||||
SealedAreaService.debugSetInstance(_FakeSealedAreas(sealed));
|
||||
Workspace.instance.debugSeed(
|
||||
projects: projects,
|
||||
active: active,
|
||||
sealed: sealed,
|
||||
activeSealed: activeSealed,
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
// Seed the singleton without a hub: tests drive the notifier
|
||||
// directly through its test hook.
|
||||
Workspace.instance.debugSeed(projects: [_general, _clientA], active: '');
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
fakeHub = installFakeHub();
|
||||
addTearDown(() => SealedAreaService.debugSetInstance(null));
|
||||
seed();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
WorkspacePrefs.sealedNamesVisible.value = false;
|
||||
WorkspacePrefs.recentContexts.value = const [];
|
||||
});
|
||||
|
||||
testWidgets('sealed areas stay aggregated until deliberately revealed', (
|
||||
tester,
|
||||
) async {
|
||||
Workspace.instance.debugSeed(
|
||||
projects: [_general],
|
||||
active: '',
|
||||
sealed: [_sealedGrid, _sealedLab],
|
||||
);
|
||||
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
||||
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||
await tester.pumpAndSettle();
|
||||
// No names on a casual glance — only the aggregate row.
|
||||
expect(find.text('grid'), findsNothing);
|
||||
|
|
@ -67,17 +99,30 @@ void main() {
|
|||
expect(find.text('lab'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the aggregate row explains itself and links to Settings', (
|
||||
tester,
|
||||
) async {
|
||||
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||
await tester.pumpAndSettle();
|
||||
// The why-line: aggregation is a confidentiality decision and
|
||||
// must say so in place (in-app docs rule), plus the jump to the
|
||||
// Settings toggle.
|
||||
expect(
|
||||
find.textContaining('reveal client identities'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('Settings'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('the Settings toggle restores the direct listing', (
|
||||
tester,
|
||||
) async {
|
||||
WorkspacePrefs.sealedNamesVisible.value = true;
|
||||
Workspace.instance.debugSeed(
|
||||
projects: [_general],
|
||||
active: '',
|
||||
sealed: [_sealedGrid, _sealedLab],
|
||||
);
|
||||
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
||||
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('grid'), findsOneWidget);
|
||||
expect(find.text('lab'), findsOneWidget);
|
||||
|
|
@ -95,7 +140,7 @@ void main() {
|
|||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
||||
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('All projects'), findsWidgets);
|
||||
|
|
@ -105,21 +150,38 @@ void main() {
|
|||
expect(find.byIcon(Icons.shield_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the active entry carries the checkmark', (tester) async {
|
||||
seed(active: 'client-a');
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||
await tester.pumpAndSettle();
|
||||
// Exactly one checkmark, and it sits in the active project's row.
|
||||
expect(find.byIcon(Icons.check), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.ancestor(
|
||||
of: find.text('Client A'),
|
||||
matching: find.byType(PopupMenuItem<String>),
|
||||
),
|
||||
matching: find.byIcon(Icons.check),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('selecting a project updates the workspace and the label', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
||||
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Client A').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(Workspace.instance.activeSlug, 'client-a');
|
||||
expect(Workspace.instance.active?.isProtected, isTrue);
|
||||
// The closed control now shows the active project (label +
|
||||
// shield marker for protected).
|
||||
// The closed control now shows the active project.
|
||||
expect(find.text('Client A'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.shield_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
test('active falls back to null when the slug left the registry', () {
|
||||
|
|
@ -132,9 +194,8 @@ void main() {
|
|||
testWidgets('lists sealed areas with lock + running/stopped status', (
|
||||
tester,
|
||||
) async {
|
||||
Workspace.instance.debugSeed(
|
||||
seed(
|
||||
projects: [_general],
|
||||
active: '',
|
||||
sealed: const [
|
||||
SealedArea(
|
||||
slug: 'grid',
|
||||
|
|
@ -153,7 +214,7 @@ void main() {
|
|||
],
|
||||
);
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
||||
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Sealed areas appear under the sealed header, aggregated by
|
||||
|
|
@ -169,13 +230,31 @@ void main() {
|
|||
expect(find.text('stopped'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the pill shows the active sealed area with a lock', (
|
||||
testWidgets('selecting a STOPPED area asks before starting its hub', (
|
||||
tester,
|
||||
) async {
|
||||
Workspace.instance.debugSeed(
|
||||
WorkspacePrefs.sealedNamesVisible.value = true;
|
||||
seed(projects: [_general], sealed: [_sealedGrid]); // grid: stopped
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('grid'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The confirmation dialog — an area switch must never boot a
|
||||
// hub daemon as a click side-effect.
|
||||
expect(find.text('Start area “grid”?'), findsOneWidget);
|
||||
expect(find.text('Cancel'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(Workspace.instance.inSealedArea, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('the anchor shows the active sealed area with a lock', (
|
||||
tester,
|
||||
) async {
|
||||
seed(
|
||||
projects: [_general],
|
||||
active: '',
|
||||
sealed: const [],
|
||||
activeSealed: const SealedArea(
|
||||
slug: 'grid',
|
||||
name: 'Grid',
|
||||
|
|
@ -186,12 +265,24 @@ void main() {
|
|||
);
|
||||
await tester.pumpWidget(_host());
|
||||
expect(Workspace.instance.inSealedArea, isTrue);
|
||||
// The closed pill names the sealed area and carries a lock.
|
||||
// The closed anchor names the sealed area and carries a lock.
|
||||
expect(find.text('Grid'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.lock_outline), findsWidgets);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeSealedAreas extends SealedAreaService {
|
||||
_FakeSealedAreas(this.areas) : super.forTest();
|
||||
|
||||
final List<SealedArea> areas;
|
||||
|
||||
@override
|
||||
Future<List<SealedArea>> list() async => areas;
|
||||
|
||||
@override
|
||||
Future<String?> boundEndpoint(String slug) async => null;
|
||||
}
|
||||
|
||||
// Sealed-area confidentiality (usertest security finding): the
|
||||
// switcher must not disclose sealed-area names — often client
|
||||
// identity — on a casual glance. Aggregated row by default,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue