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
129
lib/main.dart
129
lib/main.dart
|
|
@ -505,9 +505,18 @@ class StudioShellState extends State<StudioShell> {
|
|||
/// Refreshed alongside the health probe — same 5 s tick.
|
||||
int _pendingApprovals = 0;
|
||||
|
||||
/// Opens the workspace anchor's menu from the Cmd+P shortcut.
|
||||
final GlobalKey<PopupMenuButtonState<String>> _workspaceMenuKey =
|
||||
GlobalKey<PopupMenuButtonState<String>>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// The shell renders connection-derived labels (sidebar endpoint,
|
||||
// workspace anchor): rebuild when the workspace switches context
|
||||
// so they can never show the previous hub (persona finding: the
|
||||
// sidebar endpoint stayed stale until the next health tick).
|
||||
Workspace.instance.addListener(_onWorkspaceChanged);
|
||||
_checkHealth();
|
||||
_healthPoll = Timer.periodic(
|
||||
const Duration(seconds: 5),
|
||||
|
|
@ -515,6 +524,10 @@ class StudioShellState extends State<StudioShell> {
|
|||
);
|
||||
}
|
||||
|
||||
void _onWorkspaceChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// One-time hub-update hint (zero-learning-curve: the shell says
|
||||
/// actively that a newer hub release exists instead of hiding it
|
||||
/// on the doctor page). Null = nothing to show. Dismissal is
|
||||
|
|
@ -627,6 +640,7 @@ class StudioShellState extends State<StudioShell> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
Workspace.instance.removeListener(_onWorkspaceChanged);
|
||||
_healthPoll?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -656,10 +670,76 @@ class StudioShellState extends State<StudioShell> {
|
|||
ChainSettingsDialog.show(context);
|
||||
},
|
||||
),
|
||||
..._workspaceSearchHits(l),
|
||||
];
|
||||
ChainSearchPalette.show(context, staticHits: hits);
|
||||
}
|
||||
|
||||
/// The palette's "Projects & areas" group — every context the
|
||||
/// switcher offers, ranked by the operator's recent choices so a
|
||||
/// poweruser's frequent areas surface before the alphabet. Sealed
|
||||
/// names honour the Settings privacy choice: while the switcher
|
||||
/// aggregates them, the palette must not list them either — it
|
||||
/// offers one entry that opens the switcher's guarded picker.
|
||||
List<ChainSearchHit> _workspaceSearchHits(AppLocalizations l) {
|
||||
final ws = Workspace.instance;
|
||||
final group = l.searchGroupWorkspace;
|
||||
final recents = WorkspacePrefs.recentContexts.value;
|
||||
int rank(String v) {
|
||||
final i = recents.indexOf(v);
|
||||
return i < 0 ? recents.length : i;
|
||||
}
|
||||
|
||||
void select(String value) {
|
||||
Navigator.of(context).pop();
|
||||
handleWorkspaceMenuSelection(context, value);
|
||||
}
|
||||
|
||||
final entries = <({String value, String label, IconData icon})>[
|
||||
(
|
||||
value: workspaceMenuValueAll(),
|
||||
label: l.workspaceAll,
|
||||
icon: Icons.grid_view_outlined,
|
||||
),
|
||||
for (final p in ws.projects)
|
||||
(
|
||||
value: workspaceMenuValueProject(p.slug),
|
||||
label: p.slug == 'general' ? l.workspaceDefaultProject : p.name,
|
||||
icon: Icons.circle_outlined,
|
||||
),
|
||||
if (WorkspacePrefs.sealedNamesVisible.value)
|
||||
for (final a in ws.sealedAreas)
|
||||
(
|
||||
value: workspaceMenuValueSealed(a.slug),
|
||||
label: a.name,
|
||||
icon: Icons.lock_outline,
|
||||
),
|
||||
]..sort((a, b) => rank(a.value).compareTo(rank(b.value)));
|
||||
|
||||
return [
|
||||
for (final e in entries)
|
||||
ChainSearchHit(
|
||||
label: e.label,
|
||||
hint: l.searchWorkspaceHint,
|
||||
icon: e.icon,
|
||||
group: group,
|
||||
onSelect: () => select(e.value),
|
||||
),
|
||||
if (!WorkspacePrefs.sealedNamesVisible.value &&
|
||||
ws.sealedAreas.isNotEmpty)
|
||||
ChainSearchHit(
|
||||
label: l.workspaceSealedAggregate(ws.sealedAreas.length),
|
||||
hint: l.searchWorkspaceSealedPickerHint,
|
||||
icon: Icons.lock_outline,
|
||||
group: group,
|
||||
onSelect: () {
|
||||
Navigator.of(context).pop();
|
||||
_workspaceMenuKey.currentState?.showButtonMenu();
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -699,6 +779,12 @@ class StudioShellState extends State<StudioShell> {
|
|||
const _OpenSearchIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.keyK, control: true):
|
||||
const _OpenSearchIntent(),
|
||||
// Workspace switcher (project/area picker) — P as in
|
||||
// project. Opens the sidebar anchor's menu.
|
||||
const SingleActivator(LogicalKeyboardKey.keyP, meta: true):
|
||||
const _OpenWorkspaceIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.keyP, control: true):
|
||||
const _OpenWorkspaceIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
|
|
@ -720,6 +806,12 @@ class StudioShellState extends State<StudioShell> {
|
|||
return null;
|
||||
},
|
||||
),
|
||||
_OpenWorkspaceIntent: CallbackAction<_OpenWorkspaceIntent>(
|
||||
onInvoke: (_) {
|
||||
_workspaceMenuKey.currentState?.showButtonMenu();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
|
|
@ -738,6 +830,7 @@ class StudioShellState extends State<StudioShell> {
|
|||
pendingApprovals: _pendingApprovals,
|
||||
forceExpanded: widget.startSidebarExpanded || pinned,
|
||||
onOpenSearch: _openSearchPalette,
|
||||
workspaceMenuKey: _workspaceMenuKey,
|
||||
),
|
||||
),
|
||||
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
||||
|
|
@ -866,6 +959,10 @@ class _OpenSearchIntent extends Intent {
|
|||
const _OpenSearchIntent();
|
||||
}
|
||||
|
||||
class _OpenWorkspaceIntent extends Intent {
|
||||
const _OpenWorkspaceIntent();
|
||||
}
|
||||
|
||||
/// Platform-truthful label for the app's primary-modifier
|
||||
/// shortcuts (the activators bind ⌘ on macOS and Ctrl elsewhere).
|
||||
String _metaShortcut(String key) =>
|
||||
|
|
@ -899,6 +996,11 @@ class _Sidebar extends StatefulWidget {
|
|||
/// knowing the shortcut.
|
||||
final VoidCallback? onOpenSearch;
|
||||
|
||||
/// Menu key for the workspace anchor so the shell's Cmd+P
|
||||
/// shortcut (and the palette's guarded sealed-picker entry) can
|
||||
/// open the switcher menu.
|
||||
final GlobalKey<PopupMenuButtonState<String>>? workspaceMenuKey;
|
||||
|
||||
const _Sidebar({
|
||||
required this.selectedIndex,
|
||||
required this.onSelect,
|
||||
|
|
@ -909,6 +1011,7 @@ class _Sidebar extends StatefulWidget {
|
|||
this.pendingApprovals = 0,
|
||||
this.forceExpanded = false,
|
||||
this.onOpenSearch,
|
||||
this.workspaceMenuKey,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -932,6 +1035,7 @@ class _SidebarState extends State<_Sidebar>
|
|||
static const double _brandRowH = 48;
|
||||
static const double _connRowH = 44;
|
||||
static const double _channelRowH = 28;
|
||||
static const double _anchorRowH = 44;
|
||||
static const double _rowGap = 8;
|
||||
late final AnimationController _ctrl;
|
||||
// True while the channel-switch menu is open — suppresses the
|
||||
|
|
@ -1110,6 +1214,31 @@ class _SidebarState extends State<_Sidebar>
|
|||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: _rowGap),
|
||||
// The ONE workspace anchor — active project/area,
|
||||
// visible on every page, opens the switcher menu
|
||||
// (per-page switcher embeddings are gone; persona
|
||||
// review 2026-08-27). Fixed height like the other
|
||||
// header rows so expanding the rail never shifts
|
||||
// the destinations below.
|
||||
SizedBox(
|
||||
height: _anchorRowH,
|
||||
child: ChainWorkspaceAnchor(
|
||||
t: t,
|
||||
labelsInteractive: labelsInteractive,
|
||||
iconColumnWidth: _collapsedWidth,
|
||||
menuKey: widget.workspaceMenuKey,
|
||||
onMenuOpen: () {
|
||||
setState(() => _menuOpen = true);
|
||||
_ctrl.forward();
|
||||
},
|
||||
onMenuClose: () {
|
||||
if (!mounted) return;
|
||||
setState(() => _menuOpen = false);
|
||||
_ctrl.reverse();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: ChainSpace.md),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue