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>
136 lines
5.0 KiB
Dart
136 lines
5.0 KiB
Dart
/// 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);
|
||
});
|
||
}
|