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
|
|
@ -26,13 +26,21 @@ import 'hub_auth_policy_panel.dart';
|
|||
import 'theme_picker_grid.dart';
|
||||
|
||||
class ChainSettingsDialog extends StatefulWidget {
|
||||
const ChainSettingsDialog({super.key});
|
||||
/// Category name to open on (e.g. 'security'); null = the first
|
||||
/// category. Lets in-place explainers (the sealed-aggregate
|
||||
/// why-line) jump straight to the toggle they talk about.
|
||||
final String? initialCategory;
|
||||
|
||||
const ChainSettingsDialog({super.key, this.initialCategory});
|
||||
|
||||
/// Convenience launcher used from the sidebar gear icon.
|
||||
static Future<bool> show(BuildContext context) async {
|
||||
static Future<bool> show(
|
||||
BuildContext context, {
|
||||
String? initialCategory,
|
||||
}) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => const ChainSettingsDialog(),
|
||||
builder: (_) => ChainSettingsDialog(initialCategory: initialCategory),
|
||||
);
|
||||
return ok ?? false;
|
||||
}
|
||||
|
|
@ -88,6 +96,9 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final c in _Category.values) {
|
||||
if (c.name == widget.initialCategory) _category = c;
|
||||
}
|
||||
final ep = HubService.instance.currentEndpoint;
|
||||
_host = TextEditingController(text: ep.host);
|
||||
_port = TextEditingController(text: ep.port.toString());
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
// ChainWorkspaceSwitcher — the AppBar workspace (project) control.
|
||||
// Workspace switching — the ONE global control for "where am I
|
||||
// working?".
|
||||
//
|
||||
// One control, two mechanics (docs/architecture/projects.md,
|
||||
// § Studio): for open/protected projects the choice sets the page
|
||||
// filter AND the label new runs are stamped with. "All projects"
|
||||
// stays reachable — a filter, not a jail. Sealed areas are a real
|
||||
// CONNECTION SWITCH: selecting one reconnects Studio to the area's
|
||||
// own hub (starting it first if stopped, with a notice) and colours
|
||||
// the identity bar. Studio's blue stays the app accent — the project
|
||||
// colour is marking, not theming.
|
||||
// own hub (starting it first if stopped, after an explicit
|
||||
// confirmation) and colours the identity bar. Studio's blue stays
|
||||
// the app accent — the project colour is marking, not theming.
|
||||
//
|
||||
// The control lives ONCE in the shell sidebar (ChainWorkspaceAnchor)
|
||||
// so the active context is visible on every page — it used to be
|
||||
// embedded per-page (Flows/Runs/Audit/Approvals), which left it
|
||||
// invisible on the other five pages and moving between positions
|
||||
// (persona review 2026-08-27).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
|
@ -17,15 +24,50 @@ import '../data/workspace_prefs.dart';
|
|||
import '../data/workspace.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import 'chain_settings_dialog.dart';
|
||||
|
||||
/// Menu-value scheme: '' = all projects, `p:<slug>` = shared project,
|
||||
/// `s:<slug>` = sealed area.
|
||||
/// `s:<slug>` = sealed area, `settings:security` = the why-line's
|
||||
/// jump to the Settings toggle.
|
||||
const _kAll = '';
|
||||
const _pProject = 'p:';
|
||||
const _pSealed = 's:';
|
||||
const _kSettingsSecurity = 'settings:security';
|
||||
|
||||
class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||
const ChainWorkspaceSwitcher({super.key});
|
||||
/// Menu values for external mount points (the command palette) —
|
||||
/// the scheme itself stays private to this file.
|
||||
String workspaceMenuValueAll() => _kAll;
|
||||
String workspaceMenuValueProject(String slug) => '$_pProject$slug';
|
||||
String workspaceMenuValueSealed(String slug) => '$_pSealed$slug';
|
||||
|
||||
/// The sidebar anchor — the single place the workspace switcher is
|
||||
/// mounted. Geometry mirrors the sidebar's header rows: a fixed
|
||||
/// icon column (context marking) + the label that fades in with the
|
||||
/// rail expansion [t].
|
||||
class ChainWorkspaceAnchor extends StatelessWidget {
|
||||
/// Sidebar expansion 0..1 (collapsed → expanded).
|
||||
final double t;
|
||||
final bool labelsInteractive;
|
||||
final double iconColumnWidth;
|
||||
|
||||
/// Suppress the sidebar's hover-collapse while the menu is open —
|
||||
/// same contract as the channel pill (the menu would otherwise
|
||||
/// float at the pill's old x).
|
||||
final VoidCallback? onMenuOpen;
|
||||
final VoidCallback? onMenuClose;
|
||||
|
||||
/// Lets the shell open the menu from the keyboard shortcut.
|
||||
final GlobalKey<PopupMenuButtonState<String>>? menuKey;
|
||||
|
||||
const ChainWorkspaceAnchor({
|
||||
super.key,
|
||||
required this.t,
|
||||
required this.labelsInteractive,
|
||||
required this.iconColumnWidth,
|
||||
this.onMenuOpen,
|
||||
this.onMenuClose,
|
||||
this.menuKey,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -35,232 +77,338 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
|||
final l = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final ws = Workspace.instance;
|
||||
final label = workspaceContextLabel(l, ws);
|
||||
|
||||
final label = ws.inSealedArea
|
||||
? ws.activeSealed!.name
|
||||
: (ws.isAll
|
||||
? l.workspaceAll
|
||||
: (ws.active == null
|
||||
? ws.activeSlug
|
||||
: _projectLabel(l, ws.active!)));
|
||||
final row = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: iconColumnWidth,
|
||||
child: Center(child: _ContextMark(ws: ws)),
|
||||
),
|
||||
Expanded(
|
||||
child: t > 0
|
||||
? IgnorePointer(
|
||||
ignoring: !labelsInteractive,
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l.workspaceAnchorCaption,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Scales away mid-expansion — a fixed
|
||||
// icon would overflow the row while the
|
||||
// rail is still narrow.
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: ChainSpace.md,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.unfold_more,
|
||||
size: 16,
|
||||
color:
|
||||
theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return Tooltip(
|
||||
message: l.workspaceSwitcherTooltip,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
child: PopupMenuButton<String>(
|
||||
onOpened: ws.refresh,
|
||||
onSelected: (v) => _onSelected(context, v),
|
||||
itemBuilder: (context) => _items(context, ws, l, theme),
|
||||
child: _pill(context, ws, l, theme, label),
|
||||
key: menuKey,
|
||||
tooltip: '', // the outer Tooltip carries the message
|
||||
onOpened: () {
|
||||
onMenuOpen?.call();
|
||||
Workspace.instance.refresh();
|
||||
},
|
||||
onCanceled: () => onMenuClose?.call(),
|
||||
onSelected: (v) {
|
||||
onMenuClose?.call();
|
||||
handleWorkspaceMenuSelection(context, v);
|
||||
},
|
||||
itemBuilder: (context) =>
|
||||
workspaceMenuItems(context, ws: ws, l: l, theme: theme),
|
||||
child: SizedBox(height: 44, child: row),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<PopupMenuEntry<String>> _items(
|
||||
BuildContext context,
|
||||
Workspace ws,
|
||||
AppLocalizations l,
|
||||
ThemeData theme,
|
||||
) {
|
||||
final items = <PopupMenuEntry<String>>[
|
||||
PopupMenuItem(
|
||||
value: _kAll,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.grid_view_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Text(l.workspaceAll),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
if (ws.projects.isNotEmpty) items.add(const PopupMenuDivider());
|
||||
for (final p in ws.projects) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
value: '$_pProject${p.slug}',
|
||||
child: Row(
|
||||
children: [
|
||||
_ProjectDot(color: p.color),
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_projectLabel(l, p),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (p.isProtected) ...[
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Tooltip(
|
||||
message: l.workspaceProtectedHint,
|
||||
child: Icon(
|
||||
Icons.shield_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (ws.sealedAreas.isNotEmpty) {
|
||||
items.add(const PopupMenuDivider());
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
height: 28,
|
||||
child: Text(
|
||||
l.workspaceSealedHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
// Area names often carry client/mandate identity, so the
|
||||
// section starts aggregated ("2 sealed areas") and reveals
|
||||
// names only on a deliberate tap — unless the operator turned
|
||||
// the direct listing back on in Settings -> Security
|
||||
// (usertest security finding). The disabled PopupMenuItem is
|
||||
// just the host; the section handles its own taps and pops
|
||||
// the menu route with the regular `s:<slug>` value.
|
||||
items.add(
|
||||
PopupMenuItem<String>(
|
||||
enabled: false,
|
||||
padding: EdgeInsets.zero,
|
||||
child: SealedAreaSection(
|
||||
areas: ws.sealedAreas,
|
||||
namesVisible: WorkspacePrefs.sealedNamesVisible.value,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
/// The collapsed-column marking for the active context: grid = all
|
||||
/// projects, coloured dot = project, dot + lock = sealed area.
|
||||
class _ContextMark extends StatelessWidget {
|
||||
final Workspace ws;
|
||||
|
||||
Future<void> _onSelected(BuildContext context, String value) async {
|
||||
final ws = Workspace.instance;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
const _ContextMark({required this.ws});
|
||||
|
||||
if (value == _kAll) {
|
||||
await ws.setActive('');
|
||||
return;
|
||||
}
|
||||
if (value.startsWith(_pProject)) {
|
||||
await ws.setActive(value.substring(_pProject.length));
|
||||
return;
|
||||
}
|
||||
if (value.startsWith(_pSealed)) {
|
||||
final slug = value.substring(_pSealed.length);
|
||||
SealedArea? area;
|
||||
for (final a in ws.sealedAreas) {
|
||||
if (a.slug == slug) area = a;
|
||||
}
|
||||
if (area == null) return;
|
||||
// Announce the intent — starting a stopped area takes a beat.
|
||||
if (!area.running) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l.workspaceSealedStarting(area.name))),
|
||||
);
|
||||
}
|
||||
final r = await ws.switchToSealed(area);
|
||||
messenger.hideCurrentSnackBar();
|
||||
if (!r.ok) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: SelectableText(l.workspaceSealedSwitchFailed(r.error)),
|
||||
duration: const Duration(seconds: 8),
|
||||
),
|
||||
);
|
||||
} else if (r.started) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l.workspaceSealedStarted(area.name))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _pill(
|
||||
BuildContext context,
|
||||
Workspace ws,
|
||||
AppLocalizations l,
|
||||
ThemeData theme,
|
||||
String label,
|
||||
) {
|
||||
final Widget leading;
|
||||
if (ws.inSealedArea) {
|
||||
leading = Row(
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final sealed = ws.switching
|
||||
? (ws.switchTarget ?? ws.activeSealed)
|
||||
: ws.activeSealed;
|
||||
if (sealed != null) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ProjectDot(color: ws.activeSealed!.color),
|
||||
_ProjectDot(color: sealed.color),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
size: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
);
|
||||
} else if (ws.active != null) {
|
||||
leading = Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: _ProjectDot(color: ws.active!.color),
|
||||
);
|
||||
} else {
|
||||
leading = Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Icon(
|
||||
Icons.grid_view_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (ws.active != null) {
|
||||
return _ProjectDot(color: ws.active!.color);
|
||||
}
|
||||
return Icon(
|
||||
Icons.grid_view_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: ChainSpace.md, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||||
),
|
||||
/// Display label for the active context (shared by anchor + tests).
|
||||
String workspaceContextLabel(AppLocalizations l, Workspace ws) {
|
||||
final sealed = ws.switching
|
||||
? (ws.switchTarget ?? ws.activeSealed)
|
||||
: ws.activeSealed;
|
||||
if (sealed != null) return sealed.name;
|
||||
if (ws.isAll) return l.workspaceAll;
|
||||
final active = ws.active;
|
||||
return active == null ? ws.activeSlug : _projectLabel(l, active);
|
||||
}
|
||||
|
||||
/// The switcher menu, one source of truth for every mount point.
|
||||
/// The active entry carries a checkmark so the menu answers "where
|
||||
/// am I?" before anything is clicked.
|
||||
List<PopupMenuEntry<String>> workspaceMenuItems(
|
||||
BuildContext context, {
|
||||
required Workspace ws,
|
||||
required AppLocalizations l,
|
||||
required ThemeData theme,
|
||||
}) {
|
||||
final inSealed = ws.inSealedArea;
|
||||
final items = <PopupMenuEntry<String>>[
|
||||
PopupMenuItem(
|
||||
value: _kAll,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
leading,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 160),
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
if (!ws.inSealedArea && (ws.active?.isProtected ?? false)) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.shield_outlined,
|
||||
size: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 18,
|
||||
Icons.grid_view_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Expanded(child: Text(l.workspaceAll)),
|
||||
_ActiveCheck(active: !inSealed && ws.isAll),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
if (ws.projects.isNotEmpty) items.add(const PopupMenuDivider());
|
||||
for (final p in ws.projects) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
value: '$_pProject${p.slug}',
|
||||
child: Row(
|
||||
children: [
|
||||
_ProjectDot(color: p.color),
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_projectLabel(l, p),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (p.isProtected) ...[
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Tooltip(
|
||||
message: l.workspaceProtectedHint,
|
||||
child: Icon(
|
||||
Icons.shield_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
_ActiveCheck(active: !inSealed && ws.activeSlug == p.slug),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (ws.sealedAreas.isNotEmpty) {
|
||||
items.add(const PopupMenuDivider());
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
height: 28,
|
||||
child: Text(
|
||||
l.workspaceSealedHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
// Area names often carry client/mandate identity, so the
|
||||
// section starts aggregated ("2 sealed areas") and reveals
|
||||
// names only on a deliberate tap — unless the operator turned
|
||||
// the direct listing back on in Settings -> Security
|
||||
// (usertest security finding). The disabled PopupMenuItem is
|
||||
// just the host; the section handles its own taps and pops
|
||||
// the menu route with the regular `s:<slug>` value.
|
||||
items.add(
|
||||
PopupMenuItem<String>(
|
||||
enabled: false,
|
||||
padding: EdgeInsets.zero,
|
||||
child: SealedAreaSection(
|
||||
areas: ws.sealedAreas,
|
||||
namesVisible: WorkspacePrefs.sealedNamesVisible.value,
|
||||
activeSlug: ws.activeSealed?.slug,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/// Apply a switcher-menu selection. Shared by every mount point
|
||||
/// (sidebar anchor, command palette). Starting a STOPPED sealed
|
||||
/// area asks first — an area switch must never boot a hub daemon
|
||||
/// as a click side-effect; a running area stays one click.
|
||||
Future<void> handleWorkspaceMenuSelection(
|
||||
BuildContext context,
|
||||
String value,
|
||||
) async {
|
||||
final ws = Workspace.instance;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
if (value == _kSettingsSecurity) {
|
||||
await ChainSettingsDialog.show(context, initialCategory: 'security');
|
||||
return;
|
||||
}
|
||||
if (value == _kAll) {
|
||||
WorkspacePrefs.recordRecentContext(value);
|
||||
await ws.setActive('');
|
||||
return;
|
||||
}
|
||||
if (value.startsWith(_pProject)) {
|
||||
WorkspacePrefs.recordRecentContext(value);
|
||||
await ws.setActive(value.substring(_pProject.length));
|
||||
return;
|
||||
}
|
||||
if (value.startsWith(_pSealed)) {
|
||||
final slug = value.substring(_pSealed.length);
|
||||
SealedArea? area;
|
||||
for (final a in ws.sealedAreas) {
|
||||
if (a.slug == slug) area = a;
|
||||
}
|
||||
if (area == null) return;
|
||||
final target = area;
|
||||
if (!target.running) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l.workspaceStartConfirmTitle(target.name)),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Text(l.workspaceStartConfirmBody),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(l.buttonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(l.workspaceStartConfirmAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
// Announce the intent — starting the area's hub takes a beat.
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l.workspaceSealedStarting(target.name))),
|
||||
);
|
||||
}
|
||||
WorkspacePrefs.recordRecentContext(value);
|
||||
final r = await ws.switchToSealed(target);
|
||||
messenger.hideCurrentSnackBar();
|
||||
if (!r.ok) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: SelectableText(l.workspaceSealedSwitchFailed(r.error)),
|
||||
duration: const Duration(seconds: 8),
|
||||
),
|
||||
);
|
||||
} else if (r.started) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l.workspaceSealedStarted(target.name))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trailing checkmark slot — fixed width so rows with and without
|
||||
/// the mark keep their text aligned.
|
||||
class _ActiveCheck extends StatelessWidget {
|
||||
final bool active;
|
||||
|
||||
const _ActiveCheck({required this.active});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
width: 22,
|
||||
child: active
|
||||
? Icon(Icons.check, size: 16, color: theme.colorScheme.primary)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -305,19 +453,26 @@ Color? parseAreaColor(String hex) {
|
|||
/// widget test can pump both privacy modes directly.
|
||||
///
|
||||
/// [namesVisible] = the operator's Settings choice. When false the
|
||||
/// block renders one aggregated row (lock + count); a deliberate
|
||||
/// tap expands the named rows for THIS menu opening only — nothing
|
||||
/// is persisted from the reveal. Rows select via
|
||||
/// block renders one aggregated row (lock + count) with a why-line
|
||||
/// (the aggregation is a deliberate confidentiality decision and
|
||||
/// must explain itself in place) + a jump to the Settings toggle;
|
||||
/// a deliberate tap expands the named rows for THIS menu opening
|
||||
/// only — nothing is persisted from the reveal. Rows select via
|
||||
/// `Navigator.pop(context, 's:<slug>')`, which hands the value to
|
||||
/// the enclosing PopupMenuButton exactly like a regular item.
|
||||
class SealedAreaSection extends StatefulWidget {
|
||||
final List<SealedArea> areas;
|
||||
final bool namesVisible;
|
||||
|
||||
/// Slug of the area Studio is connected to, or null — its row
|
||||
/// carries the active checkmark.
|
||||
final String? activeSlug;
|
||||
|
||||
const SealedAreaSection({
|
||||
super.key,
|
||||
required this.areas,
|
||||
required this.namesVisible,
|
||||
this.activeSlug,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -332,61 +487,102 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
|
|||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
if (!widget.namesVisible && !_revealed) {
|
||||
return InkWell(
|
||||
onTap: () => setState(() => _revealed = true),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
// Two lines instead of one row: popup menus cap their
|
||||
// width, and action texts must never be cut off
|
||||
// (usertest finding class). Count on top, the reveal
|
||||
// action fully readable beneath it.
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => _revealed = true),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
// Two lines instead of one row: popup menus cap their
|
||||
// width, and action texts must never be cut off
|
||||
// (usertest finding class). Count on top, the reveal
|
||||
// action fully readable beneath it.
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
size: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
size: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Text(
|
||||
l.workspaceSealedAggregate(widget.areas.length),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Text(
|
||||
l.workspaceSealedAggregate(widget.areas.length),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
const SizedBox(height: 2),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 21),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l.workspaceSealedRevealAction,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.expand_more,
|
||||
size: 14,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 21),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l.workspaceSealedRevealAction,
|
||||
),
|
||||
),
|
||||
// The why-line: aggregation is not a glitch but a
|
||||
// confidentiality choice — say so where it happens, and
|
||||
// point at the Settings toggle for machines where the
|
||||
// listing is fine.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(37, 0, 16, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 240),
|
||||
child: Text(
|
||||
l.workspaceSealedAggregateWhy,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
Navigator.pop(context, _kSettingsSecurity),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
l.workspaceSealedAggregateSettings,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.expand_more,
|
||||
size: 14,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
|
|
@ -440,6 +636,8 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
|
|||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_ActiveCheck(active: a.slug == widget.activeSlug),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue