Files
clide/test/builtin/files/file_tree_nav_test.dart
T
jpmschweitzerandClaude Opus 4.8 25db19fc0e 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>
2026-06-13 14:46:04 +02:00

136 lines
5.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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);
});
}