feat(vim): ex command-line overlay (:w :q :wq :x :e :N, ZZ) (T-407)

Under the Vim preset, `:` opens a transient one-line ex overlay running a
fixed v1 table; ZZ runs :wq directly. Completes the last built child of the
T-403 cross-pane vim layer (T-405 part 2 gt/gT still open).

- ExLineController + parseExCommand grammar + editor-targeted executors
  (lib/kernel/src/ex_line.dart); the overlay (lib/widgets/src/ex_line_overlay
  .dart) reuses the quick-open chrome, mounts in the root_shell Stack, and
  publishes the exline.open scope flag. Unknown commands flash + stay open;
  with no active buffer every command no-ops (2026-06-13 decision).
- :q closes the active tab via editor.close on its id — the registry promotes
  the next buffer and the split self-collapses on the last (2026-06-12
  decision); :w/:wq/:x/ZZ save (+close) the active buffer.
- :e <path> seeds quick-open (new QuickOpenController.open(seed:)); :N adds the
  editor.goto-line IPC/CLI verb (reuses _offsetForLine). Goto needs caret sync:
  EditorController now handles editor.selection-changed and the editor view
  moves the caret on a selection-only change.
- `:` and ZZ are typed intents; the editor matcher and PaneKeyNav now bubble
  unhandled typed intents to the app-root Actions, so they fire from any focus.
  vim.yaml binds `:`, ZZ (shift+z shift+z), and Esc-dismiss.

Tests: parser/controller/executors, editor.goto-line daemon tests,
selection-changed (controller + view), full overlay widget test. make test
green; analyze + format clean.

Also files T-441 (drop bold from the ticket-id card label) and T-442
(sub-agent renders as 3 cards instead of one bundle) under the T-276 UI epic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 11:44:48 +02:00
co-authored by Claude Opus 4.8
parent c5e54ee0ac
commit e54d5263e0
22 changed files with 1269 additions and 8 deletions
+174
View File
@@ -0,0 +1,174 @@
/// Unit tests for the Vim ex command-line (T-407): the [parseExCommand]
/// grammar, the [ExLineController] open/close/flash state, and the
/// editor-targeted executor functions (which no-op when no buffer is active).
library;
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/fake_ipc.dart';
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
void main() {
group('parseExCommand', () {
test('empty (and bare colon) is a no-op', () {
expect(parseExCommand(''), isA<ExNoop>());
expect(parseExCommand(' '), isA<ExNoop>());
expect(parseExCommand(':'), isA<ExNoop>());
});
test('write / quit / write-quit and their aliases', () {
expect(parseExCommand('w'), isA<ExWrite>());
expect(parseExCommand('q'), isA<ExQuit>());
expect(parseExCommand('q!'), isA<ExQuit>());
expect(parseExCommand('wq'), isA<ExWriteQuit>());
expect(parseExCommand('wq!'), isA<ExWriteQuit>());
expect(parseExCommand('x'), isA<ExWriteQuit>());
expect(parseExCommand('x!'), isA<ExWriteQuit>());
});
test('a leading colon is tolerated', () {
expect(parseExCommand(':w'), isA<ExWrite>());
expect(parseExCommand(':q'), isA<ExQuit>());
});
test('edit seeds quick-open with the rest of the line', () {
expect(parseExCommand('e'), const ExEdit(''));
expect(parseExCommand('e lib/main.dart'), const ExEdit('lib/main.dart'));
expect(parseExCommand('e spaced '), const ExEdit('spaced'));
});
test('a positive integer is a goto-line', () {
expect(parseExCommand('1'), const ExGoto(1));
expect(parseExCommand('42'), const ExGoto(42));
});
test('zero, negatives and junk are unknown', () {
expect(parseExCommand('0'), isA<ExUnknown>());
expect(parseExCommand('-3'), isA<ExUnknown>());
expect(parseExCommand('wat'), isA<ExUnknown>());
expect(parseExCommand('e'), isNot(isA<ExUnknown>())); // guard: e is ExEdit
});
});
group('ExLineController', () {
late ExLineController c;
setUp(() => c = ExLineController());
tearDown(() => c.dispose());
test('open clears input and flips isOpen; close resets', () {
var notifications = 0;
c.addListener(() => notifications++);
c.setInput('stale');
c.open();
expect(c.isOpen, isTrue);
expect(c.input, isEmpty);
c.setInput('w');
expect(c.input, 'w');
c.close();
expect(c.isOpen, isFalse);
expect(c.input, isEmpty);
expect(notifications, greaterThan(0));
});
test('open is idempotent and does not re-clear a second time', () {
c.open();
c.setInput('w');
c.open(); // no-op
expect(c.input, 'w');
});
test('flashInvalid bumps the nonce monotonically', () {
final start = c.invalidNonce;
c.flashInvalid();
c.flashInvalid();
expect(c.invalidNonce, start + 2);
});
});
group('executors', () {
late DaemonBus bus;
late FakeDaemonClient ipc;
late List<String> calls;
setUp(() {
bus = DaemonBus();
ipc = FakeDaemonClient(log: Logger(), events: bus);
calls = [];
});
tearDown(() => bus.dispose());
void record(String cmd, [Map<String, Object?> data = const {}]) {
ipc.stub(cmd, (a) async {
calls.add(cmd);
return _ok(data);
});
}
test(':w saves the active buffer', () async {
record('editor.save');
await exWriteActive(ipc);
expect(calls, ['editor.save']);
});
test(':q resolves the active id then closes it', () async {
ipc.stub('editor.active', (_) async {
calls.add('editor.active');
return _ok({
'active': {'id': 'b_7'},
});
});
ipc.stub('editor.close', (a) async {
calls.add('editor.close:${a['id']}');
return _ok({});
});
await exQuitActive(ipc);
expect(calls, ['editor.active', 'editor.close:b_7']);
});
test(':q is a no-op with no active buffer', () async {
ipc.stub('editor.active', (_) async => _ok({'active': null}));
record('editor.close');
await exQuitActive(ipc);
expect(calls, isEmpty); // never reached editor.close
});
test(':wq saves then closes the active tab', () async {
ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_3'},
}),
);
ipc.stub('editor.save', (a) async {
calls.add('save:${a['id']}');
return _ok({});
});
ipc.stub('editor.close', (a) async {
calls.add('close:${a['id']}');
return _ok({});
});
await exWriteQuitActive(ipc);
expect(calls, ['save:b_3', 'close:b_3']);
});
test(':wq is a no-op with no active buffer', () async {
ipc.stub('editor.active', (_) async => _ok({'active': null}));
record('editor.save');
record('editor.close');
await exWriteQuitActive(ipc);
expect(calls, isEmpty);
});
test(':N dispatches editor.goto-line with the line', () async {
ipc.stub('editor.goto-line', (a) async {
calls.add('goto:${a['line']}');
return _ok({});
});
await exGotoLineActive(ipc, 42);
expect(calls, ['goto:42']);
});
});
}