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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user