chain-studio/lib/widgets/chain_workspace_switcher.dart
flemming-it 588f437395
Some checks failed
Security / Security check (push) Failing after 2s
feat(workspace): sealed-area names are confidential by default
The switcher listed sealed areas by name ('lbs', 'stromnetz') on
any glance or screenshot — but the names themselves often carry
client/mandate identity (usertest security finding). The sealed
section now renders one aggregated row ('2 sealed areas') with a
deliberate 'Show names' reveal per menu opening; selection still
pops the regular s:<slug> value. Settings -> Security gains 'list
sealed areas with their names right away' (WorkspacePrefs,
SidebarPrefs pattern, default off).

The aggregate row wraps to two lines — popup menus cap their
width and action texts must never be truncated (the first cut
showed '1 abgeschotte…' in the proof shot). Guard: switcher tests
cover aggregated-until-reveal and the Settings toggle; the old
direct-listing test now asserts the reveal contract. DE+EN.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-19 03:34:05 +02:00

450 lines
14 KiB
Dart

// ChainWorkspaceSwitcher — the AppBar workspace (project) control.
//
// 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.
import 'package:flutter/material.dart';
import '../data/hub.dart' show ProjectRef;
import '../data/sealed_areas.dart';
import '../data/workspace_prefs.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../theme/tokens.dart';
/// Menu-value scheme: '' = all projects, `p:<slug>` = shared project,
/// `s:<slug>` = sealed area.
const _kAll = '';
const _pProject = 'p:';
const _pSealed = 's:';
class ChainWorkspaceSwitcher extends StatelessWidget {
const ChainWorkspaceSwitcher({super.key});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Workspace.instance,
builder: (context, _) {
final l = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final ws = Workspace.instance;
final label = ws.inSealedArea
? ws.activeSealed!.name
: (ws.isAll
? l.workspaceAll
: (ws.active == null
? ws.activeSlug
: _projectLabel(l, ws.active!)));
return Tooltip(
message: l.workspaceSwitcherTooltip,
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),
),
);
},
);
}
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;
}
Future<void> _onSelected(BuildContext context, String value) async {
final ws = Workspace.instance;
final l = AppLocalizations.of(context)!;
final messenger = ScaffoldMessenger.of(context);
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(
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(
padding: const EdgeInsets.symmetric(horizontal: ChainSpace.md, vertical: 5),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(ChainRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
leading,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 160),
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium,
),
),
if (!ws.inSealedArea && (ws.active?.isProtected ?? false)) ...[
const SizedBox(width: 4),
Icon(
Icons.shield_outlined,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
],
const SizedBox(width: 2),
Icon(
Icons.arrow_drop_down,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
);
}
}
/// Display name for a registry project. The hub's default project
/// carries the fixed English name "General"; render it localized
/// ("Allgemein") so no English label sits in the German menu.
String _projectLabel(AppLocalizations l, ProjectRef p) =>
p.slug == 'general' ? l.workspaceDefaultProject : p.name;
/// The project's registry colour as a small marking dot. Falls back
/// to the theme's outline colour when unset.
class _ProjectDot extends StatelessWidget {
final String color;
const _ProjectDot({required this.color});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: parseAreaColor(color) ?? theme.colorScheme.outline,
shape: BoxShape.circle,
),
);
}
}
/// Parse a `#rrggbb` hex colour, or null when unparseable.
Color? parseAreaColor(String hex) {
final h = hex.replaceFirst('#', '');
if (h.length != 6) return null;
final v = int.tryParse(h, radix: 16);
if (v == null) return null;
return Color(0xFF000000 | v);
}
/// The sealed-areas block of the switcher menu. Public so the
/// 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
/// `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;
const SealedAreaSection({
super.key,
required this.areas,
required this.namesVisible,
});
@override
State<SealedAreaSection> createState() => _SealedAreaSectionState();
}
class _SealedAreaSectionState extends State<SealedAreaSection> {
bool _revealed = false;
@override
Widget build(BuildContext context) {
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(
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(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,
),
],
),
),
],
),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final a in widget.areas)
InkWell(
onTap: () => Navigator.pop(context, '$_pSealed${a.slug}'),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Row(
children: [
_ProjectDot(color: a.color),
const SizedBox(width: ChainSpace.sm),
Icon(
Icons.lock_outline,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Flexible(
child: Text(
a.name,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
),
),
const SizedBox(width: ChainSpace.sm),
Tooltip(
message: a.running
? l.workspaceSealedRunningHint
: l.workspaceSealedStoppedHint,
child: Text(
a.running
? l.workspaceSealedRunning
: l.workspaceSealedStopped,
style: theme.textTheme.labelSmall?.copyWith(
// Secondary but readable — onSurfaceVariant
// fell below comfortable contrast at this
// size (usertest finding).
color: a.running
? ChainColors.success
: theme.colorScheme.onSurface
.withValues(alpha: 0.8),
),
),
),
],
),
),
),
],
);
}
}