import { useEffect, useRef, useState, type FormEvent } from 'react'; import { PluginHeader, PluginShell, Split, Tabs } from '@rozenite/ui'; import type { DevHostFlowEntry, DevHostPanelEntry, DevHostPresetEntry, DevHostState, MessageEntry, } from './types.js'; import { useFlowRunner } from './flow-runtime.js'; import { createMessageEntry, formatPayloadForCommandInput, getInitialPanel, isPluginMessage, } from './utils.js'; import { DispatchForm } from './components/DispatchForm.js'; import { MessageDetailsPane, getDispatcherValuesFromMessage, } from './components/MessageDetailsPane.js'; import { MessageLogPane } from './components/MessageLogPane.js'; import { PanelTabs } from './components/PanelTabs.js'; import './styles.css'; type AppProps = DevHostState & { flows: DevHostFlowEntry[]; presets: DevHostPresetEntry[]; }; type MobileDevtoolsTab = 'log' | 'actions'; const useNarrowViewport = () => { const [isNarrow, setIsNarrow] = useState( () => window.matchMedia('(max-width: 960px)').matches, ); useEffect(() => { const mediaQuery = window.matchMedia('(max-width: 960px)'); const handleChange = (event: MediaQueryListEvent) => { setIsNarrow(event.matches); }; setIsNarrow(mediaQuery.matches); mediaQuery.addEventListener('change', handleChange); return () => mediaQuery.removeEventListener('change', handleChange); }, []); return isNarrow; }; export const App = ({ packageName, packageDescription, panels, flows, presets, }: AppProps) => { const [activePanel, setActivePanel] = useState(() => getInitialPanel(panels), ); const [commandType, setCommandType] = useState(''); const [commandPayload, setCommandPayload] = useState(''); const [messages, setMessages] = useState([]); const [selectedMessageId, setSelectedMessageId] = useState( null, ); const [isDetailsOpen, setIsDetailsOpen] = useState(false); const [activeMobileTab, setActiveMobileTab] = useState('log'); const [iframeLoadNonce, setIframeLoadNonce] = useState(0); const iframeRef = useRef(null); const lastAutoRunLoadRef = useRef(0); const isNarrowViewport = useNarrowViewport(); const { flowRuns, runFlow, stopFlow, hasRunningFlow, registerMessage, resetMessages, } = useFlowRunner({ sendMessage: (type, payload) => { iframeRef.current?.contentWindow?.postMessage( { pluginId: packageName, type, payload }, '*', ); appendMessage({ direction: 'in', type, payload }); }, }); const activeSource = activePanel?.source ?? ''; const activeLabel = activePanel?.label ?? ''; const selectedMessage = messages.find((message) => message.id === selectedMessageId) ?? null; const panelDescription = packageDescription.trim(); const trimmedCommandType = commandType.trim(); const trimmedCommandPayload = commandPayload.trim(); const canDispatch = (() => { if (!trimmedCommandType || !trimmedCommandPayload) { return false; } try { JSON.parse(trimmedCommandPayload); return true; } catch { return false; } })(); useEffect(() => { document.title = `${packageName} Dev Host`; }, [packageName]); useEffect(() => { if (iframeLoadNonce === 0) { return; } if (lastAutoRunLoadRef.current === iframeLoadNonce) { return; } lastAutoRunLoadRef.current = iframeLoadNonce; flows.forEach((flow) => { if (flow.autoRun) { runFlow(flow, { autoRun: true }); } }); }, [flows, iframeLoadNonce, runFlow]); useEffect(() => { const handleMessage = (event: MessageEvent) => { if (event.source !== iframeRef.current?.contentWindow) { return; } if ( typeof event.data !== 'object' || event.data === null || event.data.type !== 'rozenite-message' || !('payload' in event.data) || !isPluginMessage(event.data.payload) ) { return; } const payload = event.data.payload; appendMessage({ direction: 'out', type: payload.type, payload: payload.payload, }); }; window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, []); const appendMessage = (input: Omit) => { const nextEntry = createMessageEntry(input); registerMessage(nextEntry); setMessages((current) => [nextEntry, ...current]); }; const selectPanel = (value: string) => { const nextPanel = panels.find((panel) => panel.source === value); if (!nextPanel) { return; } setActivePanel(nextPanel); const nextUrl = new URL(window.location.href); nextUrl.searchParams.set('panel', nextPanel.label); window.history.replaceState(null, '', nextUrl); }; const clearMessages = () => { resetMessages(); setMessages([]); setSelectedMessageId(null); setIsDetailsOpen(false); setActiveMobileTab('log'); }; const resetForm = () => { setCommandType(''); setCommandPayload(''); }; const applyPreset = (preset: DevHostPresetEntry) => { setCommandType(preset.type); setCommandPayload(formatPayloadForCommandInput(preset.payload)); }; const handleDispatch = (event: FormEvent) => { event.preventDefault(); if (!canDispatch) { return; } const payload = JSON.parse(trimmedCommandPayload) as unknown; iframeRef.current?.contentWindow?.postMessage( { pluginId: packageName, type: trimmedCommandType, payload }, '*', ); appendMessage({ direction: 'in', type: trimmedCommandType, payload, }); resetForm(); }; const handleMessageSelect = (messageId: string) => { setSelectedMessageId(messageId); setIsDetailsOpen(true); if (isNarrowViewport) { setActiveMobileTab('log'); } }; const useMessage = (message: MessageEntry) => { const nextValues = getDispatcherValuesFromMessage(message); setCommandType(nextValues.commandType); setCommandPayload(nextValues.commandPayload); setIsDetailsOpen(false); if (isNarrowViewport) { setActiveMobileTab('actions'); } }; const messageLog = ( ); const actions = ( ); const devtools = isNarrowViewport ? ( setActiveMobileTab(value as MobileDevtoolsTab)} > Log Actions {isDetailsOpen && selectedMessage ? ( setIsDetailsOpen(false)} onUseMessage={useMessage} /> ) : ( messageLog )} {actions} ) : ( {isDetailsOpen && selectedMessage ? ( {messageLog} setIsDetailsOpen(false)} onUseMessage={useMessage} /> ) : ( messageLog )} {actions} ); return (
Rozenite Dev Host {packageName}
{panels.length === 0 ? (
No panels were defined in rozenite.config.ts.
) : (