add find-in-files sidebar panel + Ctrl/Cmd+Shift+F
The find-in-files UI on top of the search.grep engine. A FindInFilesController drives search.grep, accumulates streamed search.match events (scoped to the active searchId, stale ids ignored) grouped by file, and opens a match in the editor at its line. The SearchPanelView contributes a sidebar tab: a debounced query box, regex + case toggles, include/exclude glob fields, and a grouped results list with the matched span highlighted. findInFiles.open (Ctrl/Cmd+Shift+F) reveals and activates the search tab. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Find-in-files sidebar panel (Ctrl/Cmd+Shift+F): search the workspace with
|
||||||
|
regex and case toggles plus include/exclude globs; results stream in grouped
|
||||||
|
by file and clicking a match opens the editor at that line. (T-52)
|
||||||
- Workspace content-search engine with `search.grep` / `search.cancel` commands:
|
- Workspace content-search engine with `search.grep` / `search.cancel` commands:
|
||||||
a pure-Dart, isolate-parallel grep (literal or regex, case + include/exclude
|
a pure-Dart, isolate-parallel grep (literal or regex, case + include/exclude
|
||||||
glob filters) that streams matches and honours the `ignore_files:` chain. The
|
glob filters) that streams matches and honours the `ignore_files:` chain. The
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ bindings:
|
|||||||
keys: escape
|
keys: escape
|
||||||
when: quickOpen.open
|
when: quickOpen.open
|
||||||
|
|
||||||
|
# -- Find in files ----------------------------------------------------
|
||||||
|
- intent: findInFiles.open
|
||||||
|
keys: [ctrl+shift+f, meta+shift+f]
|
||||||
|
|
||||||
# -- Text scale -------------------------------------------------------
|
# -- Text scale -------------------------------------------------------
|
||||||
# On most layouts `+` is `shift+equal`; we bind both so users who
|
# On most layouts `+` is `shift+equal`; we bind both so users who
|
||||||
# think of it as Ctrl+Plus and users who hit Ctrl+= both work.
|
# think of it as Ctrl+Plus and users who hit Ctrl+= both work.
|
||||||
|
|||||||
@@ -125,6 +125,14 @@ class _RootShellState extends State<_RootShell> {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
FindInFilesIntent: CallbackAction<FindInFilesIntent>(
|
||||||
|
onInvoke: (_) {
|
||||||
|
widget.services.arrangement.setVisible(Slots.sidebar, true);
|
||||||
|
widget.services.arrangement.setCollapsed(Slots.sidebar, false);
|
||||||
|
widget.services.panels.activateTab(Slots.sidebar, 'search.findInFiles');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
|
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
|
||||||
onInvoke: (_) {
|
onInvoke: (_) {
|
||||||
widget.services.focus.focusNextSlot();
|
widget.services.focus.focusNextSlot();
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export 'src/extension.dart';
|
||||||
|
export 'src/find_in_files_controller.dart';
|
||||||
|
export 'src/search_panel_view.dart';
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:clide/builtin/search/src/search_panel_view.dart';
|
||||||
|
import 'package:clide/extension/extension.dart';
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
|
||||||
|
/// Find-in-files panel. Contributes a sidebar tab that runs workspace
|
||||||
|
/// content searches through the daemon's `search.*` subsystem (the
|
||||||
|
/// pure-Dart isolate-pool grep, D-79) and lists matches grouped by
|
||||||
|
/// file, click-to-open at the line.
|
||||||
|
class SearchExtension extends ClideExtension {
|
||||||
|
@override
|
||||||
|
String get id => 'builtin.search';
|
||||||
|
@override
|
||||||
|
String get title => 'Search';
|
||||||
|
@override
|
||||||
|
String get version => '0.1.0';
|
||||||
|
@override
|
||||||
|
List<String> get dependsOn => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<ContributionPoint> get contributions => [
|
||||||
|
TabContribution(
|
||||||
|
id: 'search.findInFiles',
|
||||||
|
slot: Slots.sidebar,
|
||||||
|
title: 'Search',
|
||||||
|
icon: PhosphorIcons.magnifyingGlass,
|
||||||
|
priority: -90,
|
||||||
|
build: (_) => const SearchPanelView(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/// State model for the find-in-files panel (T-52, per D-79).
|
||||||
|
///
|
||||||
|
/// Holds the query + option state, drives the `search.grep` IPC verb,
|
||||||
|
/// and accumulates streamed `search.match` events (scoped to the active
|
||||||
|
/// searchId) into a per-file grouping. Re-running cancels the prior
|
||||||
|
/// search; results from a stale search id are ignored.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/src/search/match.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class FindInFilesController extends ChangeNotifier {
|
||||||
|
FindInFilesController({required this.ipc, required this.events}) {
|
||||||
|
_sub = events.on<DaemonEvent>().listen(_onEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
final DaemonClient ipc;
|
||||||
|
final DaemonBus events;
|
||||||
|
StreamSubscription<DaemonEvent>? _sub;
|
||||||
|
|
||||||
|
// -- Query state ----------------------------------------------------------
|
||||||
|
String pattern = '';
|
||||||
|
bool regex = false;
|
||||||
|
bool ignoreCase = false;
|
||||||
|
String includeGlobs = '';
|
||||||
|
String excludeGlobs = '';
|
||||||
|
|
||||||
|
// -- Result state ---------------------------------------------------------
|
||||||
|
String? _activeSearchId;
|
||||||
|
final List<SearchMatch> _matches = [];
|
||||||
|
bool _running = false;
|
||||||
|
bool _done = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
List<SearchMatch> get matches => List.unmodifiable(_matches);
|
||||||
|
bool get running => _running;
|
||||||
|
bool get done => _done;
|
||||||
|
String? get error => _error;
|
||||||
|
int get matchCount => _matches.length;
|
||||||
|
|
||||||
|
/// Matches grouped by file path, preserving first-seen file order.
|
||||||
|
Map<String, List<SearchMatch>> grouped() {
|
||||||
|
final out = <String, List<SearchMatch>>{};
|
||||||
|
for (final m in _matches) {
|
||||||
|
(out[m.path] ??= []).add(m);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
int get fileCount => grouped().length;
|
||||||
|
|
||||||
|
void setRegex(bool v) {
|
||||||
|
if (regex == v) return;
|
||||||
|
regex = v;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setIgnoreCase(bool v) {
|
||||||
|
if (ignoreCase == v) return;
|
||||||
|
ignoreCase = v;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
set include(String v) => includeGlobs = v;
|
||||||
|
set exclude(String v) => excludeGlobs = v;
|
||||||
|
|
||||||
|
/// Start a search with the current query/options. Cancels any
|
||||||
|
/// in-flight search first and clears prior results.
|
||||||
|
Future<void> run(String query) async {
|
||||||
|
pattern = query;
|
||||||
|
if (_activeSearchId != null) {
|
||||||
|
unawaited(ipc.request('search.cancel', args: {'searchId': _activeSearchId}));
|
||||||
|
_activeSearchId = null;
|
||||||
|
}
|
||||||
|
_matches.clear();
|
||||||
|
_error = null;
|
||||||
|
_done = false;
|
||||||
|
if (pattern.trim().isEmpty) {
|
||||||
|
_running = false;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_running = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final resp = await ipc.request('search.grep', args: {
|
||||||
|
'pattern': pattern,
|
||||||
|
'regex': regex,
|
||||||
|
'ignoreCase': ignoreCase,
|
||||||
|
'include': _split(includeGlobs),
|
||||||
|
'exclude': _split(excludeGlobs),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
_error = resp.error?.message ?? 'search failed';
|
||||||
|
_running = false;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_activeSearchId = resp.data['searchId'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the in-flight search, if any.
|
||||||
|
void cancel() {
|
||||||
|
if (_activeSearchId != null) {
|
||||||
|
unawaited(ipc.request('search.cancel', args: {'searchId': _activeSearchId}));
|
||||||
|
_activeSearchId = null;
|
||||||
|
}
|
||||||
|
_running = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a match in the editor at its line (search always lands on the
|
||||||
|
/// source line, even for `.md`, which the reader can't position).
|
||||||
|
void openMatch(SearchMatch m) {
|
||||||
|
unawaited(ipc.request('editor.open', args: {'path': m.path, 'line': m.line}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onEvent(DaemonEvent e) {
|
||||||
|
if (e.subsystem != 'search') return;
|
||||||
|
if (e.data['searchId'] != _activeSearchId) return; // stale / cancelled
|
||||||
|
switch (e.kind) {
|
||||||
|
case 'search.match':
|
||||||
|
final raw = (e.data['matches'] as List?) ?? const [];
|
||||||
|
for (final m in raw.whereType<Map>()) {
|
||||||
|
_matches.add(SearchMatch.fromJson(m.cast<String, Object?>()));
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
case 'search.done':
|
||||||
|
_running = false;
|
||||||
|
_done = true;
|
||||||
|
_activeSearchId = null;
|
||||||
|
notifyListeners();
|
||||||
|
case 'search.error':
|
||||||
|
_error = e.data['message'] as String? ?? 'search error';
|
||||||
|
_running = false;
|
||||||
|
_activeSearchId = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _split(String s) => s.split(RegExp(r'[,\s]+')).where((x) => x.isNotEmpty).toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sub?.cancel();
|
||||||
|
_sub = null;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
/// The find-in-files sidebar panel (T-52, per D-79). A search input
|
||||||
|
/// with regex/case toggles + include/exclude glob fields, and a results
|
||||||
|
/// list grouped by file. Clicking a match opens the editor at its line.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/search/src/find_in_files_controller.dart';
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/src/search/match.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class SearchPanelView extends StatefulWidget {
|
||||||
|
const SearchPanelView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SearchPanelView> createState() => _SearchPanelViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchPanelViewState extends State<SearchPanelView> {
|
||||||
|
FindInFilesController? _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_controller != null) return;
|
||||||
|
final kernel = ClideKernel.of(context);
|
||||||
|
_controller = FindInFilesController(ipc: kernel.ipc, events: kernel.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tokens = ClideTheme.of(context).surface;
|
||||||
|
final c = _controller!;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: c,
|
||||||
|
builder: (context, _) {
|
||||||
|
final groups = c.grouped();
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ClideFilterBox(hint: 'Search', onChanged: c.run, onSubmitted: c.run),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_Toggle(
|
||||||
|
label: '.*',
|
||||||
|
tooltip: 'Regular expression',
|
||||||
|
active: c.regex,
|
||||||
|
tokens: tokens,
|
||||||
|
onTap: () {
|
||||||
|
c.setRegex(!c.regex);
|
||||||
|
c.run(c.pattern);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_Toggle(
|
||||||
|
label: 'Aa',
|
||||||
|
tooltip: 'Case insensitive',
|
||||||
|
active: c.ignoreCase,
|
||||||
|
tokens: tokens,
|
||||||
|
onTap: () {
|
||||||
|
c.setIgnoreCase(!c.ignoreCase);
|
||||||
|
c.run(c.pattern);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_StatusText(c, tokens),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
ClideFilterBox(hint: 'files to include (e.g. *.dart)', debounce: Duration.zero, onChanged: (v) => c.include = v),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ClideFilterBox(hint: 'files to exclude', debounce: Duration.zero, onChanged: (v) => c.exclude = v),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (c.error != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
child: ClideText(c.error!, color: tokens.globalTextMuted, fontSize: clideFontCaption),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
for (final entry in groups.entries) _FileGroup(path: entry.key, matches: entry.value, tokens: tokens, onTap: c.openMatch),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Toggle extends StatelessWidget {
|
||||||
|
const _Toggle({
|
||||||
|
required this.label,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.active,
|
||||||
|
required this.tokens,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String tooltip;
|
||||||
|
final bool active;
|
||||||
|
final SurfaceTokens tokens;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: tooltip,
|
||||||
|
toggled: active,
|
||||||
|
child: ClideTappable(
|
||||||
|
onTap: onTap,
|
||||||
|
builder: (context, hovered, _) => Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? tokens.listItemSelectedBackground : (hovered ? tokens.sidebarItemHover : null),
|
||||||
|
border: Border.all(color: active ? tokens.globalFocus : tokens.buttonBorder),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: ClideText(
|
||||||
|
label,
|
||||||
|
fontFamily: clideMonoFamily,
|
||||||
|
fontSize: clideFontCaption,
|
||||||
|
color: active ? tokens.listItemSelectedForeground : tokens.sidebarForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusText extends StatelessWidget {
|
||||||
|
const _StatusText(this.c, this.tokens);
|
||||||
|
final FindInFilesController c;
|
||||||
|
final SurfaceTokens tokens;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final String text;
|
||||||
|
if (c.running) {
|
||||||
|
text = 'Searching…';
|
||||||
|
} else if (c.matchCount == 0 && c.done) {
|
||||||
|
text = 'No results';
|
||||||
|
} else if (c.matchCount > 0) {
|
||||||
|
text = '${c.matchCount} in ${c.fileCount}';
|
||||||
|
} else {
|
||||||
|
text = '';
|
||||||
|
}
|
||||||
|
return ClideText(text, color: tokens.globalTextMuted, fontSize: clideFontCaption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileGroup extends StatelessWidget {
|
||||||
|
const _FileGroup({required this.path, required this.matches, required this.tokens, required this.onTap});
|
||||||
|
|
||||||
|
final String path;
|
||||||
|
final List<SearchMatch> matches;
|
||||||
|
final SurfaceTokens tokens;
|
||||||
|
final void Function(SearchMatch) onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: tokens.panelHeader,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ClideText(path, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.panelHeaderForeground),
|
||||||
|
),
|
||||||
|
ClideText('${matches.length}', fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final m in matches) _MatchRow(match: m, tokens: tokens, onTap: () => onTap(m)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MatchRow extends StatelessWidget {
|
||||||
|
const _MatchRow({required this.match, required this.tokens, required this.onTap});
|
||||||
|
|
||||||
|
final SearchMatch match;
|
||||||
|
final SurfaceTokens tokens;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: 'Open ${match.path} line ${match.line}',
|
||||||
|
onTap: onTap,
|
||||||
|
child: ClideTappable(
|
||||||
|
onTap: onTap,
|
||||||
|
builder: (context, hovered, _) => Container(
|
||||||
|
color: hovered ? tokens.listItemHoverBackground : null,
|
||||||
|
padding: const EdgeInsets.only(left: 18, right: 8, top: 2, bottom: 2),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 36,
|
||||||
|
child: ClideText('${match.line}', fontSize: clideFontCaption, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||||
|
),
|
||||||
|
Expanded(child: _highlighted()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _highlighted() {
|
||||||
|
final line = match.preview;
|
||||||
|
final start = match.matchStart.clamp(0, line.length);
|
||||||
|
final end = match.matchEnd.clamp(start, line.length);
|
||||||
|
final base = TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontCaption, color: tokens.sidebarForeground);
|
||||||
|
return RichText(
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
text: TextSpan(style: base, children: [
|
||||||
|
TextSpan(text: line.substring(0, start)),
|
||||||
|
TextSpan(text: line.substring(start, end), style: base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold)),
|
||||||
|
TextSpan(text: line.substring(end)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,13 @@ class QuickOpenAcceptIntent extends Intent {
|
|||||||
const QuickOpenAcceptIntent();
|
const QuickOpenAcceptIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Find in files ----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Reveal the find-in-files search panel in the sidebar.
|
||||||
|
class FindInFilesIntent extends Intent {
|
||||||
|
const FindInFilesIntent();
|
||||||
|
}
|
||||||
|
|
||||||
// -- Text scale -------------------------------------------------------------
|
// -- Text scale -------------------------------------------------------------
|
||||||
|
|
||||||
class TextScaleIncreaseIntent extends Intent {
|
class TextScaleIncreaseIntent extends Intent {
|
||||||
@@ -122,6 +129,7 @@ final Map<String, Intent Function()> builtinIntents = {
|
|||||||
'quickOpen.selectNext': () => const QuickOpenSelectNextIntent(),
|
'quickOpen.selectNext': () => const QuickOpenSelectNextIntent(),
|
||||||
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
|
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
|
||||||
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
|
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
|
||||||
|
'findInFiles.open': () => const FindInFilesIntent(),
|
||||||
'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
|
'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
|
||||||
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
|
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
|
||||||
'text.scaleReset': () => const TextScaleResetIntent(),
|
'text.scaleReset': () => const TextScaleResetIntent(),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:clide/builtin/editor/editor.dart';
|
|||||||
import 'package:clide/builtin/extensions_ui/extensions_ui.dart';
|
import 'package:clide/builtin/extensions_ui/extensions_ui.dart';
|
||||||
import 'package:clide/builtin/files/files.dart';
|
import 'package:clide/builtin/files/files.dart';
|
||||||
import 'package:clide/builtin/git/git.dart';
|
import 'package:clide/builtin/git/git.dart';
|
||||||
|
import 'package:clide/builtin/search/search.dart';
|
||||||
import 'package:clide/builtin/grammars_core/grammars_core.dart';
|
import 'package:clide/builtin/grammars_core/grammars_core.dart';
|
||||||
import 'package:clide/builtin/graph/graph.dart';
|
import 'package:clide/builtin/graph/graph.dart';
|
||||||
import 'package:clide/builtin/ipc_status/ipc_status.dart';
|
import 'package:clide/builtin/ipc_status/ipc_status.dart';
|
||||||
@@ -246,6 +247,7 @@ Future<void> main() async {
|
|||||||
..register(TicketsExtension())
|
..register(TicketsExtension())
|
||||||
..register(DecisionsExtension())
|
..register(DecisionsExtension())
|
||||||
..register(FilesExtension())
|
..register(FilesExtension())
|
||||||
|
..register(SearchExtension())
|
||||||
..register(GitExtension())
|
..register(GitExtension())
|
||||||
..register(PqlExtension())
|
..register(PqlExtension())
|
||||||
..register(ProblemsExtension())
|
..register(ProblemsExtension())
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/// Unit tests for [FindInFilesController] — query state, streamed match
|
||||||
|
/// accumulation, grouping, stale-id filtering, cancel, and open (T-52).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/search/src/find_in_files_controller.dart';
|
||||||
|
import 'package:clide/clide.dart';
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/src/search/match.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../../helpers/kernel_fixture.dart';
|
||||||
|
|
||||||
|
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late KernelFixture f;
|
||||||
|
FindInFilesController? ctrl;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
f = await KernelFixture.create();
|
||||||
|
f.ipc.stub('search.grep', (_) async => _ok({'searchId': 's1'}));
|
||||||
|
f.ipc.stub('search.cancel', (_) async => _ok(const {}));
|
||||||
|
f.ipc.stub('editor.open', (_) async => _ok(const {}));
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
ctrl?.dispose();
|
||||||
|
ctrl = null;
|
||||||
|
await f.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
FindInFilesController make() => ctrl = FindInFilesController(ipc: f.ipc, events: f.services.events);
|
||||||
|
|
||||||
|
void emitMatch(String id, List<Map<String, Object?>> matches) {
|
||||||
|
f.services.events.emit(DaemonEvent(
|
||||||
|
subsystem: 'search',
|
||||||
|
kind: 'search.match',
|
||||||
|
data: {'searchId': id, 'matches': matches},
|
||||||
|
ts: DateTime.now().toUtc(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> m(String path, int line) => {'path': path, 'line': line, 'matchStart': 0, 'matchEnd': 3, 'preview': 'foo bar'};
|
||||||
|
|
||||||
|
test('run sends search.grep with the current options and sets running', () async {
|
||||||
|
Map<String, Object?>? sent;
|
||||||
|
f.ipc.stub('search.grep', (args) async {
|
||||||
|
sent = args;
|
||||||
|
return _ok({'searchId': 's1'});
|
||||||
|
});
|
||||||
|
final c = make()
|
||||||
|
..setRegex(true)
|
||||||
|
..setIgnoreCase(true);
|
||||||
|
c.include = '*.dart';
|
||||||
|
await c.run('foo');
|
||||||
|
expect(sent!['pattern'], 'foo');
|
||||||
|
expect(sent!['regex'], isTrue);
|
||||||
|
expect(sent!['ignoreCase'], isTrue);
|
||||||
|
expect(sent!['include'], ['*.dart']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty pattern clears without dispatching a search', () async {
|
||||||
|
var called = false;
|
||||||
|
f.ipc.stub('search.grep', (_) async {
|
||||||
|
called = true;
|
||||||
|
return _ok({'searchId': 's1'});
|
||||||
|
});
|
||||||
|
final c = make();
|
||||||
|
await c.run(' ');
|
||||||
|
expect(called, isFalse);
|
||||||
|
expect(c.running, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streamed matches accumulate and group by file', () async {
|
||||||
|
final c = make();
|
||||||
|
await c.run('foo');
|
||||||
|
emitMatch('s1', [m('a.dart', 1), m('a.dart', 5), m('b.dart', 2)]);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(c.matchCount, 3);
|
||||||
|
final g = c.grouped();
|
||||||
|
expect(g.keys, containsAll(['a.dart', 'b.dart']));
|
||||||
|
expect(g['a.dart'], hasLength(2));
|
||||||
|
expect(c.fileCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches from a stale search id are ignored', () async {
|
||||||
|
final c = make();
|
||||||
|
await c.run('foo'); // activeSearchId == s1
|
||||||
|
emitMatch('OLD', [m('z.dart', 9)]);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(c.matchCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search.done clears running', () async {
|
||||||
|
final c = make();
|
||||||
|
await c.run('foo');
|
||||||
|
f.services.events.emit(DaemonEvent(
|
||||||
|
subsystem: 'search',
|
||||||
|
kind: 'search.done',
|
||||||
|
data: const {'searchId': 's1', 'cancelled': false},
|
||||||
|
ts: DateTime.now().toUtc(),
|
||||||
|
));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(c.running, isFalse);
|
||||||
|
expect(c.done, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search.error surfaces the message', () async {
|
||||||
|
final c = make();
|
||||||
|
await c.run('(bad');
|
||||||
|
f.services.events.emit(DaemonEvent(
|
||||||
|
subsystem: 'search',
|
||||||
|
kind: 'search.error',
|
||||||
|
data: const {'searchId': 's1', 'message': 'invalid regex: x'},
|
||||||
|
ts: DateTime.now().toUtc(),
|
||||||
|
));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(c.error, contains('invalid regex'));
|
||||||
|
expect(c.running, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-running clears prior results', () async {
|
||||||
|
final c = make();
|
||||||
|
await c.run('foo');
|
||||||
|
emitMatch('s1', [m('a.dart', 1)]);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(c.matchCount, 1);
|
||||||
|
await c.run('bar');
|
||||||
|
expect(c.matchCount, 0); // cleared on new run
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openMatch issues editor.open with the line', () async {
|
||||||
|
Map<String, Object?>? sent;
|
||||||
|
f.ipc.stub('editor.open', (args) async {
|
||||||
|
sent = args;
|
||||||
|
return _ok(const {});
|
||||||
|
});
|
||||||
|
final c = make();
|
||||||
|
c.openMatch(const SearchMatch(path: 'a.dart', line: 7, matchStart: 0, matchEnd: 3, preview: 'foo'));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(sent!['path'], 'a.dart');
|
||||||
|
expect(sent!['line'], 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel stops running', () async {
|
||||||
|
final c = make();
|
||||||
|
await c.run('foo');
|
||||||
|
expect(c.running, isTrue);
|
||||||
|
c.cancel();
|
||||||
|
expect(c.running, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/// Widget test for [SearchPanelView] — searching renders streamed
|
||||||
|
/// matches grouped by file and clicking a match opens it (T-52).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/search/src/search_panel_view.dart';
|
||||||
|
import 'package:clide/clide.dart';
|
||||||
|
import 'package:clide/kernel/kernel.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) => IpcResponse.ok(id: '', data: data);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late KernelFixture f;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
f = await KernelFixture.create();
|
||||||
|
f.ipc.stub('search.grep', (_) async => _ok({'searchId': 's1'}));
|
||||||
|
f.ipc.stub('search.cancel', (_) async => _ok(const {}));
|
||||||
|
});
|
||||||
|
tearDown(() => f.dispose());
|
||||||
|
|
||||||
|
void emitMatches() {
|
||||||
|
f.services.events.emit(DaemonEvent(
|
||||||
|
subsystem: 'search',
|
||||||
|
kind: 'search.match',
|
||||||
|
data: const {
|
||||||
|
'searchId': 's1',
|
||||||
|
'matches': [
|
||||||
|
{'path': 'lib/a.dart', 'line': 12, 'matchStart': 6, 'matchEnd': 9, 'preview': 'final foo = 1;'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ts: DateTime.now().toUtc(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('search renders matches grouped by file', (tester) async {
|
||||||
|
await tester.pumpWidget(harness(f, const SearchPanelView()));
|
||||||
|
await tester.enterText(find.byType(EditableText).first, 'foo');
|
||||||
|
await tester.pump(const Duration(milliseconds: 250)); // debounce → run()
|
||||||
|
await pumpAsync(tester); // search.grep resolves; activeSearchId set
|
||||||
|
emitMatches();
|
||||||
|
await pumpAsync(tester);
|
||||||
|
|
||||||
|
expect(find.text('lib/a.dart'), findsOneWidget); // file group header
|
||||||
|
expect(find.text('12'), findsOneWidget); // line number
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping a match opens the editor at its line', (tester) async {
|
||||||
|
Map<String, Object?>? opened;
|
||||||
|
f.ipc.stub('editor.open', (args) async {
|
||||||
|
opened = args;
|
||||||
|
return _ok(const {});
|
||||||
|
});
|
||||||
|
await tester.pumpWidget(harness(f, const SearchPanelView()));
|
||||||
|
await tester.enterText(find.byType(EditableText).first, 'foo');
|
||||||
|
await tester.pump(const Duration(milliseconds: 250));
|
||||||
|
await pumpAsync(tester);
|
||||||
|
emitMatches();
|
||||||
|
await pumpAsync(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('12')); // the match row's line number
|
||||||
|
await pumpAsync(tester);
|
||||||
|
|
||||||
|
expect(opened, isNotNull);
|
||||||
|
expect(opened!['path'], 'lib/a.dart');
|
||||||
|
expect(opened!['line'], 12);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -35,6 +35,10 @@ void main() {
|
|||||||
expect(parseIntentId('quickOpen.accept'), isA<QuickOpenAcceptIntent>());
|
expect(parseIntentId('quickOpen.accept'), isA<QuickOpenAcceptIntent>());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns the findInFiles.open intent', () {
|
||||||
|
expect(parseIntentId('findInFiles.open'), isA<FindInFilesIntent>());
|
||||||
|
});
|
||||||
|
|
||||||
test('returns the text.scale* intents', () {
|
test('returns the text.scale* intents', () {
|
||||||
expect(parseIntentId('text.scaleIncrease'), isA<TextScaleIncreaseIntent>());
|
expect(parseIntentId('text.scaleIncrease'), isA<TextScaleIncreaseIntent>());
|
||||||
expect(parseIntentId('text.scaleDecrease'), isA<TextScaleDecreaseIntent>());
|
expect(parseIntentId('text.scaleDecrease'), isA<TextScaleDecreaseIntent>());
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ void main() {
|
|||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
f = await KernelFixture.create();
|
f = await KernelFixture.create();
|
||||||
f.ipc.stub('files.walk', (_) async => IpcResponse.ok(id: '1', data: const {
|
f.ipc.stub(
|
||||||
'files': ['lib/main.dart', 'lib/app.dart', 'README.md'],
|
'files.walk',
|
||||||
'truncated': false,
|
(_) 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']}));
|
f.ipc.stub('editor.open', (args) async => IpcResponse.ok(id: '1', data: {'path': args['path']}));
|
||||||
});
|
});
|
||||||
tearDown(() => f.dispose());
|
tearDown(() => f.dispose());
|
||||||
@@ -63,9 +65,7 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('tapping an md result publishes to the markdown reader bus', (tester) async {
|
testWidgets('tapping an md result publishes to the markdown reader bus', (tester) async {
|
||||||
final published = <Message>[];
|
final published = <Message>[];
|
||||||
final sub = f.services.messages
|
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
|
||||||
.subscribe(publisher: 'builtin.markdown', channel: 'selection')
|
|
||||||
.listen(published.add);
|
|
||||||
addTearDown(sub.cancel);
|
addTearDown(sub.cancel);
|
||||||
|
|
||||||
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
|
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
|
||||||
|
|||||||
Reference in New Issue
Block a user