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:
2026-05-31 20:53:50 +02:00
co-authored by Claude Opus 4.8
parent 399a4d3a3f
commit f96c565acd
13 changed files with 695 additions and 7 deletions
+8
View File
@@ -125,6 +125,14 @@ class _RootShellState extends State<_RootShell> {
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>(
onInvoke: (_) {
widget.services.focus.focusNextSlot();
+3
View File
@@ -0,0 +1,3 @@
export 'src/extension.dart';
export 'src/find_in_files_controller.dart';
export 'src/search_panel_view.dart';
+31
View File
@@ -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)),
]),
);
}
}
+8
View File
@@ -77,6 +77,13 @@ class QuickOpenAcceptIntent extends Intent {
const QuickOpenAcceptIntent();
}
// -- Find in files ----------------------------------------------------------
/// Reveal the find-in-files search panel in the sidebar.
class FindInFilesIntent extends Intent {
const FindInFilesIntent();
}
// -- Text scale -------------------------------------------------------------
class TextScaleIncreaseIntent extends Intent {
@@ -122,6 +129,7 @@ final Map<String, Intent Function()> builtinIntents = {
'quickOpen.selectNext': () => const QuickOpenSelectNextIntent(),
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
'findInFiles.open': () => const FindInFilesIntent(),
'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
'text.scaleReset': () => const TextScaleResetIntent(),
+2
View File
@@ -12,6 +12,7 @@ import 'package:clide/builtin/editor/editor.dart';
import 'package:clide/builtin/extensions_ui/extensions_ui.dart';
import 'package:clide/builtin/files/files.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/graph/graph.dart';
import 'package:clide/builtin/ipc_status/ipc_status.dart';
@@ -246,6 +247,7 @@ Future<void> main() async {
..register(TicketsExtension())
..register(DecisionsExtension())
..register(FilesExtension())
..register(SearchExtension())
..register(GitExtension())
..register(PqlExtension())
..register(ProblemsExtension())