add ClidePane primitive + focus-driven status-bar slot

Replace the MessageBus-based pane-context slot with a focus-driven one.
Panes keep their status widget locally; the FocusTracker holds the
focused pane's widget (activeStatusWidget) and ClidePane conveys it to
the shared slot only while its contribution is focused, re-conveying on
change and clearing on blur. The status-bar item just renders
focus.activeStatusWidget, height-clamped and marquee-scrolled when it
overflows. Removes the publish/subscribe race the bus version had.

T-150.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 11:23:30 +02:00
co-authored by Claude Opus 4.7
parent 9590f555c9
commit 478cff6050
13 changed files with 492 additions and 116 deletions
@@ -1,9 +1,11 @@
/// Tests for the status-bar in-pane context slot (T-145): it shows the
/// latest text published on the bus channel and clears on empty.
/// Tests for the focus-driven status-bar slot item (T-150): it renders
/// the focused pane's status widget and clears when focus moves to a
/// pane with no status.
library;
import 'package:clide/builtin/claude/src/pane_context_status.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:clide/kernel/src/panels/slot_id.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
@@ -14,27 +16,20 @@ void main() {
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
testWidgets('renders the latest published context, clears on empty', (tester) async {
testWidgets('renders the focused pane status, clears on focus change', (tester) async {
await tester.pumpWidget(harness(f, const PaneContextStatusItem()));
await tester.pump();
expect(find.byType(ClideText), findsNothing); // nothing published yet
expect(find.text('S'), findsNothing); // nothing focused
publishPaneContext(f.services.messages, 'builtin.claude', 'opus 4.7 · default · 21k ctx');
final focus = f.services.focus;
focus.setActive(slot: Slots.workspace, contributionId: 'pane.x');
focus.setStatusWidget('pane.x', const Text('S', textDirection: TextDirection.ltr));
await tester.pump();
await tester.pump();
expect(find.textContaining('opus 4.7'), findsOneWidget);
expect(find.text('S'), findsOneWidget);
// A newer publisher overwrites the slot.
publishPaneContext(f.services.messages, 'builtin.editor', 'lib/app.dart · modified');
// Focus a pane with no status → the slot clears.
focus.setActive(slot: Slots.workspace, contributionId: 'pane.y');
await tester.pump();
await tester.pump();
expect(find.textContaining('opus 4.7'), findsNothing);
expect(find.textContaining('lib/app.dart'), findsOneWidget);
// Empty clears it.
publishPaneContext(f.services.messages, 'builtin.editor', '');
await tester.pump();
await tester.pump();
expect(find.byType(ClideText), findsNothing);
expect(find.text('S'), findsNothing);
});
}
+54
View File
@@ -0,0 +1,54 @@
/// Tests for FocusTracker's status-widget slot (T-150).
library;
import 'package:clide/kernel/src/focus.dart';
import 'package:clide/kernel/src/panels/slot_id.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('FocusTracker status widget', () {
test('setStatusWidget applies only for the focused contribution', () {
final f = FocusTracker();
f.setActive(slot: Slots.workspace, contributionId: 'a');
const wa = SizedBox(key: ValueKey('a'));
f.setStatusWidget('a', wa);
expect(f.activeStatusWidget, wa);
// A background contribution's update is ignored.
f.setStatusWidget('b', const SizedBox(key: ValueKey('b')));
expect(f.activeStatusWidget, wa);
});
test('a focus change clears the previous status widget', () {
final f = FocusTracker();
f.setActive(slot: Slots.workspace, contributionId: 'a');
f.setStatusWidget('a', const SizedBox());
expect(f.activeStatusWidget, isNotNull);
f.setActive(slot: Slots.workspace, contributionId: 'b');
expect(f.activeStatusWidget, isNull);
});
test('clear() resets focus and status', () {
final f = FocusTracker();
f.setActive(slot: Slots.workspace, contributionId: 'a');
f.setStatusWidget('a', const SizedBox());
f.clear();
expect(f.activeContributionId, isNull);
expect(f.activeStatusWidget, isNull);
});
test('setStatusWidget notifies on change, not on a repeat', () {
final f = FocusTracker();
f.setActive(slot: Slots.workspace, contributionId: 'a');
var n = 0;
f.addListener(() => n++);
const w = SizedBox();
f.setStatusWidget('a', w);
expect(n, 1);
f.setStatusWidget('a', w); // identical → no notify
expect(n, 1);
});
});
}
+32
View File
@@ -0,0 +1,32 @@
/// Tests for ClideMarquee (T-150): static when the child fits, scrolls
/// (looped copy) when it overflows; clips its box.
library;
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _boxed(double width, Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(width: width, height: 16, child: child),
),
);
void main() {
testWidgets('shows the child statically when it fits', (tester) async {
await tester.pumpWidget(_boxed(300, const ClideMarquee(child: Text('short'))));
await tester.pump();
expect(find.text('short'), findsOneWidget);
await tester.pumpWidget(const SizedBox()); // tear down the ticker
});
testWidgets('renders a looped copy and runs without overflow when wider than the slot', (tester) async {
await tester.pumpWidget(_boxed(40, const ClideMarquee(child: Text('a long status line that overflows the slot'))));
await tester.pump(); // measure
await tester.pump(const Duration(milliseconds: 100)); // advance the ticker
expect(find.text('a long status line that overflows the slot'), findsWidgets);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox()); // dispose → stop ticker
});
}
+78
View File
@@ -0,0 +1,78 @@
/// Tests for ClidePane (T-150): it surfaces its statusWidget to the
/// FocusTracker slot only while it is the shown (focused + active) pane.
library;
import 'package:clide/kernel/src/panels/slot_id.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';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
testWidgets('conveys its status while its contribution is focused, clears on blur', (tester) async {
await tester.pumpWidget(harness(
f,
const ClidePane(
contributionId: 'pane.x',
statusWidget: Text('S', textDirection: TextDirection.ltr),
child: SizedBox(),
),
));
final focus = f.services.focus;
expect(focus.activeStatusWidget, isNull); // not focused yet
focus.setActive(slot: Slots.workspace, contributionId: 'pane.x');
await tester.pump();
expect(focus.activeStatusWidget, isA<Text>());
focus.setActive(slot: Slots.workspace, contributionId: 'other');
await tester.pump();
expect(focus.activeStatusWidget, isNull); // focus moved away → cleared
});
testWidgets('an inactive pane never conveys', (tester) async {
await tester.pumpWidget(harness(
f,
const ClidePane(
contributionId: 'pane.x',
active: false,
statusWidget: Text('S', textDirection: TextDirection.ltr),
child: SizedBox(),
),
));
f.services.focus.setActive(slot: Slots.workspace, contributionId: 'pane.x');
await tester.pump();
expect(f.services.focus.activeStatusWidget, isNull);
});
testWidgets('re-conveys when statusWidget changes while focused', (tester) async {
f.services.focus.setActive(slot: Slots.workspace, contributionId: 'pane.x');
var label = 'A';
late StateSetter setLabel;
await tester.pumpWidget(harness(
f,
StatefulBuilder(
builder: (ctx, setState) {
setLabel = setState;
return ClidePane(
contributionId: 'pane.x',
statusWidget: Text(label, textDirection: TextDirection.ltr),
child: const SizedBox(),
);
},
),
));
await tester.pump();
expect((f.services.focus.activeStatusWidget! as Text).data, 'A');
setLabel(() => label = 'B');
await tester.pump();
expect((f.services.focus.activeStatusWidget! as Text).data, 'B');
});
}