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>
156 lines
5.2 KiB
Dart
156 lines
5.2 KiB
Dart
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);
|
||
});
|
||
});
|
||
}
|