'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 { 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]);
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 = (
{t('from_plugin', { name })}
);
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 ? (
) : (
{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 ? : null;
}
/** The "⋯" menu in a plugin dialog's header: the way out of a plugin that keeps asking. */
function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () => void }) {
const t = useTranslations('plugins.dialog');
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDown, true);
return () => document.removeEventListener('mousedown', onDown, true);
}, [open]);
return (