Skip to content
Open
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
56 changes: 56 additions & 0 deletions packages/core/src/code-editor/fileKind.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Comment thread
k11kirky marked this conversation as resolved.
27 changes: 25 additions & 2 deletions packages/core/src/code-editor/fileKind.ts
Original file line number Diff line number Diff line change
@@ -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;
}
100 changes: 75 additions & 25 deletions packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -75,6 +76,25 @@ function FilePanelImagePreview({
);
}

function HtmlFilePreview({ content }: { content: string }) {
return (
<Box className="flex-1 overflow-hidden bg-white">
{/*
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.
*/}
<iframe
title="HTML preview"
sandbox="allow-scripts"
srcDoc={content}
className="h-full w-full border-0"
/>
</Box>
);
}

export function CodeEditorPanel({
taskId,
task: _task,
Expand All @@ -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);
Expand Down Expand Up @@ -234,12 +259,30 @@ export function CodeEditorPanel({
);
}

if (isMarkdown) {
const sourceView = (
<Box height="100%" className="relative overflow-hidden">
<CodeMirrorEditor
content={fileContent}
filePath={absolutePath}
relativePath={filePath}
readOnly
enrichment={enrichment}
/>
<EnrichmentPopover />
</Box>
);

if (isRenderable) {
const handleCopySource = () => {
navigator.clipboard.writeText(fileContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleToggleRendered = () => {
if (renderableKind) {
toggleKind(renderableKind);
}
};

return (
<Flex direction="column" height="100%" className="overflow-hidden">
Expand All @@ -257,6 +300,18 @@ export function CodeEditorPanel({
{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"
Expand All @@ -271,30 +326,25 @@ export function CodeEditorPanel({
</Tooltip>
</Flex>
</Flex>
<Box className="flex-1 overflow-auto">
<Box className="plan-markdown max-w-[750px]" p="5">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{fileContent}
</ReactMarkdown>
{!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>
</Box>
</Box>
) : (
<HtmlFilePreview content={fileContent} />
)}
</Flex>
);
}

return (
<Box height="100%" className="relative overflow-hidden">
<CodeMirrorEditor
content={fileContent}
filePath={absolutePath}
relativePath={filePath}
readOnly
enrichment={enrichment}
/>
<EnrichmentPopover />
</Box>
);
return sourceView;
}
46 changes: 46 additions & 0 deletions packages/ui/src/features/code-editor/filePreviewStore.ts
Original file line number Diff line number Diff line change
@@ -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<RenderableKind, boolean>;
}

interface FilePreviewStoreActions {
toggleKind: (kind: RenderableKind) => void;
}

type FilePreviewStore = FilePreviewStoreState & FilePreviewStoreActions;

export const useFilePreviewStore = create<FilePreviewStore>()(
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<Record<RenderableKind, boolean>>;
};
return {
...current,
renderPreview: {
...current.renderPreview,
...persistedState.renderPreview,
},
};
},
},
Comment thread
k11kirky marked this conversation as resolved.
),
);
Loading