From 9d8f762b1d3eb55c954c973688ea2d2d50bc8384 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:47:25 +0200 Subject: [PATCH] feat: let a plugin title its sidebar section --- .../__tests__/sidebar-plugin-section.test.tsx | 20 ++++++++++ .../plugins/plugin-sidebar-sections.tsx | 7 +++- lib/plugin-sandbox/host-api.ts | 3 ++ lib/plugin-sandbox/loader.ts | 2 + lib/plugin-sandbox/runtime.tsx | 6 +++ lib/plugin-sandbox/section-titles.ts | 38 +++++++++++++++++++ 6 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 lib/plugin-sandbox/section-titles.ts diff --git a/components/layout/__tests__/sidebar-plugin-section.test.tsx b/components/layout/__tests__/sidebar-plugin-section.test.tsx index 412f2f42e..d17904faf 100644 --- a/components/layout/__tests__/sidebar-plugin-section.test.tsx +++ b/components/layout/__tests__/sidebar-plugin-section.test.tsx @@ -24,6 +24,8 @@ vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); const { Sidebar } = await import('../sidebar'); const registry = await import('@/lib/plugin-sandbox/registry'); +const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api'); +const { clearSectionTitle } = await import('@/lib/plugin-sandbox/section-titles'); const mailboxes: Mailbox[] = [ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true } }, @@ -52,6 +54,7 @@ beforeEach(() => { afterEach(() => { for (const p of registry.all()) registry.deregister(p.plugin.id); + clearSectionTitle('cal'); }); describe('Sidebar plugin sections', () => { @@ -96,4 +99,21 @@ describe('Sidebar plugin sections', () => { await new Promise((r) => setTimeout(r, 20)); expect(createSlotInstance).not.toHaveBeenCalled(); }); + + it('heads a section with the title the plugin set, and with its name again after null', async () => { + offer('cal', 'Agenda', 'sidebar-section'); + render(); + const plugin = registry.get('cal')!.plugin; + + await act(async () => { await dispatchApiCall(plugin, 'ui.setSectionTitle', ['Upcoming birthdays']); }); + expect(screen.getByRole('button', { name: 'Upcoming birthdays' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Agenda' })).toBeNull(); + + await expect(dispatchApiCall(plugin, 'ui.setSectionTitle', ['x'.repeat(41)])).rejects.toThrow(/at most 40/); + await act(async () => { await dispatchApiCall(plugin, 'ui.setSectionTitle', [null]); }); + expect(screen.getByRole('button', { name: 'Agenda' })).toBeInTheDocument(); + + const noPerm = { ...plugin, id: 'other', permissions: [], grantedPermissions: [] }; + await expect(dispatchApiCall(noPerm, 'ui.setSectionTitle', ['Mine'])).rejects.toThrow(/lacks permission "ui:sidebar-widget"/); + }); }); diff --git a/components/plugins/plugin-sidebar-sections.tsx b/components/plugins/plugin-sidebar-sections.tsx index 7476af003..f52f3bff9 100644 --- a/components/plugins/plugin-sidebar-sections.tsx +++ b/components/plugins/plugin-sidebar-sections.tsx @@ -3,6 +3,7 @@ import { useId, useMemo, useState, useSyncExternalStore } from 'react'; import { ChevronDown, ChevronRight } from '@/components/icons'; import { get as getActivePlugin, offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry'; +import { sectionTitle, subscribeSectionTitles } from '@/lib/plugin-sandbox/section-titles'; import { PluginIframeSlot } from './plugin-iframe-slot'; const STORAGE_PREFIX = 'plugin-sidebar-section-collapsed:'; @@ -27,7 +28,8 @@ function writeCollapsed(pluginId: string, collapsed: boolean): void { function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extraProps: Record }) { const [collapsed, setCollapsed] = useState(() => readCollapsed(pluginId)); const bodyId = useId(); - const name = getActivePlugin(pluginId)?.plugin.name || pluginId; + const title = useSyncExternalStore(subscribeSectionTitles, () => sectionTitle(pluginId), () => undefined); + const name = title || getActivePlugin(pluginId)?.plugin.name || pluginId; const toggle = () => { setCollapsed((prev) => { @@ -63,7 +65,8 @@ function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extr /** * The `sidebar-section` slot: one collapsible section per plugin in the mail - * sidebar, below the folders and tags. The header carries the plugin's name; + * sidebar, below the folders and tags. The header carries the title the plugin + * set (api.ui.setSectionTitle), or its name; * whether it is folded is remembered per plugin in this browser. */ export function PluginSidebarSections({ mailboxId }: { mailboxId: string | null }) { diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index aa48b2d32..12f7127ab 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -52,6 +52,7 @@ import { import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input'; import { doComposeOpen, doComposeReply, doDraftsCreate } from './host-api-compose'; import { doNotificationsShow } from './host-api-notify'; +import { setSectionTitle } from './section-titles'; import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer'; /** @@ -208,6 +209,7 @@ export const PERM_PER_METHOD: Readonly> = { 'ui.refreshRowBadges': null, 'ui.setDayLabels': 'ui:calendar-action', 'ui.setPages': 'ui:app-page', + 'ui.setSectionTitle': 'ui:sidebar-widget', // sieve (delivery-time classification) 'sieve.isSupported': 'filters:read', 'sieve.getActiveScript': 'filters:read', @@ -1735,6 +1737,7 @@ export async function dispatchApiCall( case 'ui.refreshRowBadges': refreshRowBadges(); return undefined; case 'ui.setDayLabels': setDayLabels(plugin.id, plugin.name, checkDayLabels(args[0])); return undefined; case 'ui.setPages': setPluginPages(plugin.id, plugin.name, checkPages(args[0])); return undefined; + case 'ui.setSectionTitle': setSectionTitle(plugin.id, args[0]); return undefined; case 'storage.get': return storageGet(plugin.id, args[0] as string); case 'storage.set': storageSet(plugin.id, args[0] as string, args[1]); return undefined; diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 212ee393c..80111fa5b 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -25,6 +25,7 @@ import { mayOfferSlot, mayRegisterHook, pluginHasPermission, slotPropsFor } from import { clearPluginActions, setActionDispatcher } from './actions'; import { clearDayLabels } from './day-labels'; import { clearPluginPages } from './pages'; +import { clearSectionTitle } from './section-titles'; import { cancelPluginProxyRequests } from './proxy-client'; import { cancelConnectFor } from './connect-dialog'; import type { SlotName } from '../plugin-types'; @@ -295,6 +296,7 @@ export function unloadSandboxedPlugin(pluginId: string): void { clearPluginActions(pluginId); clearDayLabels(pluginId); clearPluginPages(pluginId); + clearSectionTitle(pluginId); cancelPluginProxyRequests(pluginId); cancelConnectFor(pluginId); try { entry.background.destroy(); } catch { /* ignore */ } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index f71f72d4f..80d1641d6 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -538,6 +538,12 @@ function buildPluginApi(manifest: PluginManifest) { /** Full pages with a button in the navigation bar; the `app-page` slot renders them. */ setPages: (pages: Array<{ id: string; title: string; icon?: string }>) => callApi('ui.setPages', [pages]) as Promise, + /** + * The header of this plugin's sidebar section (the host draws it like + * Folders and Tags), e.g. a translated "Upcoming birthdays". null goes + * back to the plugin's name. At most 40 characters. + */ + setSectionTitle: (title: string | null) => callApi('ui.setSectionTitle', [title]) as Promise, }, // Requests with an account or key the manifest declares under // `credentials`; the server adds the secret (permission http:proxy). diff --git a/lib/plugin-sandbox/section-titles.ts b/lib/plugin-sandbox/section-titles.ts new file mode 100644 index 000000000..35f210c3b --- /dev/null +++ b/lib/plugin-sandbox/section-titles.ts @@ -0,0 +1,38 @@ +// Titles plugins give their sidebar section (api.ui.setSectionTitle). The +// host draws the header itself, like Folders and Tags; without a title it +// shows the plugin's name. + +export const MAX_SECTION_TITLE = 40; + +const titles = new Map(); +const listeners = new Set<() => void>(); + +function changed(): void { + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } +} + +/** Check and store a title; throws with a message the plugin sees. */ +export function setSectionTitle(pluginId: string, value: unknown): void { + if (value !== null && typeof value !== 'string') throw new Error('ui.setSectionTitle: title must be a string or null'); + // eslint-disable-next-line no-control-regex -- one line of text + const title = typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim() : ''; + if (title.length > MAX_SECTION_TITLE) throw new Error(`ui.setSectionTitle: at most ${MAX_SECTION_TITLE} characters`); + if (title) titles.set(pluginId, title); + else titles.delete(pluginId); + changed(); +} + +export function sectionTitle(pluginId: string): string | undefined { + return titles.get(pluginId); +} + +export function clearSectionTitle(pluginId: string): void { + if (titles.delete(pluginId)) changed(); +} + +export function subscribeSectionTitles(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +}