'use client'; // Host-rendered modal for plugin-requested confirm/alert/prompt dialogs. // Subscribes to the host-dialog queue and renders the head request, one at // a time. Closing the modal advances the queue. Prompts collect one or more // (optionally masked) fields so plugins never fall back to `window.prompt`, // which the sandbox blocks. // // They look like the app's own dialogs: a custom dialog (api.ui.openDialog) // is the filter editor's layout, with the plugin's buttons (kit // DialogButtons) drawn by the host in its footer; confirm, alert and prompt // are the app's ConfirmDialog and PromptDialog. import React, { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { useTranslations } from 'next-intl'; import { cancelForPlugin, head, overlay, resolveDialog, subscribe, type DialogRequest } from '@/lib/plugin-sandbox/host-dialog'; import type { DialogButtonSpec } from '@/lib/plugin-sandbox/ui-kit/dialog-buttons'; import { usePluginStore } from '@/stores/plugin-store'; import { useFocusTrap } from '@/hooks/use-focus-trap'; import { toast } from '@/stores/toast-store'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { iconForName, Loader2, MoreVertical, Power, Puzzle, X } from '@/components/icons'; import { cn } from '@/lib/utils'; import { PluginIframeSlot } from './plugin-iframe-slot'; import type { SlotName } from '@/lib/plugin-types'; // Lightweight **bold** support in plugin dialog messages. Everything else is // rendered literally (newlines come from the parent's white-space: pre-wrap). // Splitting on the ** delimiter yields alternating plain/bold segments (odd // indices are bold). Plugins control these strings, so the delimiters balance. function renderMessage(message?: string): React.ReactNode { if (!message) return null; return message.split('**').map((seg, i) => i % 2 === 1 ? {seg} : {seg}, ); } export function PluginDialogHost(): React.JSX.Element | null { const base = useSyncExternalStore(subscribe, head, () => null); const top = useSyncExternalStore(subscribe, overlay, () => null); const t = useTranslations('plugins.dialog'); // Say so when a plugin was turned off for failing; otherwise it just // stops working with no hint why. useEffect(() => { const onDisabled = (e: Event) => { const id = (e as CustomEvent<{ pluginId: string }>).detail?.pluginId; const name = usePluginStore.getState().plugins.find((p) => p.id === id)?.name ?? id; if (id) toast.warning(t('toast_auto_disabled', { name })); }; window.addEventListener('plugin:auto-disabled', onDisabled); return () => window.removeEventListener('plugin:auto-disabled', onDisabled); }, [t]); return ( <> {base && } {top && } ); } const DIALOG_BUTTON_VARIANT = { primary: 'default', secondary: 'outline', ghost: 'ghost', destructive: 'destructive' } as const; const DIALOG_BUTTON_ID_RE = /^[A-Za-z0-9._-]{1,40}$/; export const MAX_DIALOG_BUTTONS = 4; /** The buttons a plugin dialog asked for (kit DialogButtons), checked. */ export function readDialogButtons(value: unknown): DialogButtonSpec[] { if (!Array.isArray(value)) return []; const out: DialogButtonSpec[] = []; for (const raw of value.slice(0, MAX_DIALOG_BUTTONS)) { if (!raw || typeof raw !== 'object') continue; const b = raw as Record; if (typeof b.id !== 'string' || !DIALOG_BUTTON_ID_RE.test(b.id)) continue; if (typeof b.label !== 'string') continue; // eslint-disable-next-line no-control-regex -- one line of text const label = b.label.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 40); if (!label) continue; const variant = b.variant === 'secondary' || b.variant === 'ghost' || b.variant === 'destructive' ? b.variant : 'primary'; const icon = typeof b.icon === 'string' && /^[A-Za-z0-9:-]{1,48}$/.test(b.icon) ? b.icon : undefined; out.push({ id: b.id, label, variant, ...(b.disabled === true ? { disabled: true } : {}), ...(b.busy === true ? { busy: true } : {}), ...(icon ? { icon } : {}), }); } return out; } /** * One plugin dialog. `below`: a custom dialog with a confirm or prompt of * the same plugin open on top of it; it stays mounted but takes no keys. */ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: 'only' | 'below' | 'above' }): React.JSX.Element | null { const t = useTranslations('plugins.dialog'); const titleId = useId(); const pluginName = usePluginStore((s) => s.plugins.find((p) => p.id === current?.pluginId)?.name); const disablePlugin = usePluginStore((s) => s.disablePlugin); const resolveThis = (result: unknown) => resolveDialog(current.id, result); const isPrompt = current?.kind === 'prompt'; const fields = useMemo(() => (isPrompt ? current?.fields ?? [] : []), [isPrompt, current]); const hasPassword = fields.some((f) => f.type === 'password'); // Field values for a prompt, re-initialised whenever a new dialog surfaces. const [values, setValues] = useState>({}); useEffect(() => { if (!current) return; const init: Record = {}; for (const f of current.fields ?? []) init[f.name] = ''; setValues(init); }, [current]); // Footer buttons of a custom dialog: the plugin declares them, the host // draws them and passes each click back as `dialogButtonClick` ({ id, n }). const [buttons, setButtons] = useState([]); const [buttonClick, setButtonClick] = useState<{ id: string; n: number }>({ id: '', n: 0 }); const setDialogButtons = useCallback((value: unknown) => { setButtons(readDialogButtons(value)); }, []); const pressButton = (id: string) => setButtonClick((c) => ({ id, n: c.n + 1 })); // The custom slot's onResult answers its own dialog only; a stray second // call after it closed must not resolve the next one in the queue. const customProps = useMemo(() => { if (current?.kind !== 'custom') return undefined; const id = current.id; return { ...(current.extraProps ?? {}), onResult: (value: unknown) => resolveDialog(id, value), setDialogButtons, dialogButtonClick: buttonClick, }; }, [current, setDialogButtons, buttonClick]); const canSubmit = fields.every((f) => !f.required || (values[f.name] ?? '').length > 0); const cancel = () => resolveThis(current?.kind === 'prompt' || current?.kind === 'custom' ? null : false); const submitPrompt = () => { if (canSubmit) resolveThis(values); }; const turnOff = () => { if (!current) return; const id = current.pluginId; cancelForPlugin(id); disablePlugin(id); }; useEffect(() => { if (!current || layer === 'below') return; function onKey(e: KeyboardEvent) { if (e.key === 'Escape') { e.preventDefault(); cancel(); } else if (e.key === 'Enter' && (current?.kind === 'confirm' || current?.kind === 'alert')) { // For prompts, Enter is handled by the form (so it respects required // validation and works from within an input); confirm/alert accept. // Custom dialogs resolve only via their own onResult callback or the // host's X/Escape - an Enter press has no defined meaning for them. e.preventDefault(); resolveThis(true); } } document.addEventListener('keydown', onKey, true); return () => document.removeEventListener('keydown', onKey, true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [current, canSubmit, values]); // Tab stays in the dialog and the focus goes back to what opened it. The // first field of a prompt takes the focus, a confirm's safe button (Cancel // when it is destructive), else the first control. A dialog under another // one lets go. Escape is the listener above. const cancelButtonRef = useRef(null); const confirmButtonRef = useRef(null); const panelRef = useFocusTrap({ isActive: !!current && layer !== 'below', restoreFocus: true, initialFocus: () => { if (current?.kind === 'prompt') return panelRef.current?.querySelector('input'); if (current?.kind === 'confirm' && current.danger) return cancelButtonRef.current; if (current?.kind === 'confirm' || current?.kind === 'alert') return confirmButtonRef.current; return undefined; }, }); if (!current) return null; const name = pluginName ?? current.pluginId; const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? t('ok') : current.kind === 'prompt' ? t('submit') : t('confirm')); const cancelLabel = current.cancelLabel ?? t('cancel'); const custom = current.kind === 'custom'; // Who is asking, under the title: plugins choose the title and message, so // the host names the plugin in a place the plugin can't change. const attribution = (

); const message = current.message ? (

{renderMessage(current.message)}

) : null; return (
{ if (e.target === e.currentTarget) cancel(); }} >
{custom ? ( <>

{current.title}

{attribution}
{message}
{buttons.length > 0 && (
{buttons.map((b) => ( ))}
)} ) : ( <>

{current.title}

{attribution}
{message} {isPrompt ? (
{ e.preventDefault(); submitPrompt(); }}>
{fields.map((f) => ( ))} {hasPassword && (

{t('password_note', { name })}

)}
) : (
{current.kind === 'confirm' && ( )}
)}
)}
); } /** A footer button's icon by name; unknown names show none. */ function FooterIcon({ name }: { name?: string }) { const Glyph = name ? iconForName(name) : null; return Glyph ?