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