diff --git a/packages/core/src/code-editor/fileKind.test.ts b/packages/core/src/code-editor/fileKind.test.ts
new file mode 100644
index 0000000000..838cd4f2d5
--- /dev/null
+++ b/packages/core/src/code-editor/fileKind.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest";
+import { getRenderableKind, isHtmlFile, isMarkdownFile } from "./fileKind";
+
+describe("isMarkdownFile", () => {
+ it.each([
+ ["README.md", true],
+ ["notes.markdown", true],
+ ["UPPER.MD", true],
+ ["docs/guide.md", true],
+ ["index.html", false],
+ ["script.js", false],
+ ["no-extension", false],
+ // Dotfiles: the name after the leading dot is read as the extension, so a
+ // file literally named ".md" matches, while ".gitignore" does not.
+ [".md", true],
+ [".gitignore", false],
+ ["", false],
+ ])("%s -> %s", (filename, expected) => {
+ expect(isMarkdownFile(filename)).toBe(expected);
+ });
+});
+
+describe("isHtmlFile", () => {
+ it.each([
+ ["index.html", true],
+ ["page.htm", true],
+ ["INDEX.HTML", true],
+ ["build/report.html", true],
+ ["README.md", false],
+ ["styles.css", false],
+ ["no-extension", false],
+ // Dotfiles: ".html" reads as the html extension; ".htaccess" does not.
+ [".html", true],
+ [".htaccess", false],
+ ["", false],
+ ])("%s -> %s", (filename, expected) => {
+ expect(isHtmlFile(filename)).toBe(expected);
+ });
+});
+
+describe("getRenderableKind", () => {
+ it.each([
+ ["README.md", "markdown"],
+ ["notes.markdown", "markdown"],
+ ["index.html", "html"],
+ ["page.htm", "html"],
+ ["script.js", null],
+ ["styles.css", null],
+ ["no-extension", null],
+ [".gitignore", null],
+ [".htaccess", null],
+ ["", null],
+ ])("%s -> %s", (filename, expected) => {
+ expect(getRenderableKind(filename)).toBe(expected);
+ });
+});
diff --git a/packages/core/src/code-editor/fileKind.ts b/packages/core/src/code-editor/fileKind.ts
index 3080c0f0da..92e4a06bb4 100644
--- a/packages/core/src/code-editor/fileKind.ts
+++ b/packages/core/src/code-editor/fileKind.ts
@@ -1,6 +1,29 @@
+import { getFileExtension } from "@posthog/shared";
+
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown"]);
+const HTML_EXTENSIONS = new Set(["html", "htm"]);
+
+export type RenderableKind = "markdown" | "html";
export function isMarkdownFile(filename: string): boolean {
- const ext = filename.split(".").pop()?.toLowerCase();
- return !!ext && MARKDOWN_EXTENSIONS.has(ext);
+ return MARKDOWN_EXTENSIONS.has(getFileExtension(filename));
+}
+
+export function isHtmlFile(filename: string): boolean {
+ return HTML_EXTENSIONS.has(getFileExtension(filename));
+}
+
+/**
+ * The inline preview a file supports when opened from the file tree, or null if
+ * it should open as plain source. Add a kind here (plus a renderer branch in
+ * CodeEditorPanel) to make another file type previewable.
+ */
+export function getRenderableKind(filename: string): RenderableKind | null {
+ if (isMarkdownFile(filename)) {
+ return "markdown";
+ }
+ if (isHtmlFile(filename)) {
+ return "html";
+ }
+ return null;
}
diff --git a/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx b/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx
index 624e4d1161..98b2db2451 100644
--- a/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx
+++ b/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx
@@ -1,5 +1,5 @@
-import { Check, Copy } from "@phosphor-icons/react";
-import { isMarkdownFile } from "@posthog/core/code-editor/fileKind";
+import { Check, Code, Copy, Eye } from "@phosphor-icons/react";
+import { getRenderableKind } from "@posthog/core/code-editor/fileKind";
import {
collapseFileState,
resolveMarkdownLink,
@@ -25,6 +25,7 @@ import { usePanelLayoutStore } from "../../panels/panelLayoutStore";
import { useFileTreeStore } from "../../right-sidebar/fileTreeStore";
import { useCwd } from "../../sidebar/useCwd";
import { useIsWorkspaceCloudRun } from "../../workspace/useWorkspace";
+import { useFilePreviewStore } from "../filePreviewStore";
import { useCloudFileContent } from "../hooks/useCloudFileContent";
import {
useAbsoluteFileContent,
@@ -75,6 +76,25 @@ function FilePanelImagePreview({
);
}
+function HtmlFilePreview({ content }: { content: string }) {
+ return (
+
+ {/*
+ Render the HTML in a null-origin sandboxed iframe: allow-scripts WITHOUT
+ allow-same-origin lets scripts run but keeps the document on a null
+ origin, so it cannot reach the host renderer's DOM, cookies, or storage.
+ Do not add allow-same-origin — it collapses that isolation boundary.
+ */}
+
+
+ );
+}
+
export function CodeEditorPanel({
taskId,
task: _task,
@@ -84,7 +104,12 @@ export function CodeEditorPanel({
const isInsideRepo = !!repoPath && absolutePath.startsWith(repoPath);
const filePath = getRelativePath(absolutePath, repoPath);
const isImage = isRasterImageFile(absolutePath);
- const isMarkdown = isMarkdownFile(absolutePath);
+ const renderableKind = getRenderableKind(absolutePath);
+ const isRenderable = renderableKind !== null;
+ const showRendered = useFilePreviewStore((s) =>
+ renderableKind ? s.renderPreview[renderableKind] : false,
+ );
+ const toggleKind = useFilePreviewStore((s) => s.toggleKind);
const openFileInSplit = usePanelLayoutStore((s) => s.openFileInSplit);
const expandToFile = useFileTreeStore((s) => s.expandToFile);
const [copied, setCopied] = useState(false);
@@ -234,12 +259,30 @@ export function CodeEditorPanel({
);
}
- if (isMarkdown) {
+ const sourceView = (
+
+
+
+
+ );
+
+ if (isRenderable) {
const handleCopySource = () => {
navigator.clipboard.writeText(fileContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
+ const handleToggleRendered = () => {
+ if (renderableKind) {
+ toggleKind(renderableKind);
+ }
+ };
return (
@@ -257,6 +300,18 @@ export function CodeEditorPanel({
{filePath}
+
+
+ {showRendered ? : }
+
+
-
-
-
- {fileContent}
-
+ {!showRendered ? (
+ {sourceView}
+ ) : renderableKind === "markdown" ? (
+
+
+
+ {fileContent}
+
+
-
+ ) : (
+
+ )}
);
}
- return (
-
-
-
-
- );
+ return sourceView;
}
diff --git a/packages/ui/src/features/code-editor/filePreviewStore.ts b/packages/ui/src/features/code-editor/filePreviewStore.ts
new file mode 100644
index 0000000000..b7ec795c2b
--- /dev/null
+++ b/packages/ui/src/features/code-editor/filePreviewStore.ts
@@ -0,0 +1,46 @@
+import type { RenderableKind } from "@posthog/core/code-editor/fileKind";
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+// Per renderable file kind, whether files open as a rendered preview (vs raw
+// source). The preference is global per kind and persisted, so a user who
+// prefers source mode keeps it across files and sessions.
+interface FilePreviewStoreState {
+ renderPreview: Record;
+}
+
+interface FilePreviewStoreActions {
+ toggleKind: (kind: RenderableKind) => void;
+}
+
+type FilePreviewStore = FilePreviewStoreState & FilePreviewStoreActions;
+
+export const useFilePreviewStore = create()(
+ persist(
+ (set) => ({
+ renderPreview: { markdown: true, html: true },
+ toggleKind: (kind) =>
+ set((s) => ({
+ renderPreview: { ...s.renderPreview, [kind]: !s.renderPreview[kind] },
+ })),
+ }),
+ {
+ name: "file-preview-storage",
+ // Deep-merge `renderPreview` so a kind added to the defaults later (e.g.
+ // svg) keeps its rendered-by-default value for users whose stored state
+ // predates it — a shallow merge would drop it and show source instead.
+ merge: (persisted, current) => {
+ const persistedState = persisted as {
+ renderPreview?: Partial>;
+ };
+ return {
+ ...current,
+ renderPreview: {
+ ...current.renderPreview,
+ ...persistedState.renderPreview,
+ },
+ };
+ },
+ },
+ ),
+);