Merge pull request #1496 from HOLOEYE-JZ/feat/plugin-side-panel

This commit is contained in:
Linus Rath
2026-11-02 20:24:02 +01:00
11 changed files with 506 additions and 14 deletions
+40 -5
View File
@@ -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<MvSidePanelId | null>(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" && <ContactsPanel onClose={() => setOpen(null)} />}
</aside>
)}
<nav
{pluginOpen && (
<aside
data-simple-hide=""
data-plugin-side-panel={pluginOpen.pluginId}
className="mb-4 me-2 flex w-[300px] shrink-0 flex-col overflow-hidden rounded-2xl bg-background"
aria-label={pluginOpen.title}
>
<PluginSidePanelBody entry={pluginOpen} focusRequest={plugins.focusRequest} onClose={() => plugins.choose(null)} />
</aside>
)}
{(buttons.length > 0 || pluginButtons.length > 0) && <nav
data-mv-side-rail=""
className="flex w-14 shrink-0 flex-col items-center gap-2 pt-3"
aria-label={t("mv_side_panel")}
@@ -84,7 +109,7 @@ export function MvSidePanel() {
<button
key={id}
type="button"
onClick={() => setOpen((cur) => (cur === id ? null : id))}
onClick={() => openApp(active === id ? null : id)}
className={cn(
"relative grid h-10 w-10 place-items-center rounded-full transition-colors hover:bg-[var(--mv-nav-hover)]",
active === id && "bg-[var(--mv-nav-selected)]",
@@ -96,8 +121,18 @@ export function MvSidePanel() {
<Icon className={cn("h-5 w-5", color)} />
</button>
))}
{pluginButtons.map((entry) => (
<SidePanelButton
key={entry.pluginId}
entry={entry}
pressed={pluginOpen?.pluginId === entry.pluginId}
onClick={() => openPlugin(pluginOpen?.pluginId === entry.pluginId ? null : entry.pluginId)}
className="h-10 w-10 rounded-full hover:bg-[var(--mv-nav-hover)]"
pressedClassName="bg-[var(--mv-nav-selected)] text-foreground"
/>
))}
<div className="my-1 h-px w-5 bg-border" aria-hidden />
</nav>
</nav>}
</>
);
}
+5
View File
@@ -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";
@@ -5791,6 +5792,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 && <MvSidePanel />}
{/* 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 && <PluginSidePanel />}
</div>
{/* Keyboard Shortcuts Modal */}
@@ -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(<PluginSidePanel />);
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(<PluginSidePanel />);
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<string, unknown>; 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(<PluginSidePanel />);
fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' }));
first.unmount();
const { container } = render(<PluginSidePanel />);
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(<PluginSidePanel />);
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(<PluginSidePanel />);
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(<PluginSidePanel />);
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(<MvSidePanel />);
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(<MvSidePanel />);
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(<MvSidePanel />);
expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument();
});
});
+193
View File
@@ -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<string | null>(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 (
<>
<div className="flex shrink-0 items-center gap-1 ps-4 pe-2 pt-3 pb-1">
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{entry.title}</div>
<button
type="button"
onClick={onClose}
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
title={tCommon('close')}
aria-label={tCommon('close')}
>
<X className="h-4 w-4" />
</button>
</div>
<div
data-plugin-slot="side-panel"
className={cn('min-h-0 flex-1', entry.fill ? 'flex flex-col' : 'overflow-y-auto')}
>
<PluginIframeSlot key={entry.pluginId} pluginId={entry.pluginId} slot="side-panel" extraProps={extraProps} fill={entry.fill} />
</div>
</>
);
}
/**
* 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 && (
<aside
data-plugin-side-panel={active.pluginId}
className="flex w-[300px] shrink-0 flex-col overflow-hidden border-s border-border bg-background"
aria-label={active.title}
>
<PluginSidePanelBody entry={active} focusRequest={focusRequest} onClose={() => choose(null)} />
</aside>
)}
{inRail.length > 0 && <nav
data-plugin-side-rail=""
className="flex w-12 shrink-0 flex-col items-center gap-1 border-s border-border bg-background pt-2"
aria-label={t('mv_side_panel')}
>
{inRail.map((entry) => (
<SidePanelButton
key={entry.pluginId}
entry={entry}
pressed={open === entry.pluginId}
onClick={() => choose(open === entry.pluginId ? null : entry.pluginId)}
className="h-9 w-9 rounded-md hover:bg-muted"
pressedClassName="bg-muted text-foreground"
/>
))}
</nav>}
</>
);
}
/** 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 (
<button
type="button"
onClick={onClick}
data-plugin-side-panel-button={entry.pluginId}
className={cn('grid place-items-center text-muted-foreground transition-colors', className, pressed && pressedClassName)}
title={entry.title}
aria-label={entry.title}
aria-pressed={pressed}
>
<Icon className="h-5 w-5" />
</button>
);
}
+4
View File
@@ -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<Record<string, Permission | null>> = {
// 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)}"`);
+2
View File
@@ -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';
@@ -396,6 +397,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 */ }
+5 -5
View File
@@ -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';
+14 -3
View File
@@ -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<void>,
/**
* 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<void>,
/**
* 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.
*/
+72
View File
@@ -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<string, SidePanelLook> = 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<string, unknown>;
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<string, SidePanelLook> {
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); };
}
+1 -1
View File
@@ -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);
+1
View File
@@ -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'),