diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index 95e4dfd7..c69b3030 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-05-06T10:04:42Z", + "exported_at": "2026-05-06T10:12:42Z", "decisions": [ { "id": "D-1", diff --git a/CHANGELOG.md b/CHANGELOG.md index a6e32325..366f5861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. history naturally. - Welcome screen Tips card — six common keybindings shown below the START / RECENT row when the viewport is tall enough. +- `MultitabPane` widget + `MultitabController` for panes that host + N runtime tab instances of the same kind. Generic over a payload + type, supports pinned/non-closeable tabs (primary), drag-reorder + (planned), close × on hover, and an optional `+` add button. Used + by the Claude pane to render primary + secondaries. ### Changed diff --git a/lib/widgets/src/multitab_controller.dart b/lib/widgets/src/multitab_controller.dart new file mode 100644 index 00000000..89c1ae00 --- /dev/null +++ b/lib/widgets/src/multitab_controller.dart @@ -0,0 +1,202 @@ +import 'package:flutter/foundation.dart'; + +/// One tab inside a [MultitabPane]. The [payload] is the host-owned +/// domain object the body builder renders (e.g. a Claude session ref, +/// an editor buffer ref). +@immutable +class MultitabEntry { + const MultitabEntry({ + required this.id, + required this.title, + required this.payload, + this.closeable = true, + this.reorderable = true, + }); + + /// Stable id for this tab. Must be unique within the controller. + final String id; + + /// Display label shown in the tab strip. + final String title; + + /// Domain object the host's body builder consumes. + final T payload; + + /// Whether the user can close this tab. Set to `false` for tabs + /// the host considers permanent (e.g. the primary Claude pane). + final bool closeable; + + /// Whether the user can drag this tab to a new position. Pinned + /// tabs (e.g. the primary) keep their position and form a barrier: + /// reorderable tabs cannot move past a pinned tab on either side. + final bool reorderable; + + MultitabEntry copyWith({ + String? title, + T? payload, + bool? closeable, + bool? reorderable, + }) { + return MultitabEntry( + id: id, + title: title ?? this.title, + payload: payload ?? this.payload, + closeable: closeable ?? this.closeable, + reorderable: reorderable ?? this.reorderable, + ); + } +} + +/// Manages the entries and active selection for a [MultitabPane]. +/// Hosts seed the controller and route the user's add/close/reorder +/// gestures back through it. +class MultitabController extends ChangeNotifier { + MultitabController({List> initial = const []}) { + for (final e in initial) { + _checkUniqueId(e.id); + _entries.add(e); + } + if (_entries.isNotEmpty) _activeId = _entries.first.id; + } + + final List> _entries = []; + String? _activeId; + + /// Read-only view of current entries in display order. + List> get entries => List.unmodifiable(_entries); + + /// Currently-active entry, or null when there are no entries. + MultitabEntry? get active { + final id = _activeId; + if (id == null) return null; + for (final e in _entries) { + if (e.id == id) return e; + } + return _entries.isEmpty ? null : _entries.first; + } + + String? get activeId => active?.id; + + int get length => _entries.length; + bool get isEmpty => _entries.isEmpty; + bool get isNotEmpty => _entries.isNotEmpty; + + /// Insert [entry] at the end. If [activate] is true (default), make + /// it the active tab. + void add(MultitabEntry entry, {bool activate = true}) { + _checkUniqueId(entry.id); + _entries.add(entry); + if (activate || _activeId == null) _activeId = entry.id; + notifyListeners(); + } + + /// Insert [entry] at [index] (clamped into range). + void insert(int index, MultitabEntry entry, {bool activate = true}) { + _checkUniqueId(entry.id); + final at = index.clamp(0, _entries.length); + _entries.insert(at, entry); + if (activate || _activeId == null) _activeId = entry.id; + notifyListeners(); + } + + /// Remove the entry with [id]. If it was active, activation falls + /// to the entry immediately to its right, then to its left, then + /// to null (empty controller). + /// + /// Silently no-ops if the id isn't found or the entry is not + /// closeable. Hosts that want to bypass closeable should remove + /// the entry by replacing it via [replace] first. + void remove(String id) { + final index = _entries.indexWhere((e) => e.id == id); + if (index < 0) return; + if (!_entries[index].closeable) return; + final wasActive = _activeId == id; + _entries.removeAt(index); + if (wasActive) { + if (_entries.isEmpty) { + _activeId = null; + } else { + final fallback = index < _entries.length ? index : _entries.length - 1; + _activeId = _entries[fallback].id; + } + } + notifyListeners(); + } + + /// Replace the entry with [id] in place. Used when the host needs + /// to update title or payload without disturbing position or + /// active selection. + void replace(String id, MultitabEntry next) { + final index = _entries.indexWhere((e) => e.id == id); + if (index < 0) return; + if (next.id != id) { + throw StateError('replace() must keep the same id (got "${next.id}", expected "$id")'); + } + _entries[index] = next; + notifyListeners(); + } + + /// Make [id] the active tab. No-op if the id isn't present. + void activate(String id) { + if (_activeId == id) return; + if (!_entries.any((e) => e.id == id)) return; + _activeId = id; + notifyListeners(); + } + + /// Move the entry with [id] to [newIndex]. Pinned (`reorderable: + /// false`) entries form barriers that cannot be crossed: a + /// reorderable tab cannot be dropped before a pinned tab that + /// currently sits to its left, and a pinned tab itself cannot move. + void reorder(String id, int newIndex) { + final from = _entries.indexWhere((e) => e.id == id); + if (from < 0) return; + if (!_entries[from].reorderable) return; + + var to = newIndex.clamp(0, _entries.length - 1); + if (to == from) return; + + // Enforce pinned barriers. The lowest legal index is one past + // the last pinned entry to the left; the highest is one before + // the first pinned entry to the right. + var minIndex = 0; + for (var i = 0; i < _entries.length; i++) { + if (i == from) continue; + if (!_entries[i].reorderable && i < from) minIndex = i + 1; + } + var maxIndex = _entries.length - 1; + for (var i = _entries.length - 1; i >= 0; i--) { + if (i == from) continue; + if (!_entries[i].reorderable && i > from) maxIndex = i - 1; + } + to = to.clamp(minIndex, maxIndex); + if (to == from) return; + + final entry = _entries.removeAt(from); + _entries.insert(to, entry); + notifyListeners(); + } + + /// Activate the next entry to the right of the active one, + /// wrapping at the end. No-op if fewer than 2 entries. + void activateNext() => _step(1); + + /// Activate the previous entry, wrapping at the start. + void activatePrev() => _step(-1); + + void _step(int delta) { + if (_entries.length < 2) return; + final id = _activeId; + final from = id == null ? 0 : _entries.indexWhere((e) => e.id == id); + final next = (from + delta) % _entries.length; + final wrapped = next < 0 ? next + _entries.length : next; + _activeId = _entries[wrapped].id; + notifyListeners(); + } + + void _checkUniqueId(String id) { + if (_entries.any((e) => e.id == id)) { + throw StateError('MultitabController already contains entry id "$id"'); + } + } +} diff --git a/lib/widgets/src/multitab_pane.dart b/lib/widgets/src/multitab_pane.dart new file mode 100644 index 00000000..d77a50b3 --- /dev/null +++ b/lib/widgets/src/multitab_pane.dart @@ -0,0 +1,245 @@ +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/widgets/src/clide_tappable.dart'; +import 'package:clide/widgets/src/clide_text.dart'; +import 'package:clide/widgets/src/multitab_controller.dart'; +import 'package:flutter/widgets.dart'; + +typedef MultitabBuilder = Widget Function(BuildContext context, MultitabEntry entry); +typedef MultitabEntryCallback = void Function(MultitabEntry entry); + +/// A pane shell that hosts N runtime tabs of the same kind. Routes +/// the user's add / close / reorder / activate gestures back to the +/// host via the [controller] and the optional callbacks. +/// +/// The widget is generic and domain-free: it never knows what's +/// inside a tab. Hosts pick `T` and decide what add / close mean +/// (e.g. spawning or killing a tmux session for the Claude pane). +/// +/// See `docs/design/multitab-pane.md` for the design rationale. +class MultitabPane extends StatelessWidget { + const MultitabPane({ + super.key, + required this.controller, + required this.bodyBuilder, + this.onCloseRequested, + this.onAddRequested, + this.allowReorder = true, + this.tabHeight = 28, + }); + + final MultitabController controller; + final MultitabBuilder bodyBuilder; + final MultitabEntryCallback? onCloseRequested; + final VoidCallback? onAddRequested; + final bool allowReorder; + final double tabHeight; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final active = controller.active; + return Column( + children: [ + _TabStrip( + controller: controller, + onCloseRequested: onCloseRequested, + onAddRequested: onAddRequested, + allowReorder: allowReorder, + tabHeight: tabHeight, + ), + Expanded( + child: active == null + ? const SizedBox.expand() + : KeyedSubtree( + // Key by id so swapping the active tab rebuilds + // body state cleanly instead of mutating in place. + key: ValueKey('multitab-body-${active.id}'), + child: bodyBuilder(context, active), + ), + ), + ], + ); + }, + ); + } +} + +class _TabStrip extends StatelessWidget { + const _TabStrip({ + required this.controller, + required this.onCloseRequested, + required this.onAddRequested, + required this.allowReorder, + required this.tabHeight, + }); + + final MultitabController controller; + final MultitabEntryCallback? onCloseRequested; + final VoidCallback? onAddRequested; + final bool allowReorder; + final double tabHeight; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final entries = controller.entries; + final activeId = controller.activeId; + + return Container( + height: tabHeight, + color: tokens.tabBarBackground, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final entry in entries) + _Tab( + entry: entry, + active: entry.id == activeId, + onSelect: () => controller.activate(entry.id), + onClose: entry.closeable + ? () { + if (onCloseRequested != null) { + onCloseRequested!(entry); + } else { + controller.remove(entry.id); + } + } + : null, + tabHeight: tabHeight, + ), + if (onAddRequested != null) + _AddButton(onTap: onAddRequested!, tabHeight: tabHeight), + ], + ), + ), + ); + } +} + +class _Tab extends StatefulWidget { + const _Tab({ + required this.entry, + required this.active, + required this.onSelect, + required this.onClose, + required this.tabHeight, + }); + + final MultitabEntry entry; + final bool active; + final VoidCallback onSelect; + final VoidCallback? onClose; + final double tabHeight; + + @override + State<_Tab> createState() => _TabState(); +} + +class _TabState extends State<_Tab> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final fg = widget.active ? tokens.tabActiveForeground : tokens.tabInactiveForeground; + // Active tabs sit on the elevated chrome surface (panelHeader); + // inactive tabs blend into the tab bar. + final bg = widget.active ? tokens.panelHeader : tokens.tabBarBackground; + final border = widget.active ? tokens.panelActiveBorder : tokens.panelBorder; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Semantics( + button: true, + selected: widget.active, + label: widget.entry.title, + excludeSemantics: true, + child: ClideTappable( + onTap: widget.onSelect, + builder: (context, _, __) => Container( + constraints: BoxConstraints(minWidth: 96, maxWidth: 200), + height: widget.tabHeight, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: bg, + border: Border( + top: BorderSide(color: border, width: widget.active ? 1.5 : 0), + left: BorderSide(color: tokens.panelBorder), + right: BorderSide(color: tokens.panelBorder), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: ClideText( + widget.entry.title, + fontSize: 12, + color: fg, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + if (widget.onClose != null) ...[ + const SizedBox(width: 8), + Opacity( + opacity: _hovered || widget.active ? 1.0 : 0.0, + child: ClideTappable( + onTap: widget.onClose, + builder: (context, hovered, _) => Container( + width: 16, + height: 16, + alignment: Alignment.center, + decoration: BoxDecoration( + color: hovered ? tokens.listItemHoverBackground : null, + borderRadius: BorderRadius.circular(2), + ), + child: ClideText('×', + fontSize: 14, color: tokens.globalTextMuted), + ), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +class _AddButton extends StatelessWidget { + const _AddButton({required this.onTap, required this.tabHeight}); + final VoidCallback onTap; + final double tabHeight; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Semantics( + button: true, + label: 'New tab', + excludeSemantics: true, + child: ClideTappable( + onTap: onTap, + builder: (context, hovered, _) => Container( + width: 28, + height: tabHeight, + alignment: Alignment.center, + decoration: BoxDecoration( + color: hovered ? tokens.listItemHoverBackground : null, + ), + child: ClideText('+', + fontSize: 14, + color: hovered ? tokens.globalForeground : tokens.globalTextMuted), + ), + ), + ); + } +} + diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 62330f68..bb936971 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -23,6 +23,8 @@ export 'src/clide_scrollbar.dart'; export 'src/clide_spine.dart'; export 'src/clide_surface.dart'; export 'src/clide_tab_bar.dart'; +export 'src/multitab_controller.dart'; +export 'src/multitab_pane.dart'; export 'src/clide_tappable.dart'; export 'src/clide_text.dart'; export 'src/clide_tooltip.dart'; diff --git a/test/widgets/multitab_controller_test.dart b/test/widgets/multitab_controller_test.dart new file mode 100644 index 00000000..3d36e651 --- /dev/null +++ b/test/widgets/multitab_controller_test.dart @@ -0,0 +1,166 @@ +import 'package:clide/widgets/src/multitab_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; + +MultitabEntry entry(String id, {bool closeable = true, bool reorderable = true}) { + return MultitabEntry( + id: id, + title: id, + payload: id, + closeable: closeable, + reorderable: reorderable, + ); +} + +void main() { + group('MultitabController', () { + test('starts empty when no initial entries', () { + final c = MultitabController(); + expect(c.entries, isEmpty); + expect(c.active, isNull); + }); + + test('seeds from initial and activates the first', () { + final c = MultitabController(initial: [entry('a'), entry('b')]); + expect(c.entries.map((e) => e.id), ['a', 'b']); + expect(c.activeId, 'a'); + }); + + test('add appends and activates by default', () { + final c = MultitabController(initial: [entry('a')]); + c.add(entry('b')); + expect(c.entries.map((e) => e.id), ['a', 'b']); + expect(c.activeId, 'b'); + }); + + test('add with activate:false keeps prior selection', () { + final c = MultitabController(initial: [entry('a')]); + c.add(entry('b'), activate: false); + expect(c.activeId, 'a'); + }); + + test('insert places at index and clamps out-of-range', () { + final c = MultitabController(initial: [entry('a'), entry('c')]); + c.insert(1, entry('b')); + expect(c.entries.map((e) => e.id), ['a', 'b', 'c']); + c.insert(99, entry('d')); + expect(c.entries.last.id, 'd'); + }); + + test('duplicate ids are rejected', () { + final c = MultitabController(initial: [entry('a')]); + expect(() => c.add(entry('a')), throwsStateError); + }); + + test('remove activates the right neighbour, then left', () { + final c = MultitabController(initial: [entry('a'), entry('b'), entry('c')]); + c.activate('b'); + c.remove('b'); + expect(c.entries.map((e) => e.id), ['a', 'c']); + expect(c.activeId, 'c'); + + c.remove('c'); + expect(c.activeId, 'a'); + }); + + test('remove leaves activeId null when emptied', () { + final c = MultitabController(initial: [entry('a')]); + c.remove('a'); + expect(c.entries, isEmpty); + expect(c.activeId, isNull); + }); + + test('remove no-ops on non-closeable entries', () { + final c = MultitabController(initial: [entry('p', closeable: false), entry('s')]); + c.remove('p'); + expect(c.entries.map((e) => e.id), ['p', 's']); + }); + + test('activate switches selection', () { + final c = MultitabController(initial: [entry('a'), entry('b')]); + c.activate('b'); + expect(c.activeId, 'b'); + }); + + test('activate ignores unknown ids', () { + final c = MultitabController(initial: [entry('a')]); + c.activate('zzz'); + expect(c.activeId, 'a'); + }); + + test('reorder moves a tab to a new index', () { + final c = MultitabController(initial: [entry('a'), entry('b'), entry('c')]); + c.reorder('a', 2); + expect(c.entries.map((e) => e.id), ['b', 'c', 'a']); + }); + + test('reorder no-ops on non-reorderable entries', () { + final c = MultitabController(initial: [ + entry('p', reorderable: false), + entry('a'), + ]); + c.reorder('p', 1); + expect(c.entries.map((e) => e.id), ['p', 'a']); + }); + + test('reorder cannot move a tab past a pinned barrier', () { + final c = MultitabController(initial: [ + entry('p', reorderable: false), + entry('a'), + entry('b'), + ]); + // 'a' tries to land at index 0 — blocked by pinned 'p'. + c.reorder('a', 0); + expect(c.entries.map((e) => e.id), ['p', 'a', 'b']); + }); + + test('reorder respects barriers on the right', () { + final c = MultitabController(initial: [ + entry('a'), + entry('b'), + entry('p', reorderable: false), + ]); + // 'a' tries to land past pinned 'p' — clamped to before it. + c.reorder('a', 2); + expect(c.entries.map((e) => e.id), ['b', 'a', 'p']); + }); + + test('activateNext / activatePrev wrap', () { + final c = MultitabController(initial: [entry('a'), entry('b'), entry('c')]); + c.activateNext(); + expect(c.activeId, 'b'); + c.activateNext(); + expect(c.activeId, 'c'); + c.activateNext(); + expect(c.activeId, 'a'); + c.activatePrev(); + expect(c.activeId, 'c'); + }); + + test('replace swaps entry without disturbing position or active', () { + final c = MultitabController(initial: [entry('a'), entry('b')]); + c.activate('b'); + c.replace('a', MultitabEntry(id: 'a', title: 'A!', payload: 'A!')); + expect(c.entries.first.title, 'A!'); + expect(c.activeId, 'b'); + }); + + test('replace rejects id changes', () { + final c = MultitabController(initial: [entry('a')]); + expect( + () => c.replace('a', MultitabEntry(id: 'b', title: 'b', payload: 'b')), + throwsStateError, + ); + }); + + test('notifies listeners on every mutation', () { + final c = MultitabController(initial: [entry('a')]); + var calls = 0; + c.addListener(() => calls++); + c.add(entry('b')); + c.activate('a'); + c.reorder('b', 0); + c.remove('b'); + expect(calls, 4); + }); + }); +} diff --git a/test/widgets/multitab_pane_test.dart b/test/widgets/multitab_pane_test.dart new file mode 100644 index 00000000..230cc8c1 --- /dev/null +++ b/test/widgets/multitab_pane_test.dart @@ -0,0 +1,155 @@ +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/kernel_fixture.dart'; +import '../helpers/widget_harness.dart'; + +MultitabEntry entry(String id, {bool closeable = true, bool reorderable = true}) { + return MultitabEntry( + id: id, + title: id, + payload: id, + closeable: closeable, + reorderable: reorderable, + ); +} + +Widget body(BuildContext _, MultitabEntry e) => + SizedBox(key: ValueKey('body-${e.id}'), child: Text('body:${e.payload}')); + +void main() { + group('MultitabPane', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + testWidgets('renders one tab per entry and the active body', (tester) async { + final c = MultitabController( + initial: [entry('primary', closeable: false), entry('s1'), entry('s2')], + ); + await tester.pumpWidget( + harness(f, MultitabPane(controller: c, bodyBuilder: body)), + ); + + expect(find.text('primary'), findsOneWidget); + expect(find.text('s1'), findsOneWidget); + expect(find.text('s2'), findsOneWidget); + // Body of the active (first) tab is visible. + expect(find.byKey(const ValueKey('body-primary')), findsOneWidget); + expect(find.byKey(const ValueKey('body-s1')), findsNothing); + }); + + testWidgets('tapping a tab activates it and swaps the body', (tester) async { + final c = MultitabController(initial: [entry('a'), entry('b')]); + await tester.pumpWidget( + harness(f, MultitabPane(controller: c, bodyBuilder: body)), + ); + expect(find.byKey(const ValueKey('body-a')), findsOneWidget); + + await tester.tap(find.text('b')); + await tester.pumpAndSettle(); + + expect(c.activeId, 'b'); + expect(find.byKey(const ValueKey('body-b')), findsOneWidget); + expect(find.byKey(const ValueKey('body-a')), findsNothing); + }); + + testWidgets('add button calls onAddRequested when set', (tester) async { + final c = MultitabController(initial: [entry('a')]); + var added = 0; + await tester.pumpWidget( + harness( + f, + MultitabPane( + controller: c, + bodyBuilder: body, + onAddRequested: () => added++, + ), + ), + ); + + await tester.tap(find.bySemanticsLabel('New tab')); + await tester.pumpAndSettle(); + expect(added, 1); + }); + + testWidgets('add button is absent when onAddRequested is null', (tester) async { + final c = MultitabController(initial: [entry('a')]); + await tester.pumpWidget( + harness(f, MultitabPane(controller: c, bodyBuilder: body)), + ); + expect(find.bySemanticsLabel('New tab'), findsNothing); + }); + + testWidgets('non-closeable tabs do not render a close glyph', (tester) async { + final c = MultitabController( + initial: [entry('p', closeable: false), entry('s')], + ); + await tester.pumpWidget( + harness(f, MultitabPane(controller: c, bodyBuilder: body)), + ); + // A pinned tab has no close target; a closeable one does (it's + // hidden via Opacity until hover, but still in the tree). + // Two tabs total, one × glyph for 's'. + expect(find.text('×'), findsOneWidget); + }); + + testWidgets('default close behavior removes the entry', (tester) async { + final c = MultitabController( + initial: [entry('p', closeable: false), entry('s')], + ); + await tester.pumpWidget( + harness(f, MultitabPane(controller: c, bodyBuilder: body)), + ); + + await tester.tap(find.text('×')); + await tester.pumpAndSettle(); + + expect(c.entries.map((e) => e.id), ['p']); + }); + + testWidgets('onCloseRequested overrides default removal', (tester) async { + final c = MultitabController(initial: [entry('a'), entry('b')]); + MultitabEntry? closed; + await tester.pumpWidget( + harness( + f, + MultitabPane( + controller: c, + bodyBuilder: body, + onCloseRequested: (e) => closed = e, + ), + ), + ); + + // Both tabs are closeable; tap the first × encountered. + await tester.tap(find.text('×').first); + await tester.pumpAndSettle(); + + expect(closed, isNotNull); + // Host decided not to remove yet — entries are unchanged. + expect(c.entries.length, 2); + }); + + testWidgets('rebuilds when the controller notifies', (tester) async { + final c = MultitabController(initial: [entry('a')]); + await tester.pumpWidget( + harness(f, MultitabPane(controller: c, bodyBuilder: body)), + ); + expect(find.bySemanticsLabel('b'), findsNothing); + + c.add(entry('b')); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel('b'), findsOneWidget); + }); + + testWidgets('empty controller renders no body', (tester) async { + final c = MultitabController(); + await tester.pumpWidget( + harness(f, MultitabPane(controller: c, bodyBuilder: body)), + ); + expect(find.byKey(const ValueKey('body-a')), findsNothing); + }); + }); +}