searchReport function

List<SearchHit> searchReport(
  1. PluginReport report,
  2. String query, {
  3. required String worktree,
})

Everywhere in report you could go that matches query, without asking the plugin for anything.

This is the baseline every plugin gets for free. PluginReport is already all data, so walking it means a plugin is searchable the day it reports, with no search code of its own.

Only destinations. A result offers to take you somewhere, so a row that names no destination is not a result. That rules out three kinds the report also carries:

  • Actions. A verb rather than a place. "Screenshot" in a list of places is a category error, and running one off a fuzzy match is worse — some are declared danger. Commands are a different surface (a > mode, the way an editor separates go-to-file from run-command), not this one.
  • Free text and fields. A diagnostic or a label/value pair is output. Matching it strands you on the plugin, which is the wrong page.
  • Rows with no address. These used to be listed as "found, but not followed". In use that reads as a broken result: you searched a specific thing, something opened, and it was not that thing.

So a plugin is as findable as it is addressable. The way in is to give rows an address, not to be listed without one.

worktree is stamped into every address so hits stay distinguishable if a caller ever merges several.

Implementation

List<SearchHit> searchReport(
  PluginReport report,
  String query, {

  /// Which worktree the report came from. Required, because a hit exists to be
  /// gone to and a place cannot be reached without one — the same reason
  /// `2de5004` stopped offering results that named no destination.
  required String worktree,
}) {
  var trimmed = query.trim();
  if (trimmed.isEmpty) return const [];

  var hits = <SearchHit>[];
  var plugin = Address(worktree: worktree, plugin: report.id);

  void add({
    required String title,
    required Address address,
    required SearchReason reason,
    String? subtitle,
  }) {
    var match = fuzzyMatch(trimmed, title);
    var matched = match?.matched ?? const <int>[];
    var score = match?.score;
    if (score == null && subtitle != null) {
      // **Substring here, not subsequence.** A detail is an entry id, a version
      // or a sentence of prose, and a long string contains almost any short
      // subsequence — matching `dash` against "…its id and address…" is how a
      // fuzzy palette fills with noise. A name is short and the user is
      // recalling it, so a subsequence is right there and wrong here.
      var index = subtitle.toLowerCase().indexOf(trimmed.toLowerCase());
      if (index >= 0) score = 10 + (20 - index).clamp(0, 20);
    }
    if (score == null) return;

    hits.add(
      SearchHit(
        address: address,
        title: title,
        subtitle: subtitle,
        group: report.label,
        reason: reason,
        score: score + (_weights[reason] ?? 0),
        matched: matched,
      ),
    );
  }

  add(title: report.label, address: plugin, reason: SearchReason.plugin);

  for (var child in report.children) {
    add(
      title: child.label,
      subtitle: child.status.isEmpty ? null : child.status.message,
      address: _childAddress(plugin, child),
      reason: SearchReason.package,
    );
  }

  _walk(report.view.nodes, plugin, add);

  hits.sort((a, b) => b.score - a.score);
  return _dedupe(hits);
}