diff --git a/bun_client/frontend/src/components/PluginAnnotations.tsx b/bun_client/frontend/src/components/PluginAnnotations.tsx new file mode 100644 index 0000000..1eda6c6 --- /dev/null +++ b/bun_client/frontend/src/components/PluginAnnotations.tsx @@ -0,0 +1,78 @@ +import { Badge } from '../design'; +import { annotationSeverityVariant, type PluginAnnotation } from '../plugin_utils'; + +interface PluginAnnotationsProps { + annotations: PluginAnnotation[]; + /** Called when an annotation is clicked, e.g. to jump to the line in the diff. */ + onSelect?: (annotation: PluginAnnotation) => void; +} + +/** + * Lists a plugin's line-level annotations beneath its body. Rendering these + * inline in the diff is separate follow-up work; this is the flat view on the + * plugin output surfaces. + */ +export default function PluginAnnotations({ annotations, onSelect }: PluginAnnotationsProps) { + if (annotations.length === 0) return null; + + return ( +
+
+ {annotations.length} annotation{annotations.length === 1 ? '' : 's'} +
+ {annotations.map((annotation, i) => ( +
onSelect(annotation) : undefined} + style={{ + display: 'flex', + gap: '10px', + alignItems: 'baseline', + fontSize: '13px', + lineHeight: '1.5', + cursor: onSelect ? 'pointer' : 'default', + }} + > + {annotation.severity && ( + + {annotation.severity} + + )} + + {annotation.filename}:{annotation.line} + + + {annotation.content} + +
+ ))} +
+ ); +} diff --git a/bun_client/frontend/src/components/PluginBodyView.tsx b/bun_client/frontend/src/components/PluginBodyView.tsx new file mode 100644 index 0000000..128f562 --- /dev/null +++ b/bun_client/frontend/src/components/PluginBodyView.tsx @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Markdown from 'react-markdown'; +import type { PluginBody } from '../plugin_utils'; + +interface PluginBodyViewProps { + body: PluginBody; + /** Rendered when the body has no content. */ + emptyLabel?: string; +} + +// Theme variables the sandboxed HTML frame inherits from the app, so an HTML +// body doesn't render as a white box on a dark theme. +const FRAME_VARS = [ + '--bg-primary', + '--bg-tertiary', + '--text-primary', + '--text-secondary', + '--accent', + '--border', + '--font-mono', +]; + +const MIN_FRAME_HEIGHT = 32; +const MAX_FRAME_HEIGHT = 600; + +function frameCssVars(): string { + const styles = getComputedStyle(document.documentElement); + return FRAME_VARS.map(name => `${name}:${styles.getPropertyValue(name).trim()};`).join(''); +} + +function buildFrameDocument(html: string): string { + return `${html}`; +} + +/** + * Renders a plugin's HTML body inside a sandboxed frame. + * + * Plugin bodies are frequently LLM-generated text derived from a PR's diff and + * description, so they are not trusted markup. The frame withholds + * `allow-scripts`, which blocks script execution and inline event handlers; it + * keeps `allow-same-origin` only so the parent can measure the content to size + * the frame (harmless with scripting off, since nothing inside can run). + */ +function HtmlFrame({ html }: { html: string }) { + const frameRef = useRef(null); + const [height, setHeight] = useState(MIN_FRAME_HEIGHT); + // Re-read the theme's colors whenever the app's theme changes. + const [theme, setTheme] = useState(() => document.documentElement.dataset.theme ?? ''); + + const srcDoc = useMemo(() => { + // `theme` is not read directly: it re-derives the document so the frame + // picks up the new theme's variables. + void theme; + return buildFrameDocument(html); + }, [html, theme]); + + useEffect(() => { + const observer = new MutationObserver(() => + setTheme(document.documentElement.dataset.theme ?? '') + ); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }); + return () => observer.disconnect(); + }, []); + + const measure = useCallback(() => { + const frame = frameRef.current; + const doc = frame?.contentDocument; + if (!frame || !doc?.documentElement) return; + // A document can't report a height below the frame it lives in, so + // collapse the frame before reading: measuring in place would ratchet, + // leaving blank space when a re-run produces shorter output. + frame.style.height = '0px'; + const measured = doc.documentElement.scrollHeight; + const next = Math.min(Math.max(measured, MIN_FRAME_HEIGHT), MAX_FRAME_HEIGHT); + frame.style.height = `${next}px`; + setHeight(next); + }, []); + + // The frame's content can't resize itself (no scripting), but it does reflow + // when the panel around it changes width. + useEffect(() => { + const frame = frameRef.current; + if (!frame || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => measure()); + observer.observe(frame); + return () => observer.disconnect(); + }, [measure]); + + return ( +