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;