panel-to-panel focus traversal via F6 / Shift+F6 (T-105)

Each SlotHost now owns a FocusScopeNode and registers it with
FocusTracker on mount. The render is wrapped in
FocusScope + FocusTraversalGroup so Tab stays within a panel and
slot-level focus is observable.

When a slot's scope gains focus, SlotHost pushes
(slot, activeContributionId) to FocusTracker — this collapses the
parallel-tracker model the consultant flagged. FocusTracker keeps
its setActive surface for explicit callers (palette, etc.) but
slot-scoped tab activation feeds it automatically.

Two new intents, two new bindings:
  FocusNextPanelIntent     → F6
  FocusPreviousPanelIntent → Shift+F6
(VS Code convention; preset YAML.)

The cycle skips slots without a registered scope, so a layout that
hides the context panel doesn't strand focus on a missing target.
Fewer than two registered → no-op.

SlotHost split into a stateful outer (scope + registry) and a
stateless `_SlotBody` (the existing slot-specific rendering),
keeping the build straightforward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:56:35 +02:00
co-authored by Claude Opus 4.7
parent 12e0509fa3
commit 7937da1734
8 changed files with 278 additions and 53 deletions
+65
View File
@@ -6,6 +6,7 @@ library;
import 'package:clide/extension/src/contribution.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show FocusScopeNode;
import 'package:flutter_test/flutter_test.dart';
void main() {
@@ -114,6 +115,70 @@ void main() {
});
});
group('FocusTracker — slot scope registry (T-105)', () {
test('focusSlot is a no-op when no scope is registered', () {
FocusTracker().focusSlot(Slots.sidebar); // doesn't throw
});
test('focusSlot requests focus on the registered scope', () {
final tracker = FocusTracker();
final scope = FocusScopeNode();
addTearDown(scope.dispose);
tracker.registerSlotScope(Slots.workspace, scope);
tracker.focusSlot(Slots.workspace);
// FocusScopeNode.requestFocus only flips hasPrimaryFocus when
// attached to a tree; we assert the no-throw + the registration
// round-trip rather than primary focus state (covered by the
// widget test).
tracker.unregisterSlotScope(Slots.workspace, scope);
tracker.focusSlot(Slots.workspace); // now a no-op
});
test('unregister skips when a newer scope took the slot', () {
final tracker = FocusTracker();
final older = FocusScopeNode();
final newer = FocusScopeNode();
addTearDown(() {
older.dispose();
newer.dispose();
});
tracker.registerSlotScope(Slots.workspace, older);
tracker.registerSlotScope(Slots.workspace, newer);
// Older calls unregister but the slot now holds `newer` — must
// not remove it.
tracker.unregisterSlotScope(Slots.workspace, older);
tracker.focusSlot(Slots.workspace); // still wired to `newer`
});
test('focusNextSlot / focusPreviousSlot cycle through registered slots only', () {
final tracker = FocusTracker();
final sidebar = FocusScopeNode(debugLabel: 'sidebar');
final workspace = FocusScopeNode(debugLabel: 'workspace');
addTearDown(() {
sidebar.dispose();
workspace.dispose();
});
tracker.registerSlotScope(Slots.sidebar, sidebar);
tracker.registerSlotScope(Slots.workspace, workspace);
// contextPanel intentionally NOT registered — should be skipped.
tracker.setActive(slot: Slots.sidebar, contributionId: 'a');
// The cycle is silent (no notify on focus call alone), but we can
// assert it doesn't throw and produces a deterministic shape.
tracker.focusNextSlot();
tracker.focusPreviousSlot();
});
test('cycle no-ops when fewer than two slots are registered', () {
final tracker = FocusTracker();
final scope = FocusScopeNode();
addTearDown(scope.dispose);
tracker.registerSlotScope(Slots.workspace, scope);
tracker.focusNextSlot(); // no-op, no throw
tracker.focusPreviousSlot();
});
});
group('NetworkStatus', () {
test('default state is online', () {
final n = NetworkStatus();