Merge pull request #1338 from HOLOEYE-JZ/feat/plugin-search-results

This commit is contained in:
Linus Rath
2026-10-26 19:32:56 +01:00
14 changed files with 710 additions and 32 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ const sources = ['app', 'components', 'contexts', 'hooks', 'lib', 'stores'].flat
/** A call site that fires the hook: `xHooks.onFoo.emit(` and friends. */
function isFired(hookName: string, groupName: string): boolean {
const re = new RegExp(`\\b${groupName}\\.${hookName}\\s*\\.(emit|emitSync|emitTo|intercept|interceptOutcome|interceptWithValue|transform|takeOver)\\b`);
const re = new RegExp(`\\b${groupName}\\.${hookName}\\s*\\.(emit|emitSync|emitTo|intercept|interceptOutcome|interceptWithValue|transform|takeOver|collect)\\b`);
return re.test(sources);
}
@@ -0,0 +1,98 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest';
import { checkSearchResults, MAX_RESULTS_PER_PLUGIN, provideSearchResults } from '../plugin-search-results';
import { clearAllHooks, emailHooks } from '../plugin-hooks';
afterEach(() => {
clearAllHooks();
vi.useRealTimers();
});
describe('checkSearchResults', () => {
it('keeps entries with a key and a title, as plain text, with the plugin', () => {
expect(checkSearchResults('p', [
{ key: 'a', title: ' Pixel grid\n too coarse ', snippet: 'For our AR prototype', source: 'CRM', onClick: 'x', extra: 1 },
])).toEqual([{ pluginId: 'p', key: 'a', title: 'Pixel grid too coarse', snippet: 'For our AR prototype', source: 'CRM' }]);
});
it('drops what is not an entry, and a key given twice', () => {
expect(checkSearchResults('p', [
null, 'text', { title: 'no key' }, { key: 'a' }, { key: 7, title: 'number key' },
{ key: 'b', title: 'B' }, { key: 'b', title: 'B again' },
]).map((r) => r.key)).toEqual(['b']);
});
it('keeps the message an entry names, with its account', () => {
expect(checkSearchResults('p', [
{ key: 'a', title: 'A', email: { id: 'M1', accountId: 'group-1', extra: true } },
{ key: 'b', title: 'B', email: { id: 'M2' } },
]).map((r) => r.email)).toEqual([{ id: 'M1', accountId: 'group-1' }, { id: 'M2' }]);
});
it('keeps an entry whose message is not one, without it', () => {
const checked = checkSearchResults('p', [
{ key: 'a', title: 'A', email: 'M1' },
{ key: 'b', title: 'B', email: { id: '' } },
{ key: 'c', title: 'C', email: { id: 'M3', accountId: 7 } },
{ key: 'd', title: 'D', email: { id: 'x'.repeat(256) } },
]);
expect(checked.map((r) => r.key)).toEqual(['a', 'b', 'c', 'd']);
expect(checked.every((r) => !('email' in r))).toBe(true);
});
it('ignores an answer that is not a list', () => {
expect(checkSearchResults('p', { key: 'a', title: 'A' })).toEqual([]);
expect(checkSearchResults('p', undefined)).toEqual([]);
});
it('cuts long texts and takes at most the limit per plugin', () => {
const many = Array.from({ length: MAX_RESULTS_PER_PLUGIN + 5 }, (_, i) => ({ key: `k${i}`, title: 'x'.repeat(500), snippet: 'y'.repeat(900) }));
const checked = checkSearchResults('p', many);
expect(checked).toHaveLength(MAX_RESULTS_PER_PLUGIN);
expect(checked[0].title.length).toBe(200);
expect(checked[0].title.endsWith('…')).toBe(true);
expect(checked[0].snippet.length).toBe(300);
});
});
describe('provideSearchResults', () => {
it('asks no one without handlers', async () => {
expect(await provideSearchResults('q', {})).toEqual([]);
});
it('asks the plugins at once, each with an empty list', async () => {
const order: string[] = [];
emailHooks.onProvideSearchResults.register('slow', async (initial: unknown[]) => {
order.push(`slow ${initial.length}`);
await new Promise((r) => setTimeout(r, 20));
return [{ key: 's', title: 'S' }];
});
emailHooks.onProvideSearchResults.register('fast', (initial: unknown[]) => {
order.push(`fast ${initial.length}`);
return [{ key: 'f', title: 'F' }];
});
const results = await provideSearchResults('q', {});
expect(order).toEqual(['slow 0', 'fast 0']);
expect(results.map((r) => `${r.pluginId}:${r.key}`)).toEqual(['slow:s', 'fast:f']);
});
it('leaves out a plugin that throws', async () => {
emailHooks.onProvideSearchResults.register('broken', () => { throw new Error('down'); });
emailHooks.onProvideSearchResults.register('ok', () => [{ key: 'a', title: 'A' }]);
expect((await provideSearchResults('q', {})).map((r) => r.pluginId)).toEqual(['ok']);
});
it('leaves out a plugin that runs out of time', async () => {
vi.useFakeTimers();
emailHooks.onProvideSearchResults.register('hung', () => new Promise(() => {}));
emailHooks.onProvideSearchResults.register('ok', () => [{ key: 'a', title: 'A' }]);
const pending = provideSearchResults('q', {});
await vi.advanceTimersByTimeAsync(10_000);
expect((await pending).map((r) => r.pluginId)).toEqual(['ok']);
});
});
+1 -1
View File
@@ -116,7 +116,7 @@ export const HOOK_CATALOG = {
// ─── Mail: search ──────────────────────────────────────────
onSearch: H('mail-search', 'transform', 'email:read', '{ query, filters: SearchFilters }', 'before a search runs; may change the query and filters'),
onSearchResults: H('mail-search', 'transform', 'email:read', 'string[], { query, filters, emailIds }', 'after search results arrived; return ids of further messages to add to them (at most 100)'),
onProvideSearchResults: H('mail-search', 'transform', 'email:read', 'ExternalSearchResult[], { query, filters }', 'to add results from elsewhere to a search'),
onProvideSearchResults: H('mail-search', 'transform', 'email:read', 'ExternalSearchResult[], { query, filters }', 'to add results from elsewhere to a search; drawn above its message list; an entry naming a message (email) opens it on a click, any other click comes back as onAction with surface "search-results" and the entry\'s key'),
// ─── Mail: attachments ─────────────────────────────────────
onBeforeAttachmentUpload: H('mail-attachments', 'intercept', 'email:read', 'AttachmentInfo', 'before a file is attached in the composer'),
+24 -3
View File
@@ -303,6 +303,26 @@ export class HookBus<T extends (...args: any[]) => any> {
return { allowed: true, value };
}
/**
* Ask every handler at once, each with the same initial value, and keep
* each answer with the plugin that gave it, for hooks whose results the
* host shows per plugin (onProvideSearchResults). A handler that throws or
* runs out of time gives nothing.
*/
async collect<V>(initial: V, ...rest: unknown[]): Promise<{ pluginId: string; value: unknown }[]> {
const asked = this.handlers.filter(({ pluginId }) => !pluginErrorTracker.isDisabled(pluginId));
const answers = await Promise.all(asked.map(async ({ pluginId, handler }) => {
try {
const value: unknown = await withTimeout(pluginId, handler(initial, ...rest), this.timeoutMs);
return { pluginId, value };
} catch (err) {
pluginErrorTracker.record(pluginId, err);
return null;
}
}));
return answers.filter((a): a is { pluginId: string; value: unknown } => a !== null);
}
/** Fire handlers as transforms - each receives the output of the previous */
async transform<V>(initial: V, ...rest: unknown[]): Promise<V> {
let value = initial;
@@ -464,9 +484,10 @@ export const emailHooks = {
// renderer for an attachment. Initial value: AttachmentPreview, second
// argument: AttachmentInfo.
onAttachmentPreview: new HookBus(),
// Transform hook - lets plugins contribute additional results to the global
// search panel. Initial value: ExternalSearchResult[]. Second argument:
// { query: string, filters: SearchFilters }.
// Lets plugins contribute results from elsewhere to a search, drawn above
// its message list (lib/plugin-search-results.ts). Each handler is asked
// on its own with an empty ExternalSearchResult[] and { query, filters },
// so a click can go back to the plugin that gave the entry.
onProvideSearchResults: new HookBus(),
// Observer fired (debounced) when the composer draft body, subject, or
// recipients change. Handler receives a DraftView snapshot. Use for AI
+6 -1
View File
@@ -196,6 +196,11 @@ export function setActionDispatcher(next: ActionDispatcher | null): void {
}
export async function runAction(action: RegisteredAction, surface: string, context: Record<string, unknown>): Promise<void> {
await runPluginAction(action.pluginId, surface, action.id, context);
}
/** A click on something a plugin gave as data elsewhere (a search result's entry). */
export async function runPluginAction(pluginId: string, surface: string, actionId: string, context: Record<string, unknown>): Promise<void> {
if (!dispatcher) return;
await dispatcher(action.pluginId, surface, action.id, context);
await dispatcher(pluginId, surface, actionId, context);
}
+79
View File
@@ -0,0 +1,79 @@
// Results plugins add to a search through onProvideSearchResults, drawn as a
// block above the search's message list (components/email/plugin-search-results.tsx).
// Each plugin is asked on its own, so every entry keeps the plugin it came
// from: a click goes back to that plugin as onAction, unless the entry names a
// message, which the click opens. What a plugin returns crosses from its
// sandbox, so it is checked and cut here, never trusted.
import { emailHooks } from './plugin-hooks';
import type { ExternalSearchResult, SearchResultEmail } from './plugin-types';
/** An entry as the host keeps it: checked, with the plugin that gave it. */
export interface ProvidedSearchResult extends ExternalSearchResult {
pluginId: string;
}
/** Most entries one plugin may add to one search. */
export const MAX_RESULTS_PER_PLUGIN = 20;
const MAX_KEY = 200;
const MAX_TITLE = 200;
const MAX_SNIPPET = 300;
const MAX_SOURCE = 40;
/** JMAP ids are at most 255 octets (RFC 8620 §1.2). */
const MAX_ID = 255;
function text(value: unknown, max: number): string {
if (typeof value !== 'string') return '';
const flat = value.replace(/\s+/g, ' ').trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
function jmapId(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 && value.length <= MAX_ID ? value : null;
}
/** The message an entry names, or null for anything that is not one. */
function email(value: unknown): SearchResultEmail | null {
if (!value || typeof value !== 'object') return null;
const raw = value as Record<string, unknown>;
const id = jmapId(raw.id);
if (!id) return null;
if (raw.accountId === undefined) return { id };
const accountId = jmapId(raw.accountId);
return accountId ? { id, accountId } : 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.
* Anything else is dropped without failing the rest.
*/
export function checkSearchResults(pluginId: string, value: unknown): ProvidedSearchResult[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: ProvidedSearchResult[] = [];
for (const raw of value) {
if (out.length >= MAX_RESULTS_PER_PLUGIN) break;
if (!raw || typeof raw !== 'object') continue;
const entry = raw as Record<string, unknown>;
const key = typeof entry.key === 'string' ? entry.key : '';
const title = text(entry.title, MAX_TITLE);
if (!key || key.length > MAX_KEY || seen.has(key) || !title) continue;
seen.add(key);
const source = text(entry.source, MAX_SOURCE);
const message = email(entry.email);
out.push({
pluginId, key, title, snippet: text(entry.snippet, MAX_SNIPPET),
...(source ? { source } : {}),
...(message ? { email: message } : {}),
});
}
return out;
}
/** Ask every plugin for results to a search; each answer checked, in the plugins' order. */
export async function provideSearchResults(query: string, filters: unknown): Promise<ProvidedSearchResult[]> {
if (emailHooks.onProvideSearchResults.size === 0) return [];
const answers = await emailHooks.onProvideSearchResults.collect([] as ExternalSearchResult[], { query, filters });
return answers.flatMap(({ pluginId, value }) => checkSearchResults(pluginId, value));
}
+1
View File
@@ -62,6 +62,7 @@ export const SLOT_CATALOG = {
'message-bulk-actions': S('mail-list', 'action', ['ui:message-list'], { emailIds: 'email:read' }, 'buttons in the toolbar shown while messages are selected', true),
'context-menu-email': S('mail-list', 'frame', ['ui:context-menu'], {}, 'the right-click menu of a message'),
'toolbar-actions': S('mail-list', 'frame', ['ui:toolbar'], {}, 'the message toolbar', true),
'search-results': S('mail-list', 'data', ['email:read'], { query: 'email:read' }, 'entries a plugin returns from onProvideSearchResults, in a block above a search\'s message list; a click opens the message an entry names, any other reaches the plugin as onAction with the entry\'s key as actionId'),
// ─── Reader ────────────────────────────────────────────────
'reader-header': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'at the top of the open message, above the subject'),
+21 -7
View File
@@ -1199,19 +1199,33 @@ export interface ConflictWarning {
}
/**
* Returned by onProvideSearchResults transform handlers. Plugins extend the
* initial array with their own results (CRM hits, Slack messages, etc.).
* The host renders these in a grouped section below native email results.
* Returned by onProvideSearchResults handlers: results from elsewhere (CRM
* hits, chat messages, ...) for the search that is running. The host draws
* them as a block above the search's message list, grouped by `source`.
* A click on an entry that names a message the user can reach (`email`)
* 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
* lib/plugin-search-results.ts for the limits.
*/
export interface ExternalSearchResult {
/** Stable unique key */
/** Unique among the plugin's entries for this search; comes back as actionId. */
key: string;
title: string;
snippet: string;
/** Plugin-handled action when the result row is clicked */
onClick: () => void;
/** Optional source label, e.g. "Slack", "Notion" */
/** Group heading, e.g. "CRM", "Slack"; the plugin's name when missing. */
source?: string;
/** The message the entry stands for, opened in the reader on a click. */
email?: SearchResultEmail;
}
/** A message named by a search result. */
export interface SearchResultEmail {
/** The message's JMAP id. */
id: string;
/** The JMAP account holding it (a shared or group account); the signed-in account's own when missing. */
accountId?: string;
}
/**