feat(vim): gt / gT cycle workspace tabs (T-405 part 2)

Closes out the T-403 cross-pane vim layer. gt/gT bind to the existing
workspace.tab.next/previous commands (also on ctrl+pagedown/up for every
preset), resolved by the focused editor's matcher or a pane's PaneKeyNav —
bare-g sequences stay editor/pane-local (T-404's global matcher only engages
on modified-chord prefixes), so no global-matcher surgery.

- vim.yaml: g t -> command:workspace.tab.next, g shift+t -> .previous
  (vim.normal); shares the `g` prefix with `g g` (docStart / nav.top),
  distinguished by the final chord.
- PaneKeyNav now EXECUTES non-editor.vim.* command intents (e.g.
  workspace.tab.*) instead of swallowing all command intents, so gt/gT work
  from a focused pane; editor.vim.* buffer edits stay blocked in panes.

Tests: vim-preset resolution (gt/gT bind, gg intact), PaneKeyNav executes
the command from a pane, and editor.vim.* is never run from a pane. make
test green; analyze + format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 12:08:07 +02:00
co-authored by Claude Opus 4.8
parent 3a59d5be8a
commit 2e4f87455a
7 changed files with 151 additions and 6 deletions
@@ -79,6 +79,27 @@ void main() {
});
});
group('vim preset tab motions (T-405)', () {
bool seqEq(List<KeyChord> seq, List<String> chords) {
if (seq.length != chords.length) return false;
for (var i = 0; i < chords.length; i++) {
if (seq[i] != KeyChord.parse(chords[i])) return false;
}
return true;
}
bool hasSeq(KeymapService svc, List<String> chords, String cmd) =>
svc.keymap!.effectiveBindings.any((b) => seqEq(b.sequence, chords) && isCommand(b.intent, cmd));
test('gt / gT bind workspace tab cycling, sharing the g prefix with gg', () async {
final svc = await activate('vim');
expect(hasSeq(svc, ['g', 't'], 'workspace.tab.next'), isTrue, reason: 'gt → workspace.tab.next');
expect(hasSeq(svc, ['g', 'shift+t'], 'workspace.tab.previous'), isTrue, reason: 'gT → workspace.tab.previous');
// The shared `g` prefix must not break gg (docStart) — different final.
expect(hasSeq(svc, ['g', 'g'], 'editor.vim.docStart'), isTrue, reason: 'gg → docStart intact');
});
});
group('jetbrains preset (T-66)', () {
test('setPreset activates it and the representative subset resolves', () async {
final svc = await activate('jetbrains');
@@ -3,6 +3,8 @@
/// end-to-end against the real vim preset and scope flags.
library;
import 'package:clide/clide.dart';
import 'package:clide/extension/src/contribution.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -67,6 +69,47 @@ void main() {
expect(got, isEmpty);
});
testWidgets('gt executes a non-editor command (workspace.tab.next) from a focused pane (T-405)', (tester) async {
var ran = 0;
f.services.commands.register(
CommandContribution(
id: 'workspace.tab.next',
command: 'workspace.tab.next',
title: 'Next Workspace Tab',
run: (_) async {
ran++;
return IpcResponse.ok(id: '', data: const {});
},
),
);
await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyT);
await tester.pump();
expect(ran, 1);
});
testWidgets('editor.vim.* buffer edits never execute from a pane (T-405 guard)', (tester) async {
var ran = 0;
// If the pane wrongly ran editor.vim.*, this would fire on `d d`.
f.services.commands.register(
CommandContribution(
id: 'editor.vim.deleteLine',
command: 'editor.vim.deleteLine',
title: 'vim: delete line',
run: (_) async {
ran++;
return IpcResponse.ok(id: '', data: const {});
},
),
);
await pump(tester, scope: {'vim.normal': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.pump();
expect(ran, 0);
});
testWidgets('keys pass through outside vim normal mode', (tester) async {
final got = await pump(tester, scope: {'vim.insert': true});
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);