From 64c2a77dc9c590e2e68d5f5df0d6b17a326e799c Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 28 Aug 2026 00:00:06 +0200 Subject: [PATCH] feat(workspace): one global switcher anchor in the shell sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- integration_test/guide_shots_test.dart | 5 +- lib/data/workspace_prefs.dart | 23 + lib/l10n/app_de.arb | 16 +- lib/l10n/app_en.arb | 16 +- lib/l10n/app_localizations.dart | 74 ++- lib/l10n/app_localizations_de.dart | 46 +- lib/l10n/app_localizations_en.dart | 46 +- lib/main.dart | 129 ++++ lib/pages/approvals.dart | 2 - lib/pages/audit.dart | 8 +- lib/pages/flows.dart | 9 +- lib/pages/runs.dart | 61 +- lib/widgets/chain_settings_dialog.dart | 17 +- lib/widgets/chain_workspace_switcher.dart | 690 ++++++++++++++-------- test/support/fake_hub.dart | 7 +- test/workspace_switcher_test.dart | 163 +++-- 16 files changed, 988 insertions(+), 324 deletions(-) diff --git a/integration_test/guide_shots_test.dart b/integration_test/guide_shots_test.dart index f1c7dc7..cae4260 100644 --- a/integration_test/guide_shots_test.dart +++ b/integration_test/guide_shots_test.dart @@ -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); diff --git a/lib/data/workspace_prefs.dart b/lib/data/workspace_prefs.dart index 2ed030a..c8a2ca5 100644 --- a/lib/data/workspace_prefs.dart +++ b/lib/data/workspace_prefs.dart @@ -22,6 +22,8 @@ class WorkspacePrefs { static Future load() async { final prefs = await SharedPreferences.getInstance(); sealedNamesVisible.value = prefs.getBool(_kSealedNamesKey) ?? false; + recentContexts.value = + prefs.getStringList(_kRecentContextsKey) ?? const []; } static Future 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:` / `s:`), 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> recentContexts = + ValueNotifier(const []); + + /// Record a switcher selection (deduped, capped, persisted). + static Future 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); + } } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c0b5d6f..cdb6e49 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7376af6..33d5f44 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 453356a..935df78 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e4f14ec..74fc698 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5e22e89..aa2ab68 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/main.dart b/lib/main.dart index 9342523..9e790e0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -505,9 +505,18 @@ class StudioShellState extends State { /// 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> _workspaceMenuKey = + GlobalKey>(); + @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 { ); } + 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 { @override void dispose() { + Workspace.instance.removeListener(_onWorkspaceChanged); _healthPoll?.cancel(); super.dispose(); } @@ -656,10 +670,76 @@ class StudioShellState extends State { 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 _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 { 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: >{ @@ -720,6 +806,12 @@ class StudioShellState extends State { return null; }, ), + _OpenWorkspaceIntent: CallbackAction<_OpenWorkspaceIntent>( + onInvoke: (_) { + _workspaceMenuKey.currentState?.showButtonMenu(); + return null; + }, + ), }, child: Focus( autofocus: true, @@ -738,6 +830,7 @@ class StudioShellState extends State { 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>? 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( diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index ca7da0d..70b653d 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -376,8 +376,6 @@ class _ApprovalsPageState extends State { ), ), actions: [ - const ChainWorkspaceSwitcher(), - const SizedBox(width: ChainSpace.md), IconButton( icon: const Icon(Icons.help_outline, size: 18), tooltip: AppLocalizations.of(context)!.helpTooltip, diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index b84a35a..c70b536 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -306,11 +306,9 @@ class _AuditPageState extends State { 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 diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index 98a6d81..b68c05e 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -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 { 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, diff --git a/lib/pages/runs.dart b/lib/pages/runs.dart index 1961972..ba2a3be 100644 --- a/lib/pages/runs.dart +++ b/lib/pages/runs.dart @@ -254,6 +254,14 @@ class _RunsPageState extends State { } } + /// 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 _cancel(DetachedRun run) async { final l = AppLocalizations.of(context)!; setState(() => _cancelling.add(run.id)); @@ -333,8 +341,6 @@ class _RunsPageState extends State { 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 { updating: _updatingHub, ) : _runs.isEmpty - ? 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. - action: OutlinedButton.icon( - icon: const Icon(Icons.menu_book_outlined, size: 16), - label: Text(l.runsEmptyGuideButton), - onPressed: () => showFaiDoc(context, 'runs'), - ), - ) + // 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. + 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, diff --git a/lib/widgets/chain_settings_dialog.dart b/lib/widgets/chain_settings_dialog.dart index 56de1a3..d90c1c1 100644 --- a/lib/widgets/chain_settings_dialog.dart +++ b/lib/widgets/chain_settings_dialog.dart @@ -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 show(BuildContext context) async { + static Future show( + BuildContext context, { + String? initialCategory, + }) async { final ok = await showDialog( context: context, - builder: (_) => const ChainSettingsDialog(), + builder: (_) => ChainSettingsDialog(initialCategory: initialCategory), ); return ok ?? false; } @@ -88,6 +96,9 @@ class _FaiSettingsDialogState extends State { @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()); diff --git a/lib/widgets/chain_workspace_switcher.dart b/lib/widgets/chain_workspace_switcher.dart index 8c86bfc..f9d9e76 100644 --- a/lib/widgets/chain_workspace_switcher.dart +++ b/lib/widgets/chain_workspace_switcher.dart @@ -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:` = shared project, -/// `s:` = sealed area. +/// `s:` = 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>? 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,232 +77,338 @@ 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( - 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> _items( - BuildContext context, - Workspace ws, - AppLocalizations l, - ThemeData theme, - ) { - final items = >[ - PopupMenuItem( - value: _kAll, - child: Row( - children: [ - Icon( - Icons.grid_view_outlined, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: ChainSpace.sm), - Text(l.workspaceAll), - ], - ), - ), - ]; - if (ws.projects.isNotEmpty) items.add(const PopupMenuDivider()); - for (final p in ws.projects) { - items.add( - PopupMenuItem( - value: '$_pProject${p.slug}', - child: Row( - children: [ - _ProjectDot(color: p.color), - const SizedBox(width: ChainSpace.sm), - Flexible( - child: Text( - _projectLabel(l, p), - overflow: TextOverflow.ellipsis, - ), - ), - if (p.isProtected) ...[ - const SizedBox(width: ChainSpace.sm), - Tooltip( - message: l.workspaceProtectedHint, - child: Icon( - Icons.shield_outlined, - size: 14, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - ); - } - if (ws.sealedAreas.isNotEmpty) { - items.add(const PopupMenuDivider()); - items.add( - PopupMenuItem( - enabled: false, - height: 28, - child: Text( - l.workspaceSealedHeader, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 0.6, - ), - ), - ), - ); - // Area names often carry client/mandate identity, so the - // section starts aggregated ("2 sealed areas") and reveals - // names only on a deliberate tap — unless the operator turned - // the direct listing back on in Settings -> Security - // (usertest security finding). The disabled PopupMenuItem is - // just the host; the section handles its own taps and pops - // the menu route with the regular `s:` value. - items.add( - PopupMenuItem( - enabled: false, - padding: EdgeInsets.zero, - child: SealedAreaSection( - areas: ws.sealedAreas, - namesVisible: WorkspacePrefs.sealedNamesVisible.value, - ), - ), - ); - } - return items; - } +/// 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; - Future _onSelected(BuildContext context, String value) async { - final ws = Workspace.instance; - final l = AppLocalizations.of(context)!; - final messenger = ScaffoldMessenger.of(context); + const _ContextMark({required this.ws}); - if (value == _kAll) { - await ws.setActive(''); - return; - } - if (value.startsWith(_pProject)) { - await ws.setActive(value.substring(_pProject.length)); - return; - } - if (value.startsWith(_pSealed)) { - final slug = value.substring(_pSealed.length); - SealedArea? area; - for (final a in ws.sealedAreas) { - if (a.slug == slug) area = a; - } - if (area == null) return; - // Announce the intent — starting a stopped area takes a beat. - if (!area.running) { - messenger.showSnackBar( - SnackBar(content: Text(l.workspaceSealedStarting(area.name))), - ); - } - final r = await ws.switchToSealed(area); - messenger.hideCurrentSnackBar(); - if (!r.ok) { - messenger.showSnackBar( - SnackBar( - content: SelectableText(l.workspaceSealedSwitchFailed(r.error)), - duration: const Duration(seconds: 8), - ), - ); - } else if (r.started) { - messenger.showSnackBar( - SnackBar(content: Text(l.workspaceSealedStarted(area.name))), - ); - } - } - } - - Widget _pill( - BuildContext context, - Workspace ws, - AppLocalizations l, - ThemeData theme, - String label, - ) { - final Widget leading; - if (ws.inSealedArea) { - leading = Row( + @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: ws.activeSealed!.color), + _ProjectDot(color: sealed.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, - ), - ); } + if (ws.active != null) { + return _ProjectDot(color: ws.active!.color); + } + return Icon( + Icons.grid_view_outlined, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ); + } +} - return Container( - padding: const EdgeInsets.symmetric(horizontal: ChainSpace.md, vertical: 5), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(ChainRadius.sm), - ), +/// 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> workspaceMenuItems( + BuildContext context, { + required Workspace ws, + required AppLocalizations l, + required ThemeData theme, +}) { + final inSealed = ws.inSealedArea; + final items = >[ + PopupMenuItem( + value: _kAll, 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, + Icons.grid_view_outlined, + size: 16, color: theme.colorScheme.onSurfaceVariant, ), + const SizedBox(width: ChainSpace.sm), + Expanded(child: Text(l.workspaceAll)), + _ActiveCheck(active: !inSealed && ws.isAll), ], ), + ), + ]; + if (ws.projects.isNotEmpty) items.add(const PopupMenuDivider()); + for (final p in ws.projects) { + items.add( + PopupMenuItem( + value: '$_pProject${p.slug}', + child: Row( + children: [ + _ProjectDot(color: p.color), + const SizedBox(width: ChainSpace.sm), + Flexible( + child: Text( + _projectLabel(l, p), + overflow: TextOverflow.ellipsis, + ), + ), + if (p.isProtected) ...[ + const SizedBox(width: ChainSpace.sm), + Tooltip( + message: l.workspaceProtectedHint, + child: Icon( + Icons.shield_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const Spacer(), + _ActiveCheck(active: !inSealed && ws.activeSlug == p.slug), + ], + ), + ), + ); + } + if (ws.sealedAreas.isNotEmpty) { + items.add(const PopupMenuDivider()); + items.add( + PopupMenuItem( + enabled: false, + height: 28, + child: Text( + l.workspaceSealedHeader, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + ), + ), + ), + ); + // Area names often carry client/mandate identity, so the + // section starts aggregated ("2 sealed areas") and reveals + // names only on a deliberate tap — unless the operator turned + // the direct listing back on in Settings -> Security + // (usertest security finding). The disabled PopupMenuItem is + // just the host; the section handles its own taps and pops + // the menu route with the regular `s:` value. + items.add( + PopupMenuItem( + enabled: false, + padding: EdgeInsets.zero, + child: SealedAreaSection( + areas: ws.sealedAreas, + namesVisible: WorkspacePrefs.sealedNamesVisible.value, + activeSlug: ws.activeSealed?.slug, + ), + ), + ); + } + return items; +} + +/// 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 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; + } + if (value.startsWith(_pSealed)) { + final slug = value.substring(_pSealed.length); + SealedArea? area; + for (final a in ws.sealedAreas) { + if (a.slug == slug) area = a; + } + if (area == null) return; + final target = area; + if (!target.running) { + final confirmed = await showDialog( + 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(target.name))), + ); + } + WorkspacePrefs.recordRecentContext(value); + final r = await ws.switchToSealed(target); + messenger.hideCurrentSnackBar(); + if (!r.ok) { + messenger.showSnackBar( + SnackBar( + content: SelectableText(l.workspaceSealedSwitchFailed(r.error)), + duration: const Duration(seconds: 8), + ), + ); + } else if (r.started) { + messenger.showSnackBar( + SnackBar(content: Text(l.workspaceSealedStarted(target.name))), + ); + } + } +} + +/// Trailing checkmark slot — fixed width so rows with and without +/// the mark keep their text aligned. +class _ActiveCheck extends StatelessWidget { + final bool active; + + 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:')`, which hands the value to /// the enclosing PopupMenuButton exactly like a regular item. class SealedAreaSection extends StatefulWidget { final List 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,61 +487,102 @@ class _SealedAreaSectionState extends State { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; if (!widget.namesVisible && !_revealed) { - return InkWell( - onTap: () => setState(() => _revealed = true), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - // Two lines instead of one row: popup menus cap their - // width, and action texts must never be cut off - // (usertest finding class). Count on top, the reveal - // action fully readable beneath it. - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => setState(() => _revealed = true), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + // Two lines instead of one row: popup menus cap their + // width, and action texts must never be cut off + // (usertest finding class). Count on top, the reveal + // action fully readable beneath it. + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.lock_outline, - size: 13, - color: theme.colorScheme.onSurfaceVariant, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lock_outline, + size: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: ChainSpace.sm), + Text( + l.workspaceSealedAggregate(widget.areas.length), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ], ), - const SizedBox(width: ChainSpace.sm), - Text( - l.workspaceSealedAggregate(widget.areas.length), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface, + const SizedBox(height: 2), + Padding( + padding: const EdgeInsets.only(left: 21), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l.workspaceSealedRevealAction, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + Icon( + Icons.expand_more, + size: 14, + color: theme.colorScheme.primary, + ), + ], ), ), ], ), - const SizedBox(height: 2), - Padding( - padding: const EdgeInsets.only(left: 21), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l.workspaceSealedRevealAction, + ), + ), + // 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, ), ), - Icon( - Icons.expand_more, - size: 14, - color: theme.colorScheme.primary, - ), - ], + ), ), - ), - ], + ], + ), ), - ), + ], ); } return Column( @@ -440,6 +636,8 @@ class _SealedAreaSectionState extends State { ), ), ), + const Spacer(), + _ActiveCheck(active: a.slug == widget.activeSlug), ], ), ), diff --git a/test/support/fake_hub.dart b/test/support/fake_hub.dart index 21b166d..42739a0 100644 --- a/test/support/fake_hub.dart +++ b/test/support/fake_hub.dart @@ -183,9 +183,14 @@ class FakeHubService extends Fake implements HubService { @override Future> 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 projects = const []; + @override Future> listProjects() => - _async('listProjects', () => const []); + _async('listProjects', () => projects); @override Stream streamEvents({ diff --git a/test/workspace_switcher_test.dart b/test/workspace_switcher_test.dart index f5f4c50..6c36327 100644 --- a/test/workspace_switcher_test.dart +++ b/test/workspace_switcher_test.dart @@ -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 projects = const [_general, _clientA], + String active = '', + List 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), + ), + 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 areas; + + @override + Future> list() async => areas; + + @override + Future 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,