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:
@@ -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'}));
|
||||
|
||||
Reference in New Issue
Block a user