vim normal-mode navigation in non-editor panes (T-406)

The structural T-403 child: make vim normal mode mean navigation in panes
that were mouse-only. The passive global key path can't run multi-chord
sequences (D-82), so each pane hosts its own SequenceMatcher — factored into
a reusable PaneKeyNav that resolves the live keymap and dispatches nav.*
intents while a pane holds focus under the vim preset.

- nav.* intents (down/up/pageDown/pageUp/top/bottom/expandOrRight/
  collapseOrLeft/activate) — preset-neutral; vim.yaml binds j/k/ctrl+d/ctrl+u/
  gg/G/l/h/[o,enter] under `vim.normal && !editor.focused`.
- The editor publishes an `editor.focused` scope flag from its focus node, so
  the same keys stay buffer motions while the editor is focused and become nav
  when a pane is — resolved by file order + the guard (no change to the editor
  motion bindings).
- File tree: a flattened visible-index selection cursor in FileTreeController
  (j/k move, h collapse-or-out, l expand-or-into, o/enter open), with a focus
  ring + scroll-into-view.
- Conversation: j/k line-scroll, ctrl+d/u half-page, gg top, G bottom — G
  re-arms follow-tail.

Foundation for T-404/T-405/T-407, which build on the per-pane matcher and the
editor.focused guard. Git panel + ticket board list nav deferred to a
follow-up (the ticket says lists can trail). Tests: keymap resolution under
both scopes, PaneKeyNav dispatch, the controller selection model, and
end-to-end key-driven nav in both panes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 14:46:04 +02:00
co-authored by Claude Opus 4.8
parent 9a44001fbf
commit 25db19fc0e
17 changed files with 1093 additions and 26 deletions
@@ -382,6 +382,99 @@ void main() {
});
});
group('FileTreeController — keyboard selection (T-406)', () {
// Tree: '' (root) → [lib/ (→ app.dart), main.dart]
Future<FileTreeController> tree({bool expandLib = false}) async {
f.ipc.stub('files.root', (_) async => _ok({'path': '/ws'}));
f.ipc.stub('files.watch', (_) async => _ok(const {}));
f.ipc.stub('files.ls', (args) async {
final path = args['path'] as String? ?? '';
if (path == '') {
return _ok({
'entries': [_fileEntry(name: 'lib', path: 'lib', isDirectory: true), _fileEntry(name: 'main.dart', path: 'main.dart')],
});
}
if (path == 'lib') {
return _ok({
'entries': [_fileEntry(name: 'app.dart', path: 'lib/app.dart')],
});
}
return _ok({'entries': <Object?>[]});
});
final c = makeCtrl();
await c.load();
if (expandLib) await c.toggle('lib');
return c;
}
test('visibleNodes flattens the root + expanded children in render order', () async {
final c = await tree(expandLib: true);
expect(c.visibleNodes().map((n) => n.path), ['', 'lib', 'lib/app.dart', 'main.dart']);
expect(c.visibleNodes().map((n) => n.depth), [0, 1, 2, 1]);
});
test('a collapsed directory hides its children from the visible list', () async {
final c = await tree();
expect(c.visibleNodes().map((n) => n.path), ['', 'lib', 'main.dart']);
});
test('moveSelection walks the visible list and clamps at the ends', () async {
final c = await tree(expandLib: true);
expect(c.selectedPath, isNull);
c.moveSelection(1);
expect(c.selectedPath, ''); // first move lands on the root
c.moveSelection(1);
expect(c.selectedPath, 'lib');
c.moveSelection(2);
expect(c.selectedPath, 'main.dart'); // lib/app.dart skipped over by +2
c.moveSelection(5); // clamp at the bottom
expect(c.selectedPath, 'main.dart');
c.moveSelection(-100); // clamp at the top
expect(c.selectedPath, '');
});
test('selectEdge jumps to the first / last visible row (gg / G)', () async {
final c = await tree(expandLib: true);
c.selectEdge(top: false);
expect(c.selectedPath, 'main.dart');
c.selectEdge(top: true);
expect(c.selectedPath, '');
});
test('expandOrInto expands a collapsed dir, then steps into its first child', () async {
final c = await tree();
c.moveSelection(1); // root
c.moveSelection(1); // lib (collapsed)
expect(c.isExpanded('lib'), isFalse);
await c.expandOrInto(); // expands
expect(c.isExpanded('lib'), isTrue);
expect(c.selectedPath, 'lib'); // selection stays on the dir
await c.expandOrInto(); // steps into first child
expect(c.selectedPath, 'lib/app.dart');
});
test('collapseOrOut collapses an expanded dir, then steps out to the parent', () async {
final c = await tree(expandLib: true);
c.selectEdge(top: true);
c.moveSelection(2); // lib/app.dart
expect(c.selectedPath, 'lib/app.dart');
await c.collapseOrOut(); // a file → step to parent
expect(c.selectedPath, 'lib');
await c.collapseOrOut(); // an expanded dir → collapse in place
expect(c.isExpanded('lib'), isFalse);
expect(c.selectedPath, 'lib');
});
test('activateTarget reports the selected row as dir-or-file for the view', () async {
final c = await tree(expandLib: true);
c.selectEdge(top: true);
c.moveSelection(1); // lib
expect(c.activateTarget(), (isDirectory: true, path: 'lib'));
c.moveSelection(2); // main.dart
expect(c.activateTarget(), (isDirectory: false, path: 'main.dart'));
});
});
group('FileTreeController — dispose()', () {
test('dispose cancels event subscription without error', () async {
f.ipc.stub('files.root', (_) async => _ok({'path': '/ws'}));
+135
View File
@@ -0,0 +1,135 @@
/// Widget tests for keyboard navigation in the file tree (T-406): under the vim
/// preset a focused tree moves a selection cursor with j/k, expands with l, and
/// opens the selected file with o/enter — driving the FileTreeController through
/// PaneKeyNav.
library;
import 'package:clide/builtin/files/src/file_tree_view.dart';
import 'package:clide/clide.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';
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
Map<String, Object?> _entry(String name, String path, {bool dir = false}) => {
'name': name,
'path': path,
'isDirectory': dir,
'isSymlink': false,
'sizeBytes': 0,
'modifiedMs': 0,
};
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
// Tree: /repo → [lib/ (→ app.dart), main.dart].
void stubTree() {
f.ipc.stub('files.root', (_) async => _ok({'path': '/repo'}));
f.ipc.stub('files.watch', (_) async => _ok(const {}));
f.ipc.stub('files.ls', (args) async {
final path = args['path'] as String? ?? '';
if (path == '') {
return _ok({
'entries': [_entry('lib', 'lib', dir: true), _entry('main.dart', 'main.dart')],
});
}
if (path == 'lib') {
return _ok({
'entries': [_entry('app.dart', 'lib/app.dart')],
});
}
return _ok({'entries': <Object?>[]});
});
}
Future<void> mountFocused(WidgetTester tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await tester.pumpWidget(harness(f, const FileTreeView()));
await pumpAsync(tester);
final node = tester.widget<Focus>(find.descendant(of: find.byType(PaneKeyNav), matching: find.byType(Focus)).first).focusNode!;
node.requestFocus();
await tester.pump();
}
testWidgets('j moves the selection and o opens the selected file (T-406)', (tester) async {
stubTree();
final opened = <String>[];
f.ipc.stub('editor.open', (args) async {
opened.add(args['path'] as String? ?? '');
return _ok(const {});
});
await mountFocused(tester);
// visible: '' (root), 'lib', 'main.dart'. j×3 lands on main.dart.
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
await tester.pump();
await pumpAsync(tester);
expect(opened, ['main.dart']);
});
testWidgets('l expands the selected directory, h collapses it (T-406)', (tester) async {
stubTree();
await mountFocused(tester);
expect(find.text('app.dart'), findsNothing); // lib collapsed
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); // root
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ); // lib
await tester.sendKeyEvent(LogicalKeyboardKey.keyL); // expand
await tester.pump();
await pumpAsync(tester);
expect(find.text('app.dart'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.keyH); // collapse lib
await tester.pump();
await pumpAsync(tester);
expect(find.text('app.dart'), findsNothing);
});
testWidgets('G/gg/k and ctrl+d/u move the cursor; o on a dir toggles it (T-406)', (tester) async {
stubTree();
await mountFocused(tester);
// G → last visible row (main.dart), o → main.dart is a file → opens it.
final opened = <String>[];
f.ipc.stub('editor.open', (args) async {
opened.add(args['path'] as String? ?? '');
return _ok(const {});
});
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG); // G → bottom
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyK); // up → lib
await tester.pump();
// o on the 'lib' directory toggles (expands) it rather than opening a file.
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
await tester.pump();
await pumpAsync(tester);
expect(find.text('app.dart'), findsOneWidget); // lib expanded, no file opened
expect(opened, isEmpty);
// gg → top, then ctrl+d / ctrl+u exercise the half-page paths.
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.sendKeyEvent(LogicalKeyboardKey.keyU);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
// No crash, selection stayed in bounds — the dispatch paths ran.
expect(opened, isEmpty);
});
}