add workspace grep engine + search.grep/cancel, editor.open --line

The pure-Dart content-search engine behind find-in-files (D-79): walks
the ignore-pruned workspace, fans files across worker isolates
(Isolate.run) for parallelism, matches each line with a literal
indexOf fast-path or a RegExp, and streams match batches with
cooperative cancellation. No ripgrep dependency; the search.grep IPC
contract is engine-agnostic so an rg accelerator can slot in later.

search.grep returns a searchId and streams search.match / search.done
(or search.error) events, mirroring files.watch; search.cancel stops
an in-flight search. The service reuses the files service's resolved
ignore set so both honour the same ignore_files: layering.

editor.open gains an optional 1-based line argument: it converts the
line to a byte offset and sets the initial selection, enabling
click-to-line from search results.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 20:31:18 +02:00
co-authored by Claude Opus 4.8
parent 0c7a6e86d5
commit 399a4d3a3f
10 changed files with 824 additions and 0 deletions
+22
View File
@@ -11,6 +11,7 @@ library;
import 'dart:io' show FileSystemException;
import '../editor/buffer.dart' show Selection;
import '../editor/registry.dart';
import '../ipc/envelope.dart';
import '../ipc/errno_mapping.dart';
@@ -66,6 +67,14 @@ Future<IpcResponse> _open(IpcRequest req, EditorRegistry r) async {
}
try {
final buf = await r.open(path);
// Optional 1-based line: jump the initial selection to that line's
// start (used by find-in-files click-to-line, T-52). Out-of-range
// lines clamp via setSelection. A non-numeric/<1 line is ignored.
final rawLine = req.args['line'];
final line = rawLine is num ? rawLine.toInt() : int.tryParse('$rawLine');
if (line != null && line >= 1) {
r.setSelection(buf.id, Selection.collapsed(_offsetForLine(buf.content, line)));
}
return IpcResponse.ok(id: req.id, data: buf.toJson());
} on FileSystemException catch (e) {
final errno = e.osError?.errorCode;
@@ -95,6 +104,19 @@ Future<IpcResponse> _open(IpcRequest req, EditorRegistry r) async {
}
}
/// Byte offset of the start of the 1-based [line] in [content]. Lines
/// past the end clamp to the content length.
int _offsetForLine(String content, int line) {
if (line <= 1) return 0;
var remaining = line - 1;
var i = 0;
while (i < content.length && remaining > 0) {
if (content.codeUnitAt(i) == 0x0A) remaining--;
i++;
}
return i;
}
Future<IpcResponse> _active(IpcRequest req, EditorRegistry r) async {
final buf = r.active;
if (buf == null) {
+136
View File
@@ -0,0 +1,136 @@
/// Registers `search.*` command handlers (T-52, per D-79).
///
/// `search.grep` kicks off a workspace content search and returns a
/// `searchId` immediately; match batches stream back as `search.match`
/// events, terminated by `search.done` (or `search.error`). This
/// mirrors `files.watch`'s event-streaming shape. `search.cancel` stops
/// an in-flight search by id.
library;
import 'dart:async';
import 'dart:io';
import '../files/ignore.dart';
import '../ipc/command_schema.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import '../panes/event_sink.dart';
import '../search/grep_engine.dart';
import '../search/match.dart';
import 'dispatcher.dart';
/// Owns in-flight searches and streams their results onto the event
/// sink. One instance per workspace, constructed alongside the other
/// daemon services.
class SearchService {
SearchService({
required this.root,
required this.ignore,
required this.events,
this.useIsolates = true,
});
final Directory root;
final IgnoreSet ignore;
final DaemonEventSink events;
/// Whether the engine fans work across isolates. Tests set this false
/// for deterministic, in-process runs.
final bool useIsolates;
final Map<String, CancelToken> _active = {};
int _seq = 0;
/// Begin a search; returns the id that scopes its stream of events.
String start(SearchQuery query) {
final id = 'search-${_seq++}';
final cancel = CancelToken();
_active[id] = cancel;
unawaited(_run(id, query, cancel));
return id;
}
/// Cancel an in-flight search (no-op if already finished).
void cancel(String id) {
_active.remove(id)?.cancel();
}
Future<void> _run(String id, SearchQuery query, CancelToken cancel) async {
try {
await for (final batch in grepWorkspace(
root: root,
ignore: ignore,
query: query,
cancel: cancel,
useIsolates: useIsolates,
)) {
if (cancel.isCancelled) break;
_emit('search.match', {
'searchId': id,
'matches': [for (final m in batch) m.toJson()],
});
}
_emit('search.done', {'searchId': id, 'cancelled': cancel.isCancelled});
} on FormatException catch (e) {
_emit('search.error', {'searchId': id, 'message': 'invalid regex: ${e.message}'});
} catch (e) {
_emit('search.error', {'searchId': id, 'message': '$e'});
} finally {
_active.remove(id);
}
}
void _emit(String kind, Map<String, Object?> data) {
events.emit(IpcEvent(
subsystem: 'search',
kind: kind,
timestamp: DateTime.now().toUtc(),
data: data,
));
}
}
const CommandSchema _grepSchema = CommandSchema(
positional: ['pattern'],
args: {
'pattern': ArgSpec(required: true),
'regex': ArgSpec(type: ArgType.boolean),
'ignoreCase': ArgSpec(type: ArgType.boolean),
'include': ArgSpec(type: ArgType.stringList),
'exclude': ArgSpec(type: ArgType.stringList),
},
);
void registerSearchCommands(DaemonDispatcher d, SearchService search) {
d.register('search.grep', (req) async {
final query = SearchQuery.fromJson(req.args);
if (query.pattern.isEmpty) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: 'search.grep requires a non-empty pattern',
),
);
}
final id = search.start(query);
return IpcResponse.ok(id: req.id, data: {'searchId': id});
}, schema: _grepSchema);
d.register('search.cancel', (req) async {
final id = req.args['searchId'] as String?;
if (id == null || id.isEmpty) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: 'search.cancel requires a searchId',
),
);
}
search.cancel(id);
return IpcResponse.ok(id: req.id, data: {'cancelled': id});
});
}