Files
clide/test/kernel/src/keymap/pane_key_nav_test.dart
T
jpmschweitzerandClaude Opus 4.8 2e4f87455a feat(vim): gt / gT cycle workspace tabs (T-405 part 2)
Closes out the T-403 cross-pane vim layer. gt/gT bind to the existing
workspace.tab.next/previous commands (also on ctrl+pagedown/up for every
preset), resolved by the focused editor's matcher or a pane's PaneKeyNav —
bare-g sequences stay editor/pane-local (T-404's global matcher only engages
on modified-chord prefixes), so no global-matcher surgery.

- vim.yaml: g t -> command:workspace.tab.next, g shift+t -> .previous
  (vim.normal); shares the `g` prefix with `g g` (docStart / nav.top),
  distinguished by the final chord.
- PaneKeyNav now EXECUTES non-editor.vim.* command intents (e.g.
  workspace.tab.*) instead of swallowing all command intents, so gt/gT work
  from a focused pane; editor.vim.* buffer edits stay blocked in panes.

Tests: vim-preset resolution (gt/gT bind, gg intact), PaneKeyNav executes
the command from a pane, and editor.vim.* is never run from a pane. make
test green; analyze + format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:08:07 +02:00

125 lines
4.9 KiB
Dart

/// Widget tests for PaneKeyNav (T-406): the per-pane vim-normal key handler
/// that runs its own SequenceMatcher and dispatches nav.* intents — proven
/// end-to-end against the real vim preset and scope flags.
library;
import 'package:clide/clide.dart';
import 'package:clide/extension/src/contribution.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.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());
Future<List<NavIntent>> pump(WidgetTester tester, {required Map<String, bool> scope}) async {
// setPreset does real asset + keybindings-file I/O; run it outside the
// fake-async zone or the testWidgets body hangs (the T-122 lesson).
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
for (final e in scope.entries) {
f.services.keymap.setScopeFlag(e.key, e.value);
}
final got = <NavIntent>[];
final node = FocusNode();
addTearDown(node.dispose);
await tester.pumpWidget(
harness(f, PaneKeyNav(focusNode: node, autofocus: true, onNav: (i, _) => got.add(i), child: const SizedBox(width: 100, height: 100))),
);
node.requestFocus();
await tester.pump();
return got;
}
testWidgets('bare motions dispatch nav.* under vim.normal (pane focused)', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.sendKeyEvent(LogicalKeyboardKey.keyK);
await tester.sendKeyEvent(LogicalKeyboardKey.keyH);
await tester.sendKeyEvent(LogicalKeyboardKey.keyL);
expect(got, [isA<NavDownIntent>(), isA<NavUpIntent>(), isA<NavCollapseOrLeftIntent>(), isA<NavExpandOrRightIntent>()]);
});
testWidgets('gg sequence resolves to nav.top', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
expect(got, [isA<NavTopIntent>()]);
});
testWidgets('ctrl+d / ctrl+u are claimed as half-page nav', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.sendKeyEvent(LogicalKeyboardKey.keyU);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
expect(got, [isA<NavPageDownIntent>(), isA<NavPageUpIntent>()]);
});
testWidgets('the editor.focused guard suppresses nav (keys go to the editor)', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true, 'editor.focused': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.sendKeyEvent(LogicalKeyboardKey.keyK);
// j/k now resolve to editor.vim.* — not NavIntents — so onNav never fires.
expect(got, isEmpty);
});
testWidgets('gt executes a non-editor command (workspace.tab.next) from a focused pane (T-405)', (tester) async {
var ran = 0;
f.services.commands.register(
CommandContribution(
id: 'workspace.tab.next',
command: 'workspace.tab.next',
title: 'Next Workspace Tab',
run: (_) async {
ran++;
return IpcResponse.ok(id: '', data: const {});
},
),
);
await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyT);
await tester.pump();
expect(ran, 1);
});
testWidgets('editor.vim.* buffer edits never execute from a pane (T-405 guard)', (tester) async {
var ran = 0;
// If the pane wrongly ran editor.vim.*, this would fire on `d d`.
f.services.commands.register(
CommandContribution(
id: 'editor.vim.deleteLine',
command: 'editor.vim.deleteLine',
title: 'vim: delete line',
run: (_) async {
ran++;
return IpcResponse.ok(id: '', data: const {});
},
),
);
await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.pump();
expect(ran, 0);
});
testWidgets('keys pass through outside vim normal mode', (tester) async {
final got = await pump(tester, scope: {'vim.insert': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
expect(got, isEmpty);
});
testWidgets('an unbound bare key is swallowed without dispatching nav', (tester) async {
final got = await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyZ);
expect(got, isEmpty);
});
}