implement MultitabPane widget + controller (T-83)

MultitabController<T> is a Flutter-free ChangeNotifier owning the
tab list, active selection, and reorder/close invariants:
- pinned (non-reorderable) entries form barriers that other tabs
  cannot cross
- non-closeable entries silently no-op on remove() so hosts don't
  need to gate the call site
- closing the active tab falls right, then left, then to null
- duplicate ids are rejected

MultitabPane<T> is the widget shell: a horizontal tab strip
followed by the active entry's body. Active tab gets the
panelHeader background and a panelActiveBorder top accent;
inactive tabs blend into the tab bar. Close × is hidden until
hover. Add button only renders when onAddRequested is wired.

Hosts route the user's add/close intent through callbacks so the
widget stays domain-free — for the Claude pane, add will spawn a
new tmux session and close will kill one. Drag-to-reorder is
controller-side only for now (the gesture wiring lands with T-24).

19 controller tests + 9 widget tests.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:12:42 +02:00
co-authored by Claude
parent bdc46a4fdd
commit 1caaf5f4dd
7 changed files with 776 additions and 1 deletions
+166
View File
@@ -0,0 +1,166 @@
import 'package:clide/widgets/src/multitab_controller.dart';
import 'package:flutter_test/flutter_test.dart';
MultitabEntry<String> entry(String id, {bool closeable = true, bool reorderable = true}) {
return MultitabEntry<String>(
id: id,
title: id,
payload: id,
closeable: closeable,
reorderable: reorderable,
);
}
void main() {
group('MultitabController', () {
test('starts empty when no initial entries', () {
final c = MultitabController<String>();
expect(c.entries, isEmpty);
expect(c.active, isNull);
});
test('seeds from initial and activates the first', () {
final c = MultitabController<String>(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<String>(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<String>(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<String>(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<String>(initial: [entry('a')]);
expect(() => c.add(entry('a')), throwsStateError);
});
test('remove activates the right neighbour, then left', () {
final c = MultitabController<String>(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<String>(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<String>(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<String>(initial: [entry('a'), entry('b')]);
c.activate('b');
expect(c.activeId, 'b');
});
test('activate ignores unknown ids', () {
final c = MultitabController<String>(initial: [entry('a')]);
c.activate('zzz');
expect(c.activeId, 'a');
});
test('reorder moves a tab to a new index', () {
final c = MultitabController<String>(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<String>(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<String>(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<String>(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<String>(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<String>(initial: [entry('a'), entry('b')]);
c.activate('b');
c.replace('a', MultitabEntry<String>(id: 'a', title: 'A!', payload: 'A!'));
expect(c.entries.first.title, 'A!');
expect(c.activeId, 'b');
});
test('replace rejects id changes', () {
final c = MultitabController<String>(initial: [entry('a')]);
expect(
() => c.replace('a', MultitabEntry<String>(id: 'b', title: 'b', payload: 'b')),
throwsStateError,
);
});
test('notifies listeners on every mutation', () {
final c = MultitabController<String>(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);
});
});
}
+155
View File
@@ -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<String> entry(String id, {bool closeable = true, bool reorderable = true}) {
return MultitabEntry<String>(
id: id,
title: id,
payload: id,
closeable: closeable,
reorderable: reorderable,
);
}
Widget body(BuildContext _, MultitabEntry<String> 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<String>(
initial: [entry('primary', closeable: false), entry('s1'), entry('s2')],
);
await tester.pumpWidget(
harness(f, MultitabPane<String>(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<String>(initial: [entry('a'), entry('b')]);
await tester.pumpWidget(
harness(f, MultitabPane<String>(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<String>(initial: [entry('a')]);
var added = 0;
await tester.pumpWidget(
harness(
f,
MultitabPane<String>(
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<String>(initial: [entry('a')]);
await tester.pumpWidget(
harness(f, MultitabPane<String>(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<String>(
initial: [entry('p', closeable: false), entry('s')],
);
await tester.pumpWidget(
harness(f, MultitabPane<String>(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<String>(
initial: [entry('p', closeable: false), entry('s')],
);
await tester.pumpWidget(
harness(f, MultitabPane<String>(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<String>(initial: [entry('a'), entry('b')]);
MultitabEntry<String>? closed;
await tester.pumpWidget(
harness(
f,
MultitabPane<String>(
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<String>(initial: [entry('a')]);
await tester.pumpWidget(
harness(f, MultitabPane<String>(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<String>();
await tester.pumpWidget(
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
);
expect(find.byKey(const ValueKey('body-a')), findsNothing);
});
});
}