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
+143
View File
@@ -0,0 +1,143 @@
/// Widget tests for the Vim ex command-line overlay (T-407): each v1 command
/// row dispatches the right editor IPC verb (or seeds quick-open), unknown
/// commands keep the overlay open with a hint, and Esc dismisses it.
///
/// Built on a tight, sized Stack rather than the shared `harness()` — the
/// overlay is a `Positioned` child and needs a bounded Stack ancestor (the
/// canSizeOverlay harness mis-sizes positioned content).
library;
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.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 = const {}]) => IpcResponse.ok(id: '', data: data);
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
Widget mount() => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: const MediaQuery(
data: MediaQueryData(size: Size(800, 600)),
child: SizedBox(width: 800, height: 600, child: Stack(children: [ExLineOverlay()])),
),
),
),
);
/// Open the overlay and type [text] into it (no submit yet).
Future<void> openAndType(WidgetTester tester, String text) async {
await tester.pumpWidget(mount());
f.services.exLine.open();
await pumpAsync(tester);
await tester.enterText(find.byType(EditableText), text);
await pumpAsync(tester);
}
Future<void> submit(WidgetTester tester) async {
await tester.testTextInput.receiveAction(TextInputAction.done);
await pumpAsync(tester);
}
testWidgets('closed: renders nothing', (tester) async {
await tester.pumpWidget(mount());
expect(find.byType(EditableText), findsNothing);
});
testWidgets(':w saves the active buffer and closes', (tester) async {
var saved = false;
f.ipc.stub('editor.save', (_) async {
saved = true;
return _ok();
});
await openAndType(tester, 'w');
await submit(tester);
expect(saved, isTrue);
expect(f.services.exLine.isOpen, isFalse);
});
testWidgets(':q closes the active tab via its id', (tester) async {
String? closed;
f.ipc.stub(
'editor.active',
(_) async => _ok({
'active': {'id': 'b_9'},
}),
);
f.ipc.stub('editor.close', (a) async {
closed = a['id'] as String?;
return _ok();
});
await openAndType(tester, 'q');
await submit(tester);
expect(closed, 'b_9');
expect(f.services.exLine.isOpen, isFalse);
});
testWidgets(':42 dispatches editor.goto-line', (tester) async {
Object? line;
f.ipc.stub('editor.goto-line', (a) async {
line = a['line'];
return _ok();
});
await openAndType(tester, '42');
await submit(tester);
expect(line, 42);
});
testWidgets(':e seeds quick-open and closes the ex-line', (tester) async {
await openAndType(tester, 'e lib/main.dart');
await submit(tester);
expect(f.services.exLine.isOpen, isFalse);
expect(f.services.quickOpen.isOpen, isTrue);
expect(f.services.quickOpen.filter, 'lib/main.dart');
});
testWidgets('unknown command keeps the overlay open and shows the hint', (tester) async {
await openAndType(tester, 'nope');
await submit(tester);
expect(f.services.exLine.isOpen, isTrue);
expect(find.text('Not an editor command'), findsOneWidget);
});
testWidgets('editing after a rejection clears the hint', (tester) async {
await openAndType(tester, 'nope');
await submit(tester);
expect(find.text('Not an editor command'), findsOneWidget);
await tester.enterText(find.byType(EditableText), 'w');
await pumpAsync(tester);
expect(find.text('Not an editor command'), findsNothing);
});
testWidgets('DismissIntent closes the overlay', (tester) async {
await openAndType(tester, 'w');
final ctx = tester.element(find.byType(EditableText));
Actions.invoke(ctx, const DismissIntent());
await pumpAsync(tester);
expect(f.services.exLine.isOpen, isFalse);
});
testWidgets('opening publishes the exline.open scope flag; closing clears it', (tester) async {
await tester.pumpWidget(mount());
f.services.exLine.open();
await pumpAsync(tester);
expect(f.services.keymap.scope['exline.open'], isTrue);
f.services.exLine.close();
await pumpAsync(tester);
expect(f.services.keymap.scope['exline.open'], isNot(true));
});
}