add quick-open fuzzy file finder (Ctrl/Cmd+P)
A file picker overlay over the whole workspace, distinct from the command palette. QuickOpenController holds the file list + a subsequence fuzzy filter; the overlay loads the list via files.walk on open, shows RecentFilesService entries on an empty query, and opens the selection through a shared openWorkspaceFile helper (.md → markdown reader bus, else editor.open) that the files panel now also routes through, so recents stay in sync from every open site. Bound to ctrl+p / meta+p with `when: !palette.open` so it never collides with the palette's ctrl+p navigation; in-overlay arrows/enter/ escape reuse the palette's keymap-driven model via quickOpen.* intents. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
/// Unit tests for [QuickOpenController] — state, fuzzy ranking, recents
|
||||
/// fallback, and selection wrapping (T-51).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/src/quick_open.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
late List<String> recents;
|
||||
QuickOpenController make() => QuickOpenController(recentPaths: () => recents);
|
||||
|
||||
setUp(() => recents = ['recent_a.dart', 'recent_b.dart']);
|
||||
|
||||
test('open/close/toggle track isOpen and reset the filter', () {
|
||||
final c = make();
|
||||
expect(c.isOpen, isFalse);
|
||||
c.open();
|
||||
expect(c.isOpen, isTrue);
|
||||
c.setFilter('main');
|
||||
c.close();
|
||||
expect(c.isOpen, isFalse);
|
||||
expect(c.filter, isEmpty);
|
||||
c.toggle();
|
||||
expect(c.isOpen, isTrue);
|
||||
});
|
||||
|
||||
test('empty query shows recents', () {
|
||||
final c = make()..open();
|
||||
c.setFiles(['lib/main.dart', 'lib/app.dart']);
|
||||
expect(c.filtered(), recents);
|
||||
expect(c.selectedPath, 'recent_a.dart');
|
||||
});
|
||||
|
||||
test('non-empty query fuzzy-matches the file list, not recents', () {
|
||||
final c = make()..open();
|
||||
c.setFiles(['lib/main.dart', 'lib/app.dart', 'README.md']);
|
||||
c.setFilter('main');
|
||||
expect(c.filtered(), contains('lib/main.dart'));
|
||||
expect(c.filtered(), isNot(contains('README.md')));
|
||||
});
|
||||
|
||||
test('subsequence match ranks contiguous/early hits above scattered', () {
|
||||
final c = make()..open();
|
||||
c.setFiles(['x/abc_extra.dart', 'abc.dart', 'a_b_c.dart']);
|
||||
c.setFilter('abc');
|
||||
// 'abc.dart' (contiguous, earliest) should rank first.
|
||||
expect(c.filtered().first, 'abc.dart');
|
||||
});
|
||||
|
||||
test('non-matching query yields an empty result list', () {
|
||||
final c = make()..open();
|
||||
c.setFiles(['lib/main.dart']);
|
||||
c.setFilter('zzzzz');
|
||||
expect(c.filtered(), isEmpty);
|
||||
expect(c.selectedPath, isNull);
|
||||
});
|
||||
|
||||
test('selectNext/Previous wrap around the result list', () {
|
||||
final c = make()..open();
|
||||
c.setFiles(['a.dart', 'b.dart', 'c.dart']);
|
||||
c.setFilter('dart'); // matches all three
|
||||
expect(c.selectedIndex, 0);
|
||||
c.selectPrevious(); // wraps to last
|
||||
expect(c.selectedIndex, c.filtered().length - 1);
|
||||
c.selectNext(); // wraps back to 0
|
||||
expect(c.selectedIndex, 0);
|
||||
});
|
||||
|
||||
test('selection is a no-op with fewer than two results', () {
|
||||
final c = make()..open();
|
||||
c.setFiles(['only.dart']);
|
||||
c.setFilter('only');
|
||||
c.selectNext();
|
||||
expect(c.selectedIndex, 0);
|
||||
});
|
||||
|
||||
test('setFiles records truncation', () {
|
||||
final c = make()..open();
|
||||
c.setFiles(['a.dart'], truncated: true);
|
||||
expect(c.truncated, isTrue);
|
||||
});
|
||||
|
||||
test('result list is capped at resultCap', () {
|
||||
final c = make()..open();
|
||||
c.setFiles([for (var i = 0; i < QuickOpenController.resultCap + 50; i++) 'f$i.dart']);
|
||||
c.setFilter('dart');
|
||||
expect(c.filtered(), hasLength(QuickOpenController.resultCap));
|
||||
});
|
||||
|
||||
test('setLoading toggles the flag and notifies', () {
|
||||
final c = make();
|
||||
var n = 0;
|
||||
c.addListener(() => n++);
|
||||
c.setLoading(true);
|
||||
expect(c.isLoading, isTrue);
|
||||
expect(n, 1);
|
||||
c.setLoading(true); // no change → no notify
|
||||
expect(n, 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/// Unit tests for [RecentFilesService] — the session-scoped recent
|
||||
/// files list backing quick-open's empty-query state (T-51).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/src/recent_files.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('push records most-recent-first', () {
|
||||
final r = RecentFilesService();
|
||||
r.push('a.dart');
|
||||
r.push('b.dart');
|
||||
expect(r.paths, ['b.dart', 'a.dart']);
|
||||
});
|
||||
|
||||
test('push de-duplicates and moves an existing entry to the front', () {
|
||||
final r = RecentFilesService();
|
||||
r.push('a.dart');
|
||||
r.push('b.dart');
|
||||
r.push('a.dart');
|
||||
expect(r.paths, ['a.dart', 'b.dart']);
|
||||
});
|
||||
|
||||
test('trims to the cap, dropping the oldest', () {
|
||||
final r = RecentFilesService(cap: 2);
|
||||
r.push('a');
|
||||
r.push('b');
|
||||
r.push('c');
|
||||
expect(r.paths, ['c', 'b']);
|
||||
});
|
||||
|
||||
test('empty path is ignored', () {
|
||||
final r = RecentFilesService();
|
||||
r.push('');
|
||||
expect(r.paths, isEmpty);
|
||||
});
|
||||
|
||||
test('clear empties the list and notifies once', () {
|
||||
final r = RecentFilesService();
|
||||
var notifications = 0;
|
||||
r.addListener(() => notifications++);
|
||||
r.push('a');
|
||||
r.clear();
|
||||
expect(r.paths, isEmpty);
|
||||
expect(notifications, 2);
|
||||
// A second clear on an empty list is a no-op (no extra notify).
|
||||
r.clear();
|
||||
expect(notifications, 2);
|
||||
});
|
||||
|
||||
test('paths is an unmodifiable snapshot', () {
|
||||
final r = RecentFilesService();
|
||||
r.push('a');
|
||||
expect(() => r.paths.add('b'), throwsUnsupportedError);
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,13 @@ void main() {
|
||||
expect(parseIntentId('palette.accept'), isA<PaletteAcceptIntent>());
|
||||
});
|
||||
|
||||
test('returns the quickOpen.* intents', () {
|
||||
expect(parseIntentId('quickOpen.open'), isA<QuickOpenIntent>());
|
||||
expect(parseIntentId('quickOpen.selectNext'), isA<QuickOpenSelectNextIntent>());
|
||||
expect(parseIntentId('quickOpen.selectPrevious'), isA<QuickOpenSelectPreviousIntent>());
|
||||
expect(parseIntentId('quickOpen.accept'), isA<QuickOpenAcceptIntent>());
|
||||
});
|
||||
|
||||
test('returns the text.scale* intents', () {
|
||||
expect(parseIntentId('text.scaleIncrease'), isA<TextScaleIncreaseIntent>());
|
||||
expect(parseIntentId('text.scaleDecrease'), isA<TextScaleDecreaseIntent>());
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/// Widget tests for [QuickOpenOverlay] — loads the file list via the
|
||||
/// stubbed `files.walk`, routes a selection through [openWorkspaceFile]
|
||||
/// (.md → markdown reader bus, else editor.open), and records recents
|
||||
/// (T-51).
|
||||
library;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/kernel_fixture.dart';
|
||||
import '../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
f.ipc.stub('files.walk', (_) async => IpcResponse.ok(id: '1', data: const {
|
||||
'files': ['lib/main.dart', 'lib/app.dart', 'README.md'],
|
||||
'truncated': false,
|
||||
}));
|
||||
f.ipc.stub('editor.open', (args) async => IpcResponse.ok(id: '1', data: {'path': args['path']}));
|
||||
});
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
testWidgets('hidden until opened, then loads files and filters', (tester) async {
|
||||
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
|
||||
expect(find.byType(EditableText), findsNothing);
|
||||
|
||||
f.services.quickOpen.open();
|
||||
await pumpAsync(tester);
|
||||
// File list loaded from files.walk.
|
||||
expect(f.services.quickOpen.truncated, isFalse);
|
||||
|
||||
await tester.enterText(find.byType(EditableText), 'app');
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('app.dart'), findsOneWidget);
|
||||
expect(find.text('main.dart'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tapping a non-md result opens the editor and records a recent', (tester) async {
|
||||
String? opened;
|
||||
f.ipc.stub('editor.open', (args) async {
|
||||
opened = args['path'] as String?;
|
||||
return IpcResponse.ok(id: '1', data: {'path': args['path']});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
|
||||
f.services.quickOpen.open();
|
||||
await pumpAsync(tester);
|
||||
await tester.enterText(find.byType(EditableText), 'main');
|
||||
await pumpAsync(tester);
|
||||
|
||||
await tester.tap(find.text('main.dart'));
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(opened, 'lib/main.dart');
|
||||
expect(f.services.quickOpen.isOpen, isFalse);
|
||||
expect(f.services.recentFiles.paths, contains('lib/main.dart'));
|
||||
});
|
||||
|
||||
testWidgets('tapping an md result publishes to the markdown reader bus', (tester) async {
|
||||
final published = <Message>[];
|
||||
final sub = f.services.messages
|
||||
.subscribe(publisher: 'builtin.markdown', channel: 'selection')
|
||||
.listen(published.add);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
|
||||
f.services.quickOpen.open();
|
||||
await pumpAsync(tester);
|
||||
await tester.enterText(find.byType(EditableText), 'readme');
|
||||
await pumpAsync(tester);
|
||||
|
||||
await tester.tap(find.text('README.md'));
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(published, hasLength(1));
|
||||
expect(published.first.data['path'], 'README.md');
|
||||
expect(f.services.recentFiles.paths, contains('README.md'));
|
||||
});
|
||||
|
||||
testWidgets('empty query shows recents once any file has been opened', (tester) async {
|
||||
f.services.recentFiles.push('lib/app.dart');
|
||||
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
|
||||
f.services.quickOpen.open();
|
||||
await pumpAsync(tester);
|
||||
// Empty query → recents listed.
|
||||
expect(find.text('app.dart'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user