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;
|
// 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');
|
shell.navigateTo('audit');
|
||||||
await _pumpFrames(tester);
|
await _pumpFrames(tester);
|
||||||
final switcher = find.byType(ChainWorkspaceSwitcher);
|
final switcher = find.byType(ChainWorkspaceAnchor);
|
||||||
if (switcher.evaluate().isNotEmpty) {
|
if (switcher.evaluate().isNotEmpty) {
|
||||||
await tester.tap(switcher.first);
|
await tester.tap(switcher.first);
|
||||||
await _pumpFrames(tester);
|
await _pumpFrames(tester);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ class WorkspacePrefs {
|
||||||
static Future<void> load() async {
|
static Future<void> load() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
sealedNamesVisible.value = prefs.getBool(_kSealedNamesKey) ?? false;
|
sealedNamesVisible.value = prefs.getBool(_kSealedNamesKey) ?? false;
|
||||||
|
recentContexts.value =
|
||||||
|
prefs.getStringList(_kRecentContextsKey) ?? const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setSealedNamesVisible(bool value) async {
|
static Future<void> setSealedNamesVisible(bool value) async {
|
||||||
|
|
@ -29,4 +31,25 @@ class WorkspacePrefs {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_kSealedNamesKey, value);
|
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",
|
"searchHintOpen": "Enter öffnen",
|
||||||
"searchHintClose": "Esc schließen",
|
"searchHintClose": "Esc schließen",
|
||||||
"searchGroupPages": "Seiten",
|
"searchGroupPages": "Seiten",
|
||||||
|
"searchGroupWorkspace": "Projekte & Bereiche",
|
||||||
|
"searchWorkspaceHint": "Kontext wechseln · ⌘P",
|
||||||
|
"searchWorkspaceSealedPickerHint": "Auswahl öffnen · ⌘P",
|
||||||
"searchGroupModules": "Module",
|
"searchGroupModules": "Module",
|
||||||
"searchGroupStore": "Store",
|
"searchGroupStore": "Store",
|
||||||
"searchGroupFlows": "Flows",
|
"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.",
|
"federationEnrollmentHint": "Übergeben Sie das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte Zertifikatsstelle authentifiziert die erste Verbindung des Satelliten.",
|
||||||
"workspaceAll": "Alle Projekte",
|
"workspaceAll": "Alle Projekte",
|
||||||
"workspaceDefaultProject": "Allgemein",
|
"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.",
|
"workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.",
|
||||||
"runsTitle": "Läufe",
|
"runsTitle": "Läufe",
|
||||||
"runsReloadTooltip": "Lauf-Liste neu laden",
|
"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.",
|
"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.",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"runsHubTooOldButton": "Diagnose öffnen",
|
||||||
|
|
|
||||||
|
|
@ -1437,6 +1437,9 @@
|
||||||
"searchHintOpen": "enter open",
|
"searchHintOpen": "enter open",
|
||||||
"searchHintClose": "esc close",
|
"searchHintClose": "esc close",
|
||||||
"searchGroupPages": "Pages",
|
"searchGroupPages": "Pages",
|
||||||
|
"searchGroupWorkspace": "Projects & areas",
|
||||||
|
"searchWorkspaceHint": "switch context · ⌘P",
|
||||||
|
"searchWorkspaceSealedPickerHint": "open the picker · ⌘P",
|
||||||
"searchGroupModules": "Modules",
|
"searchGroupModules": "Modules",
|
||||||
"searchGroupStore": "Store",
|
"searchGroupStore": "Store",
|
||||||
"searchGroupFlows": "Flows",
|
"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.",
|
"federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect.",
|
||||||
"workspaceAll": "All projects",
|
"workspaceAll": "All projects",
|
||||||
"workspaceDefaultProject": "General",
|
"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.",
|
"workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.",
|
||||||
"runsTitle": "Runs",
|
"runsTitle": "Runs",
|
||||||
"runsReloadTooltip": "Reload the runs list",
|
"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.",
|
"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.",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"runsHubTooOldButton": "Open Doctor",
|
||||||
|
|
|
||||||
|
|
@ -4400,6 +4400,24 @@ abstract class AppLocalizations {
|
||||||
/// **'Pages'**
|
/// **'Pages'**
|
||||||
String get searchGroupPages;
|
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.
|
/// No description provided for @searchGroupModules.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -5752,9 +5770,45 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @workspaceSwitcherTooltip.
|
/// No description provided for @workspaceSwitcherTooltip.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// 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;
|
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.
|
/// No description provided for @workspaceProtectedHint.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -5797,6 +5851,24 @@ abstract class AppLocalizations {
|
||||||
/// **'Open the guide'**
|
/// **'Open the guide'**
|
||||||
String get runsEmptyGuideButton;
|
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.
|
/// No description provided for @runsHubTooOldTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -2595,6 +2595,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get searchGroupPages => 'Seiten';
|
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
|
@override
|
||||||
String get searchGroupModules => 'Module';
|
String get searchGroupModules => 'Module';
|
||||||
|
|
||||||
|
|
@ -3414,7 +3423,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get workspaceSwitcherTooltip =>
|
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
|
@override
|
||||||
String get workspaceProtectedHint =>
|
String get workspaceProtectedHint =>
|
||||||
|
|
@ -3440,6 +3472,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get runsEmptyGuideButton => 'Anleitung öffnen';
|
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
|
@override
|
||||||
String get runsHubTooOldTitle =>
|
String get runsHubTooOldTitle =>
|
||||||
'Diese Ansicht braucht eine neuere Hub-Version';
|
'Diese Ansicht braucht eine neuere Hub-Version';
|
||||||
|
|
|
||||||
|
|
@ -2593,6 +2593,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get searchGroupPages => 'Pages';
|
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
|
@override
|
||||||
String get searchGroupModules => 'Modules';
|
String get searchGroupModules => 'Modules';
|
||||||
|
|
||||||
|
|
@ -3407,7 +3416,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get workspaceSwitcherTooltip =>
|
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
|
@override
|
||||||
String get workspaceProtectedHint =>
|
String get workspaceProtectedHint =>
|
||||||
|
|
@ -3433,6 +3465,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get runsEmptyGuideButton => 'Open the guide';
|
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
|
@override
|
||||||
String get runsHubTooOldTitle => 'This view needs a newer hub version';
|
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.
|
/// Refreshed alongside the health probe — same 5 s tick.
|
||||||
int _pendingApprovals = 0;
|
int _pendingApprovals = 0;
|
||||||
|
|
||||||
|
/// Opens the workspace anchor's menu from the Cmd+P shortcut.
|
||||||
|
final GlobalKey<PopupMenuButtonState<String>> _workspaceMenuKey =
|
||||||
|
GlobalKey<PopupMenuButtonState<String>>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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();
|
_checkHealth();
|
||||||
_healthPoll = Timer.periodic(
|
_healthPoll = Timer.periodic(
|
||||||
const Duration(seconds: 5),
|
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
|
/// One-time hub-update hint (zero-learning-curve: the shell says
|
||||||
/// actively that a newer hub release exists instead of hiding it
|
/// actively that a newer hub release exists instead of hiding it
|
||||||
/// on the doctor page). Null = nothing to show. Dismissal is
|
/// on the doctor page). Null = nothing to show. Dismissal is
|
||||||
|
|
@ -627,6 +640,7 @@ class StudioShellState extends State<StudioShell> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
Workspace.instance.removeListener(_onWorkspaceChanged);
|
||||||
_healthPoll?.cancel();
|
_healthPoll?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -656,10 +670,76 @@ class StudioShellState extends State<StudioShell> {
|
||||||
ChainSettingsDialog.show(context);
|
ChainSettingsDialog.show(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
..._workspaceSearchHits(l),
|
||||||
];
|
];
|
||||||
ChainSearchPalette.show(context, staticHits: hits);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -699,6 +779,12 @@ class StudioShellState extends State<StudioShell> {
|
||||||
const _OpenSearchIntent(),
|
const _OpenSearchIntent(),
|
||||||
const SingleActivator(LogicalKeyboardKey.keyK, control: true):
|
const SingleActivator(LogicalKeyboardKey.keyK, control: true):
|
||||||
const _OpenSearchIntent(),
|
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(
|
child: Actions(
|
||||||
actions: <Type, Action<Intent>>{
|
actions: <Type, Action<Intent>>{
|
||||||
|
|
@ -720,6 +806,12 @@ class StudioShellState extends State<StudioShell> {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
_OpenWorkspaceIntent: CallbackAction<_OpenWorkspaceIntent>(
|
||||||
|
onInvoke: (_) {
|
||||||
|
_workspaceMenuKey.currentState?.showButtonMenu();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
child: Focus(
|
child: Focus(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
|
@ -738,6 +830,7 @@ class StudioShellState extends State<StudioShell> {
|
||||||
pendingApprovals: _pendingApprovals,
|
pendingApprovals: _pendingApprovals,
|
||||||
forceExpanded: widget.startSidebarExpanded || pinned,
|
forceExpanded: widget.startSidebarExpanded || pinned,
|
||||||
onOpenSearch: _openSearchPalette,
|
onOpenSearch: _openSearchPalette,
|
||||||
|
workspaceMenuKey: _workspaceMenuKey,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
||||||
|
|
@ -866,6 +959,10 @@ class _OpenSearchIntent extends Intent {
|
||||||
const _OpenSearchIntent();
|
const _OpenSearchIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _OpenWorkspaceIntent extends Intent {
|
||||||
|
const _OpenWorkspaceIntent();
|
||||||
|
}
|
||||||
|
|
||||||
/// Platform-truthful label for the app's primary-modifier
|
/// Platform-truthful label for the app's primary-modifier
|
||||||
/// shortcuts (the activators bind ⌘ on macOS and Ctrl elsewhere).
|
/// shortcuts (the activators bind ⌘ on macOS and Ctrl elsewhere).
|
||||||
String _metaShortcut(String key) =>
|
String _metaShortcut(String key) =>
|
||||||
|
|
@ -899,6 +996,11 @@ class _Sidebar extends StatefulWidget {
|
||||||
/// knowing the shortcut.
|
/// knowing the shortcut.
|
||||||
final VoidCallback? onOpenSearch;
|
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({
|
const _Sidebar({
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelect,
|
required this.onSelect,
|
||||||
|
|
@ -909,6 +1011,7 @@ class _Sidebar extends StatefulWidget {
|
||||||
this.pendingApprovals = 0,
|
this.pendingApprovals = 0,
|
||||||
this.forceExpanded = false,
|
this.forceExpanded = false,
|
||||||
this.onOpenSearch,
|
this.onOpenSearch,
|
||||||
|
this.workspaceMenuKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -932,6 +1035,7 @@ class _SidebarState extends State<_Sidebar>
|
||||||
static const double _brandRowH = 48;
|
static const double _brandRowH = 48;
|
||||||
static const double _connRowH = 44;
|
static const double _connRowH = 44;
|
||||||
static const double _channelRowH = 28;
|
static const double _channelRowH = 28;
|
||||||
|
static const double _anchorRowH = 44;
|
||||||
static const double _rowGap = 8;
|
static const double _rowGap = 8;
|
||||||
late final AnimationController _ctrl;
|
late final AnimationController _ctrl;
|
||||||
// True while the channel-switch menu is open — suppresses the
|
// True while the channel-switch menu is open — suppresses the
|
||||||
|
|
@ -1110,6 +1214,31 @@ class _SidebarState extends State<_Sidebar>
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: 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),
|
const SizedBox(height: ChainSpace.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
|
|
||||||
|
|
@ -376,8 +376,6 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
const ChainWorkspaceSwitcher(),
|
|
||||||
const SizedBox(width: ChainSpace.md),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.help_outline, size: 18),
|
icon: const Icon(Icons.help_outline, size: 18),
|
||||||
tooltip: AppLocalizations.of(context)!.helpTooltip,
|
tooltip: AppLocalizations.of(context)!.helpTooltip,
|
||||||
|
|
|
||||||
|
|
@ -306,11 +306,9 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(AppLocalizations.of(context)!.auditTitle),
|
title: Text(AppLocalizations.of(context)!.auditTitle),
|
||||||
actions: [
|
actions: [
|
||||||
const ChainWorkspaceSwitcher(),
|
// Narrow windows can't fit the inline chip row — collapse
|
||||||
const SizedBox(width: ChainSpace.md),
|
// to a checkmark menu so the app bar never overflows
|
||||||
// Narrow windows can't fit the inline chip row next to the
|
// (responsive_test.dart pins this).
|
||||||
// workspace switcher — collapse to a checkmark menu so the
|
|
||||||
// app bar never overflows (responsive_test.dart pins this).
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: ChainSpace.lg),
|
padding: const EdgeInsets.only(right: ChainSpace.lg),
|
||||||
child: MediaQuery.sizeOf(context).width < 900
|
child: MediaQuery.sizeOf(context).width < 900
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import '../data/store_caps.dart';
|
||||||
import '../data/workspace.dart';
|
import '../data/workspace.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/chain_install_confirm.dart';
|
import '../widgets/chain_install_confirm.dart';
|
||||||
import '../widgets/chain_workspace_switcher.dart';
|
|
||||||
|
|
||||||
class FlowsPage extends StatefulWidget {
|
class FlowsPage extends StatefulWidget {
|
||||||
/// Pre-load this flow when the editor first builds. Studio
|
/// Pre-load this flow when the editor first builds. Studio
|
||||||
|
|
@ -220,11 +219,9 @@ class _FlowsPageState extends State<FlowsPage> {
|
||||||
onInstallCapability: _onInstallCapability,
|
onInstallCapability: _onInstallCapability,
|
||||||
onAddModuleSource: _onAddModuleSource,
|
onAddModuleSource: _onAddModuleSource,
|
||||||
activeProject: Workspace.instance.activeSlug,
|
activeProject: Workspace.instance.activeSlug,
|
||||||
// Same switcher as Audit/Approvals/Runs, hosted in the
|
// The workspace switcher lives ONCE in the shell sidebar
|
||||||
// editor's toolbar (the page's single header): it
|
// (global anchor) — the editor toolbar no longer hosts
|
||||||
// filters the flow list and is the project a new flow
|
// its own copy.
|
||||||
// gets stamped with.
|
|
||||||
toolbarTrailing: const ChainWorkspaceSwitcher(),
|
|
||||||
// Native file dialog for the Run tab's file inputs —
|
// Native file dialog for the Run tab's file inputs —
|
||||||
// nobody should have to type an absolute path by hand.
|
// nobody should have to type an absolute path by hand.
|
||||||
onPickFile: _pickFlowInputFile,
|
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 {
|
Future<void> _cancel(DetachedRun run) async {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
setState(() => _cancelling.add(run.id));
|
setState(() => _cancelling.add(run.id));
|
||||||
|
|
@ -333,8 +341,6 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(l.runsTitle),
|
title: Text(l.runsTitle),
|
||||||
actions: [
|
actions: [
|
||||||
const ChainWorkspaceSwitcher(),
|
|
||||||
const SizedBox(width: ChainSpace.md),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.help_outline, size: 18),
|
icon: const Icon(Icons.help_outline, size: 18),
|
||||||
tooltip: l.helpTooltip,
|
tooltip: l.helpTooltip,
|
||||||
|
|
@ -361,22 +367,41 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
updating: _updatingHub,
|
updating: _updatingHub,
|
||||||
)
|
)
|
||||||
: _runs.isEmpty
|
: _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(
|
? 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,
|
icon: Icons.rocket_launch_outlined,
|
||||||
title: l.runsEmptyTitle,
|
title: l.runsEmptyTitle,
|
||||||
// The hub reports whether the operator enabled the
|
// The hub reports whether the operator enabled the
|
||||||
// feature — never claim "switched off" while it is on
|
// feature — never claim "switched off" while it is
|
||||||
// and there simply are no runs yet (usertest finding).
|
// on and there simply are no runs yet (usertest
|
||||||
hint: _detachedEnabled ? l.runsEmptyEnabledHint : l.runsEmptyHint,
|
// finding).
|
||||||
// The guide carries the plain-language explanation plus
|
hint: _detachedEnabled
|
||||||
// the exact operator steps (config snippet) — a click
|
? l.runsEmptyEnabledHint
|
||||||
// target instead of a raw config key in the hint.
|
: 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(
|
action: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
||||||
label: Text(l.runsEmptyGuideButton),
|
label: Text(l.runsEmptyGuideButton),
|
||||||
onPressed: () => showFaiDoc(context, 'runs'),
|
onPressed: () => showFaiDoc(context, 'runs'),
|
||||||
),
|
),
|
||||||
)
|
))
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
padding: const EdgeInsets.all(ChainSpace.lg),
|
padding: const EdgeInsets.all(ChainSpace.lg),
|
||||||
itemCount: _runs.length,
|
itemCount: _runs.length,
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,21 @@ import 'hub_auth_policy_panel.dart';
|
||||||
import 'theme_picker_grid.dart';
|
import 'theme_picker_grid.dart';
|
||||||
|
|
||||||
class ChainSettingsDialog extends StatefulWidget {
|
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.
|
/// 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>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => const ChainSettingsDialog(),
|
builder: (_) => ChainSettingsDialog(initialCategory: initialCategory),
|
||||||
);
|
);
|
||||||
return ok ?? false;
|
return ok ?? false;
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +96,9 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
for (final c in _Category.values) {
|
||||||
|
if (c.name == widget.initialCategory) _category = c;
|
||||||
|
}
|
||||||
final ep = HubService.instance.currentEndpoint;
|
final ep = HubService.instance.currentEndpoint;
|
||||||
_host = TextEditingController(text: ep.host);
|
_host = TextEditingController(text: ep.host);
|
||||||
_port = TextEditingController(text: ep.port.toString());
|
_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,
|
// One control, two mechanics (docs/architecture/projects.md,
|
||||||
// § Studio): for open/protected projects the choice sets the page
|
// § Studio): for open/protected projects the choice sets the page
|
||||||
// filter AND the label new runs are stamped with. "All projects"
|
// filter AND the label new runs are stamped with. "All projects"
|
||||||
// stays reachable — a filter, not a jail. Sealed areas are a real
|
// stays reachable — a filter, not a jail. Sealed areas are a real
|
||||||
// CONNECTION SWITCH: selecting one reconnects Studio to the area's
|
// CONNECTION SWITCH: selecting one reconnects Studio to the area's
|
||||||
// own hub (starting it first if stopped, with a notice) and colours
|
// own hub (starting it first if stopped, after an explicit
|
||||||
// the identity bar. Studio's blue stays the app accent — the project
|
// confirmation) and colours the identity bar. Studio's blue stays
|
||||||
// colour is marking, not theming.
|
// 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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
@ -17,15 +24,50 @@ import '../data/workspace_prefs.dart';
|
||||||
import '../data/workspace.dart';
|
import '../data/workspace.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
import 'chain_settings_dialog.dart';
|
||||||
|
|
||||||
/// Menu-value scheme: '' = all projects, `p:<slug>` = shared project,
|
/// 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 _kAll = '';
|
||||||
const _pProject = 'p:';
|
const _pProject = 'p:';
|
||||||
const _pSealed = 's:';
|
const _pSealed = 's:';
|
||||||
|
const _kSettingsSecurity = 'settings:security';
|
||||||
|
|
||||||
class ChainWorkspaceSwitcher extends StatelessWidget {
|
/// Menu values for external mount points (the command palette) —
|
||||||
const ChainWorkspaceSwitcher({super.key});
|
/// 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -35,34 +77,157 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final ws = Workspace.instance;
|
final ws = Workspace.instance;
|
||||||
|
final label = workspaceContextLabel(l, ws);
|
||||||
|
|
||||||
final label = ws.inSealedArea
|
final row = Row(
|
||||||
? ws.activeSealed!.name
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
: (ws.isAll
|
children: [
|
||||||
? l.workspaceAll
|
SizedBox(
|
||||||
: (ws.active == null
|
width: iconColumnWidth,
|
||||||
? ws.activeSlug
|
child: Center(child: _ContextMark(ws: ws)),
|
||||||
: _projectLabel(l, ws.active!)));
|
),
|
||||||
|
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(
|
return Tooltip(
|
||||||
message: l.workspaceSwitcherTooltip,
|
message: l.workspaceSwitcherTooltip,
|
||||||
|
waitDuration: const Duration(milliseconds: 400),
|
||||||
child: PopupMenuButton<String>(
|
child: PopupMenuButton<String>(
|
||||||
onOpened: ws.refresh,
|
key: menuKey,
|
||||||
onSelected: (v) => _onSelected(context, v),
|
tooltip: '', // the outer Tooltip carries the message
|
||||||
itemBuilder: (context) => _items(context, ws, l, theme),
|
onOpened: () {
|
||||||
child: _pill(context, ws, l, theme, label),
|
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(
|
/// The collapsed-column marking for the active context: grid = all
|
||||||
BuildContext context,
|
/// projects, coloured dot = project, dot + lock = sealed area.
|
||||||
Workspace ws,
|
class _ContextMark extends StatelessWidget {
|
||||||
AppLocalizations l,
|
final Workspace ws;
|
||||||
ThemeData theme,
|
|
||||||
) {
|
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>>[
|
final items = <PopupMenuEntry<String>>[
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _kAll,
|
value: _kAll,
|
||||||
|
|
@ -74,7 +239,8 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.sm),
|
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(
|
child: SealedAreaSection(
|
||||||
areas: ws.sealedAreas,
|
areas: ws.sealedAreas,
|
||||||
namesVisible: WorkspacePrefs.sealedNamesVisible.value,
|
namesVisible: WorkspacePrefs.sealedNamesVisible.value,
|
||||||
|
activeSlug: ws.activeSealed?.slug,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return items;
|
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 ws = Workspace.instance;
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
||||||
|
if (value == _kSettingsSecurity) {
|
||||||
|
await ChainSettingsDialog.show(context, initialCategory: 'security');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (value == _kAll) {
|
if (value == _kAll) {
|
||||||
|
WorkspacePrefs.recordRecentContext(value);
|
||||||
await ws.setActive('');
|
await ws.setActive('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (value.startsWith(_pProject)) {
|
if (value.startsWith(_pProject)) {
|
||||||
|
WorkspacePrefs.recordRecentContext(value);
|
||||||
await ws.setActive(value.substring(_pProject.length));
|
await ws.setActive(value.substring(_pProject.length));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -166,13 +348,36 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
if (a.slug == slug) area = a;
|
if (a.slug == slug) area = a;
|
||||||
}
|
}
|
||||||
if (area == null) return;
|
if (area == null) return;
|
||||||
// Announce the intent — starting a stopped area takes a beat.
|
final target = area;
|
||||||
if (!area.running) {
|
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(
|
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();
|
messenger.hideCurrentSnackBar();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
|
|
@ -183,84 +388,27 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
);
|
);
|
||||||
} else if (r.started) {
|
} else if (r.started) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(content: Text(l.workspaceSealedStarted(area.name))),
|
SnackBar(content: Text(l.workspaceSealedStarted(target.name))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _pill(
|
/// Trailing checkmark slot — fixed width so rows with and without
|
||||||
BuildContext context,
|
/// the mark keep their text aligned.
|
||||||
Workspace ws,
|
class _ActiveCheck extends StatelessWidget {
|
||||||
AppLocalizations l,
|
final bool active;
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
const _ActiveCheck({required this.active});
|
||||||
padding: const EdgeInsets.symmetric(horizontal: ChainSpace.md, vertical: 5),
|
|
||||||
decoration: BoxDecoration(
|
@override
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
Widget build(BuildContext context) {
|
||||||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
final theme = Theme.of(context);
|
||||||
),
|
return SizedBox(
|
||||||
child: Row(
|
width: 22,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: active
|
||||||
children: [
|
? Icon(Icons.check, size: 16, color: theme.colorScheme.primary)
|
||||||
leading,
|
: null,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -305,19 +453,26 @@ Color? parseAreaColor(String hex) {
|
||||||
/// widget test can pump both privacy modes directly.
|
/// widget test can pump both privacy modes directly.
|
||||||
///
|
///
|
||||||
/// [namesVisible] = the operator's Settings choice. When false the
|
/// [namesVisible] = the operator's Settings choice. When false the
|
||||||
/// block renders one aggregated row (lock + count); a deliberate
|
/// block renders one aggregated row (lock + count) with a why-line
|
||||||
/// tap expands the named rows for THIS menu opening only — nothing
|
/// (the aggregation is a deliberate confidentiality decision and
|
||||||
/// is persisted from the reveal. Rows select via
|
/// 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
|
/// `Navigator.pop(context, 's:<slug>')`, which hands the value to
|
||||||
/// the enclosing PopupMenuButton exactly like a regular item.
|
/// the enclosing PopupMenuButton exactly like a regular item.
|
||||||
class SealedAreaSection extends StatefulWidget {
|
class SealedAreaSection extends StatefulWidget {
|
||||||
final List<SealedArea> areas;
|
final List<SealedArea> areas;
|
||||||
final bool namesVisible;
|
final bool namesVisible;
|
||||||
|
|
||||||
|
/// Slug of the area Studio is connected to, or null — its row
|
||||||
|
/// carries the active checkmark.
|
||||||
|
final String? activeSlug;
|
||||||
|
|
||||||
const SealedAreaSection({
|
const SealedAreaSection({
|
||||||
super.key,
|
super.key,
|
||||||
required this.areas,
|
required this.areas,
|
||||||
required this.namesVisible,
|
required this.namesVisible,
|
||||||
|
this.activeSlug,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -332,7 +487,11 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
if (!widget.namesVisible && !_revealed) {
|
if (!widget.namesVisible && !_revealed) {
|
||||||
return InkWell(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
onTap: () => setState(() => _revealed = true),
|
onTap: () => setState(() => _revealed = true),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
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(
|
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
|
@override
|
||||||
Future<List<SavedFlow>> listFlows() => _async('listFlows', () => const []);
|
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
|
@override
|
||||||
Future<List<ProjectRef>> listProjects() =>
|
Future<List<ProjectRef>> listProjects() =>
|
||||||
_async('listProjects', () => const []);
|
_async('listProjects', () => projects);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<AuditEvent> streamEvents({
|
Stream<AuditEvent> streamEvents({
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
// Workspace switcher — stage-1 contract: the control renders the
|
// Workspace anchor — contract: the sidebar's ONE switcher control
|
||||||
// active selection, lists "All projects" plus every registry
|
// renders the active selection, lists "All projects" plus every
|
||||||
// project (colour dot, shield for protected), and switching
|
// registry project (colour dot, shield for protected, checkmark on
|
||||||
// updates the shared Workspace notifier.
|
// 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/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/hub.dart';
|
||||||
import 'package:chain_studio/data/sealed_areas.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/l10n/app_localizations.dart';
|
||||||
import 'package:chain_studio/widgets/chain_workspace_switcher.dart';
|
import 'package:chain_studio/widgets/chain_workspace_switcher.dart';
|
||||||
|
|
||||||
|
import 'support/fake_hub.dart';
|
||||||
|
|
||||||
Widget _host() {
|
Widget _host() {
|
||||||
return const MaterialApp(
|
return const MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
appBar: PreferredSize(
|
body: Align(
|
||||||
preferredSize: Size.fromHeight(kToolbarHeight),
|
alignment: Alignment.topLeft,
|
||||||
child: Material(child: ChainWorkspaceSwitcher()),
|
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() {
|
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(() {
|
setUp(() {
|
||||||
// Seed the singleton without a hub: tests drive the notifier
|
SharedPreferences.setMockInitialValues({});
|
||||||
// directly through its test hook.
|
fakeHub = installFakeHub();
|
||||||
Workspace.instance.debugSeed(projects: [_general, _clientA], active: '');
|
addTearDown(() => SealedAreaService.debugSetInstance(null));
|
||||||
|
seed();
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
WorkspacePrefs.sealedNamesVisible.value = false;
|
WorkspacePrefs.sealedNamesVisible.value = false;
|
||||||
|
WorkspacePrefs.recentContexts.value = const [];
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('sealed areas stay aggregated until deliberately revealed', (
|
testWidgets('sealed areas stay aggregated until deliberately revealed', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
Workspace.instance.debugSeed(
|
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
|
||||||
projects: [_general],
|
|
||||||
active: '',
|
|
||||||
sealed: [_sealedGrid, _sealedLab],
|
|
||||||
);
|
|
||||||
await tester.pumpWidget(_host());
|
await tester.pumpWidget(_host());
|
||||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
// No names on a casual glance — only the aggregate row.
|
// No names on a casual glance — only the aggregate row.
|
||||||
expect(find.text('grid'), findsNothing);
|
expect(find.text('grid'), findsNothing);
|
||||||
|
|
@ -67,17 +99,30 @@ void main() {
|
||||||
expect(find.text('lab'), findsOneWidget);
|
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', (
|
testWidgets('the Settings toggle restores the direct listing', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
WorkspacePrefs.sealedNamesVisible.value = true;
|
WorkspacePrefs.sealedNamesVisible.value = true;
|
||||||
Workspace.instance.debugSeed(
|
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
|
||||||
projects: [_general],
|
|
||||||
active: '',
|
|
||||||
sealed: [_sealedGrid, _sealedLab],
|
|
||||||
);
|
|
||||||
await tester.pumpWidget(_host());
|
await tester.pumpWidget(_host());
|
||||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('grid'), findsOneWidget);
|
expect(find.text('grid'), findsOneWidget);
|
||||||
expect(find.text('lab'), findsOneWidget);
|
expect(find.text('lab'), findsOneWidget);
|
||||||
|
|
@ -95,7 +140,7 @@ void main() {
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(_host());
|
await tester.pumpWidget(_host());
|
||||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('All projects'), findsWidgets);
|
expect(find.text('All projects'), findsWidgets);
|
||||||
|
|
@ -105,21 +150,38 @@ void main() {
|
||||||
expect(find.byIcon(Icons.shield_outlined), findsOneWidget);
|
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', (
|
testWidgets('selecting a project updates the workspace and the label', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(_host());
|
await tester.pumpWidget(_host());
|
||||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Client A').last);
|
await tester.tap(find.text('Client A').last);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(Workspace.instance.activeSlug, 'client-a');
|
expect(Workspace.instance.activeSlug, 'client-a');
|
||||||
expect(Workspace.instance.active?.isProtected, isTrue);
|
expect(Workspace.instance.active?.isProtected, isTrue);
|
||||||
// The closed control now shows the active project (label +
|
// The closed control now shows the active project.
|
||||||
// shield marker for protected).
|
|
||||||
expect(find.text('Client A'), findsOneWidget);
|
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', () {
|
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', (
|
testWidgets('lists sealed areas with lock + running/stopped status', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
Workspace.instance.debugSeed(
|
seed(
|
||||||
projects: [_general],
|
projects: [_general],
|
||||||
active: '',
|
|
||||||
sealed: const [
|
sealed: const [
|
||||||
SealedArea(
|
SealedArea(
|
||||||
slug: 'grid',
|
slug: 'grid',
|
||||||
|
|
@ -153,7 +214,7 @@ void main() {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(_host());
|
await tester.pumpWidget(_host());
|
||||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Sealed areas appear under the sealed header, aggregated by
|
// Sealed areas appear under the sealed header, aggregated by
|
||||||
|
|
@ -169,13 +230,31 @@ void main() {
|
||||||
expect(find.text('stopped'), findsOneWidget);
|
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,
|
tester,
|
||||||
) async {
|
) 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],
|
projects: [_general],
|
||||||
active: '',
|
|
||||||
sealed: const [],
|
|
||||||
activeSealed: const SealedArea(
|
activeSealed: const SealedArea(
|
||||||
slug: 'grid',
|
slug: 'grid',
|
||||||
name: 'Grid',
|
name: 'Grid',
|
||||||
|
|
@ -186,12 +265,24 @@ void main() {
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(_host());
|
await tester.pumpWidget(_host());
|
||||||
expect(Workspace.instance.inSealedArea, isTrue);
|
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.text('Grid'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.lock_outline), findsWidgets);
|
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
|
// Sealed-area confidentiality (usertest security finding): the
|
||||||
// switcher must not disclose sealed-area names — often client
|
// switcher must not disclose sealed-area names — often client
|
||||||
// identity — on a casual glance. Aggregated row by default,
|
// identity — on a casual glance. Aggregated row by default,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue