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:
@@ -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) {
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user