Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 14 additions & 59 deletions packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Check, Code, Copy, Eye } from "@phosphor-icons/react";
import { getRenderableKind } from "@posthog/core/code-editor/fileKind";
import {
collapseFileState,
Expand All @@ -14,11 +13,9 @@ import {
parseImageDataUrl,
} from "@posthog/shared";
import type { Task } from "@posthog/shared/domain-types";
import { Box, Flex, IconButton, Text } from "@radix-ui/themes";
import { useCallback, useMemo, useState } from "react";
import { Box, Flex } from "@radix-ui/themes";
import { useCallback, useMemo } from "react";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { PanelMessage } from "../../../primitives/PanelMessage";
import { SafeImagePreview } from "../../../primitives/SafeImagePreview";
import { Tooltip } from "../../../primitives/Tooltip";
Expand All @@ -37,7 +34,9 @@ import {
} from "../hooks/useFileContent";
import { useFileEnrichment } from "../hooks/useFileEnrichment";
import { CodeMirrorEditor } from "./CodeMirrorEditor";
import { DocumentPreviewHeader } from "./DocumentPreviewHeader";
import { EnrichmentPopover } from "./EnrichmentPopover";
import { MarkdownDocumentPreview } from "./MarkdownDocumentPreview";
import {
SelectionCommentOverlay,
useSelectionComposer,
Expand Down Expand Up @@ -119,7 +118,6 @@ export function CodeEditorPanel({
const toggleKind = useFilePreviewStore((s) => s.toggleKind);
const openFileInSplit = usePanelLayoutStore((s) => s.openFileInSplit);
const expandToFile = useFileTreeStore((s) => s.expandToFile);
const [copied, setCopied] = useState(false);

const composer = useSelectionComposer();
const handleAddSelectionToChat = useCallback(
Expand Down Expand Up @@ -306,11 +304,6 @@ export function CodeEditorPanel({
);

if (isRenderable) {
const handleCopySource = () => {
navigator.clipboard.writeText(fileContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleToggleRendered = () => {
if (renderableKind) {
toggleKind(renderableKind);
Expand All @@ -319,58 +312,20 @@ export function CodeEditorPanel({

return (
<Flex direction="column" height="100%" className="overflow-hidden">
<Flex
px="3"
py="2"
align="center"
justify="between"
className="shrink-0 border-b border-b-(--gray-6)"
>
<Text
color="gray"
className="font-[var(--code-font-family)] text-[13px]"
>
{filePath}
</Text>
<Flex align="center" gap="1">
<Tooltip content={showRendered ? "View source" : "View preview"}>
<IconButton
size="1"
variant="ghost"
color="gray"
className="cursor-pointer"
onClick={handleToggleRendered}
aria-label={showRendered ? "View source" : "View preview"}
>
{showRendered ? <Code size={14} /> : <Eye size={14} />}
</IconButton>
</Tooltip>
<Tooltip content={copied ? "Copied" : "Copy source"}>
<IconButton
size="1"
variant="ghost"
color="gray"
className="cursor-pointer"
onClick={handleCopySource}
aria-label="Copy source"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</IconButton>
</Tooltip>
</Flex>
</Flex>
<DocumentPreviewHeader
label={filePath}
content={fileContent}
showRendered={showRendered}
onToggleRendered={handleToggleRendered}
/>
{!showRendered ? (
<Box className="flex-1 overflow-hidden">{sourceView}</Box>
) : renderableKind === "markdown" ? (
<Box className="flex-1 overflow-auto">
<Box className="plan-markdown max-w-[750px]" p="5">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{fileContent}
</ReactMarkdown>
</Box>
<MarkdownDocumentPreview
content={fileContent}
components={markdownComponents}
/>
</Box>
) : (
<HtmlFilePreview content={fileContent} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Check, Code, Copy, Eye } from "@phosphor-icons/react";
import { Flex, IconButton, Text } from "@radix-ui/themes";
import { useState } from "react";
import { Tooltip } from "../../../primitives/Tooltip";

export function DocumentPreviewHeader({
label,
content,
showRendered,
onToggleRendered,
}: {
label: string;
content: string;
showRendered: boolean;
onToggleRendered: () => void;
}) {
const [copied, setCopied] = useState(false);

const handleCopySource = () => {
navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};

return (
<Flex
px="3"
py="2"
align="center"
justify="between"
className="shrink-0 border-b border-b-(--gray-6)"
>
<Text color="gray" className="font-[var(--code-font-family)] text-[13px]">
{label}
</Text>
<Flex align="center" gap="1">
<Tooltip content={showRendered ? "View source" : "View preview"}>
<IconButton
size="1"
variant="ghost"
color="gray"
className="cursor-pointer"
onClick={onToggleRendered}
aria-label={showRendered ? "View source" : "View preview"}
>
{showRendered ? <Code size={14} /> : <Eye size={14} />}
</IconButton>
</Tooltip>
<Tooltip content={copied ? "Copied" : "Copy source"}>
<IconButton
size="1"
variant="ghost"
color="gray"
className="cursor-pointer"
onClick={handleCopySource}
aria-label="Copy source"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</IconButton>
</Tooltip>
</Flex>
</Flex>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

export function MarkdownDocumentPreview({
content,
components,
}: {
content: string;
components?: Components;
}) {
return (
<div className="plan-markdown mx-auto max-w-[750px] p-5">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { ArtifactPreview } from "./ArtifactPreview";
import {
artifactHtmlDocument,
artifactPreviewBlob,
markdownDocument,
} from "./artifactPreviewDocument";

const previewBlob = new Blob(["<h1>Artifact content</h1>"], {
Expand Down Expand Up @@ -34,6 +33,12 @@ vi.mock("@tanstack/react-query", () => ({
useQuery,
}));

vi.mock("../../code-editor/components/CodeMirrorEditor", () => ({
CodeMirrorEditor: ({ content }: { content: string }) => (
<div data-testid="source-view">{content}</div>
),
}));

describe("ArtifactPreview", () => {
beforeEach(() => {
auth.identity = "auth-1";
Expand Down Expand Up @@ -215,17 +220,35 @@ describe("ArtifactPreview", () => {
).toBeInTheDocument();
});

it("renders GFM Markdown while escaping embedded HTML", () => {
const document = markdownDocument(
"# Report\n\n**Ready**\n\n| Name | Value |\n| --- | --- |\n| Cost | 12 |\n\n<script>alert('no')</script>",
it("renders Markdown artifacts with the file preview styling", () => {
useQuery.mockReturnValue({
data: "# Report\n\n**Ready**\n\n| Name | Value |\n| --- | --- |\n| Cost | 12 |",
isLoading: false,
isError: false,
});

const { container } = render(
<ArtifactPreview
taskId="task-1"
runId="run-1"
artifactId="artifact-1"
name="report.md"
/>,
);

expect(document).toContain("<h1>Report</h1>");
expect(document).toContain("<strong>Ready</strong>");
expect(document).toContain("<table>");
expect(document).toContain("&lt;script&gt;");
expect(document).not.toContain("<script>");
expect(document).toContain("default-src &#39;none&#39;");
expect(screen.getByRole("heading", { name: "Report" })).toBeInTheDocument();
expect(screen.getByRole("table")).toBeInTheDocument();
expect(
container.querySelector(".plan-markdown.mx-auto"),
).toBeInTheDocument();
expect(screen.getByText("report.md")).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "View source" }));

expect(screen.getByTestId("source-view")).toHaveTextContent("# Report");
expect(
screen.getByRole("button", { name: "View preview" }),
).toBeInTheDocument();
});

it("blocks network subresources in HTML artifacts", () => {
Expand Down
43 changes: 41 additions & 2 deletions packages/ui/src/features/sessions/components/ArtifactPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,21 @@ import {
useAuthStateValue,
} from "@posthog/ui/features/auth/store";
import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser";
import { Flex } from "@radix-ui/themes";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { ZoomableImage } from "../../../primitives/SafeImagePreview";
import { CodeMirrorEditor } from "../../code-editor/components/CodeMirrorEditor";
import { DocumentPreviewHeader } from "../../code-editor/components/DocumentPreviewHeader";
import { MarkdownDocumentPreview } from "../../code-editor/components/MarkdownDocumentPreview";
import { artifactPreviewBlob } from "./artifactPreviewDocument";

const MARKDOWN_EXTENSIONS = new Set(["md", "mdx", "markdown"]);

function extension(filename: string): string {
return filename.split(".").pop()?.toLowerCase() ?? "";
}

function ArtifactPreviewError() {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
Expand Down Expand Up @@ -50,6 +60,7 @@ export function ArtifactPreview({
name: string;
}) {
const sessionService = useService<SessionService>(SESSION_SERVICE);
const [showRendered, setShowRendered] = useState(true);
const authIdentity = useAuthStateValue(getAuthIdentity);
const { data, isLoading, isError } = useQuery({
queryKey: ["artifactPreview", authIdentity, taskId, runId, artifactId],
Expand All @@ -62,15 +73,19 @@ export function ArtifactPreview({
if (!url) throw new Error("Artifact is unavailable");
const response = await fetch(url);
if (!response.ok) throw new Error("Artifact preview failed");
return artifactPreviewBlob(await response.blob(), name);
const blob = await response.blob();
if (MARKDOWN_EXTENSIONS.has(extension(name))) {
return blob.text();
}
return artifactPreviewBlob(blob, name);
},
enabled: authIdentity !== null,
staleTime: Infinity,
retry: false,
meta: AUTH_SCOPED_QUERY_META,
});
const previewUrl = useMemo(
() => (data ? URL.createObjectURL(data) : null),
() => (data instanceof Blob ? URL.createObjectURL(data) : null),
[data],
);

Expand All @@ -87,6 +102,30 @@ export function ArtifactPreview({
</div>
);
}
if (typeof data === "string") {
return (
<Flex direction="column" height="100%" className="overflow-hidden">
<DocumentPreviewHeader
label={name}
content={data}
showRendered={showRendered}
onToggleRendered={() => setShowRendered((rendered) => !rendered)}
/>
{showRendered ? (
<div className="flex-1 overflow-auto">
<MarkdownDocumentPreview
content={data}
components={{ img: () => null }}
/>
</div>
) : (
<div className="flex-1 overflow-hidden">
<CodeMirrorEditor content={data} filePath={name} readOnly />
</div>
)}
</Flex>
);
}
if (isError || !previewUrl) {
return <ArtifactPreviewError />;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,13 @@
import { getImageMimeType, isAllowedImageMimeType } from "@posthog/shared";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { applyCspToHtml } from "../../mcp-apps/utils/mcp-app-csp";

const MARKDOWN_EXTENSIONS = new Set(["md", "mdx", "markdown"]);

function extension(filename: string): string {
return filename.split(".").pop()?.toLowerCase() ?? "";
}

export function artifactHtmlDocument(html: string): string {
return applyCspToHtml(html);
}

export function markdownDocument(markdown: string): string {
const content = renderToStaticMarkup(
createElement(ReactMarkdown, { remarkPlugins: [remarkGfm] }, markdown),
);
return artifactHtmlDocument(`<!doctype html><html><head><meta charset="utf-8"><meta name="color-scheme" content="light dark"><style>
:root{font:15px/1.6 system-ui,sans-serif;color-scheme:light dark}body{box-sizing:border-box;margin:0 auto;max-width:900px;padding:32px;color:CanvasText;background:Canvas}h1,h2{border-bottom:1px solid color-mix(in srgb,CanvasText 18%,transparent);padding-bottom:.3em}h1{font-size:2em}h2{font-size:1.5em}h3{font-size:1.25em}a{color:LinkText}blockquote{margin-left:0;padding-left:1em;border-left:4px solid color-mix(in srgb,CanvasText 25%,transparent);color:GrayText}pre,code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}code{border-radius:4px;background:color-mix(in srgb,CanvasText 8%,transparent);padding:.15em .3em}pre{overflow:auto;border-radius:6px;background:color-mix(in srgb,CanvasText 8%,transparent);padding:16px}pre code{background:none;padding:0}table{border-spacing:0;border-collapse:collapse}th,td{border:1px solid color-mix(in srgb,CanvasText 20%,transparent);padding:6px 12px}img{max-width:100%}hr{border:0;border-top:1px solid color-mix(in srgb,CanvasText 20%,transparent)}
</style></head><body>${content}</body></html>`);
}

export async function artifactPreviewBlob(
blob: Blob,
filename: string,
Expand All @@ -36,11 +20,6 @@ export async function artifactPreviewBlob(
if (isAllowedImageMimeType(imageMimeType)) {
return new Blob([blob], { type: imageMimeType });
}
if (MARKDOWN_EXTENSIONS.has(extension(filename))) {
return new Blob([markdownDocument(await blob.text())], {
type: "text/html",
});
}
if (["html", "htm"].includes(extension(filename))) {
return new Blob([artifactHtmlDocument(await blob.text())], {
type: "text/html",
Expand Down
Loading