From 1b6e3ec7e22a769af8d82bfa39968bfe7fc41883 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 21 Sep 2026 04:30:11 +0200 Subject: [PATCH] inspector --- src/devtools/AutomatInspector.jsx | 537 ++++++++++++++++++++++++++++++ src/main.jsx | 12 + 2 files changed, 549 insertions(+) create mode 100644 src/devtools/AutomatInspector.jsx diff --git a/src/devtools/AutomatInspector.jsx b/src/devtools/AutomatInspector.jsx new file mode 100644 index 0000000..7254d1f --- /dev/null +++ b/src/devtools/AutomatInspector.jsx @@ -0,0 +1,537 @@ +import { PureComponent } from 'react'; +import Badge from '@mui/material/Badge'; +import Box from '@mui/material/Box'; +import ButtonBase from '@mui/material/ButtonBase'; +import Chip from '@mui/material/Chip'; +import Drawer from '@mui/material/Drawer'; +import Fab from '@mui/material/Fab'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import BugReportIcon from '@mui/icons-material/BugReport'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import CloseIcon from '@mui/icons-material/Close'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import UnfoldLessIcon from '@mui/icons-material/UnfoldLess'; +import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; +import { enqueueSnackbar } from 'notistack'; +import { notifyError } from '../utils/notify'; + +/** + * Dev-only overlay that lists every Automat registered in the + * `window.__AUTOMATS__` registry (react-automat writes named instances there). + * + * Loaded exclusively through a `import.meta.env.DEV` guarded dynamic import in + * `main.jsx`, so the module never reaches the production bundle. + */ + +/** Registry polling interval in ms; lazy automats (`delivery-`) appear over time. */ +const REGISTRY_POLL_MS = 2000; + +/** Serialization limits so huge states stay renderable. */ +const MAX_DEPTH = 6; +const MAX_STRING = 1500; +const MAX_ITEMS = 50; + +/** + * Live registry view. Named automats register themselves in the constructor, + * so the map may be absent until the first one is created. + * + * @returns {Map} Registered automats. + */ +function getRegistry() { + const registry = window.__AUTOMATS__; + return registry instanceof Map ? registry : new Map(); +} + +/** + * Quotes a string and truncates oversized values. + * + * @param {string} text Value to quote. + * @returns {string} Quoted, possibly truncated string. + */ +function quote(text) { + if (text.length <= MAX_STRING) return JSON.stringify(text); + const rest = text.length - MAX_STRING; + return `${JSON.stringify(text.slice(0, MAX_STRING))} … (+${rest} Zeichen)`; +} + +/** + * Renders `entries` as an indented block. + * + * @param {string} open Opening bracket. + * @param {string} close Closing bracket. + * @param {Array} entries Already serialized entries. + * @param {string} pad Indentation of the enclosing level. + * @returns {string} Indented block. + */ +function block(open, close, entries, pad) { + if (entries.length === 0) return `${open}${close}`; + const body = entries.map((entry) => `${pad} ${entry}`).join(',\n'); + return `${open}\n${body}\n${pad}${close}`; +} + +/** + * Serializes an arbitrary state value for display. Handles functions, blobs, + * DOM nodes, maps, sets and circular references, which plain `JSON.stringify` + * would drop or throw on. + * + * @param {*} value Value to serialize. + * @param {number} depth Current nesting depth. + * @param {WeakSet} ancestors Objects on the current path, for cycle detection. + * @param {string} pad Indentation of the current level. + * @returns {string} Human-readable representation. + */ +function serialize(value, depth, ancestors, pad) { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + + const type = typeof value; + if (type === 'string') return quote(value); + if (type === 'number' || type === 'boolean') return String(value); + if (type === 'bigint') return `${value}n`; + if (type === 'function') return `[Function ${value.name || 'anonym'}]`; + if (type === 'symbol') return `[Symbol ${value.description ?? ''}]`; + if (type !== 'object') return String(value); + + if (value instanceof Date) return `[Date ${value.toISOString()}]`; + if (value instanceof Blob) return `[Blob ${value.size}B ${value.type || 'ohne Typ'}]`; + if (value instanceof ArrayBuffer) return `[ArrayBuffer ${value.byteLength}B]`; + if (value instanceof Promise) return '[Promise]'; + if (typeof Node !== 'undefined' && value instanceof Node) return `[Node ${value.nodeName}]`; + + if (ancestors.has(value)) return '[Circular]'; + if (depth >= MAX_DEPTH) return Array.isArray(value) ? '[Array …]' : '[Object …]'; + + ancestors.add(value); + try { + if (value instanceof Map) { + const shown = Array.from(value.entries()).slice(0, MAX_ITEMS); + const entries = shown.map( + ([k, v]) => + `${serialize(k, depth + 1, ancestors, `${pad} `)}: ${serialize(v, depth + 1, ancestors, `${pad} `)}`, + ); + if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`); + return block(`Map(${value.size}) {`, '}', entries, pad); + } + + if (value instanceof Set) { + const shown = Array.from(value.values()).slice(0, MAX_ITEMS); + const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `)); + if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`); + return block(`Set(${value.size}) {`, '}', entries, pad); + } + + if (Array.isArray(value)) { + const shown = value.slice(0, MAX_ITEMS); + const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `)); + if (value.length > shown.length) entries.push(`${JSON.stringify('…')}: +${value.length - shown.length} weitere`); + return block('[', ']', entries, pad); + } + + const keys = Object.keys(value); + const shown = keys.slice(0, MAX_ITEMS); + const entries = shown.map( + (key) => `${JSON.stringify(key)}: ${serialize(value[key], depth + 1, ancestors, `${pad} `)}`, + ); + if (keys.length > shown.length) entries.push(`${JSON.stringify('…')}: +${keys.length - shown.length} weitere Schlüssel`); + + const label = value.constructor && value.constructor !== Object ? `${value.constructor.name} ` : ''; + return block(`${label}{`, '}', entries, pad); + } finally { + ancestors.delete(value); + } +} + +/** + * Formats an automat state for the overlay. Serialization failures are surfaced + * instead of being swallowed. + * + * @param {*} value Automat state. + * @returns {string} Display text. + */ +export function formatState(value) { + try { + return serialize(value, 0, new WeakSet(), ''); + } catch (err) { + return `[Serialisierung fehlgeschlagen: ${err?.message ?? err}]`; + } +} + +/** + * Floating dev button plus overlay listing all registered Automats with live + * state, readiness flags, subscriber counts and available actions. + * + * @augments {PureComponent<{}, {open: boolean, names: Array, expanded: Object, query: string, revision: number}>} + */ +class AutomatInspector extends PureComponent { + constructor(props) { + super(props); + this.state = { + open: false, + names: [], + expanded: {}, + query: '', + revision: 0, + }; + + /** @type {Map void>} Unsubscribe functions per automat name. */ + this.unsubs = new Map(); + this.frame = null; + + this.handleToggle = this.handleToggle.bind(this); + this.handleClose = this.handleClose.bind(this); + this.handleRefresh = this.handleRefresh.bind(this); + this.handleToggleAll = this.handleToggleAll.bind(this); + this.handleToggleEntry = this.handleToggleEntry.bind(this); + this.handleQueryChange = this.handleQueryChange.bind(this); + this.handleCopy = this.handleCopy.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.syncRegistry = this.syncRegistry.bind(this); + this.scheduleUpdate = this.scheduleUpdate.bind(this); + } + + componentDidMount() { + this.syncRegistry(); + this.registryTimer = window.setInterval(this.syncRegistry, REGISTRY_POLL_MS); + window.addEventListener('keydown', this.handleKeyDown); + } + + componentWillUnmount() { + window.clearInterval(this.registryTimer); + window.removeEventListener('keydown', this.handleKeyDown); + this.unsubscribeAll(); + if (this.frame) cancelAnimationFrame(this.frame); + } + + /** Mirrors the registry keys into state and keeps subscriptions in sync. */ + syncRegistry() { + const names = Array.from(getRegistry().keys()).sort((a, b) => a.localeCompare(b)); + const changed = + names.length !== this.state.names.length || names.some((name, i) => name !== this.state.names[i]); + if (changed) this.setState({ names }); + if (this.state.open) this.subscribeToAll(); + } + + /** + * Subscribes to every registered automat so the overlay reflects live state. + * Note: subscribing also flushes a pending deferred load of a dirty automat. + */ + subscribeToAll() { + const registry = getRegistry(); + for (const [name, automat] of registry) { + if (this.unsubs.has(name)) continue; + this.unsubs.set(name, automat.subscribe(() => this.scheduleUpdate())); + } + for (const [name, unsub] of Array.from(this.unsubs)) { + if (!registry.has(name)) { + unsub(); + this.unsubs.delete(name); + } + } + } + + unsubscribeAll() { + for (const unsub of this.unsubs.values()) unsub(); + this.unsubs.clear(); + } + + /** Coalesces automat notifications into one render per animation frame. */ + scheduleUpdate() { + if (this.frame) return; + this.frame = requestAnimationFrame(() => { + this.frame = null; + this.setState((prev) => ({ revision: prev.revision + 1 })); + }); + } + + handleKeyDown(event) { + if (!event.altKey || !event.shiftKey) return; + if (event.key?.toLowerCase() !== 'a') return; + event.preventDefault(); + this.handleToggle(); + } + + handleToggle() { + const open = !this.state.open; + this.setState({ open }); + if (open) { + this.syncRegistry(); + this.subscribeToAll(); + } else { + this.unsubscribeAll(); + } + } + + handleClose() { + this.setState({ open: false }); + this.unsubscribeAll(); + } + + handleRefresh() { + this.syncRegistry(); + } + + handleToggleAll() { + const { names, expanded } = this.state; + const allExpanded = names.length > 0 && names.every((name) => expanded[name]); + this.setState({ + expanded: allExpanded + ? {} + : Object.fromEntries(names.map((name) => [name, true])), + }); + } + + /** + * @param {string} name Automat name. + */ + handleToggleEntry(name) { + this.setState((prev) => ({ + expanded: { ...prev.expanded, [name]: !prev.expanded[name] }, + })); + } + + /** + * @param {React.ChangeEvent} event Change event. + */ + handleQueryChange(event) { + this.setState({ query: event.target.value }); + } + + /** + * Copies the current state of one automat as text. + * + * @param {string} name Automat name. + * @returns {Promise} + */ + async handleCopy(name) { + const automat = getRegistry().get(name); + if (!automat) return; + try { + if (!navigator.clipboard?.writeText) { + throw new Error('Clipboard API nicht verfügbar (sichere Origin erforderlich).'); + } + await navigator.clipboard.writeText(`${name}\n${formatState(automat.state)}`); + enqueueSnackbar(`State von "${name}" kopiert`, { variant: 'success' }); + } catch (err) { + notifyError(err); + } + } + + /** + * Renders one automat entry. + * + * @param {import('react-automat').Automat} automat Automat instance. + * @param {boolean} isExpanded Whether the details are visible. + * @returns {React.ReactElement} Entry element. + */ + renderEntry(automat, isExpanded) { + const name = automat.name; + const actionNames = Object.keys(automat.actions ?? {}); + + return ( + + this.handleToggleEntry(name)} + sx={{ + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + gap: 1, + px: 1, + py: 0.75, + textAlign: 'left', + }} + > + {isExpanded ? : } + + {name} + + + + {automat.isDirty ? : null} + + + {isExpanded ? ( + + + {automat.url ? : null} + {automat.persist ? : null} + {automat.error ? ( + + ) : null} + + + + + Aktionen ({actionNames.length}) + + + this.handleCopy(name)}> + + + + + + {actionNames.length === 0 ? ( + + keine + + ) : ( + actionNames.map((action) => ) + )} + + + + State + + + {formatState(automat.state)} + + + ) : null} + + ); + } + + render() { + const { open, names, expanded, query } = this.state; + const registry = getRegistry(); + const needle = query.trim().toLowerCase(); + const entries = names + .map((name) => registry.get(name)) + .filter((automat) => automat && (!needle || automat.name.toLowerCase().includes(needle))); + const allExpanded = names.length > 0 && names.every((name) => expanded[name]); + + return ( + <> + theme.zIndex.modal + 1 }} + > + + + + + + + + + + + + Automat-Inspector + + + + + + + + + + {allExpanded ? : } + + + + + + + + + + + + + + + {entries.length === 0 ? ( + + {names.length === 0 ? 'Kein Automat registriert.' : 'Kein Automat passt zum Filter.'} + + ) : ( + {entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))} + )} + + + + ); + } +} + +export default AutomatInspector; diff --git a/src/main.jsx b/src/main.jsx index 82c5f23..4491f5b 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,4 +1,5 @@ import { createRoot } from 'react-dom/client'; +import { lazy, Suspense } from 'react'; import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider } from '@mui/material/styles'; import { Provider } from 'react-redux'; @@ -10,6 +11,12 @@ import { store } from './store'; import { getActiveTheme } from './theme'; import './index.css'; +// Dev-only Automat inspector. The `import.meta.env.DEV` branch is statically +// eliminated in production builds, so this module never ships to users. +const AutomatInspector = import.meta.env.DEV + ? lazy(() => import('./devtools/AutomatInspector')) + : null; + async function clearDevServiceWorker() { if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return; const regs = await navigator.serviceWorker.getRegistrations(); @@ -36,6 +43,11 @@ createRoot(document.getElementById('root')).render( + {AutomatInspector ? ( + + + + ) : null}