'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 ( ); }