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
+7
View File
@@ -56,6 +56,13 @@ void main() {
await tester.pump();
}
testWidgets('focusing the editor publishes editor.focused (T-406)', (tester) async {
stubOneBuffer('hello');
expect(f.services.keymap.scope['editor.focused'], isNot(true));
await pumpEditor(tester); // taps into the editor → focus
expect(f.services.keymap.scope['editor.focused'], isTrue, reason: 'pane nav guards on !editor.focused');
});
testWidgets('normal-mode x deletes the char under the caret', (tester) async {
String? sentText;
f.ipc.stub('editor.set-content', (a) async {
+44 -1
View File
@@ -22,9 +22,13 @@ void main() {
km = Keymap([KeymapLayer.fromYaml(src)]);
});
const normal = {'vim.normal': true};
// Editor-focused normal mode: j/k/h/l/gg/G/o are buffer motions here because
// the `editor.focused` flag suppresses the pane-nav bindings (T-406).
const normal = {'vim.normal': true, 'editor.focused': true};
const insert = {'vim.insert': true};
const visual = {'vim.visual': true};
// A non-editor pane focused under vim normal mode: the same keys are nav.*.
const paneNormal = {'vim.normal': true};
Intent? resolve(String chord, Map<String, bool> scope) => km.resolve(KeyChord.parse(chord), scope);
@@ -83,4 +87,43 @@ void main() {
expect(m.feed(KeyChord.parse('g')).outcome, SeqOutcome.pending);
expect(_cmd(m.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart');
});
group('pane navigation (T-406)', () {
test('motion keys resolve to nav.* when a non-editor pane is focused', () {
expect(resolve('j', paneNormal), isA<NavDownIntent>());
expect(resolve('k', paneNormal), isA<NavUpIntent>());
expect(resolve('h', paneNormal), isA<NavCollapseOrLeftIntent>());
expect(resolve('l', paneNormal), isA<NavExpandOrRightIntent>());
expect(resolve('ctrl+d', paneNormal), isA<NavPageDownIntent>());
expect(resolve('ctrl+u', paneNormal), isA<NavPageUpIntent>());
expect(resolve('shift+g', paneNormal), isA<NavBottomIntent>());
expect(resolve('o', paneNormal), isA<NavActivateIntent>());
expect(resolve('enter', paneNormal), isA<NavActivateIntent>());
});
test('the editor.focused guard hands the same keys to the editor', () {
// With the editor focused, nav.* is suppressed and the buffer motions win.
expect(_cmd(resolve('j', normal)), 'editor.vim.down');
expect(_cmd(resolve('h', normal)), 'editor.vim.left');
expect(_cmd(resolve('l', normal)), 'editor.vim.right');
expect(_cmd(resolve('shift+g', normal)), 'editor.vim.docEnd');
expect(_cmd(resolve('o', normal)), 'editor.vim.openBelow');
});
test('gg resolves to nav.top in a pane, docStart in the editor', () {
final pane = SequenceMatcher(keymap: () => km, context: () => paneNormal);
pane.feed(KeyChord.parse('g'));
expect(pane.feed(KeyChord.parse('g')).intent, isA<NavTopIntent>());
final editor = SequenceMatcher(keymap: () => km, context: () => normal);
editor.feed(KeyChord.parse('g'));
expect(_cmd(editor.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart');
});
test('pane nav is normal-mode only — visual mode keeps the editor motion', () {
// nav.* is guarded `vim.normal && !editor.focused`; visual mode has no
// vim.normal flag, so j stays the editor motion even without editor.focused.
expect(_cmd(resolve('j', visual)), 'editor.vim.down');
});
});
}