From b5558a66d88dfe59c0dbc109b14832c312f2c566 Mon Sep 17 00:00:00 2001 From: Jacob Zeitz Date: Sun, 1 Nov 2026 20:07:03 +0100 Subject: [PATCH] feat(plugins): a side panel beside the mail for plugins A frame slot `side-panel`: a plugin's panel right of the mail list and reader, opened from a button in the panel rail (Mountain View's, or a slim rail in the default layout while a plugin offers one). `ui.setSidePanel` names its button; `ui.openPanel('side-panel')` opens it. `ui.setSidePanel` takes `rail: false`: the rail draws no button for the plugin's panel, which then opens only on the plugin's request (openPanel / togglePanel('side-panel')), e.g. from a top bar action. A rail left without buttons is not drawn, in the default layout and in Mountain View's side panel alike. --- components/layout/mv-side-panel.tsx | 45 +++- components/mail/mail-app.tsx | 5 + .../__tests__/plugin-side-panels.test.tsx | 169 +++++++++++++++ components/plugins/plugin-side-panels.tsx | 193 ++++++++++++++++++ lib/plugin-sandbox/host-api.ts | 4 + lib/plugin-sandbox/loader.ts | 2 + lib/plugin-sandbox/panel-requests.ts | 10 +- lib/plugin-sandbox/runtime.tsx | 17 +- lib/plugin-sandbox/side-panels.ts | 72 +++++++ lib/plugin-sandbox/slot-layout.ts | 2 +- lib/plugin-slot-catalog.ts | 1 + 11 files changed, 506 insertions(+), 14 deletions(-) create mode 100644 components/plugins/__tests__/plugin-side-panels.test.tsx create mode 100644 components/plugins/plugin-side-panels.tsx create mode 100644 lib/plugin-sandbox/side-panels.ts diff --git a/components/layout/mv-side-panel.tsx b/components/layout/mv-side-panel.tsx index 3a2e54241..40d8ba4dd 100644 --- a/components/layout/mv-side-panel.tsx +++ b/components/layout/mv-side-panel.tsx @@ -28,6 +28,7 @@ import { readableTextOn } from "@/lib/color-transform"; import { Avatar } from "@/components/ui/avatar"; import type { CalendarEvent } from "@/lib/jmap/types"; import { useCvdColor } from "@/hooks/use-cvd-color"; +import { PluginSidePanelBody, SidePanelButton, usePluginSidePanel } from "@/components/plugins/plugin-side-panels"; export type MvSidePanelId = "calendar" | "tasks" | "contacts"; @@ -46,6 +47,18 @@ export function MvSidePanel() { const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled")); const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks); const [open, setOpen] = useState(null); + // Plugins' panels (the side-panel slot), their buttons below the apps'. + // A plugin's panel opening (a click, or the plugin's openPanel) shuts the app's, and the other way round. + const plugins = usePluginSidePanel(() => setOpen(null)); + const pluginOpen = plugins.entries.find((e) => e.pluginId === plugins.open) ?? null; + const openApp = (id: MvSidePanelId | null) => { + if (id) plugins.choose(null); + setOpen(id); + }; + const openPlugin = (pluginId: string | null) => { + if (pluginId) setOpen(null); + plugins.choose(pluginId); + }; const showCalendar = supportsCalendar && calendarEnabled; const showContacts = (client?.supportsContacts() ?? false) && contactsEnabled; @@ -57,9 +70,11 @@ export function MvSidePanel() { ...(showContacts ? [{ id: "contacts" as const, label: t("contacts"), icon: Users, color: "text-[#1a73e8]" }] : []), ]; - if (buttons.length === 0) return null; + if (buttons.length === 0 && plugins.entries.length === 0) return null; + // A plugin's panel without a rail button (setSidePanel's rail: false) opens on its request only. + const pluginButtons = plugins.entries.filter((e) => e.inRail); // A panel whose app went away (policy change, account switch) stays shut. - const active = open && buttons.some((b) => b.id === open) ? open : null; + const active = open && !pluginOpen && buttons.some((b) => b.id === open) ? open : null; return ( <> @@ -75,7 +90,17 @@ export function MvSidePanel() { {active === "contacts" && setOpen(null)} />} )} - } ); } diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 33ae06070..598fb0f75 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -107,6 +107,7 @@ import { PluginActions } from "@/components/plugins/plugin-actions"; import { useAppCommands } from "@/lib/app-commands"; import { MvSearchForm } from "@/components/search/mv-search-form"; import { MvSidePanel } from "@/components/layout/mv-side-panel"; +import { PluginSidePanel } from "@/components/plugins/plugin-side-panels"; import { MvListToolbar } from "@/components/email/mv-list-toolbar"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; @@ -5762,6 +5763,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { {/* Mountain View: Gmail's side panel - calendar, tasks and contacts next to the mail, with their icons down the right edge. */} {mvShell && !inlineApp && !fullscreenReading && } + {/* The default layout: plugins' panels beside the mail (the + side-panel slot), with their buttons in a rail at the right edge. + Desktop only; Mountain View has them in its side panel. */} + {!mvShell && !isMobile && !isTablet && !isEmbedded && !inlineApp && !fullscreenReading && } {/* Keyboard Shortcuts Modal */} diff --git a/components/plugins/__tests__/plugin-side-panels.test.tsx b/components/plugins/__tests__/plugin-side-panels.test.tsx new file mode 100644 index 000000000..d250299c0 --- /dev/null +++ b/components/plugins/__tests__/plugin-side-panels.test.tsx @@ -0,0 +1,169 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { InstalledPlugin } from '@/lib/plugin-types'; + +const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() })); +vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({ + createSlotInstance: (...args: unknown[]) => createSlotInstance(...args), +})); +vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) })); + +const { PluginSidePanel } = await import('../plugin-side-panels'); +const { MvSidePanel } = await import('@/components/layout/mv-side-panel'); +const registry = await import('@/lib/plugin-sandbox/registry'); +const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api'); +const { clearSidePanel } = await import('@/lib/plugin-sandbox/side-panels'); +const { useCalendarStore } = await import('@/stores/calendar-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); + +function offer(id: string, name: string, opts: { slot?: string; fill?: boolean } = {}) { + const plugin: InstalledPlugin = { + id, name, version: '1.0.0', author: '', description: '', type: 'ui-extension', + permissions: ['ui:sidebar-widget'], grantedPermissions: ['ui:sidebar-widget'], entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; + registry.register({ + plugin, + code: '', + tier: 'untrusted', + background: { evaluateShouldShow: vi.fn(async () => true) } as never, + slotOffers: [{ name: (opts.slot ?? 'side-panel') as never, order: 100, hasShouldShow: false, ...(opts.fill ? { fill: true } : {}) }], + hookDisposables: [], + }); + return plugin; +} + +beforeEach(() => { + createSlotInstance.mockClear(); + localStorage.clear(); +}); + +afterEach(() => { + for (const p of registry.all()) { + clearSidePanel(p.plugin.id); + registry.deregister(p.plugin.id); + } +}); + +describe('PluginSidePanel (default layout)', () => { + it('draws nothing when no plugin offers a side panel', () => { + offer('w', 'Widget', { slot: 'sidebar-widget' }); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('gives each plugin a button in the rail that opens and closes its panel, filled by the frame', async () => { + offer('fu', 'Follow-ups', { fill: true }); + const { container } = render(); + const button = screen.getByRole('button', { name: 'Follow-ups' }); + expect(button).toHaveAttribute('aria-pressed', 'false'); + expect(container.querySelector('[data-plugin-side-panel]')).toBeNull(); + + fireEvent.click(button); + expect(button).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('complementary', { name: 'Follow-ups' })).toBeInTheDocument(); + await waitFor(() => expect(createSlotInstance).toHaveBeenCalled()); + const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record; fill?: boolean }; + expect(opts.slot).toBe('side-panel'); + expect(opts.extraProps).toEqual({ focusRequest: 0 }); + expect(container.querySelector('[data-plugin-iframe-fill]')).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'close' })); + expect(container.querySelector('[data-plugin-side-panel]')).toBeNull(); + }); + + it('remembers the open panel in this browser', () => { + offer('fu', 'Follow-ups'); + const first = render(); + fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' })); + first.unmount(); + + const { container } = render(); + expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull(); + }); + + it('names the button and panel as the plugin set them, and by its name again after null', async () => { + const plugin = offer('fu', 'Follow-ups'); + render(); + await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'Upcoming for me', icon: 'flag' }]); }); + expect(screen.getByRole('button', { name: 'Upcoming for me' })).toBeInTheDocument(); + + await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'x'.repeat(41) }])).rejects.toThrow(/at most 40/); + await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ icon: 'no icon' }])).rejects.toThrow(/icon/); + await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [null]); }); + expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument(); + + const noPerm = { ...plugin, id: 'other', permissions: [], grantedPermissions: [] }; + await expect(dispatchApiCall(noPerm, 'ui.setSidePanel', [{ title: 'Mine' }])).rejects.toThrow(/lacks permission "ui:sidebar-widget"/); + }); + + it('leaves the button out with rail: false, the panel opened on the plugin\'s request only', async () => { + const plugin = offer('fu', 'Follow-ups'); + const { container } = render(); + await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'Upcoming for me', icon: 'flag', rail: false }]); }); + expect(screen.queryByRole('button', { name: 'Upcoming for me' })).toBeNull(); + expect(container.querySelector('[data-plugin-side-rail]')).toBeNull(); + + await act(async () => { await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'toggle']); }); + expect(screen.getByRole('complementary', { name: 'Upcoming for me' })).toBeInTheDocument(); + + await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ rail: 'no' }])).rejects.toThrow(/rail/); + }); + + it('opens on the plugin\'s request with a new focusRequest, and answers null without a panel on screen', async () => { + const plugin = offer('fu', 'Follow-ups'); + const { container, unmount } = render(); + let answer: unknown; + await act(async () => { answer = await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open']); }); + expect(answer).toBe(true); + expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull(); + await waitFor(() => expect(createSlotInstance).toHaveBeenCalled()); + expect((createSlotInstance.mock.calls[0][0] as { extraProps: unknown }).extraProps).toEqual({ focusRequest: 1 }); + + await act(async () => { answer = await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'toggle']); }); + expect(answer).toBe(false); + expect(container.querySelector('[data-plugin-side-panel]')).toBeNull(); + + unmount(); + await expect(dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open'])).resolves.toBeNull(); + }); +}); + +describe('MvSidePanel with plugin panels', () => { + beforeEach(() => { + useCalendarStore.setState({ supportsCalendar: true, calendars: [], events: [] } as never); + useSettingsStore.setState({ enableCalendarTasks: false } as never); + }); + + it('adds the plugins\' buttons to its rail, one panel open at a time', () => { + offer('fu', 'Follow-ups'); + const { container } = render(); + fireEvent.click(screen.getByRole('button', { name: 'calendar' })); + expect(container.querySelector('[data-mv-side-panel="calendar"]')).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' })); + expect(container.querySelector('[data-mv-side-panel]')).toBeNull(); + expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'calendar' })); + expect(container.querySelector('[data-plugin-side-panel]')).toBeNull(); + expect(container.querySelector('[data-mv-side-panel="calendar"]')).not.toBeNull(); + }); + + it('draws no rail for a plugin panel without a button alone, and opens it on request', async () => { + useCalendarStore.setState({ supportsCalendar: false } as never); + const plugin = offer('fu', 'Follow-ups'); + const { container } = render(); + await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ rail: false }]); }); + expect(container.querySelector('[data-mv-side-rail]')).toBeNull(); + await act(async () => { await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open']); }); + expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull(); + }); + + it('shows the rail for a plugin panel alone', () => { + useCalendarStore.setState({ supportsCalendar: false } as never); + offer('fu', 'Follow-ups'); + render(); + expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument(); + }); +}); diff --git a/components/plugins/plugin-side-panels.tsx b/components/plugins/plugin-side-panels.tsx new file mode 100644 index 000000000..b81d389e2 --- /dev/null +++ b/components/plugins/plugin-side-panels.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useTranslations } from 'next-intl'; +import { iconForName, Puzzle, X, type AppIcon } from '@/components/icons'; +import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers'; +import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; +import { onPanelRequest } from '@/lib/plugin-sandbox/panel-requests'; +import { sidePanelLooks, subscribeSidePanels } from '@/lib/plugin-sandbox/side-panels'; +import { cn } from '@/lib/utils'; +import { PluginIframeSlot } from './plugin-iframe-slot'; + +// The `side-panel` slot: one panel per plugin beside the mail, opened from +// its button in the rail at the right edge. Mountain View puts the buttons +// in its own side panel's rail (mv-side-panel.tsx); the default layout +// shows PluginSidePanel. At most one panel is open; which one is +// remembered in this browser. A plugin that set `rail: false` has no +// button: its panel opens only on its request; a rail without buttons is +// not drawn. + +const STORAGE_KEY = 'plugin-side-panel-open'; + +export interface SidePanelEntry { + pluginId: string; + title: string; + Icon: AppIcon; + fill: boolean; + /** Whether the rail shows its button (api.ui.setSidePanel's `rail`). */ + inRail: boolean; +} + +function readStored(): string | null { + try { return localStorage.getItem(STORAGE_KEY); } catch { return null; } +} + +function writeStored(pluginId: string | null): void { + try { + if (pluginId) localStorage.setItem(STORAGE_KEY, pluginId); + else localStorage.removeItem(STORAGE_KEY); + } catch { + // Storage may be blocked; the panel is just shut next time. + } +} + +/** The plugins offering a side panel, each with the title and icon it set (api.ui.setSidePanel). */ +export function usePluginSidePanelEntries(): SidePanelEntry[] { + const offers = usePluginSlotOffers('side-panel'); + const looks = useSyncExternalStore(subscribeSidePanels, sidePanelLooks, sidePanelLooks); + return useMemo(() => { + return offers.map((offer) => { + const look = looks.get(offer.pluginId); + return { + pluginId: offer.pluginId, + title: look?.title || getActivePlugin(offer.pluginId)?.plugin.name || offer.pluginId, + Icon: iconForName(look?.icon) ?? Puzzle, + fill: !!offer.fill, + inRail: look?.rail !== false, + }; + }); + }, [offers, looks]); +} + +/** + * Which plugin's side panel is open (null: none), remembered in this + * browser, and the plugins' api.ui.openPanel('side-panel') requests; + * `onOpen` runs when a plugin's request opens its panel. + */ +export function usePluginSidePanel(onOpen?: () => void) { + const entries = usePluginSidePanelEntries(); + const [chosen, setChosen] = useState(readStored); + const [focusRequest, setFocusRequest] = useState(0); + // A plugin that went away (disabled, removed) leaves its panel shut. + const open = chosen && entries.some((e) => e.pluginId === chosen) ? chosen : null; + const choose = useCallback((pluginId: string | null) => { + setChosen(pluginId); + writeStored(pluginId); + }, []); + const state = useRef({ entries, open, onOpen }); + state.current = { entries, open, onOpen }; + useEffect(() => onPanelRequest((request) => { + if (request.slot !== 'side-panel') return null; + if (!state.current.entries.some((e) => e.pluginId === request.pluginId)) return null; + const isOpen = state.current.open === request.pluginId; + const wantOpen = request.mode === 'open' ? true : request.mode === 'close' ? false : !isOpen; + if (wantOpen) { + choose(request.pluginId); + setFocusRequest((n) => n + 1); + state.current.onOpen?.(); + } else if (isOpen) { + choose(null); + } + return wantOpen; + }), [choose]); + return { entries, open, choose, focusRequest }; +} + +/** A plugin's panel: the host's header (title, close) over the plugin's frame, which fills the rest. */ +export function PluginSidePanelBody({ entry, focusRequest, onClose }: { + entry: SidePanelEntry; + focusRequest: number; + onClose: () => void; +}) { + const tCommon = useTranslations('common'); + const extraProps = useMemo(() => ({ focusRequest }), [focusRequest]); + return ( + <> +
+
{entry.title}
+ +
+
+ +
+ + ); +} + +/** + * The default layout's side panel: a slim rail at the right edge with a + * button per plugin, and the open plugin's panel left of it. Nothing + * without a plugin offering one. + */ +export function PluginSidePanel() { + const t = useTranslations('sidebar'); + const { entries, open, choose, focusRequest } = usePluginSidePanel(); + if (entries.length === 0) return null; + const active = entries.find((e) => e.pluginId === open) ?? null; + const inRail = entries.filter((e) => e.inRail); + return ( + <> + {active && ( + + )} + {inRail.length > 0 && } + + ); +} + +/** A plugin's button in a side panel's rail. */ +export function SidePanelButton({ entry, pressed, onClick, className, pressedClassName }: { + entry: SidePanelEntry; + pressed: boolean; + onClick: () => void; + className?: string; + pressedClassName?: string; +}) { + const { Icon } = entry; + return ( + + ); +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 76e9a50e7..8a645afaa 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -67,6 +67,7 @@ import { doUiPrint } from './host-api-print'; import { doNotificationsShow } from './host-api-notify'; import { doAccountDataGet, doAccountDataSet, doUserFindPeople, doUserGetMailAccounts, doUserGetSelf } from './host-api-account'; import { setSectionTitle } from './section-titles'; +import { setSidePanel } from './side-panels'; import { requestPanel } from './panel-requests'; import { requestPageNavigation } from './page-navigation'; import { requestOpenEmail } from './open-email'; @@ -257,6 +258,8 @@ export const PERM_PER_METHOD: Readonly> = { // Opening a message shows its content, as reading it does. 'ui.openEmail': 'email:read', 'ui.setSectionTitle': 'ui:sidebar-widget', + // The button and header of the plugin's side-panel slot beside the mail. + 'ui.setSidePanel': 'ui:sidebar-widget', // Open, close or toggle the panel the plugin's slot sits in; the panel's // own permission is checked per call (see the slot catalogue). 'ui.setPanel': null, @@ -2037,6 +2040,7 @@ export async function dispatchApiCall( case 'ui.openPage': return requestPageNavigation(plugin.id, args[0], args[1]); case 'ui.openEmail': return requestOpenEmail(plugin, args[0]); case 'ui.setSectionTitle': setSectionTitle(plugin.id, args[0]); return undefined; + case 'ui.setSidePanel': setSidePanel(plugin.id, args[0]); return undefined; case 'ui.setPanel': { const slot = args[0]; if (typeof slot !== 'string' || !mayOfferSlot(plugin, slot)) throw new Error(`Plugin "${plugin.id}" may not open the panel "${String(slot)}"`); diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index ec61ff6e7..3df6b7755 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -30,6 +30,7 @@ import { clearDayLabels } from './day-labels'; import { clearPluginListFilters } from './list-filters'; import { clearPluginPages } from './pages'; import { clearSectionTitle } from './section-titles'; +import { clearSidePanel } from './side-panels'; import { cancelPluginProxyRequests } from './proxy-client'; import { cancelConnectFor } from './connect-dialog'; import { isHookHandedOver } from './host-features'; @@ -390,6 +391,7 @@ export function unloadSandboxedPlugin(pluginId: string): void { clearDayLabels(pluginId); clearPluginPages(pluginId); clearSectionTitle(pluginId); + clearSidePanel(pluginId); cancelPluginProxyRequests(pluginId); cancelConnectFor(pluginId); try { entry.background.destroy(); } catch { /* ignore */ } diff --git a/lib/plugin-sandbox/panel-requests.ts b/lib/plugin-sandbox/panel-requests.ts index fc11423e1..e7f26887b 100644 --- a/lib/plugin-sandbox/panel-requests.ts +++ b/lib/plugin-sandbox/panel-requests.ts @@ -1,15 +1,15 @@ // Plugins opening and closing the panel their slot sits in -// (api.ui.openPanel / closePanel / togglePanel): the composer's side panels -// and the reader's detail panel. A toolbar button, a shortcut or a -// right-click entry can then bring the plugin's panel up instead of a -// dialog. The panels subscribe here while they are on screen; a request +// (api.ui.openPanel / closePanel / togglePanel): the composer's side panels, +// the reader's detail panel and the panel beside the mail (side-panel). A +// toolbar button, a shortcut or a right-click entry can then bring the +// plugin's panel up instead of a dialog. The panels subscribe here while they are on screen; a request // nobody answers (no composer open, no message open, a phone) resolves to // null, so the plugin can fall back to a dialog. // // When a panel opens on request, its slot gets a new `focusRequest` number // in its props, so the plugin can put the focus where it belongs. -export const PANEL_SLOTS = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar'] as const; +export const PANEL_SLOTS = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'side-panel'] as const; export type PanelSlot = typeof PANEL_SLOTS[number]; export type PanelMode = 'open' | 'close' | 'toggle'; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index daf0ee0db..9e393f74a 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -758,12 +758,23 @@ function buildPluginApi(manifest: PluginManifest) { * back to the plugin's name. At most 40 characters. */ setSectionTitle: (title: string | null) => callApi('ui.setSectionTitle', [title]) as Promise, + /** + * The button and header of this plugin's `side-panel` slot, the panel + * beside the mail: a title (at most 40 characters, e.g. a translated + * "Upcoming for me") and a host icon by name (as for setPages). null + * goes back to the plugin's name and a puzzle piece. `rail: false` + * draws no button in the rail: the plugin opens the panel itself + * with openPanel / togglePanel('side-panel'), e.g. from a top bar + * action. + */ + setSidePanel: (look: { title?: string; icon?: string; rail?: boolean } | null) => callApi('ui.setSidePanel', [look]) as Promise, /** * Open, close or toggle the panel this plugin's slot sits in: * 'composer-sidebar', 'composer-sidebar-right' (pass the composerId - * from the click context or the slot's props) or 'email-detail-sidebar'. - * Resolve to whether the panel is open afterwards, or null when no such - * panel is on screen (no composer or message open, or a phone), so the + * from the click context or the slot's props), 'email-detail-sidebar' + * or 'side-panel' (this plugin's panel beside the mail). Resolve to + * whether the panel is open afterwards, or null when no such panel is + * on screen (no composer or message open, or a phone), so the * plugin can fall back to a dialog. A panel opened this way gets a new * `focusRequest` prop, so the slot can focus its input. */ diff --git a/lib/plugin-sandbox/side-panels.ts b/lib/plugin-sandbox/side-panels.ts new file mode 100644 index 000000000..90a937a88 --- /dev/null +++ b/lib/plugin-sandbox/side-panels.ts @@ -0,0 +1,72 @@ +// How plugins' side panels are named in the rail beside the mail +// (api.ui.setSidePanel): a title and a host icon. The host draws the button +// and the panel's header itself; without a title it shows the plugin's name, +// without an icon a puzzle piece. `rail: false` leaves the button out: the +// plugin opens its panel itself (api.ui.openPanel / togglePanel), e.g. from +// a top bar action. + +export interface SidePanelLook { + title?: string; + /** Name of a host icon (see components/icons), as for api.ui.setPages. */ + icon?: string; + /** false: no button in the rail; the panel opens only on the plugin's request. */ + rail?: boolean; +} + +export const MAX_SIDE_PANEL_TITLE = 40; +const ICON_RE = /^[A-Za-z0-9-]{1,40}$/; + +let looks: ReadonlyMap = new Map(); +const listeners = new Set<() => void>(); + +function changed(): void { + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } +} + +/** Check and store a panel's title and icon (null clears both); throws with a message the plugin sees. */ +export function setSidePanel(pluginId: string, value: unknown): void { + if (value === null || value === undefined) { + clearSidePanel(pluginId); + return; + } + if (typeof value !== 'object' || Array.isArray(value)) throw new Error('ui.setSidePanel: expects { title, icon, rail } or null'); + const raw = value as Record; + const look: SidePanelLook = {}; + if (raw.title !== undefined && raw.title !== null) { + if (typeof raw.title !== 'string') throw new Error('ui.setSidePanel: title must be a string'); + // eslint-disable-next-line no-control-regex -- one line of text + const title = raw.title.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim(); + if (title.length > MAX_SIDE_PANEL_TITLE) throw new Error(`ui.setSidePanel: title has at most ${MAX_SIDE_PANEL_TITLE} characters`); + if (title) look.title = title; + } + if (raw.icon !== undefined && raw.icon !== null) { + if (typeof raw.icon !== 'string' || !ICON_RE.test(raw.icon)) throw new Error('ui.setSidePanel: icon is invalid'); + look.icon = raw.icon; + } + if (raw.rail !== undefined && raw.rail !== null) { + if (typeof raw.rail !== 'boolean') throw new Error('ui.setSidePanel: rail must be a boolean'); + if (!raw.rail) look.rail = false; + } + looks = new Map(looks).set(pluginId, look); + changed(); +} + +/** Every plugin's title and icon; a new map after each change, the same one between. */ +export function sidePanelLooks(): ReadonlyMap { + return looks; +} + +export function clearSidePanel(pluginId: string): void { + if (!looks.has(pluginId)) return; + const next = new Map(looks); + next.delete(pluginId); + looks = next; + changed(); +} + +export function subscribeSidePanels(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} diff --git a/lib/plugin-sandbox/slot-layout.ts b/lib/plugin-sandbox/slot-layout.ts index d3dedb2e6..495e708ad 100644 --- a/lib/plugin-sandbox/slot-layout.ts +++ b/lib/plugin-sandbox/slot-layout.ts @@ -44,7 +44,7 @@ export function clampSlotWidth(width: unknown): number | null { * can pin controls at the bottom, like a chat input. Elsewhere a frame grows * with its content. */ -export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-page']; +export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'side-panel', 'app-page']; export function isFillableSlot(slot: string): boolean { return FILLABLE_SLOTS.includes(slot); diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts index 043667986..5e277d665 100644 --- a/lib/plugin-slot-catalog.ts +++ b/lib/plugin-slot-catalog.ts @@ -51,6 +51,7 @@ export const SLOT_CATALOG = { 'navigation-rail-bottom': S('navigation', 'frame', ['ui:navigation-rail'], {}, 'the bottom of the navigation rail'), 'sidebar-widget': S('navigation', 'frame', ['ui:sidebar-widget'], {}, 'a widget at the bottom of the mail sidebar'), 'sidebar-section': S('navigation', 'frame', ['ui:sidebar-widget'], { mailboxId: null }, 'a collapsible section in the mail sidebar, below the folders'), + 'side-panel': S('navigation', 'frame', ['ui:sidebar-widget'], { focusRequest: null }, 'a panel beside the mail (list and reader), opened from its button in the rail at the right edge (Mountain View\'s side panel; in the default layout a rail of its own, desktop only); the button\'s title and icon from api.ui.setSidePanel, else the plugin\'s name, no button with its `rail: false` (then opened by api.ui.openPanel / togglePanel only); the open panel is remembered in this browser; `slots[\'side-panel\'].fill = true` makes the frame as tall as the panel; `focusRequest` counts up when the plugin opened it with api.ui.openPanel(\'side-panel\')'), 'sidebar-section-actions': S('navigation', 'frame', ['ui:sidebar-widget'], {}, 'compact controls beside a collapsible plugin sidebar-section heading', true), 'top-bar-actions': S('navigation', 'action', ['ui:toolbar'], {}, 'buttons in the top bar, next to search', true), 'app-page': S('navigation', 'frame', ['ui:app-page'], { pageId: null, path: null }, 'a full page of its own, with an entry in the navigation rail (api.ui.setPages); api.ui.openPage moves the tab there'),