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:
@@ -187,6 +187,59 @@ void main() {
|
||||
expect(c.activeId, isNull); // cleared until an active-changed arrives
|
||||
});
|
||||
|
||||
test('editor.selection-changed mirrors an external caret move onto the active buffer (T-407)', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')],
|
||||
}),
|
||||
);
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'},
|
||||
}),
|
||||
);
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'line1\nline2')));
|
||||
await c.hydrate();
|
||||
expect(c.selection.start, 0);
|
||||
|
||||
// An external setSelection (ex-line :N goto) jumps the caret server-side.
|
||||
emitEditor(bus, 'editor.selection-changed', {
|
||||
'id': 'b_1',
|
||||
'selection': {'start': 6, 'end': 6},
|
||||
});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.selection.start, 6);
|
||||
expect(c.selection.end, 6);
|
||||
});
|
||||
|
||||
test('editor.selection-changed for a non-active buffer is ignored', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
(_) async => _ok({
|
||||
'buffers': [_buf('b_1', 'a.dart')],
|
||||
}),
|
||||
);
|
||||
ipc.stub(
|
||||
'editor.active',
|
||||
(_) async => _ok({
|
||||
'active': {'id': 'b_1'},
|
||||
}),
|
||||
);
|
||||
ipc.stub('editor.read', (_) async => _ok(_read('b_1', 'a.dart', 'hello')));
|
||||
await c.hydrate();
|
||||
|
||||
emitEditor(bus, 'editor.selection-changed', {
|
||||
'id': 'b_other',
|
||||
'selection': {'start': 3, 'end': 3},
|
||||
});
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(c.selection.start, 0); // untouched
|
||||
});
|
||||
|
||||
test('editor.saved clears the dirty marker on the buffer', () async {
|
||||
ipc.stub(
|
||||
'editor.list',
|
||||
|
||||
@@ -6,6 +6,7 @@ library;
|
||||
|
||||
import 'package:clide/builtin/editor/src/editor_view.dart';
|
||||
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';
|
||||
@@ -76,6 +77,32 @@ void main() {
|
||||
expect(find.text('Open a file to begin editing.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an external selection-changed moves the caret without retyping (T-407)', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart')], active: 'b_1');
|
||||
await tester.pumpWidget(harness(f, const EditorView()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.selection.baseOffset, 0);
|
||||
|
||||
// An ex-line `:N` goto sets the selection server-side; the buffer content
|
||||
// is unchanged, so only the caret should move (the selection-only branch).
|
||||
f.services.events.emit(
|
||||
DaemonEvent(
|
||||
subsystem: 'editor',
|
||||
kind: 'editor.selection-changed',
|
||||
data: const {
|
||||
'id': 'b_1',
|
||||
'selection': {'start': 5, 'end': 5},
|
||||
},
|
||||
ts: DateTime.now().toUtc(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final field = tester.widget<EditableText>(find.byType(EditableText));
|
||||
expect(field.controller.selection.baseOffset, 5);
|
||||
expect(field.controller.text, 'content of lib/a.dart'); // unchanged
|
||||
});
|
||||
|
||||
testWidgets('tapping an inactive tab routes to editor.activate', (tester) async {
|
||||
stubBuffers([_buf('b_1', 'lib/a.dart'), _buf('b_2', 'src/b.dart')], active: 'b_1');
|
||||
String? activated;
|
||||
|
||||
@@ -89,6 +89,49 @@ void main() {
|
||||
expect((r.data['selection'] as Map)['start'], 8);
|
||||
});
|
||||
|
||||
test('editor.goto-line jumps the active buffer to a 1-based line (T-407)', () async {
|
||||
await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\nthree\n');
|
||||
await call('editor.open', {'path': 'multi.txt'});
|
||||
final r = await call('editor.goto-line', {'line': 3});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['line'], 3);
|
||||
// Line 3 starts after 'one\n' + 'two\n' = 8 characters.
|
||||
final read = await call('editor.read');
|
||||
expect((read.data['selection'] as Map)['start'], 8);
|
||||
});
|
||||
|
||||
test('editor.goto-line clamps an out-of-range line to the content end', () async {
|
||||
await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\n'); // 8 chars
|
||||
await call('editor.open', {'path': 'multi.txt'});
|
||||
final r = await call('editor.goto-line', {'line': 999});
|
||||
expect(r.ok, isTrue);
|
||||
final read = await call('editor.read');
|
||||
expect((read.data['selection'] as Map)['start'], 8);
|
||||
});
|
||||
|
||||
test('editor.goto-line rejects a non-positive line', () async {
|
||||
await call('editor.open', {'path': 'doc.md'});
|
||||
final r = await call('editor.goto-line', {'line': 0});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'user_error');
|
||||
});
|
||||
|
||||
test('editor.goto-line with no active buffer is not-found', () async {
|
||||
final r = await call('editor.goto-line', {'line': 2});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'not_found');
|
||||
});
|
||||
|
||||
test('CLI positional line binds to editor.goto-line (T-232)', () async {
|
||||
await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\nthree\n');
|
||||
await call('editor.open', {'path': 'multi.txt'});
|
||||
final r = await call('editor.goto-line', {
|
||||
'positional': ['2'],
|
||||
});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['line'], 2);
|
||||
});
|
||||
|
||||
test('editor.insert without id targets the active buffer', () async {
|
||||
await call('editor.open', {'path': 'doc.md'});
|
||||
final r = await call('editor.insert', {'text': 'X '});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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