From fa4db600321c9c6cd22c82da5531a1d477a7c79e Mon Sep 17 00:00:00 2001 From: Jacob Zeitz Date: Thu, 29 Oct 2026 10:03:27 +0100 Subject: [PATCH] feat(plugins): let a plugin search result's snippet mark what matched An entry from onProvideSearchResults may wrap what matched in , as SearchSnippet/get does, and the block above the message list highlights it with the list's own SearchSnippetText. The host keeps the marks, escapes everything else and cuts the snippet by its text, closing a mark it cuts, so the snippet is still never rendered as HTML. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/plugin-search-results.test.tsx | 13 +++++++ components/email/plugin-search-results.tsx | 7 +++- lib/__tests__/plugin-search-results.test.ts | 18 ++++++++++ lib/plugin-search-results.ts | 35 +++++++++++++++++-- lib/plugin-types.ts | 8 ++++- 5 files changed, 77 insertions(+), 4 deletions(-) diff --git a/components/email/__tests__/plugin-search-results.test.tsx b/components/email/__tests__/plugin-search-results.test.tsx index b7f80893d..454ed6ba9 100644 --- a/components/email/__tests__/plugin-search-results.test.tsx +++ b/components/email/__tests__/plugin-search-results.test.tsx @@ -72,6 +72,19 @@ describe('PluginSearchResults', () => { expect(screen.getByRole('region', { name: 'Team chat' })).toHaveTextContent('Snippet b'); }); + it('highlights what a snippet marks, its other text as text', () => { + useEmailStore.setState({ + searchQuery: 'pixel grid', + externalSearchResults: [{ ...result('crm', 'a', 'CRM'), snippet: 'The pixel grid is <b>coarse</b>' }], + }); + render(); + + const mark = screen.getByText('pixel grid'); + expect(mark.tagName).toBe('MARK'); + expect(mark.parentElement).toHaveTextContent('The pixel grid is coarse'); + expect(mark.parentElement?.querySelector('b')).toBeNull(); + }); + it('sends a click to the plugin that gave the entry, with the query', () => { useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [result('crm', 'mail-123', 'CRM')] }); render(); diff --git a/components/email/plugin-search-results.tsx b/components/email/plugin-search-results.tsx index 9f71b1505..1c24218e0 100644 --- a/components/email/plugin-search-results.tsx +++ b/components/email/plugin-search-results.tsx @@ -2,6 +2,7 @@ import { useId, useMemo, useState } from "react"; import { ChevronDown, ChevronRight } from "@/components/icons"; +import { SearchSnippetText } from "@/components/email/search-snippet-text"; import { cn } from "@/lib/utils"; import { isFilterEmpty } from "@/lib/jmap/search-utils"; import { runPluginAction } from "@/lib/plugin-sandbox/actions"; @@ -153,7 +154,11 @@ export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResult )} > {r.title} - {r.snippet && {r.snippet}} + {r.snippet && ( + + + + )} ); diff --git a/lib/__tests__/plugin-search-results.test.ts b/lib/__tests__/plugin-search-results.test.ts index 1b92e9c62..44c2061d0 100644 --- a/lib/__tests__/plugin-search-results.test.ts +++ b/lib/__tests__/plugin-search-results.test.ts @@ -40,6 +40,24 @@ describe('checkSearchResults', () => { expect(checked.every((r) => !('email' in r))).toBe(true); }); + it('keeps the marks of a snippet and escapes everything else', () => { + const [entry] = checkSearchResults('p', [ + { key: 'a', title: 'A', snippet: ' The pixel\n grid & more ' }, + ]); + expect(entry.snippet).toBe('The pixel grid & <b>more</b>'); + }); + + it('escapes a plain snippet, so it reads back as written', () => { + expect(checkSearchResults('p', [{ key: 'a', title: 'A', snippet: 'Tom & Jerry <3' }])[0].snippet) + .toBe('Tom & Jerry <3'); + }); + + it('cuts a snippet by its text, closing a mark it cuts', () => { + const snippet = `${'x'.repeat(290)}${'y'.repeat(20)}`; + const [entry] = checkSearchResults('p', [{ key: 'a', title: 'A', snippet }]); + expect(entry.snippet).toBe(`${'x'.repeat(290)}${'y'.repeat(9)}…`); + }); + it('ignores an answer that is not a list', () => { expect(checkSearchResults('p', { key: 'a', title: 'A' })).toEqual([]); expect(checkSearchResults('p', undefined)).toEqual([]); diff --git a/lib/plugin-search-results.ts b/lib/plugin-search-results.ts index 91b5f2120..6bad2bd99 100644 --- a/lib/plugin-search-results.ts +++ b/lib/plugin-search-results.ts @@ -7,6 +7,7 @@ import { emailHooks } from './plugin-hooks'; import type { ExternalSearchResult, SearchResultEmail } from './plugin-types'; +import { parseSearchSnippet } from './search-snippet'; /** An entry as the host keeps it: checked, with the plugin that gave it. */ export interface ProvidedSearchResult extends ExternalSearchResult { @@ -28,6 +29,36 @@ function text(value: unknown, max: number): string { return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; } +/** `&`, `<` and `>` escaped as SearchSnippet/get escapes them, so parseSearchSnippet reads the text back as written. */ +function escaped(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +/** + * A snippet in SearchSnippet/get's form: text with `` around what + * matched. Flattened and cut to `max` characters of its text as text() cuts, + * each mark kept and closed, everything else escaped, so the list renders it + * with parseSearchSnippet and never as HTML. + */ +function markedText(value: unknown, max: number): string { + if (typeof value !== 'string') return ''; + const segments = parseSearchSnippet(value.replace(/\s+/g, ' ')).filter((segment) => segment.text); + if (segments.length > 0) { + segments[0] = { ...segments[0], text: segments[0].text.trimStart() }; + const last = segments.length - 1; + segments[last] = { ...segments[last], text: segments[last].text.trimEnd() }; + } + const length = segments.reduce((n, segment) => n + segment.text.length, 0); + let left = length > max ? max - 1 : length; + let out = ''; + for (const { text: part, marked } of segments) { + const kept = part.slice(0, left); + left -= kept.length; + if (kept) out += marked ? `${escaped(kept)}` : escaped(kept); + } + return length > max ? `${out}…` : out; +} + function jmapId(value: unknown): string | null { return typeof value === 'string' && value.length > 0 && value.length <= MAX_ID ? value : null; } @@ -45,7 +76,7 @@ function email(value: unknown): SearchResultEmail | null { /** * One plugin's answer, checked: a list of entries with a key and a title, - * plain text cut to its limits, keys unique, at most MAX_RESULTS_PER_PLUGIN. + * plain text cut to its limits (the snippet with its marks), keys unique, at most MAX_RESULTS_PER_PLUGIN. * Anything else is dropped without failing the rest. */ export function checkSearchResults(pluginId: string, value: unknown): ProvidedSearchResult[] { @@ -63,7 +94,7 @@ export function checkSearchResults(pluginId: string, value: unknown): ProvidedSe const source = text(entry.source, MAX_SOURCE); const message = email(entry.email); out.push({ - pluginId, key, title, snippet: text(entry.snippet, MAX_SNIPPET), + pluginId, key, title, snippet: markedText(entry.snippet, MAX_SNIPPET), ...(source ? { source } : {}), ...(message ? { email: message } : {}), }); diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 81bedd346..2cae31efe 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1221,13 +1221,19 @@ export interface ConflictWarning { * opens it in the reader, as a click on a row of the list does. Any other * click reaches the plugin that returned the entry as `onAction`, with * `surface: 'search-results'`, the entry's `key` as `actionId` and the - * search's `query` in the context. Plain text only; see + * search's `query` in the context. Plain text only, but the snippet may + * mark what matched as SearchSnippet/get does; see * lib/plugin-search-results.ts for the limits. */ export interface ExternalSearchResult { /** Unique among the plugin's entries for this search; comes back as actionId. */ key: string; title: string; + /** + * May wrap what matched in ``…``, highlighted as the list's + * own search snippets; `&`, `<`, `>` meant as text escaped as `&`, + * `<`, `>`. No other markup: any other tag shows as text. + */ snippet: string; /** Group heading, e.g. "CRM", "Slack"; the plugin's name when missing. */ source?: string;