'use client'; /** * Reusable data inspector component built on react-inspector. * * All data rendering in the o11y UI should use this component to ensure * consistent theming, custom type handling (StreamRef, ClassInstanceRef), * and expand behavior. */ import { Lock } from 'lucide-react'; import { createContext, useContext, useEffect, useRef, useState } from 'react'; import { Spinner } from './spinner'; import { ObjectInspector, ObjectLabel, ObjectName, ObjectRootLabel, ObjectValue, } from 'react-inspector'; import { useDarkMode } from '../../hooks/use-dark-mode'; import { ENCRYPTED_DISPLAY_NAME } from '../../lib/hydration'; import { type InspectorThemeExtended, inspectorThemeDark, inspectorThemeExtendedDark, inspectorThemeExtendedLight, inspectorThemeLight, } from './inspector-theme'; // --------------------------------------------------------------------------- // StreamRef / ClassInstanceRef type detection // (inline to avoid circular deps with hydration module) // --------------------------------------------------------------------------- const STREAM_REF_TYPE = '__workflow_stream_ref__'; const CLASS_INSTANCE_REF_TYPE = '__workflow_class_instance_ref__'; interface StreamRef { __type: typeof STREAM_REF_TYPE; streamId: string; } function isStreamRef(value: unknown): value is StreamRef { return ( value !== null && typeof value === 'object' && '__type' in value && (value as Record).__type === STREAM_REF_TYPE ); } function isClassInstanceRef(value: unknown): value is { __type: string; className: string; classId: string; data: unknown; } { return ( value !== null && typeof value === 'object' && '__type' in value && (value as Record).__type === CLASS_INSTANCE_REF_TYPE ); } // --------------------------------------------------------------------------- // Stream click context (passed through from the panel) // --------------------------------------------------------------------------- /** * Context for passing stream click handlers down to DataInspector instances. * Exported so that parent components (e.g., AttributePanel) can provide the handler. */ export const StreamClickContext = createContext< ((streamId: string) => void) | undefined >(undefined); /** * Context for passing a decrypt handler down to DataInspector instances. * When provided, encrypted markers become clickable buttons that trigger decryption. */ export type DecryptClickContextValue = { onDecrypt: () => void; isDecrypting: boolean; }; export const DecryptClickContext = createContext< DecryptClickContextValue | undefined >(undefined); function EncryptedInlineLabel() { const ctx = useContext(DecryptClickContext); if (ctx) { return ( ); } return ( Encrypted ); } function StreamRefInline({ streamRef }: { streamRef: StreamRef }) { const onStreamClick = useContext(StreamClickContext); return ( ); } // --------------------------------------------------------------------------- // Extended theme context (for colors react-inspector doesn't support natively) // --------------------------------------------------------------------------- const ExtendedThemeContext = createContext( inspectorThemeExtendedLight ); // --------------------------------------------------------------------------- // Custom nodeRenderer // --------------------------------------------------------------------------- /** * Extends the default react-inspector nodeRenderer with special handling * for ClassInstanceRef, StreamRef, and Date types. * * react-inspector renders Date instances as unstyled plain text (no theme * key exists for them), so we intercept here and apply the magenta color * from our extended theme — matching Node.js util.inspect()'s date style. * * Default nodeRenderer reference: * https://github.com/storybookjs/react-inspector/blob/main/README.md#noderenderer */ function NodeRenderer({ depth, name, data, isNonenumerable, }: { depth: number; name?: string; data: unknown; isNonenumerable?: boolean; expanded?: boolean; }) { const extendedTheme = useContext(ExtendedThemeContext); // Encrypted marker → flat label with Lock icon, clickable when onDecrypt is available if ( data !== null && typeof data === 'object' && data.constructor?.name === ENCRYPTED_DISPLAY_NAME ) { const label = ; if (depth === 0) { return label; } return ( {name != null && } {name != null && : } {label} ); } // StreamRef → inline clickable badge if (isStreamRef(data)) { return ( {name != null && } {name != null && : } ); } // ClassInstanceRef → show className as type, data as the inspectable value if (isClassInstanceRef(data)) { if (depth === 0) { return ; } return ( {name != null && } {name != null && : } {data.className} ); } // Date → magenta color (Node.js: 'date' → 'magenta') // react-inspector has no OBJECT_VALUE_DATE_COLOR theme key, so we handle it here. if (data instanceof Date) { const dateStr = data.toISOString(); if (depth === 0) { return ( {dateStr} ); } return ( {name != null && } {name != null && : } {dateStr} ); } // Default rendering (same as react-inspector's built-in) if (depth === 0) { return ; } return ( ); } // --------------------------------------------------------------------------- // Public component // --------------------------------------------------------------------------- export interface DataInspectorProps { /** The data to inspect */ data: unknown; /** Initial expand depth (default: 2) */ expandLevel?: number; /** Optional name for the root node */ name?: string; /** Callback when a stream reference is clicked */ onStreamClick?: (streamId: string) => void; /** Callback when an encrypted marker is clicked (triggers decryption) */ onDecrypt?: () => void; /** Whether decryption is currently in progress */ isDecrypting?: boolean; } export function DataInspector({ data, expandLevel = 2, name, onStreamClick, onDecrypt, isDecrypting = false, }: DataInspectorProps) { const stableData = useStableInspectorData(data); const [initialExpandLevel, setInitialExpandLevel] = useState(expandLevel); const isDark = useDarkMode(); const extendedTheme = isDark ? inspectorThemeExtendedDark : inspectorThemeExtendedLight; useEffect(() => { // react-inspector reapplies expandLevel on every data change, which can // reopen paths the user manually collapsed. Apply it only on mount. setInitialExpandLevel(0); }, []); const content = ( ); let wrapped = content; if (onStreamClick) { wrapped = ( {wrapped} ); } if (onDecrypt) { wrapped = ( {wrapped} ); } return wrapped; } function useStableInspectorData(next: T): T { const previousRef = useRef(next); if (!isDeepEqual(previousRef.current, next)) { previousRef.current = next; } return previousRef.current; } function isObjectLike(value: unknown): value is Record { return typeof value === 'object' && value !== null; } function isDeepEqual(a: unknown, b: unknown, seen = new WeakMap()): boolean { if (Object.is(a, b)) return true; if (a instanceof Date && b instanceof Date) { return a.getTime() === b.getTime(); } if (a instanceof RegExp && b instanceof RegExp) { return a.source === b.source && a.flags === b.flags; } if (a instanceof Map && b instanceof Map) { if (a.size !== b.size) return false; for (const [key, value] of a.entries()) { if (!b.has(key) || !isDeepEqual(value, b.get(key), seen)) return false; } return true; } if (a instanceof Set && b instanceof Set) { if (a.size !== b.size) return false; for (const value of a.values()) { if (!b.has(value)) return false; } return true; } if (!isObjectLike(a) || !isObjectLike(b)) { return false; } if (seen.get(a) === b) return true; seen.set(a, b); const aIsArray = Array.isArray(a); const bIsArray = Array.isArray(b); if (aIsArray !== bIsArray) return false; if (aIsArray && bIsArray) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i += 1) { if (!isDeepEqual(a[i], b[i], seen)) return false; } return true; } const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) return false; for (const key of aKeys) { if (!Object.hasOwn(b, key)) return false; if (!isDeepEqual(a[key], b[key], seen)) return false; } return true; }