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
|
|
@ -262,10 +262,11 @@ void main() {
|
|||
}
|
||||
|
||||
// 09: the workspace switcher, opened (shows the demo projects;
|
||||
// with the sealed seed also the shield entry).
|
||||
// with the sealed seed also the shield entry). The switcher is
|
||||
// the ONE sidebar anchor now — global on every page.
|
||||
shell.navigateTo('audit');
|
||||
await _pumpFrames(tester);
|
||||
final switcher = find.byType(ChainWorkspaceSwitcher);
|
||||
final switcher = find.byType(ChainWorkspaceAnchor);
|
||||
if (switcher.evaluate().isNotEmpty) {
|
||||
await tester.tap(switcher.first);
|
||||
await _pumpFrames(tester);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ class WorkspacePrefs {
|
|||
static Future<void> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
sealedNamesVisible.value = prefs.getBool(_kSealedNamesKey) ?? false;
|
||||
recentContexts.value =
|
||||
prefs.getStringList(_kRecentContextsKey) ?? const [];
|
||||
}
|
||||
|
||||
static Future<void> setSealedNamesVisible(bool value) async {
|
||||
|
|
@ -29,4 +31,25 @@ class WorkspacePrefs {
|
|||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_kSealedNamesKey, value);
|
||||
}
|
||||
|
||||
static const _kRecentContextsKey = 'workspace.recent_contexts';
|
||||
static const _kRecentContextsMax = 5;
|
||||
|
||||
/// Recently chosen switcher values (menu-value scheme: '' /
|
||||
/// `p:<slug>` / `s:<slug>`), most recent first. The command
|
||||
/// palette ranks matching contexts by this list so a poweruser's
|
||||
/// frequent areas surface before the alphabet.
|
||||
static final ValueNotifier<List<String>> recentContexts =
|
||||
ValueNotifier(const []);
|
||||
|
||||
/// Record a switcher selection (deduped, capped, persisted).
|
||||
static Future<void> recordRecentContext(String value) async {
|
||||
final next = [
|
||||
value,
|
||||
...recentContexts.value.where((v) => v != value),
|
||||
].take(_kRecentContextsMax).toList();
|
||||
recentContexts.value = next;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(_kRecentContextsKey, next);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1413,6 +1413,9 @@
|
|||
"searchHintOpen": "Enter öffnen",
|
||||
"searchHintClose": "Esc schließen",
|
||||
"searchGroupPages": "Seiten",
|
||||
"searchGroupWorkspace": "Projekte & Bereiche",
|
||||
"searchWorkspaceHint": "Kontext wechseln · ⌘P",
|
||||
"searchWorkspaceSealedPickerHint": "Auswahl öffnen · ⌘P",
|
||||
"searchGroupModules": "Module",
|
||||
"searchGroupStore": "Store",
|
||||
"searchGroupFlows": "Flows",
|
||||
|
|
@ -1833,7 +1836,14 @@
|
|||
"federationEnrollmentHint": "Übergeben Sie das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte Zertifikatsstelle authentifiziert die erste Verbindung des Satelliten.",
|
||||
"workspaceAll": "Alle Projekte",
|
||||
"workspaceDefaultProject": "Allgemein",
|
||||
"workspaceSwitcherTooltip": "Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt",
|
||||
"workspaceSwitcherTooltip": "Arbeitskontext wählen — gilt überall: filtert Flows, Läufe, Audit und Freigaben und stempelt neue Läufe mit dem gewählten Projekt",
|
||||
"workspaceAnchorCaption": "Projekt / Bereich",
|
||||
"workspaceStartConfirmTitle": "Bereich „{name}“ starten?",
|
||||
"@workspaceStartConfirmTitle": {"placeholders": {"name": {"type": "String"}}},
|
||||
"workspaceStartConfirmBody": "Dieser abgeschottete Bereich ist gerade gestoppt. Studio startet seine eigene Hub-Instanz auf diesem Rechner und verbindet sich mit ihr — laufende Bereiche wechseln ohne diese Rückfrage.",
|
||||
"workspaceStartConfirmAction": "Starten und verbinden",
|
||||
"workspaceSealedAggregateWhy": "Namen bleiben zusammengefasst, weil sie Mandanten verraten können.",
|
||||
"workspaceSealedAggregateSettings": "Immer anzeigen: Einstellungen → Sicherheit",
|
||||
"workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.",
|
||||
"runsTitle": "Läufe",
|
||||
"runsReloadTooltip": "Lauf-Liste neu laden",
|
||||
|
|
@ -1841,6 +1851,10 @@
|
|||
"runsEmptyHint": "Hier erscheinen Läufe, die im Hintergrund weiterlaufen, während Sie anderes tun. Diese Funktion ist optional und standardmäßig ausgeschaltet — die Anleitung zeigt Schritt für Schritt, wie sie eingeschaltet wird.",
|
||||
"runsEmptyEnabledHint": "Läufe im Hintergrund sind eingeschaltet — es wurde nur noch keiner gestartet. Starten Sie einen Flow mit der Option „im Hintergrund ausführen“, dann erscheint er hier.",
|
||||
"runsEmptyGuideButton": "Anleitung öffnen",
|
||||
"runsEmptyFilteredTitle": "Keine Läufe in Projekt „{name}“",
|
||||
"@runsEmptyFilteredTitle": {"placeholders": {"name": {"type": "String"}}},
|
||||
"runsEmptyFilteredHint": "Der Arbeitskontext filtert diese Liste — in anderen Projekten kann es Läufe geben.",
|
||||
"runsEmptyShowAll": "Alle Projekte anzeigen",
|
||||
"runsHubTooOldTitle": "Diese Ansicht braucht eine neuere Hub-Version",
|
||||
"runsHubTooOldHint": "Der Hub ist verbunden, aber seine Version kennt die Laufübersicht noch nicht. Aktualisieren Sie den Hub, dann erscheinen die Läufe hier.",
|
||||
"runsHubTooOldButton": "Diagnose öffnen",
|
||||
|
|
|
|||
|
|
@ -1437,6 +1437,9 @@
|
|||
"searchHintOpen": "enter open",
|
||||
"searchHintClose": "esc close",
|
||||
"searchGroupPages": "Pages",
|
||||
"searchGroupWorkspace": "Projects & areas",
|
||||
"searchWorkspaceHint": "switch context · ⌘P",
|
||||
"searchWorkspaceSealedPickerHint": "open the picker · ⌘P",
|
||||
"searchGroupModules": "Modules",
|
||||
"searchGroupStore": "Store",
|
||||
"searchGroupFlows": "Flows",
|
||||
|
|
@ -1872,7 +1875,14 @@
|
|||
"federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect.",
|
||||
"workspaceAll": "All projects",
|
||||
"workspaceDefaultProject": "General",
|
||||
"workspaceSwitcherTooltip": "Workspace — filters this view and stamps new runs with the selected project",
|
||||
"workspaceSwitcherTooltip": "Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project",
|
||||
"workspaceAnchorCaption": "Project / area",
|
||||
"workspaceStartConfirmTitle": "Start area “{name}”?",
|
||||
"@workspaceStartConfirmTitle": {"placeholders": {"name": {"type": "String"}}},
|
||||
"workspaceStartConfirmBody": "This sealed area is currently stopped. Studio starts its own hub instance on this machine and connects to it — running areas switch without this prompt.",
|
||||
"workspaceStartConfirmAction": "Start and connect",
|
||||
"workspaceSealedAggregateWhy": "Names stay aggregated because they can reveal client identities.",
|
||||
"workspaceSealedAggregateSettings": "Always show: Settings → Security",
|
||||
"workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.",
|
||||
"runsTitle": "Runs",
|
||||
"runsReloadTooltip": "Reload the runs list",
|
||||
|
|
@ -1880,6 +1890,10 @@
|
|||
"runsEmptyHint": "Runs that keep working in the background while you do something else appear here. The feature is optional and off by default — the guide shows step by step how to turn it on.",
|
||||
"runsEmptyEnabledHint": "Background runs are switched on — none has been started yet. Start a flow with the \"run in background\" option and it will appear here.",
|
||||
"runsEmptyGuideButton": "Open the guide",
|
||||
"runsEmptyFilteredTitle": "No runs in project “{name}”",
|
||||
"@runsEmptyFilteredTitle": {"placeholders": {"name": {"type": "String"}}},
|
||||
"runsEmptyFilteredHint": "The working context filters this list — other projects may have runs.",
|
||||
"runsEmptyShowAll": "Show all projects",
|
||||
"runsHubTooOldTitle": "This view needs a newer hub version",
|
||||
"runsHubTooOldHint": "The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.",
|
||||
"runsHubTooOldButton": "Open Doctor",
|
||||
|
|
|
|||
|
|
@ -4400,6 +4400,24 @@ abstract class AppLocalizations {
|
|||
/// **'Pages'**
|
||||
String get searchGroupPages;
|
||||
|
||||
/// No description provided for @searchGroupWorkspace.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Projects & areas'**
|
||||
String get searchGroupWorkspace;
|
||||
|
||||
/// No description provided for @searchWorkspaceHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'switch context · ⌘P'**
|
||||
String get searchWorkspaceHint;
|
||||
|
||||
/// No description provided for @searchWorkspaceSealedPickerHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'open the picker · ⌘P'**
|
||||
String get searchWorkspaceSealedPickerHint;
|
||||
|
||||
/// No description provided for @searchGroupModules.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -5752,9 +5770,45 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @workspaceSwitcherTooltip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Workspace — filters this view and stamps new runs with the selected project'**
|
||||
/// **'Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project'**
|
||||
String get workspaceSwitcherTooltip;
|
||||
|
||||
/// No description provided for @workspaceAnchorCaption.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Project / area'**
|
||||
String get workspaceAnchorCaption;
|
||||
|
||||
/// No description provided for @workspaceStartConfirmTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Start area “{name}”?'**
|
||||
String workspaceStartConfirmTitle(String name);
|
||||
|
||||
/// No description provided for @workspaceStartConfirmBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This sealed area is currently stopped. Studio starts its own hub instance on this machine and connects to it — running areas switch without this prompt.'**
|
||||
String get workspaceStartConfirmBody;
|
||||
|
||||
/// No description provided for @workspaceStartConfirmAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Start and connect'**
|
||||
String get workspaceStartConfirmAction;
|
||||
|
||||
/// No description provided for @workspaceSealedAggregateWhy.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Names stay aggregated because they can reveal client identities.'**
|
||||
String get workspaceSealedAggregateWhy;
|
||||
|
||||
/// No description provided for @workspaceSealedAggregateSettings.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Always show: Settings → Security'**
|
||||
String get workspaceSealedAggregateSettings;
|
||||
|
||||
/// No description provided for @workspaceProtectedHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -5797,6 +5851,24 @@ abstract class AppLocalizations {
|
|||
/// **'Open the guide'**
|
||||
String get runsEmptyGuideButton;
|
||||
|
||||
/// No description provided for @runsEmptyFilteredTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No runs in project “{name}”'**
|
||||
String runsEmptyFilteredTitle(String name);
|
||||
|
||||
/// No description provided for @runsEmptyFilteredHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The working context filters this list — other projects may have runs.'**
|
||||
String get runsEmptyFilteredHint;
|
||||
|
||||
/// No description provided for @runsEmptyShowAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show all projects'**
|
||||
String get runsEmptyShowAll;
|
||||
|
||||
/// No description provided for @runsHubTooOldTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -2595,6 +2595,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get searchGroupPages => 'Seiten';
|
||||
|
||||
@override
|
||||
String get searchGroupWorkspace => 'Projekte & Bereiche';
|
||||
|
||||
@override
|
||||
String get searchWorkspaceHint => 'Kontext wechseln · ⌘P';
|
||||
|
||||
@override
|
||||
String get searchWorkspaceSealedPickerHint => 'Auswahl öffnen · ⌘P';
|
||||
|
||||
@override
|
||||
String get searchGroupModules => 'Module';
|
||||
|
||||
|
|
@ -3414,7 +3423,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get workspaceSwitcherTooltip =>
|
||||
'Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt';
|
||||
'Arbeitskontext wählen — gilt überall: filtert Flows, Läufe, Audit und Freigaben und stempelt neue Läufe mit dem gewählten Projekt';
|
||||
|
||||
@override
|
||||
String get workspaceAnchorCaption => 'Projekt / Bereich';
|
||||
|
||||
@override
|
||||
String workspaceStartConfirmTitle(String name) {
|
||||
return 'Bereich „$name“ starten?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get workspaceStartConfirmBody =>
|
||||
'Dieser abgeschottete Bereich ist gerade gestoppt. Studio startet seine eigene Hub-Instanz auf diesem Rechner und verbindet sich mit ihr — laufende Bereiche wechseln ohne diese Rückfrage.';
|
||||
|
||||
@override
|
||||
String get workspaceStartConfirmAction => 'Starten und verbinden';
|
||||
|
||||
@override
|
||||
String get workspaceSealedAggregateWhy =>
|
||||
'Namen bleiben zusammengefasst, weil sie Mandanten verraten können.';
|
||||
|
||||
@override
|
||||
String get workspaceSealedAggregateSettings =>
|
||||
'Immer anzeigen: Einstellungen → Sicherheit';
|
||||
|
||||
@override
|
||||
String get workspaceProtectedHint =>
|
||||
|
|
@ -3440,6 +3472,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get runsEmptyGuideButton => 'Anleitung öffnen';
|
||||
|
||||
@override
|
||||
String runsEmptyFilteredTitle(String name) {
|
||||
return 'Keine Läufe in Projekt „$name“';
|
||||
}
|
||||
|
||||
@override
|
||||
String get runsEmptyFilteredHint =>
|
||||
'Der Arbeitskontext filtert diese Liste — in anderen Projekten kann es Läufe geben.';
|
||||
|
||||
@override
|
||||
String get runsEmptyShowAll => 'Alle Projekte anzeigen';
|
||||
|
||||
@override
|
||||
String get runsHubTooOldTitle =>
|
||||
'Diese Ansicht braucht eine neuere Hub-Version';
|
||||
|
|
|
|||
|
|
@ -2593,6 +2593,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get searchGroupPages => 'Pages';
|
||||
|
||||
@override
|
||||
String get searchGroupWorkspace => 'Projects & areas';
|
||||
|
||||
@override
|
||||
String get searchWorkspaceHint => 'switch context · ⌘P';
|
||||
|
||||
@override
|
||||
String get searchWorkspaceSealedPickerHint => 'open the picker · ⌘P';
|
||||
|
||||
@override
|
||||
String get searchGroupModules => 'Modules';
|
||||
|
||||
|
|
@ -3407,7 +3416,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get workspaceSwitcherTooltip =>
|
||||
'Workspace — filters this view and stamps new runs with the selected project';
|
||||
'Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project';
|
||||
|
||||
@override
|
||||
String get workspaceAnchorCaption => 'Project / area';
|
||||
|
||||
@override
|
||||
String workspaceStartConfirmTitle(String name) {
|
||||
return 'Start area “$name”?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get workspaceStartConfirmBody =>
|
||||
'This sealed area is currently stopped. Studio starts its own hub instance on this machine and connects to it — running areas switch without this prompt.';
|
||||
|
||||
@override
|
||||
String get workspaceStartConfirmAction => 'Start and connect';
|
||||
|
||||
@override
|
||||
String get workspaceSealedAggregateWhy =>
|
||||
'Names stay aggregated because they can reveal client identities.';
|
||||
|
||||
@override
|
||||
String get workspaceSealedAggregateSettings =>
|
||||
'Always show: Settings → Security';
|
||||
|
||||
@override
|
||||
String get workspaceProtectedHint =>
|
||||
|
|
@ -3433,6 +3465,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get runsEmptyGuideButton => 'Open the guide';
|
||||
|
||||
@override
|
||||
String runsEmptyFilteredTitle(String name) {
|
||||
return 'No runs in project “$name”';
|
||||
}
|
||||
|
||||
@override
|
||||
String get runsEmptyFilteredHint =>
|
||||
'The working context filters this list — other projects may have runs.';
|
||||
|
||||
@override
|
||||
String get runsEmptyShowAll => 'Show all projects';
|
||||
|
||||
@override
|
||||
String get runsHubTooOldTitle => 'This view needs a newer hub version';
|
||||
|
||||
|
|
|
|||
129
lib/main.dart
129
lib/main.dart
|
|
@ -505,9 +505,18 @@ class StudioShellState extends State<StudioShell> {
|
|||
/// Refreshed alongside the health probe — same 5 s tick.
|
||||
int _pendingApprovals = 0;
|
||||
|
||||
/// Opens the workspace anchor's menu from the Cmd+P shortcut.
|
||||
final GlobalKey<PopupMenuButtonState<String>> _workspaceMenuKey =
|
||||
GlobalKey<PopupMenuButtonState<String>>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// The shell renders connection-derived labels (sidebar endpoint,
|
||||
// workspace anchor): rebuild when the workspace switches context
|
||||
// so they can never show the previous hub (persona finding: the
|
||||
// sidebar endpoint stayed stale until the next health tick).
|
||||
Workspace.instance.addListener(_onWorkspaceChanged);
|
||||
_checkHealth();
|
||||
_healthPoll = Timer.periodic(
|
||||
const Duration(seconds: 5),
|
||||
|
|
@ -515,6 +524,10 @@ class StudioShellState extends State<StudioShell> {
|
|||
);
|
||||
}
|
||||
|
||||
void _onWorkspaceChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// One-time hub-update hint (zero-learning-curve: the shell says
|
||||
/// actively that a newer hub release exists instead of hiding it
|
||||
/// on the doctor page). Null = nothing to show. Dismissal is
|
||||
|
|
@ -627,6 +640,7 @@ class StudioShellState extends State<StudioShell> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
Workspace.instance.removeListener(_onWorkspaceChanged);
|
||||
_healthPoll?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -656,10 +670,76 @@ class StudioShellState extends State<StudioShell> {
|
|||
ChainSettingsDialog.show(context);
|
||||
},
|
||||
),
|
||||
..._workspaceSearchHits(l),
|
||||
];
|
||||
ChainSearchPalette.show(context, staticHits: hits);
|
||||
}
|
||||
|
||||
/// The palette's "Projects & areas" group — every context the
|
||||
/// switcher offers, ranked by the operator's recent choices so a
|
||||
/// poweruser's frequent areas surface before the alphabet. Sealed
|
||||
/// names honour the Settings privacy choice: while the switcher
|
||||
/// aggregates them, the palette must not list them either — it
|
||||
/// offers one entry that opens the switcher's guarded picker.
|
||||
List<ChainSearchHit> _workspaceSearchHits(AppLocalizations l) {
|
||||
final ws = Workspace.instance;
|
||||
final group = l.searchGroupWorkspace;
|
||||
final recents = WorkspacePrefs.recentContexts.value;
|
||||
int rank(String v) {
|
||||
final i = recents.indexOf(v);
|
||||
return i < 0 ? recents.length : i;
|
||||
}
|
||||
|
||||
void select(String value) {
|
||||
Navigator.of(context).pop();
|
||||
handleWorkspaceMenuSelection(context, value);
|
||||
}
|
||||
|
||||
final entries = <({String value, String label, IconData icon})>[
|
||||
(
|
||||
value: workspaceMenuValueAll(),
|
||||
label: l.workspaceAll,
|
||||
icon: Icons.grid_view_outlined,
|
||||
),
|
||||
for (final p in ws.projects)
|
||||
(
|
||||
value: workspaceMenuValueProject(p.slug),
|
||||
label: p.slug == 'general' ? l.workspaceDefaultProject : p.name,
|
||||
icon: Icons.circle_outlined,
|
||||
),
|
||||
if (WorkspacePrefs.sealedNamesVisible.value)
|
||||
for (final a in ws.sealedAreas)
|
||||
(
|
||||
value: workspaceMenuValueSealed(a.slug),
|
||||
label: a.name,
|
||||
icon: Icons.lock_outline,
|
||||
),
|
||||
]..sort((a, b) => rank(a.value).compareTo(rank(b.value)));
|
||||
|
||||
return [
|
||||
for (final e in entries)
|
||||
ChainSearchHit(
|
||||
label: e.label,
|
||||
hint: l.searchWorkspaceHint,
|
||||
icon: e.icon,
|
||||
group: group,
|
||||
onSelect: () => select(e.value),
|
||||
),
|
||||
if (!WorkspacePrefs.sealedNamesVisible.value &&
|
||||
ws.sealedAreas.isNotEmpty)
|
||||
ChainSearchHit(
|
||||
label: l.workspaceSealedAggregate(ws.sealedAreas.length),
|
||||
hint: l.searchWorkspaceSealedPickerHint,
|
||||
icon: Icons.lock_outline,
|
||||
group: group,
|
||||
onSelect: () {
|
||||
Navigator.of(context).pop();
|
||||
_workspaceMenuKey.currentState?.showButtonMenu();
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -699,6 +779,12 @@ class StudioShellState extends State<StudioShell> {
|
|||
const _OpenSearchIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.keyK, control: true):
|
||||
const _OpenSearchIntent(),
|
||||
// Workspace switcher (project/area picker) — P as in
|
||||
// project. Opens the sidebar anchor's menu.
|
||||
const SingleActivator(LogicalKeyboardKey.keyP, meta: true):
|
||||
const _OpenWorkspaceIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.keyP, control: true):
|
||||
const _OpenWorkspaceIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
|
|
@ -720,6 +806,12 @@ class StudioShellState extends State<StudioShell> {
|
|||
return null;
|
||||
},
|
||||
),
|
||||
_OpenWorkspaceIntent: CallbackAction<_OpenWorkspaceIntent>(
|
||||
onInvoke: (_) {
|
||||
_workspaceMenuKey.currentState?.showButtonMenu();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
|
|
@ -738,6 +830,7 @@ class StudioShellState extends State<StudioShell> {
|
|||
pendingApprovals: _pendingApprovals,
|
||||
forceExpanded: widget.startSidebarExpanded || pinned,
|
||||
onOpenSearch: _openSearchPalette,
|
||||
workspaceMenuKey: _workspaceMenuKey,
|
||||
),
|
||||
),
|
||||
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
||||
|
|
@ -866,6 +959,10 @@ class _OpenSearchIntent extends Intent {
|
|||
const _OpenSearchIntent();
|
||||
}
|
||||
|
||||
class _OpenWorkspaceIntent extends Intent {
|
||||
const _OpenWorkspaceIntent();
|
||||
}
|
||||
|
||||
/// Platform-truthful label for the app's primary-modifier
|
||||
/// shortcuts (the activators bind ⌘ on macOS and Ctrl elsewhere).
|
||||
String _metaShortcut(String key) =>
|
||||
|
|
@ -899,6 +996,11 @@ class _Sidebar extends StatefulWidget {
|
|||
/// knowing the shortcut.
|
||||
final VoidCallback? onOpenSearch;
|
||||
|
||||
/// Menu key for the workspace anchor so the shell's Cmd+P
|
||||
/// shortcut (and the palette's guarded sealed-picker entry) can
|
||||
/// open the switcher menu.
|
||||
final GlobalKey<PopupMenuButtonState<String>>? workspaceMenuKey;
|
||||
|
||||
const _Sidebar({
|
||||
required this.selectedIndex,
|
||||
required this.onSelect,
|
||||
|
|
@ -909,6 +1011,7 @@ class _Sidebar extends StatefulWidget {
|
|||
this.pendingApprovals = 0,
|
||||
this.forceExpanded = false,
|
||||
this.onOpenSearch,
|
||||
this.workspaceMenuKey,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -932,6 +1035,7 @@ class _SidebarState extends State<_Sidebar>
|
|||
static const double _brandRowH = 48;
|
||||
static const double _connRowH = 44;
|
||||
static const double _channelRowH = 28;
|
||||
static const double _anchorRowH = 44;
|
||||
static const double _rowGap = 8;
|
||||
late final AnimationController _ctrl;
|
||||
// True while the channel-switch menu is open — suppresses the
|
||||
|
|
@ -1110,6 +1214,31 @@ class _SidebarState extends State<_Sidebar>
|
|||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: _rowGap),
|
||||
// The ONE workspace anchor — active project/area,
|
||||
// visible on every page, opens the switcher menu
|
||||
// (per-page switcher embeddings are gone; persona
|
||||
// review 2026-08-27). Fixed height like the other
|
||||
// header rows so expanding the rail never shifts
|
||||
// the destinations below.
|
||||
SizedBox(
|
||||
height: _anchorRowH,
|
||||
child: ChainWorkspaceAnchor(
|
||||
t: t,
|
||||
labelsInteractive: labelsInteractive,
|
||||
iconColumnWidth: _collapsedWidth,
|
||||
menuKey: widget.workspaceMenuKey,
|
||||
onMenuOpen: () {
|
||||
setState(() => _menuOpen = true);
|
||||
_ctrl.forward();
|
||||
},
|
||||
onMenuClose: () {
|
||||
if (!mounted) return;
|
||||
setState(() => _menuOpen = false);
|
||||
_ctrl.reverse();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: ChainSpace.md),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
|
|
|
|||
|
|
@ -376,8 +376,6 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
),
|
||||
),
|
||||
actions: [
|
||||
const ChainWorkspaceSwitcher(),
|
||||
const SizedBox(width: ChainSpace.md),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_outline, size: 18),
|
||||
tooltip: AppLocalizations.of(context)!.helpTooltip,
|
||||
|
|
|
|||
|
|
@ -306,11 +306,9 @@ class _AuditPageState extends State<AuditPage> {
|
|||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.auditTitle),
|
||||
actions: [
|
||||
const ChainWorkspaceSwitcher(),
|
||||
const SizedBox(width: ChainSpace.md),
|
||||
// Narrow windows can't fit the inline chip row next to the
|
||||
// workspace switcher — collapse to a checkmark menu so the
|
||||
// app bar never overflows (responsive_test.dart pins this).
|
||||
// Narrow windows can't fit the inline chip row — collapse
|
||||
// to a checkmark menu so the app bar never overflows
|
||||
// (responsive_test.dart pins this).
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: ChainSpace.lg),
|
||||
child: MediaQuery.sizeOf(context).width < 900
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import '../data/store_caps.dart';
|
|||
import '../data/workspace.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../widgets/chain_install_confirm.dart';
|
||||
import '../widgets/chain_workspace_switcher.dart';
|
||||
|
||||
class FlowsPage extends StatefulWidget {
|
||||
/// Pre-load this flow when the editor first builds. Studio
|
||||
|
|
@ -220,11 +219,9 @@ class _FlowsPageState extends State<FlowsPage> {
|
|||
onInstallCapability: _onInstallCapability,
|
||||
onAddModuleSource: _onAddModuleSource,
|
||||
activeProject: Workspace.instance.activeSlug,
|
||||
// Same switcher as Audit/Approvals/Runs, hosted in the
|
||||
// editor's toolbar (the page's single header): it
|
||||
// filters the flow list and is the project a new flow
|
||||
// gets stamped with.
|
||||
toolbarTrailing: const ChainWorkspaceSwitcher(),
|
||||
// The workspace switcher lives ONCE in the shell sidebar
|
||||
// (global anchor) — the editor toolbar no longer hosts
|
||||
// its own copy.
|
||||
// Native file dialog for the Run tab's file inputs —
|
||||
// nobody should have to type an absolute path by hand.
|
||||
onPickFile: _pickFlowInputFile,
|
||||
|
|
|
|||
|
|
@ -254,6 +254,14 @@ class _RunsPageState extends State<RunsPage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Display name of the filtered project ("General" localized).
|
||||
String _activeProjectLabel(AppLocalizations l) {
|
||||
final ws = Workspace.instance;
|
||||
final p = ws.active;
|
||||
if (p == null) return ws.activeSlug;
|
||||
return p.slug == 'general' ? l.workspaceDefaultProject : p.name;
|
||||
}
|
||||
|
||||
Future<void> _cancel(DetachedRun run) async {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
setState(() => _cancelling.add(run.id));
|
||||
|
|
@ -333,8 +341,6 @@ class _RunsPageState extends State<RunsPage> {
|
|||
appBar: AppBar(
|
||||
title: Text(l.runsTitle),
|
||||
actions: [
|
||||
const ChainWorkspaceSwitcher(),
|
||||
const SizedBox(width: ChainSpace.md),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_outline, size: 18),
|
||||
tooltip: l.helpTooltip,
|
||||
|
|
@ -361,22 +367,41 @@ class _RunsPageState extends State<RunsPage> {
|
|||
updating: _updatingHub,
|
||||
)
|
||||
: _runs.isEmpty
|
||||
// With an active project filter the honest cause is the
|
||||
// filter, not the feature — name the project and offer the
|
||||
// way out instead of the enable-the-feature guide
|
||||
// (persona finding: the old hint claimed a wrong cause).
|
||||
? (_detachedEnabled && !Workspace.instance.isAll
|
||||
? ChainEmptyState(
|
||||
icon: Icons.rocket_launch_outlined,
|
||||
title: l.runsEmptyFilteredTitle(_activeProjectLabel(l)),
|
||||
hint: l.runsEmptyFilteredHint,
|
||||
action: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.grid_view_outlined, size: 16),
|
||||
label: Text(l.runsEmptyShowAll),
|
||||
onPressed: () => Workspace.instance.setActive(''),
|
||||
),
|
||||
)
|
||||
: ChainEmptyState(
|
||||
icon: Icons.rocket_launch_outlined,
|
||||
title: l.runsEmptyTitle,
|
||||
// The hub reports whether the operator enabled the
|
||||
// feature — never claim "switched off" while it is on
|
||||
// and there simply are no runs yet (usertest finding).
|
||||
hint: _detachedEnabled ? l.runsEmptyEnabledHint : l.runsEmptyHint,
|
||||
// The guide carries the plain-language explanation plus
|
||||
// the exact operator steps (config snippet) — a click
|
||||
// target instead of a raw config key in the hint.
|
||||
// feature — never claim "switched off" while it is
|
||||
// on and there simply are no runs yet (usertest
|
||||
// finding).
|
||||
hint: _detachedEnabled
|
||||
? l.runsEmptyEnabledHint
|
||||
: l.runsEmptyHint,
|
||||
// The guide carries the plain-language explanation
|
||||
// plus the exact operator steps (config snippet) —
|
||||
// a click target instead of a raw config key in
|
||||
// the hint.
|
||||
action: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
||||
label: Text(l.runsEmptyGuideButton),
|
||||
onPressed: () => showFaiDoc(context, 'runs'),
|
||||
),
|
||||
)
|
||||
))
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(ChainSpace.lg),
|
||||
itemCount: _runs.length,
|
||||
|
|
|
|||
|
|
@ -26,13 +26,21 @@ import 'hub_auth_policy_panel.dart';
|
|||
import 'theme_picker_grid.dart';
|
||||
|
||||
class ChainSettingsDialog extends StatefulWidget {
|
||||
const ChainSettingsDialog({super.key});
|
||||
/// Category name to open on (e.g. 'security'); null = the first
|
||||
/// category. Lets in-place explainers (the sealed-aggregate
|
||||
/// why-line) jump straight to the toggle they talk about.
|
||||
final String? initialCategory;
|
||||
|
||||
const ChainSettingsDialog({super.key, this.initialCategory});
|
||||
|
||||
/// Convenience launcher used from the sidebar gear icon.
|
||||
static Future<bool> show(BuildContext context) async {
|
||||
static Future<bool> show(
|
||||
BuildContext context, {
|
||||
String? initialCategory,
|
||||
}) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => const ChainSettingsDialog(),
|
||||
builder: (_) => ChainSettingsDialog(initialCategory: initialCategory),
|
||||
);
|
||||
return ok ?? false;
|
||||
}
|
||||
|
|
@ -88,6 +96,9 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final c in _Category.values) {
|
||||
if (c.name == widget.initialCategory) _category = c;
|
||||
}
|
||||
final ep = HubService.instance.currentEndpoint;
|
||||
_host = TextEditingController(text: ep.host);
|
||||
_port = TextEditingController(text: ep.port.toString());
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
// ChainWorkspaceSwitcher — the AppBar workspace (project) control.
|
||||
// Workspace switching — the ONE global control for "where am I
|
||||
// working?".
|
||||
//
|
||||
// One control, two mechanics (docs/architecture/projects.md,
|
||||
// § Studio): for open/protected projects the choice sets the page
|
||||
// filter AND the label new runs are stamped with. "All projects"
|
||||
// stays reachable — a filter, not a jail. Sealed areas are a real
|
||||
// CONNECTION SWITCH: selecting one reconnects Studio to the area's
|
||||
// own hub (starting it first if stopped, with a notice) and colours
|
||||
// the identity bar. Studio's blue stays the app accent — the project
|
||||
// colour is marking, not theming.
|
||||
// own hub (starting it first if stopped, after an explicit
|
||||
// confirmation) and colours the identity bar. Studio's blue stays
|
||||
// the app accent — the project colour is marking, not theming.
|
||||
//
|
||||
// The control lives ONCE in the shell sidebar (ChainWorkspaceAnchor)
|
||||
// so the active context is visible on every page — it used to be
|
||||
// embedded per-page (Flows/Runs/Audit/Approvals), which left it
|
||||
// invisible on the other five pages and moving between positions
|
||||
// (persona review 2026-08-27).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
|
@ -17,15 +24,50 @@ import '../data/workspace_prefs.dart';
|
|||
import '../data/workspace.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import 'chain_settings_dialog.dart';
|
||||
|
||||
/// Menu-value scheme: '' = all projects, `p:<slug>` = shared project,
|
||||
/// `s:<slug>` = sealed area.
|
||||
/// `s:<slug>` = sealed area, `settings:security` = the why-line's
|
||||
/// jump to the Settings toggle.
|
||||
const _kAll = '';
|
||||
const _pProject = 'p:';
|
||||
const _pSealed = 's:';
|
||||
const _kSettingsSecurity = 'settings:security';
|
||||
|
||||
class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||
const ChainWorkspaceSwitcher({super.key});
|
||||
/// Menu values for external mount points (the command palette) —
|
||||
/// the scheme itself stays private to this file.
|
||||
String workspaceMenuValueAll() => _kAll;
|
||||
String workspaceMenuValueProject(String slug) => '$_pProject$slug';
|
||||
String workspaceMenuValueSealed(String slug) => '$_pSealed$slug';
|
||||
|
||||
/// The sidebar anchor — the single place the workspace switcher is
|
||||
/// mounted. Geometry mirrors the sidebar's header rows: a fixed
|
||||
/// icon column (context marking) + the label that fades in with the
|
||||
/// rail expansion [t].
|
||||
class ChainWorkspaceAnchor extends StatelessWidget {
|
||||
/// Sidebar expansion 0..1 (collapsed → expanded).
|
||||
final double t;
|
||||
final bool labelsInteractive;
|
||||
final double iconColumnWidth;
|
||||
|
||||
/// Suppress the sidebar's hover-collapse while the menu is open —
|
||||
/// same contract as the channel pill (the menu would otherwise
|
||||
/// float at the pill's old x).
|
||||
final VoidCallback? onMenuOpen;
|
||||
final VoidCallback? onMenuClose;
|
||||
|
||||
/// Lets the shell open the menu from the keyboard shortcut.
|
||||
final GlobalKey<PopupMenuButtonState<String>>? menuKey;
|
||||
|
||||
const ChainWorkspaceAnchor({
|
||||
super.key,
|
||||
required this.t,
|
||||
required this.labelsInteractive,
|
||||
required this.iconColumnWidth,
|
||||
this.onMenuOpen,
|
||||
this.onMenuClose,
|
||||
this.menuKey,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -35,34 +77,157 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
|||
final l = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final ws = Workspace.instance;
|
||||
final label = workspaceContextLabel(l, ws);
|
||||
|
||||
final label = ws.inSealedArea
|
||||
? ws.activeSealed!.name
|
||||
: (ws.isAll
|
||||
? l.workspaceAll
|
||||
: (ws.active == null
|
||||
? ws.activeSlug
|
||||
: _projectLabel(l, ws.active!)));
|
||||
final row = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: iconColumnWidth,
|
||||
child: Center(child: _ContextMark(ws: ws)),
|
||||
),
|
||||
Expanded(
|
||||
child: t > 0
|
||||
? IgnorePointer(
|
||||
ignoring: !labelsInteractive,
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l.workspaceAnchorCaption,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Scales away mid-expansion — a fixed
|
||||
// icon would overflow the row while the
|
||||
// rail is still narrow.
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: ChainSpace.md,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.unfold_more,
|
||||
size: 16,
|
||||
color:
|
||||
theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return Tooltip(
|
||||
message: l.workspaceSwitcherTooltip,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
child: PopupMenuButton<String>(
|
||||
onOpened: ws.refresh,
|
||||
onSelected: (v) => _onSelected(context, v),
|
||||
itemBuilder: (context) => _items(context, ws, l, theme),
|
||||
child: _pill(context, ws, l, theme, label),
|
||||
key: menuKey,
|
||||
tooltip: '', // the outer Tooltip carries the message
|
||||
onOpened: () {
|
||||
onMenuOpen?.call();
|
||||
Workspace.instance.refresh();
|
||||
},
|
||||
onCanceled: () => onMenuClose?.call(),
|
||||
onSelected: (v) {
|
||||
onMenuClose?.call();
|
||||
handleWorkspaceMenuSelection(context, v);
|
||||
},
|
||||
itemBuilder: (context) =>
|
||||
workspaceMenuItems(context, ws: ws, l: l, theme: theme),
|
||||
child: SizedBox(height: 44, child: row),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<PopupMenuEntry<String>> _items(
|
||||
BuildContext context,
|
||||
Workspace ws,
|
||||
AppLocalizations l,
|
||||
ThemeData theme,
|
||||
) {
|
||||
/// The collapsed-column marking for the active context: grid = all
|
||||
/// projects, coloured dot = project, dot + lock = sealed area.
|
||||
class _ContextMark extends StatelessWidget {
|
||||
final Workspace ws;
|
||||
|
||||
const _ContextMark({required this.ws});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final sealed = ws.switching
|
||||
? (ws.switchTarget ?? ws.activeSealed)
|
||||
: ws.activeSealed;
|
||||
if (sealed != null) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ProjectDot(color: sealed.color),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
size: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (ws.active != null) {
|
||||
return _ProjectDot(color: ws.active!.color);
|
||||
}
|
||||
return Icon(
|
||||
Icons.grid_view_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Display label for the active context (shared by anchor + tests).
|
||||
String workspaceContextLabel(AppLocalizations l, Workspace ws) {
|
||||
final sealed = ws.switching
|
||||
? (ws.switchTarget ?? ws.activeSealed)
|
||||
: ws.activeSealed;
|
||||
if (sealed != null) return sealed.name;
|
||||
if (ws.isAll) return l.workspaceAll;
|
||||
final active = ws.active;
|
||||
return active == null ? ws.activeSlug : _projectLabel(l, active);
|
||||
}
|
||||
|
||||
/// The switcher menu, one source of truth for every mount point.
|
||||
/// The active entry carries a checkmark so the menu answers "where
|
||||
/// am I?" before anything is clicked.
|
||||
List<PopupMenuEntry<String>> workspaceMenuItems(
|
||||
BuildContext context, {
|
||||
required Workspace ws,
|
||||
required AppLocalizations l,
|
||||
required ThemeData theme,
|
||||
}) {
|
||||
final inSealed = ws.inSealedArea;
|
||||
final items = <PopupMenuEntry<String>>[
|
||||
PopupMenuItem(
|
||||
value: _kAll,
|
||||
|
|
@ -74,7 +239,8 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
|||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Text(l.workspaceAll),
|
||||
Expanded(child: Text(l.workspaceAll)),
|
||||
_ActiveCheck(active: !inSealed && ws.isAll),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -105,6 +271,8 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
_ActiveCheck(active: !inSealed && ws.activeSlug == p.slug),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -139,23 +307,37 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
|||
child: SealedAreaSection(
|
||||
areas: ws.sealedAreas,
|
||||
namesVisible: WorkspacePrefs.sealedNamesVisible.value,
|
||||
activeSlug: ws.activeSealed?.slug,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSelected(BuildContext context, String value) async {
|
||||
/// Apply a switcher-menu selection. Shared by every mount point
|
||||
/// (sidebar anchor, command palette). Starting a STOPPED sealed
|
||||
/// area asks first — an area switch must never boot a hub daemon
|
||||
/// as a click side-effect; a running area stays one click.
|
||||
Future<void> handleWorkspaceMenuSelection(
|
||||
BuildContext context,
|
||||
String value,
|
||||
) async {
|
||||
final ws = Workspace.instance;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
if (value == _kSettingsSecurity) {
|
||||
await ChainSettingsDialog.show(context, initialCategory: 'security');
|
||||
return;
|
||||
}
|
||||
if (value == _kAll) {
|
||||
WorkspacePrefs.recordRecentContext(value);
|
||||
await ws.setActive('');
|
||||
return;
|
||||
}
|
||||
if (value.startsWith(_pProject)) {
|
||||
WorkspacePrefs.recordRecentContext(value);
|
||||
await ws.setActive(value.substring(_pProject.length));
|
||||
return;
|
||||
}
|
||||
|
|
@ -166,13 +348,36 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
|||
if (a.slug == slug) area = a;
|
||||
}
|
||||
if (area == null) return;
|
||||
// Announce the intent — starting a stopped area takes a beat.
|
||||
if (!area.running) {
|
||||
final target = area;
|
||||
if (!target.running) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l.workspaceStartConfirmTitle(target.name)),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Text(l.workspaceStartConfirmBody),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(l.buttonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(l.workspaceStartConfirmAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
// Announce the intent — starting the area's hub takes a beat.
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l.workspaceSealedStarting(area.name))),
|
||||
SnackBar(content: Text(l.workspaceSealedStarting(target.name))),
|
||||
);
|
||||
}
|
||||
final r = await ws.switchToSealed(area);
|
||||
WorkspacePrefs.recordRecentContext(value);
|
||||
final r = await ws.switchToSealed(target);
|
||||
messenger.hideCurrentSnackBar();
|
||||
if (!r.ok) {
|
||||
messenger.showSnackBar(
|
||||
|
|
@ -183,84 +388,27 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
|||
);
|
||||
} else if (r.started) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l.workspaceSealedStarted(area.name))),
|
||||
SnackBar(content: Text(l.workspaceSealedStarted(target.name))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _pill(
|
||||
BuildContext context,
|
||||
Workspace ws,
|
||||
AppLocalizations l,
|
||||
ThemeData theme,
|
||||
String label,
|
||||
) {
|
||||
final Widget leading;
|
||||
if (ws.inSealedArea) {
|
||||
leading = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ProjectDot(color: ws.activeSealed!.color),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
size: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
);
|
||||
} else if (ws.active != null) {
|
||||
leading = Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: _ProjectDot(color: ws.active!.color),
|
||||
);
|
||||
} else {
|
||||
leading = Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Icon(
|
||||
Icons.grid_view_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
/// Trailing checkmark slot — fixed width so rows with and without
|
||||
/// the mark keep their text aligned.
|
||||
class _ActiveCheck extends StatelessWidget {
|
||||
final bool active;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: ChainSpace.md, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
leading,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 160),
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
if (!ws.inSealedArea && (ws.active?.isProtected ?? false)) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.shield_outlined,
|
||||
size: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
const _ActiveCheck({required this.active});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
width: 22,
|
||||
child: active
|
||||
? Icon(Icons.check, size: 16, color: theme.colorScheme.primary)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -305,19 +453,26 @@ Color? parseAreaColor(String hex) {
|
|||
/// widget test can pump both privacy modes directly.
|
||||
///
|
||||
/// [namesVisible] = the operator's Settings choice. When false the
|
||||
/// block renders one aggregated row (lock + count); a deliberate
|
||||
/// tap expands the named rows for THIS menu opening only — nothing
|
||||
/// is persisted from the reveal. Rows select via
|
||||
/// block renders one aggregated row (lock + count) with a why-line
|
||||
/// (the aggregation is a deliberate confidentiality decision and
|
||||
/// must explain itself in place) + a jump to the Settings toggle;
|
||||
/// a deliberate tap expands the named rows for THIS menu opening
|
||||
/// only — nothing is persisted from the reveal. Rows select via
|
||||
/// `Navigator.pop(context, 's:<slug>')`, which hands the value to
|
||||
/// the enclosing PopupMenuButton exactly like a regular item.
|
||||
class SealedAreaSection extends StatefulWidget {
|
||||
final List<SealedArea> areas;
|
||||
final bool namesVisible;
|
||||
|
||||
/// Slug of the area Studio is connected to, or null — its row
|
||||
/// carries the active checkmark.
|
||||
final String? activeSlug;
|
||||
|
||||
const SealedAreaSection({
|
||||
super.key,
|
||||
required this.areas,
|
||||
required this.namesVisible,
|
||||
this.activeSlug,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -332,7 +487,11 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
|
|||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
if (!widget.namesVisible && !_revealed) {
|
||||
return InkWell(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => _revealed = true),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
|
|
@ -387,6 +546,43 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
|
|||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// The why-line: aggregation is not a glitch but a
|
||||
// confidentiality choice — say so where it happens, and
|
||||
// point at the Settings toggle for machines where the
|
||||
// listing is fine.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(37, 0, 16, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 240),
|
||||
child: Text(
|
||||
l.workspaceSealedAggregateWhy,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
Navigator.pop(context, _kSettingsSecurity),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
l.workspaceSealedAggregateSettings,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
|
|
@ -440,6 +636,8 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
|
|||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_ActiveCheck(active: a.slug == widget.activeSlug),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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