feat: let a plugin title its sidebar section

This commit is contained in:
Linus Rath
2026-10-14 11:47:25 +02:00
parent 6098c1f928
commit 9d8f762b1d
6 changed files with 74 additions and 2 deletions
@@ -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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
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"/);
});
});
@@ -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<string, unknown> }) {
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 }) {
+3
View File
@@ -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<Record<string, Permission | null>> = {
'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;
+2
View File
@@ -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 */ }
+6
View File
@@ -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<void>,
/**
* 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<void>,
},
// Requests with an account or key the manifest declares under
// `credentials`; the server adds the secret (permission http:proxy).
+38
View File
@@ -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<string, string>();
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); };
}