- URL explainer
-
- Why does this URL look weird?
-
-
+
+ Why does this URL look weird?
+
The long part after the # (which starts with a one-character codec tag) is the artifact itself, compressed into the URL fragment so a static host can show it without receiving the content in the page request.
-
-
-
The shape
-
- https://agent-render.com/#a<compressed-payload>
-
-
- Everything before # loads the app. Everything after # stays in the browser and tells the app what to render.
-
-
-
-
-
-
v1
-
- The payload format version. It lets old and new links fail clearly instead of guessing.
-
-
-
-
-
-
arx
-
- The compression method. It uses an agent-render dictionary, Brotli compression, and URL-safe text encoding to keep rich artifacts linkable.
-
-
-
-
-
-
Privacy
-
- The static host does not receive fragment contents during the page request. The link is still not a secret: browser history, copied URLs, screenshots, logs from tools that inspect the full URL, and future client-side analytics can expose it.
-
+
+
+
+ ADDRESS
+ https://agent-render.com/#a<compressed-payload>
+
+
+
HOST
+ Everything before # loads the static application.
+
+
+
TAG
+ The first character after # identifies the codec.
+
+
+
PAYLOAD
+ The remaining characters carry the compressed artifact bundle.
+
+
+
BOUNDARY
+ The browser omits the fragment from the initial request to the static host.
+
+
+
+ WARN
+ The link is not a secret: browser history, copied URLs, screenshots, URL-inspecting tools, and future client-side analytics can expose it.
+
-
- In 30 seconds
-
-
+
+ In 30 seconds
+
+
A normal page URL asks the server for a route. An agent-render URL also carries a compressed artifact after the hash mark. Browsers do not send that hash to the server in the initial request, so the static app loads first and then decodes the artifact locally.
-
+
The weird-looking text is a transport format, not a tracking code. Shorter codecs like deflate and arx make markdown, code, diffs, CSV, and JSON fit into shareable links.
-
+
Use fragment links for quick static sharing. Use the optional self-hosted UUID mode when the payload is too large, the target chat app mangles long links, or you need a short URL and accept server-side storage.
diff --git a/src/components/file-tree-nav.tsx b/src/components/file-tree-nav.tsx
new file mode 100644
index 0000000..df49690
--- /dev/null
+++ b/src/components/file-tree-nav.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useLayoutEffect, useMemo, useRef } from "react";
+import { FileTree, useFileTree } from "@pierre/trees/react";
+
+const TREE_ROW_HEIGHT = 24;
+const TREE_MIN_ROWS = 4;
+const TREE_MAX_ROWS = 12;
+const TREE_SEARCH_THRESHOLD = 8;
+
+type FileTreeNavProps = {
+ paths: readonly string[];
+ selectedPath?: string;
+ onSelectPath: (path: string) => void;
+ ariaLabel?: string;
+};
+
+/**
+ * Renders a list of paths as a compact, keyboard-navigable file tree.
+ *
+ * `paths` contains leaf entries only; directory rows are synthesized by the tree and are filtered
+ * out of selection events, so `onSelectPath` always receives a real listed path. `useFileTree`
+ * builds its path model once per mount, so callers remount via `key` when the path set changes;
+ * `selectedPath` is synchronized in place through the model's public item handles. Load this module
+ * through `next/dynamic`: it carries the
+ * @pierre/trees runtime, which should stay out of surfaces that never show a tree.
+ */
+export function FileTreeNav({ paths, selectedPath, onSelectPath, ariaLabel = "Files" }: FileTreeNavProps) {
+ const onSelectRef = useRef(onSelectPath);
+ useLayoutEffect(() => {
+ onSelectRef.current = onSelectPath;
+ }, [onSelectPath]);
+ const filePathSet = useMemo(() => new Set(paths), [paths]);
+ const { model } = useFileTree({
+ density: "compact",
+ flattenEmptyDirectories: true,
+ initialExpansion: "open",
+ initialSelectedPaths: selectedPath ? [selectedPath] : [],
+ onSelectionChange(selectedPaths) {
+ const path = selectedPaths.at(-1);
+ if (path && filePathSet.has(path)) {
+ onSelectRef.current(path);
+ }
+ },
+ paths,
+ search: paths.length >= TREE_SEARCH_THRESHOLD,
+ });
+ useLayoutEffect(() => {
+ const selectedPaths = model.getSelectedPaths();
+ if (selectedPaths.length === 1 && selectedPaths[0] === selectedPath) {
+ return;
+ }
+ for (const path of selectedPaths) {
+ model.getItem(path)?.deselect();
+ }
+ if (selectedPath) {
+ model.getItem(selectedPath)?.select();
+ }
+ }, [model, selectedPath]);
+
+ const rowCount = Math.min(
+ TREE_MAX_ROWS,
+ Math.max(TREE_MIN_ROWS, paths.length + (paths.length >= TREE_SEARCH_THRESHOLD ? 2 : 1)),
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/generated-link.tsx b/src/components/generated-link.tsx
new file mode 100644
index 0000000..a84d8ec
--- /dev/null
+++ b/src/components/generated-link.tsx
@@ -0,0 +1,241 @@
+"use client";
+
+import { ArrowUpRight, Check, Copy, ExternalLink, Link2 } from "lucide-react";
+import type { Ref } from "react";
+import { numberFormatter } from "@/lib/format";
+import type { GeneratedArtifactLink } from "@/lib/payload/link-creator";
+import {
+ codecPickerLabel,
+ codecs,
+ isDeprecatedEmitCodec,
+} from "@/lib/payload/schema";
+import { cn } from "@/lib/utils";
+
+const codecOptions = ["auto", ...codecs] as const;
+
+export type CodecChoice = (typeof codecOptions)[number];
+
+type CodecPickerProps = {
+ value: string | undefined;
+ onSelect: (codec: CodecChoice) => void;
+ label?: string;
+};
+
+/**
+ * Shared compression-codec key row used by the link creator and the in-viewer
+ * artifact editor. Deprecated emit codecs render with a warning style.
+ */
+export function CodecPicker({ value, onSelect, label }: CodecPickerProps) {
+ const active = value ?? "auto";
+
+ return (
+
+ {label ? {label} : null}
+ {codecOptions.map((option) => (
+ onSelect(option)}
+ >
+ {codecPickerLabel(option)}
+
+ ))}
+
+ );
+}
+
+type GeneratedLinkResultProps = {
+ link: GeneratedArtifactLink;
+ stale: boolean;
+ copyState: "idle" | "copied" | "failed";
+ markdownLinkCopyState: "idle" | "copied" | "failed";
+ onCopy: () => void;
+ onCopyMarkdownLink: () => void;
+ onPreview: () => void;
+ /** Editor variant: disables the result actions once the draft moves on. */
+ disableActionsWhenStale?: boolean;
+ /** Creator variant: also lists markdown-link length and bundle title. */
+ extendedMetrics?: boolean;
+ as?: "section" | "aside";
+ containerRef?: Ref;
+ testId?: string;
+};
+
+/**
+ * Shared generated-link result panel: the URL and markdown link outputs,
+ * codec/fragment metrics, and the copy/preview/open actions used by both the
+ * home link creator and the artifact editor.
+ */
+export function GeneratedLinkResult({
+ link,
+ stale,
+ copyState,
+ markdownLinkCopyState,
+ onCopy,
+ onCopyMarkdownLink,
+ onPreview,
+ disableActionsWhenStale = false,
+ extendedMetrics = false,
+ as: Element = "section",
+ containerRef,
+ testId,
+}: GeneratedLinkResultProps) {
+ const actionsDisabled = disableActionsWhenStale && stale;
+
+ return (
+
+
+
+
+
+ URL
+
+
+
+
+ Markdown link
+
+
+
+
+
+
+
CODEC
+ {link.codec}
+
+
+
FRAGMENT
+ {numberFormatter.format(link.fragmentLength)} chars
+
+ {extendedMetrics ? (
+ <>
+
+
MARKDOWN LINK
+ {numberFormatter.format(link.markdownLinkLength)} chars
+
+
+
BUNDLE
+ {link.envelope.title}
+
+ >
+ ) : null}
+
+
+ {link.discordMarkdownLinkWarning ? (
+
+ {link.discordMarkdownLinkWarning}
+
+ ) : null}
+
+
+
+ {copyState === "copied" ? (
+
+ ) : (
+
+ )}
+ {copyState === "copied"
+ ? "Copied"
+ : copyState === "failed"
+ ? "Copy failed"
+ : "Copy link"}
+
+
+ {markdownLinkCopyState === "copied" ? (
+
+ ) : (
+
+ )}
+ {markdownLinkCopyState === "copied"
+ ? "Copied"
+ : markdownLinkCopyState === "failed"
+ ? "Copy failed"
+ : "Copy markdown link"}
+
+
+
+ Preview here
+
+
{
+ if (actionsDisabled) {
+ event.preventDefault();
+ }
+ }}
+ >
+
+ Open in new tab
+
+
+
+ {stale ? (
+
+ Draft changed since last generation.
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/home/link-creator.tsx b/src/components/home/link-creator.tsx
index 7deed0f..d0622eb 100644
--- a/src/components/home/link-creator.tsx
+++ b/src/components/home/link-creator.tsx
@@ -1,15 +1,15 @@
"use client";
-import { useEffect, useRef, useState } from "react";
-import { ArrowUpRight, Check, Copy, ExternalLink, Link2 } from "lucide-react";
-import { kindIcons } from "@/components/artifact-kind-icons";
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import { Link2 } from "lucide-react";
import { copyTextToClipboard } from "@/lib/copy-text";
-import { numberFormatter } from "@/lib/format";
+import { CODE_LANGUAGE_CHOICES } from "@/lib/code/language";
+import { CodecPicker, GeneratedLinkResult } from "@/components/generated-link";
import type {
GeneratedArtifactLink,
LinkCreatorDraft,
} from "@/lib/payload/link-creator";
-import { artifactKinds, codecPickerLabel, codecs, isDeprecatedEmitCodec, type ArtifactKind } from "@/lib/payload/schema";
+import { artifactKinds, type ArtifactKind } from "@/lib/payload/schema";
import { cn } from "@/lib/utils";
type LinkCreatorProps = {
@@ -18,10 +18,10 @@ type LinkCreatorProps = {
const fieldHints: Record = {
markdown: "Paste markdown notes, release docs, or a spec excerpt.",
- code: "Paste a code snippet and add a language hint when it helps.",
- diff: "Paste a unified git patch to open the review-style diff renderer.",
- csv: "Paste raw CSV and the table renderer will take it from there.",
- json: "Paste formatted or compact JSON for a tree and raw source preview.",
+ code: "Paste a code snippet and add a language hint when known.",
+ diff: "Paste a unified git patch.",
+ csv: "Paste comma-separated headings and rows.",
+ json: "Paste formatted or compact JSON.",
};
const fieldPlaceholders: Record = {
@@ -32,8 +32,6 @@ const fieldPlaceholders: Record = {
json: '{\n "status": "ready",\n "artifacts": 1\n}',
};
-const codecOptions = ["auto", ...codecs] as const;
-
const defaultLinkCreatorDraft: LinkCreatorDraft = {
kind: "markdown",
title: "Product brief",
@@ -85,10 +83,10 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) {
const isGeneratedLinkStale =
Boolean(generatedLink) && draftVersion !== generatedVersion;
const contentFieldLabel = getBodyFieldLabel(draft.kind);
- const GeneratedKindIcon =
- kindIcons[generatedLink?.artifact.kind ?? draft.kind];
- generatedLinkRef.current = generatedLink;
+ useLayoutEffect(() => {
+ generatedLinkRef.current = generatedLink;
+ }, [generatedLink]);
useEffect(() => {
setCopyState("idle");
@@ -195,32 +193,29 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) {
};
return (
-
-
-
-
-
-
Try it now
-
- Create a link
-
-
-
- Paste content, pick a format, and get a shareable URL. Everything
- encodes client-side.
-
-
-
+
+
+
+
+
+
+
+ {contentFieldLabel}
+ {fieldHints[draft.kind]}
+
+ updateDraft("content", event.target.value)}
+ placeholder={fieldPlaceholders[draft.kind]}
+ className="creator-textarea"
+ rows={12}
+ />
+
+
+
+
+
+ updateDraft("codec", option)}
+ />
+
+
+
+
+
+
+
+ Generate link
+
+
+
+
+
+ {generatedLink ? (
+
{
+ void handleCopy();
+ }}
+ onCopyMarkdownLink={() => {
+ void handleCopyMarkdownLink();
+ }}
+ onPreview={() => onPreviewHash(generatedLink.hash)}
+ extendedMetrics
+ />
+ ) : null}
+
+ {error ? (
+
+ FAULT
+ {error}
+
+ ) : null}
);
}
diff --git a/src/components/home/sample-link-data.ts b/src/components/home/sample-link-data.ts
index 6c12644..5da8e2b 100644
--- a/src/components/home/sample-link-data.ts
+++ b/src/components/home/sample-link-data.ts
@@ -42,7 +42,7 @@ export const sampleLinkCards = [
},
{
title: "arx showcase",
- hash: "#fmB.9UT8WKbq5PLRGIWwBwP4U7ZcrIO7_3bszINfzuMZQ4SJkf8dvoRB-yANFGVV3H6Oc2jiivp9OXi4J9oyCIkNZV0Uj8hT-0yj23KiwYndmDl3Ztf6QJr7jTuhTYwXO-4yE2qvrUME7-SSGv91Y5Dlll6pi-GyItQsLlGGu8Ya4gdNemut3OcklputjrqZE-qp7sGG3ySYOdB0J8QQ8ujF1SP4B7stEQuGCCurhg3WZfD33U1AAdwe4UAN8PbZHTr2VJ_IWthl_XV28Rhha9oJxKPnQHmx3A7076kzLDmbhGpLQeUEn0wLMEjJzjr62nc2T1OSvkgYJqtCn_CJtecbiJDCrGWD2emhHkW6QqeItKkQKxMkBAIRvvRJEdyY26kBmP_ph9ecPQRo-hSQ-gVGSKpzeTRxYAHJoexpKoJD-1CdEWbzgudE4FETren53QNJqyzubY69FgdYQ8dPj6eo98fQWQR6XrcjuWhFGPSL9PlRUHFDjhPDe1TMmyc77yMJa8TV8TBaftZGNJUtdA9wMn4Z8q_5bCEN5hzUcF4cwTTmMSNe8traBkQY3fl-5B2_Ybr5aVOO-vCLdCgyBYffxZ9lOQfN6VVPQdRVFE6aeFTUfEIqXO1w-UrJPZKgYjpF6V21K8dKGi9HQWEGJtZk9Ll5hyTdREH4ZNydZyCQSYGqXV181hioc6O0AubKDHtHSleWmZdH-GocYgbSMHHfb9zNK1tDVAU_QuJH4NKZF35J8-lVBtfn1kRB9u99ccrHjhP_fyGeeJNP5EYwDS2-npmPZ9fbhq_eIhdbW1p6DZw-3looGe1Khbzn2rr0Kw3hFmu_jKBaIv-ao5_3CAa_QB9yKky3rOkR2GbuHutSt3-QjwdEf4qRwLrN7mVZovLi_x8xHzhg7hk5wUkxY4ZxawWuj4LyVduC9WsCsNFZd8wWVz4uWFB-EktKoJf9brRgll3BQslaGkKhNCFdoa0G81pupJFo7HaMDCtw6LRNq_PQI6DbQeNoUoAOBWMTkWk3vhO6WxwSzJNNYATDUhXMa341swHoi8xUoSTBLZFlYsxmqNmxDhKnXZUt_pFvRID0vdNlsHhlwzWsdFqFuhiEztM__PN_fp_k4RNJCvVlsK150_NxchVy8SRC5WDckMtjZIY2IS82eDagkzO4xj3APJ-dXxNBqajKlF9kKfKC08gqpiI5AELedXjAtg1KlbdSMDoGLJLepalRd6DzxaHlCOE6i_5hw2O1GnXVP4i8GT8IvGaJlTIcDhrn9XMBucxXmYIk_DgMPHccOvKO9bsE8z1dSzhyEjkW2vnSejrVzkq7y-aBKHefmtrqmtcoZsqDeNIyoeBNmbRBuKTcVk-4HSNWk1Zsezihol3zDjGE3rcq85FJIv3bi5jz1J7BLlZB8bV7VNLq3lVydDytz8HSTFFhZFEYG1HkxuYabAT26L10ih6VWk8j_8Z-Oz_zx2-nZXGpCj-yemjJAExwOS6X0J7OpIf4GqOBJXrWFoF_VhJWKqoJJo4-RqwzQRT_XP9Bm4KmCZo5nFe4oIRWMRlnB1l57JS6Cd8wO-lBaGP0OmwAtVh7U-KnYTEQ2Vk-lYnnX1XHGkZUm7PhbPO2ztXmHsH0mLCb0xJPFBLO5klTRXuIPDnpalfhHtXDTBWExOmN6hwfoVzWe9mQ7MBVaPeKiTmz_FgaOaUMlPXSqkSxy_J2epc0WFFqL7QNYV0QVHAoQDXgWy0s2_vVCyBcbd-p9qFhu1ti9MUOmI6QJXE2iILyBGDM02mfjxF3v6MxCu-i05_xcM4zl7arBvSKqcS_sZfX1WCJzFGiOZTd-VC7FmNTFY2JlF9LSE2aeJCT30zlO7nkQWnP6-So3cZJuqhOIKvjX0hRo-T_zSyf4cOBWF8EmP4FzhUCVO2yvO_BBF-YdROgg6bqrsDc1v_3_LTyADlVG6Sa60ktxmMvX3Vp7rMTenNT3r4qvNEr2P1XlFGgOXvaH1s7vRa9IvMwNveXElumc4TL9DwE4TGfL64Dwe-4NTYulwu_WLGlL0cts2xTQed0g78U9JzduEHec6pKsT93dYESigIvw9HlgcB3-A-u-oLK0h2sxWYlsG6vrlmqn4kapFtA5MvDRWHmyUzXU2J2To4iMXK-N1qh5JeSv2ZMpg4ixEzfrsoL0dyUCKgCLvg18LTnLstRKaxdl37cvjDAhH9LipCDp_i27G8r68s4NJ2KRcvyXOuJFyppyama2CDrr-fhD5YsBxwbZDSQeDmnSeaGIiq0dZiiq-44l3oFDvr0NHctR5eKuY1nWZk5zLDPGFhF6RP4N-yw6lXK8sc39VcSvblIot8NvToktQ6UyKytGn9Z5Xj4tICd5lnjYMKJoplbi4tqewuB5ON0rHpkOLaXFh4S_X8QTeW_jUw4kL9aFf721291-_HwROAXdCA_q_tx4i2oVrpATFRMHZ9FezzNCu2ax9BAKwpzPRAa6gvMrLnsfiE8v5_jAXJAaDWSw4iUNo2ddEKBN1FMFS6QsCs8jWTh_NB9ZGr9h8jSBV1zjOtv_EXs8AYreCdzZ5iWe3xFCCXnIudqNrDi5odC9e3Hp67OIp2dD6eCs2l2eF1NX4xojLV6DoZws3ipPi9Nf1TWJtDxZVQSBlgT6Awqlsn3T2NDNI22F4pJmJf3kFgvH5JP1KTOZd53ICfrZO-jLrfILfSgG-q9fsikEncB8fIIfGbzmtYLsrT1YLa6F6Pz43oDF0Pgd7VqagrMF1NoSL--l8g8iuVgHZSc96ruiknBWltb8exiVVRXxdhOQsiM_Zuu9cxMC_fLnKG3sb_LWV-fOX8XTIP3QcVOwG-m3agqnDYu59Jl7ZVszvGVAIjjZjOxh5lmMOGzOU2ksKyDNJ4o2eRTeNasvcrs0FHDLUUtbme5PaiScnPzi5G2Q7OqWyIk7VI2YMR778rMBkjDC3Ej_esbysurY_lxd8ZhF7Ohl1ssKI_UgkgNyYF_JCvSO-LmuxQISNO8llMrnnVasRiOLxxy-3DCKDFgi6ahSBOlpIhFnJMEOmTpBVjeHwTQ6lfyGUDQp5tMga9Y6NS3v_ewCr-W6VqmhO4TQm4mitjI38ew4uUZvaCRatjjE-qeG0sxz-xMW9aVeJaF7IBPiKIFLuNK7hwNAEKUWCK8rRiVWNs4u2F8jfpdZ63PfGJ0JEcGOZfgmpRTK4yev2t5EqZTL1M6g98jZZLFXc7arDgZ_5YWne9jIpY3vkwqOtonNGpX-5iYskjlbfqVxmhdAlMxjXpLnhyiFTMUxu0R-2j_Bu8jDGnhtsK9cqjp3fEgHqIkUE1yHwCwxh7gcEEt_8ipII-3VzO7fzhbNl-coHxcHKCyoXISOiVcyrRRuupXC6g36CKIM6Kdkxr_uZ-QMuitNng0HpxqWiI7iXrmbQJxqx2GGkDmkdYOFKVzraMYjDce3cC4eSTn4IS20dQ497gmX0z-tnpsy9fS4y7Ui2ICzVRD0JHKdrFbaP6pntTUHXdsO72uQYwiLiypYKYlcjXM4whSni3SB-AVV-vit3RC2Zi38T4cgwWdptGmOPj9z1irx6SDphOH4HrIN53cQszq7HMdsVYpIGEwLHy0mGWDOdKDZmb9NhHJqjK-uOeHNig7h7kqvnme5F2iaThqh4A5J4ZCXfKQpGO-IV8LyYA96UhUI-t1spn8bLcPFHhyvUqhmqYnAgA",
+ hash: "#fmB.9UT8WKbq5PLRGIWwBwP4U7ZcrIO7_3bszINfzuMZQ4SJkf8dvoRB-yANFGVV3H6Oc2jiivp9OXi4J9oyCIkNZV0Uj8hT-0yj23KiwYndmDl3Ztf6QJr7jTuhTYwXO-4yE2qvrUME7-SSGv91Y5Dlll6pi-GyItQsLlGGu8Ya4gdNemut3OcklputjrqZE-qp7sGG3ySYOdB0J8QQ8ujF1SP4B7stEQuGCCurhg3WZfD33U1AAdwe4UAN8PbZHTr2VJ_IWthl_XV28Rhha9oJxKPnQHmx3A7076kzLDmbhGpLQeUEn0wLMEjJzjr62nc2T1OSvkgYJqtCn_CJtecbiJDCrGWD2emhHkW6QqeItKkQKxMkBAIRvvRJEdyY26kBmP_ph9ecPQRo-hSQ-gVGSKpzeTRxYAHJoexpKoJD-1CdEWbzgudE4FETren53QNJqyzubY69FgdYQ8dPj6eo98fQWQR6XrcjuWhFGPSL9PlRUHFDjhPDe1TMmyc77yMJa8TV8TBaftZGNJUtdA9wMn4Z8q_5bCEN5hzUcF4cwTTmMSNe8traBkQY3fl-5B2_Ybr5aVOO-vCLdCgyBYffxZ9lOQfN6VVPQdRVFE6aeFTUfEIqXO1w-UrJPZKgYjpF6V21K8dKGi9HQWEGJtZk9Ll5hyTdREH4ZNydZyCQSYGqXV181hioc6O0AubKDHtHSleWmZdH-GocYgbSMHHfb9zNK1tDVAU_QuJH4NKZF35J8-lVBtfn1kRB9u99ccrHjhP_fyGeeJNP5EYwDS2-npmPZ9fbhq_eIhdbW1p6DZw-3looGe1Khbzn2rr0Kw3hFmu_jKBaIv-ao5_3CAa_QB9yKky3rOkR2GbuHutSt3-QjwdEf4qRwLrN7mVZovLi_x8xHzhg7hk5wUkxY4ZxawWuj4LyVduC9WsCsNFZd8wWVz4uWFB-EktKoJf9brRgll3BQslaGkKhNCFdoa0G81pupJFo7HaMDCtw6LRNq_PQI6DbQeNoUoAOBWMTkWk3vhO6WxwSzJNNYATDUhXMa341swHoi8xUoSTBLZFlYsxmqNmxDhKnXZUt_pFvRID0vdNlsHhlwzWsdFqFuhiEztM__PN_fp_k4RNJCvVlsK150_NxchVy8SRC5WDckMtjZIY2IS82eDagkzO4xj3APJ-dXxNBqajKlF9kKfKC08gqpiI5AELedXjAtg1KlbdSMDoGLJLepalRd6DzxaHlCOE6i_5hw2O1GnXVP4i8GT8IvGaJlTIcDhrn9XMBucxXmYIk_DgMPHccOvKO9bsE8z1dSzhyEjkW2vnSejrVzkq7y-aBKHefmtrqmtcoZsqDeNIyoeBNmbRBuKTcVk-4HSNWk1Zsezihol3zDjGE3rcq85FJIv3bi5jz1J7BLlZB8bV7VNLq3lVydDytz8HSTFFhZFEYG1HkxuYabAT26L10ih6VWk8j_8Z-Oz_zx2-nZXGpCj-yemjJAExwOS6X0J7OpIf4GqOBJXrWFoF_VhJWKqoJJo4-RqwzQRT_XP9Bm4KmCZo5nFe4oIRWMRlnB1l57JS6Cd8wO-lBaGP0OmwAtVh7U-KnYTEQ2Vk-lYnnX1XHGkZUm7PhbPO2ztXmHsH0mLCb0xJPFBLO5klTRXuIPDnpalfhHtXDTBWExOmN6hwfoVzWe9mQ7MBVaPeKiTmz_FgaOaUMlPXSqkSxy_J2epc0WFFqL7QNYV0QVHAoQDXgWy0s2_vVCyBcbd-p9qFhu1ti9MUOmI6QJXE2iILyBGDM02mfjxF3v6MxCu-i05_xcM4zl7arBvSKqcS_sZfX1WCJzFGiOZTd-VC7FmNTFY2JlF9LSE2aeJCT30zlO7nkQWnP6-So3cZJuqhOIKvjX0hRo-T_zSyf4cOBWF8EmP4FzhUCVO2yvO_BBF-YdROgg6bqrsDc1v_3_LTyADlVG6Sa60ktxmMvX3Vp7rMTenNT3r4qvNEr2P1XlFGgOXvaH1s7vRa9IvMwNveXElumc4TL9DwE4TGfL64Dwe-4NTYulwu_WLGlL0cts2xTQed0g78U9JzduEHec6pKsT93dYESigIvw9HlgcB3-A-u-oLK0h2sxWYlsG6vrlmqn4kapFtA5MvDRWHmyUzXU2J2To4iMXK-N1qh5JeSv2ZMpg4ixEzfrsoL0dyUCKgCLvg18LTnLstRKaxdl37cvjDAhH9LipCDp_i27G8r68s4NJ2KRcvyXOuJFyppyama2CDrr-dP9qRM8r5ta3cxGvu5bVk7NEGXoJ_EDBCKudFPy4_ZRASEvnMjUvO-b4oucxGxqx6X4UPAxKqUH11b6_meKXbDnU_ZSVJ7cxwpnbHDkOgBxO33o8Zu9jZM9TtpIbrqsPi-K5k_JaCXzOWV1BBYAvZHmm_u_cGvX8NCryhNoq_O86TBKyKJxT6tXvALPZm5olWWqf9l4bI99xGPvBuBjcYLk22RvWbL31NjCEGt4IlGbqJEj2mbfbjxj5je4KK5VaXfuO0STG6esVIXDf_ytweplSmylWnXl8iFr-7EPIoxeN-5zu-6rUgMuQ9iMQnb1kv5j9weBp5UQohMpuFu_DQoAEsXGBdAa2avVHA53tPnlPhSkJAlo33GechUz8ONizEHY2AkRF0T2B9wYSQGDfP-BXMTP3Nqhc4P6ZVBiiEd_YmU8n0Hy8rHfBH7yLjc962ItR-8THjw0dc-enYLHoUTUIbtzaeoUKhYwmT1EdgXUct6wXD_jk_gOTVzSvh94ZtMFUArDDosoeZH2vFdrAqNqIsFxvmg1RmpOAGHSArRM1p734b_wKK6cybtBd8e32biHXPoLfnfhfSuKN5ltJMz6mrXh3Y57YysgPJ6esQTqjmtii3YBvRZ_ZTaNrNheMRx-CBe8MnpkjPciY1Wnhs4tts41NJ0yTOKRMVfnvcn7vokRMbZqo8Ja4ahVjAwBqAvyuLtA51voHwfSIjPpLPH1hAGIqiMm0JNJ85c0E8MfWU_boFJZGIkN1_avhGmoubRqozFk8L97zBSAzvA48lKrYUzMrL2pJETOLgBb_4zcZGTx9yDqeUnoywm6hbIqQQH7WQijPHRO1Yp78LKwVW9pm_w3IeldxkHdGINtppmOuaLujmtrhZf6dEZ4pi6BMLXz1_7cMbUWGUr5KEcmfNBJeog4aNeKpx6I2bwTVRW1r9nKwqIB-aWIJBd4JgSfaUm5Muzv2QvabDKDBzVaqseJ-vypQ6_mnPj35x2Zlo3ldSGfQmIRybginSCll46-6sPM9TrrbUDdXxZh2foLhAf3Ckm3CG6xTVh_j-1tR-i9MfT9ciXnGio6ZGaytkFvHcXxr-C-o4_bJ8qngFVje144Pp24aA2j-t-IJD3RxF3RjBDwhE4fxr3OllYYCbGiynMyDVqdiECD3HDvqLTT7f2eVPh7N-VIDCzyJCEYMZZk9MvGxYH551EbpoCyR1RnCkKycnpqhRNEjmgfKnaiIHbh87zKehHYSmtuShTACzkwgMByWVzUScQn2If9cKgWfw00450O8OIKJIPdxesn3NeTbzPhl3UlBaGdfBEz2ymc-lm3Cn3LY-27a_Pe8AaJehssEMdx6dAzQJxc5LhP7sEPo1EdZBQTvOzjvFIJ92RtO2ei2AIOYiS_w7OuX9ZUaCD7tGuQG_UcyMeZ9jI4K2pkL-nHgQ7OQtGhkSv3I7vZ61KPe4xGwA",
fragmentLength: 3711,
kind: "json",
artifactCount: 5,
diff --git a/src/components/home/sample-links.tsx b/src/components/home/sample-links.tsx
index 61b409f..d1287bb 100644
--- a/src/components/home/sample-links.tsx
+++ b/src/components/home/sample-links.tsx
@@ -1,15 +1,11 @@
"use client";
-import type { CSSProperties } from "react";
-import { ArrowUpRight } from "lucide-react";
-import { kindIcons } from "@/components/artifact-kind-icons";
import { sampleLinkCards } from "@/components/home/sample-link-data";
import { numberFormatter } from "@/lib/format";
import { cn } from "@/lib/utils";
type SampleLinksProps = {
activeHash: string;
- animationStyle: CSSProperties;
};
/**
@@ -18,60 +14,39 @@ type SampleLinksProps = {
* Keeps the large preset envelope strings and prebuilt hashes out of the initial viewer shell chunk
* while preserving the same visible sample links once the empty-state page finishes hydrating.
*/
-export function SampleLinks({ activeHash, animationStyle }: SampleLinksProps) {
+export function SampleLinks({ activeHash }: SampleLinksProps) {
return (
-
-
-
-
Example fragments
-
- Load a sample envelope
-
-
-
{sampleLinkCards.length} presets
-
-
- Click any sample to open it in the viewer. Same encoding as production
- links.
-
+
+
+ Samples
+ INDEX / {String(sampleLinkCards.length).padStart(2, "0")}
+
-
+
);
}
diff --git a/src/components/renderers/code-renderer.tsx b/src/components/renderers/code-renderer.tsx
index c585b13..c5670ae 100644
--- a/src/components/renderers/code-renderer.tsx
+++ b/src/components/renderers/code-renderer.tsx
@@ -2,20 +2,9 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { WrapText } from "lucide-react";
-import { EditorState, RangeSetBuilder } from "@codemirror/state";
-import { indentationMarkers } from "@replit/codemirror-indentation-markers";
-import {
- Decoration,
- type DecorationSet,
- EditorView,
- ViewPlugin,
- type ViewUpdate,
- highlightActiveLine,
- lineNumbers,
-} from "@codemirror/view";
-import { bracketMatching, defaultHighlightStyle, syntaxTree, syntaxHighlighting } from "@codemirror/language";
+import { File as PierreFile, type FileOptions } from "@/lib/diff/pierre-react";
+import { detectCodeLanguage, toPierreLanguage } from "@/lib/code/language";
import { useResolvedTheme } from "@/components/theme/use-theme-controller";
-import { detectCodeLanguage, loadLanguageSupport } from "@/lib/code/language";
import type { CodeArtifact } from "@/lib/payload/schema";
const MOBILE_CODE_MEDIA_QUERY = "(max-width: 640px)";
@@ -28,185 +17,50 @@ type CodeRendererProps = {
onReady?: () => void;
};
-type LoadedLanguageExtension = Awaited>;
-
-const MAX_DECORATED_CONTENT_LENGTH = 120000;
-const RAINBOW_BRACKET_LEVELS = 6;
-const BRACKET_DECORATION_PATTERN = /[()[\]{}]/;
-const rainbowBracketThemeRules: Record = {};
-
-for (let index = 0; index < RAINBOW_BRACKET_LEVELS; index += 1) {
- rainbowBracketThemeRules[`.cm-rb-${index}`] = {
- color: `var(--rb-${index}) !important`,
- };
-}
-
-/**
- * Builds the read-only CodeMirror theme. Passes `dark` so CodeMirror’s theme facet
- * matches the app (indentation markers and other plugins use it, not CSS alone).
- */
-function createEditorTheme(isDark: boolean) {
- return EditorView.theme(
- {
- "&": {
- height: "100%",
- color: "var(--surface-code-text)",
- backgroundColor: "var(--surface-code)",
- fontFamily: "var(--font-mono), monospace",
- fontSize: "13px",
- },
- ".cm-scroller": {
- overflow: "auto",
- lineHeight: "1.65",
- },
- ".cm-content": {
- padding: "0.9rem 0 1.1rem 0",
- caretColor: "var(--surface-code-text)",
- },
- ".cm-gutters": {
- backgroundColor: "var(--surface-code-raised)",
- color: "var(--surface-code-gutter-fg)",
- borderRight: "1px solid var(--surface-code-gutter-border)",
- minWidth: "3.3rem",
- },
- ".cm-gutterElement": {
- padding: "0 0.9rem 0 0.7rem",
- textAlign: "right",
- },
- ".cm-activeLine": {
- backgroundColor: "var(--surface-code-active-line)",
- },
- ".cm-activeLineGutter": {
- backgroundColor: "var(--surface-code-active-gutter)",
- },
- ".cm-selectionBackground": {
- backgroundColor: "var(--surface-code-selection-bg) !important",
- },
- ".cm-rainbow-bracket": {
- fontWeight: "700",
- },
- ...rainbowBracketThemeRules,
- },
- { dark: isDark },
- );
-}
-
-function buildIgnoredRanges(state: EditorState) {
- const ignored: Array<{ from: number; to: number }> = [];
- let previousFrom = -1;
- let needsSort = false;
-
- syntaxTree(state).iterate({
- enter(node) {
- if (/(Comment|String|Template|RegExp)/i.test(node.name)) {
- if (node.from < previousFrom) {
- needsSort = true;
- }
- previousFrom = node.from;
- ignored.push({ from: node.from, to: node.to });
- }
- },
- });
-
- return needsSort ? ignored.sort((left, right) => left.from - right.from) : ignored;
-}
-
-function buildRainbowDecorations(state: EditorState): DecorationSet {
- const text = state.doc.toString();
-
- if (!BRACKET_DECORATION_PATTERN.test(text)) {
- return Decoration.none;
- }
-
- const builder = new RangeSetBuilder();
- const ignored = buildIgnoredRanges(state);
-
- let ignoredIndex = 0;
- const stack: number[] = [];
-
- for (let index = 0; index < text.length; index += 1) {
- while (ignoredIndex < ignored.length && index >= ignored[ignoredIndex]!.to) {
- ignoredIndex += 1;
- }
-
- const currentIgnored = ignored[ignoredIndex];
- if (currentIgnored && index >= currentIgnored.from && index < currentIgnored.to) {
- continue;
- }
-
- const char = text[index];
- if (char === "{" || char === "[" || char === "(") {
- const level = stack.length % RAINBOW_BRACKET_LEVELS;
- stack.push(level);
- builder.add(index, index + 1, Decoration.mark({ class: `cm-rainbow-bracket cm-rb-${level}` }));
- continue;
- }
-
- if (char === "}" || char === "]" || char === ")") {
- const level = stack.length > 0 ? (stack.pop() ?? 0) : 0;
- builder.add(index, index + 1, Decoration.mark({ class: `cm-rainbow-bracket cm-rb-${level}` }));
- }
- }
-
- return builder.finish();
-}
-
-const rainbowBrackets = ViewPlugin.fromClass(
- class {
- decorations: DecorationSet;
-
- constructor(view: EditorView) {
- this.decorations = buildRainbowDecorations(view.state);
- }
-
- update(update: ViewUpdate) {
- if (update.docChanged) {
- this.decorations = buildRainbowDecorations(update.state);
- }
- }
- },
- {
- decorations: (instance) => instance.decorations,
- },
-);
-
/**
- * Presents code artifacts in a read-only CodeMirror surface for standalone and embedded renderer flows.
- * Accepts `artifact`, optional `compact`, and `onReady` to notify parent renderers when mount is complete.
- * Lazily loads language support, offers optional line wrapping, and falls back to baseline highlighting when needed.
+ * Presents code artifacts in a read-only Pierre `File` surface for standalone and embedded
+ * renderer flows. Accepts `artifact`, optional `compact` (markdown fences, JSON raw), and
+ * `onReady` to notify parent renderers when the first render mounts. The wrap toggle maps to
+ * Pierre's `overflow` option, so toggling re-renders in place instead of remounting.
*/
export function CodeRenderer({ artifact, compact = false, onReady }: CodeRendererProps) {
- const hostRef = useRef(null);
const onReadyRef = useRef(onReady);
+ const reportedReadyFileRef = useRef(null);
const wrapPreferenceRef = useRef("auto");
- const [wrapLines, setWrapLines] = useState(compact);
- const [languageSupport, setLanguageSupport] = useState<{
- extension: LoadedLanguageExtension;
- language: string;
- }>({ extension: null, language: "" });
- const [isReady, setIsReady] = useState(false);
+ const [wrapLines, setWrapLines] = useState(false);
const resolvedTheme = useResolvedTheme();
- /**
- * CodeMirror’s `dark` facet (syntax + indentation markers) follows the resolved shell theme.
- */
- const isCmDark = useMemo(() => {
- return resolvedTheme === "dark";
- }, [resolvedTheme]);
- const editorTheme = useMemo(() => createEditorTheme(isCmDark), [isCmDark]);
- const language = useMemo(() => detectCodeLanguage(artifact.filename, artifact.language), [artifact.filename, artifact.language]);
- const languageExtension = languageSupport.language === language ? languageSupport.extension : null;
+ const language = useMemo(
+ () => detectCodeLanguage(artifact.filename, artifact.language),
+ [artifact.filename, artifact.language],
+ );
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
- // Runs before paint so the first CodeMirror mount matches the viewport (call sites use dynamic(..., { ssr: false })).
- // Preference stays on wrapPreferenceRef (not state) so the matchMedia listener closure stays correct without
- // re-subscribing each render. compact=true resets to "auto"; compact is static at all call sites today.
+ const file = useMemo(
+ () => ({
+ name: artifact.filename ?? `${artifact.id}.txt`,
+ contents: artifact.content,
+ lang: toPierreLanguage(language),
+ // The worker pool is disabled on this surface, so it does not need a cross-render cache key.
+ }),
+ [artifact.filename, artifact.id, artifact.content, language],
+ );
+
+ // Readiness belongs to the exact file object that Pierre rendered. A stale post-render
+ // callback can only mark its own file ready, so an in-place artifact swap reads false
+ // immediately without a state update during render.
+ const [readyFile, setReadyFile] = useState(null);
+ const isReady = readyFile === file;
+
+ // Runs before paint so the first mount matches the viewport (call sites use
+ // dynamic(..., { ssr: false })). Compact blocks preserve source whitespace and scroll
+ // horizontally.
useLayoutEffect(() => {
if (compact) {
- setWrapLines(true);
- wrapPreferenceRef.current = "auto";
+ setWrapLines(false);
+ wrapPreferenceRef.current = "off";
return;
}
@@ -241,90 +95,27 @@ export function CodeRenderer({ artifact, compact = false, onReady }: CodeRendere
};
}, [compact]);
- useEffect(() => {
- let cancelled = false;
-
- void loadLanguageSupport(language)
- .then((extension) => {
- if (!cancelled) {
- setLanguageSupport({ extension, language });
+ const options = useMemo>(
+ () => ({
+ theme: "agent-render",
+ // themeType carries Pierre's light/dark semantics; the palette itself comes
+ // from the --diffs-* vars, which flip under .dark.
+ themeType: resolvedTheme,
+ overflow: wrapLines ? "wrap" : "scroll",
+ disableFileHeader: compact,
+ onPostRender: (_node, _instance, phase) => {
+ // "mount" fires on first hydrate and "update" on every later render, including the
+ // in-place artifact swap the stage waits on. "unmount" is the only non-ready phase.
+ if (phase === "unmount" || reportedReadyFileRef.current === file) {
+ return;
}
- })
- .catch(() => {
- if (!cancelled) {
- setLanguageSupport({ extension: null, language });
- }
- });
-
- return () => {
- cancelled = true;
- };
- }, [language]);
-
- useEffect(() => {
- if (!hostRef.current) {
- return;
- }
-
- setIsReady(false);
- hostRef.current.replaceChildren();
-
- const extensions = [
- lineNumbers(),
- highlightActiveLine(),
- syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
- bracketMatching(),
- indentationMarkers({
- markerType: "codeOnly",
- thickness: 2,
- hideFirstIndent: true,
- highlightActiveBlock: false,
- colors: {
- light: "rgba(70, 92, 129, 0.14)",
- dark: "rgba(239, 243, 247, 0.08)",
- activeLight: "rgba(105, 209, 221, 0.18)",
- activeDark: "rgba(105, 209, 221, 0.22)",
- },
- }),
- EditorState.readOnly.of(true),
- EditorView.editable.of(false),
- editorTheme,
- ];
-
- if (wrapLines) {
- extensions.push(EditorView.lineWrapping);
- }
-
- if (languageExtension) {
- extensions.push(languageExtension);
- }
-
- if (artifact.content.length <= MAX_DECORATED_CONTENT_LENGTH && BRACKET_DECORATION_PATTERN.test(artifact.content)) {
- extensions.push(rainbowBrackets);
- }
-
- const view = new EditorView({
- state: EditorState.create({
- doc: artifact.content,
- extensions,
- }),
- parent: hostRef.current,
- });
-
- let cancelled = false;
- const animationFrame = window.requestAnimationFrame(() => {
- if (!cancelled) {
- setIsReady(true);
+ reportedReadyFileRef.current = file;
+ setReadyFile(file);
onReadyRef.current?.();
- }
- });
-
- return () => {
- cancelled = true;
- window.cancelAnimationFrame(animationFrame);
- view.destroy();
- };
- }, [artifact.content, editorTheme, languageExtension, wrapLines]);
+ },
+ }),
+ [wrapLines, compact, resolvedTheme, file],
+ );
return (
{compact ? null : (
-
- {language}
- read-only codemirror
-
)}
-
+
);
}
diff --git a/src/components/renderers/diff-renderer.tsx b/src/components/renderers/diff-renderer.tsx
index 7235ffe..418e00e 100644
--- a/src/components/renderers/diff-renderer.tsx
+++ b/src/components/renderers/diff-renderer.tsx
@@ -1,17 +1,22 @@
"use client";
-import { Component, type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
+import dynamic from "next/dynamic";
+import { Component, type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, Columns2, Copy, Rows3 } from "lucide-react";
-import { useResolvedTheme, type ResolvedTheme } from "@/components/theme/use-theme-controller";
+import { FileDiff, MultiFileDiff, setLanguageOverride, type FileDiffProps } from "@/lib/diff/pierre-react";
import { copyTextToClipboard } from "@/lib/copy-text";
-import { detectCodeLanguage } from "@/lib/code/language";
-import { parseGitPatchBundle } from "@/lib/diff/git-patch";
+import { detectCodeLanguage, toPierreLanguage } from "@/lib/code/language";
+import { getContentKey } from "@/lib/content-key";
+import { useResolvedTheme } from "@/components/theme/use-theme-controller";
+import { getPatchFileLabels, parseRenderablePatchFiles, type ParsedPatchFile } from "@/lib/diff/git-patch";
import type { DiffArtifact } from "@/lib/payload/schema";
-import {
- loadDiffViewStylesheet,
- releaseDiffViewStylesheet,
- retainDiffViewStylesheet,
-} from "@/components/renderers/diff-view-stylesheet";
+
+// The Trees runtime only mounts for multi-file patches, so it loads behind its own
+// boundary rather than inflating every diff render.
+const FileTreeNav = dynamic(
+ () => import("@/components/file-tree-nav").then((module) => module.FileTreeNav),
+ { ssr: false },
+);
type DiffRendererProps = {
artifact: DiffArtifact;
@@ -21,52 +26,29 @@ type DiffRendererProps = {
const NARROW_DIFF_BREAKPOINT = 640;
const MOBILE_DIFF_MEDIA_QUERY = `(max-width: ${NARROW_DIFF_BREAKPOINT}px)`;
-type DiffViewModule = typeof import("@git-diff-view/react");
-type DiffViewLibrary = Pick;
type DiffViewMode = "unified" | "split";
-type DiffFileInstance = InstanceType;
-
-type RenderableDiffFile = {
- meta: ReturnType[number];
- diffFile: DiffFileInstance | null;
-};
+type DiffOptions = NonNullable["options"]>;
type DiffRenderState =
| {
- kind: "loading";
+ kind: "rich-patch";
+ patchFiles: ParsedPatchFile[];
}
| {
- kind: "rich";
- diffFiles: RenderableDiffFile[];
+ kind: "rich-contents";
+ fileName: string;
+ language: string | undefined;
}
| {
kind: "fallback";
message: string;
- rawPatch: string;
detail?: string;
};
-type ParsedPatchBundleState =
- | {
- kind: "none";
- }
- | {
- kind: "invalid-shape";
- }
- | {
- error: unknown;
- kind: "parse-error";
- }
- | {
- kind: "parsed";
- patchFiles: ReturnType;
- };
-
type DiffRendererBoundaryProps = {
artifact: DiffArtifact;
onReady?: () => void;
children: ReactNode;
- resetKey: string;
};
type DiffRendererBoundaryState = {
@@ -77,117 +59,38 @@ function getIsNarrowScreen() {
return typeof window !== "undefined" && window.matchMedia(MOBILE_DIFF_MEDIA_QUERY).matches;
}
-function hashResetValue(value: string | undefined): string {
- if (value === undefined) {
- return "u";
- }
-
- let hash = 2166136261;
- for (let index = 0; index < value.length; index += 1) {
- hash ^= value.charCodeAt(index);
- hash = Math.imul(hash, 16777619);
- }
-
- return `${value.length}:${(hash >>> 0).toString(36)}`;
-}
-
function getDefaultMode(view: DiffArtifact["view"], isNarrowScreen: boolean) {
return view === "split" && !isNarrowScreen ? "split" : "unified";
}
-function getDiffLibraryMode(mode: DiffViewMode, diffLibrary: DiffViewLibrary) {
- return mode === "split" ? diffLibrary.DiffModeEnum.Split : diffLibrary.DiffModeEnum.Unified;
-}
-
-function patchFilesNeedDiffLibrary(patchFiles: ReturnType): boolean {
- for (const patchFile of patchFiles) {
- if (!patchFile.isBinary) {
- return true;
- }
- }
-
- return false;
-}
-
-function diffFilesHaveRenderableFile(diffFiles: RenderableDiffFile[]): boolean {
- for (const { diffFile } of diffFiles) {
- if (diffFile) {
- return true;
- }
- }
-
- return false;
-}
-
-/** Run the four-call @git-diff-view warmup (theme + parse both view modes) a DiffFile needs before render. */
-function warmDiffFile(diffFile: DiffFileInstance, resolvedTheme: ResolvedTheme): void {
- diffFile.initTheme(resolvedTheme === "dark" ? "dark" : "light");
- diffFile.init();
- diffFile.buildSplitDiffLines();
- diffFile.buildUnifiedDiffLines();
-}
-
-function looksLikeUnifiedDiff(patch: string) {
- if (!/\S/.test(patch)) {
- return false;
- }
-
- return (
- /^diff --git /m.test(patch) ||
- (/^--- /m.test(patch) && /^\+\+\+ /m.test(patch)) ||
- /^@@ /m.test(patch) ||
- /^Binary files .* differ\r?$/m.test(patch) ||
- /^GIT binary patch\r?$/m.test(patch)
- );
+// The Shiki theme is the same CSS-variable theme in both app modes; colors come
+// from the --diffs-* custom properties in globals.css, which pierce the shadow
+// DOM and flip under .dark. themeType only sets Pierre's light/dark semantics.
+function getDiffOptions(mode: DiffViewMode, themeType: "light" | "dark"): DiffOptions {
+ return {
+ diffStyle: mode,
+ theme: "agent-render",
+ themeType,
+ overflow: "wrap",
+ disableFileHeader: true,
+ diffIndicators: "classic",
+ };
}
function getRawPatch(artifact: DiffArtifact) {
return artifact.patch ?? "";
}
-function getFallbackState(artifact: DiffArtifact, message: string, error?: unknown): DiffRenderState {
+function getFallbackState(message: string, error?: unknown): DiffRenderState {
const detail = error instanceof Error ? error.message : undefined;
return {
kind: "fallback",
message,
- rawPatch: getRawPatch(artifact),
detail,
};
}
-function buildRenderablePatchFile(
- patchFile: ReturnType[number],
- artifact: DiffArtifact,
- resolvedTheme: ResolvedTheme,
- diffLibrary: DiffViewLibrary,
-): RenderableDiffFile {
- if (patchFile.isBinary) {
- return {
- meta: patchFile,
- diffFile: null,
- };
- }
-
- const language = detectCodeLanguage(patchFile.newPath ?? patchFile.oldPath ?? undefined, artifact.language);
- const diffFile = new diffLibrary.DiffFile(
- patchFile.oldPath ? `a/${patchFile.oldPath}` : "/dev/null",
- "",
- patchFile.newPath ? `b/${patchFile.newPath}` : "/dev/null",
- "",
- [patchFile.patch],
- language,
- language,
- );
-
- warmDiffFile(diffFile, resolvedTheme);
-
- return {
- meta: patchFile,
- diffFile,
- };
-}
-
const diffFallbackFrameStyle = {
overflow: "auto",
border: "1px solid var(--border)",
@@ -220,12 +123,6 @@ class DiffRendererBoundary extends Component
-
- raw patch fallback
- invalid unified diff
-
{rawPatch ? (
-
+
{copyState === "copied" ? : }
{copyState === "copied" ? "Copied raw diff" : "Copy raw diff"}
@@ -322,50 +215,28 @@ function DiffFallback({
);
}
-function DiffLoading({
- mode,
- isNarrowScreen,
-}: {
- mode: DiffViewMode;
- isNarrowScreen: boolean;
-}) {
- return (
-
-
-
Preparing the rich diff renderer.
-
-
- );
-}
-
function DiffRendererContent({ artifact, onReady }: DiffRendererProps) {
const resolvedTheme = useResolvedTheme();
const onReadyRef = useRef(onReady);
- const [diffLibrary, setDiffLibrary] = useState(null);
- const [diffLibraryError, setDiffLibraryError] = useState(null);
- const [mounted, setMounted] = useState(false);
- const [stylesReady, setStylesReady] = useState(false);
+ const readyFiredRef = useRef(false);
const [isReady, setIsReady] = useState(false);
const [activeFileId, setActiveFileId] = useState(null);
const [isNarrowScreen, setIsNarrowScreen] = useState(getIsNarrowScreen);
const [mode, setMode] = useState(() => getDefaultMode(artifact.view, getIsNarrowScreen()));
- useEffect(() => {
- setMounted(true);
- }, []);
-
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
+ const reportReady = useCallback(() => {
+ if (readyFiredRef.current) {
+ return;
+ }
+ readyFiredRef.current = true;
+ setIsReady(true);
+ onReadyRef.current?.();
+ }, []);
+
useEffect(() => {
if (typeof window === "undefined") {
return;
@@ -384,227 +255,93 @@ function DiffRendererContent({ artifact, onReady }: DiffRendererProps) {
};
}, []);
- useEffect(() => {
- setMode(getDefaultMode(artifact.view, isNarrowScreen));
- }, [artifact.id, artifact.view, isNarrowScreen]);
-
- const parsedPatchBundle = useMemo(() => {
- if (!artifact.patch) {
- return { kind: "none" };
- }
-
- if (!looksLikeUnifiedDiff(artifact.patch)) {
- return { kind: "invalid-shape" };
- }
-
- try {
- return { kind: "parsed", patchFiles: parseGitPatchBundle(artifact.patch) };
- } catch (error) {
- return { error, kind: "parse-error" };
- }
- }, [artifact.patch]);
-
- const shouldLoadDiffLibrary = useMemo(() => {
- if (artifact.oldContent !== undefined && artifact.newContent !== undefined) {
- return true;
- }
-
- return parsedPatchBundle.kind === "parsed" && patchFilesNeedDiffLibrary(parsedPatchBundle.patchFiles);
- }, [artifact.oldContent, artifact.newContent, parsedPatchBundle]);
-
- useEffect(() => {
- let cancelled = false;
-
- if (!shouldLoadDiffLibrary) {
- setDiffLibraryError(null);
- return () => {
- cancelled = true;
- };
- }
-
- if (diffLibrary) {
- return () => {
- cancelled = true;
- };
- }
-
- setDiffLibraryError(null);
- import("@git-diff-view/react")
- .then((module) => {
- if (!cancelled) {
- setDiffLibrary(module);
- }
- })
- .catch((error) => {
- if (!cancelled) {
- setDiffLibraryError(error instanceof Error ? error : new Error("Failed to load the rich diff renderer."));
- }
- });
-
- return () => {
- cancelled = true;
- };
- }, [artifact.id, diffLibrary, shouldLoadDiffLibrary]);
-
const renderedDiff = useMemo(() => {
- if (diffLibraryError) {
- return getFallbackState(
- artifact,
- "The rich diff renderer could not be loaded. Showing the raw patch instead.",
- diffLibraryError,
- );
- }
-
if (artifact.patch) {
- if (parsedPatchBundle.kind === "invalid-shape") {
- return getFallbackState(
- artifact,
- "This patch is not a valid unified diff, so the raw patch is shown instead.",
- );
- }
-
- if (parsedPatchBundle.kind === "parse-error") {
- return getFallbackState(
- artifact,
- "This patch could not be rendered as a valid unified diff. Showing the raw patch instead.",
- parsedPatchBundle.error,
- );
- }
-
- if (parsedPatchBundle.kind === "parsed") {
- const patchFiles = parsedPatchBundle.patchFiles;
- const diffFiles = new Array(patchFiles.length);
-
- for (let index = 0; index < patchFiles.length; index += 1) {
- const patchFile = patchFiles[index]!;
- if (!diffLibrary && !patchFile.isBinary) {
- return { kind: "loading" };
+ try {
+ const patchFiles = parseRenderablePatchFiles(artifact.patch);
+ if (patchFiles.length === 0) {
+ return getFallbackState(
+ "This patch is not a valid unified diff, so the raw patch is shown instead.",
+ );
+ }
+ const hint = artifact.language?.trim().toLowerCase();
+ if (hint) {
+ const lang = toPierreLanguage(hint);
+ for (const file of patchFiles) {
+ if (file.meta) {
+ file.meta = setLanguageOverride(file.meta, lang);
+ }
}
-
- diffFiles[index] = diffLibrary
- ? buildRenderablePatchFile(patchFile, artifact, resolvedTheme, diffLibrary)
- : { meta: patchFile, diffFile: null };
}
-
- return { kind: "rich", diffFiles };
- }
- }
-
- if (artifact.oldContent !== undefined && artifact.newContent !== undefined) {
- if (!diffLibrary) {
- return { kind: "loading" };
- }
-
- try {
- const fileName = artifact.filename ?? artifact.id;
- const language = detectCodeLanguage(fileName, artifact.language);
- const diffFile = new diffLibrary.DiffFile(`a/${fileName}`, artifact.oldContent, `b/${fileName}`, artifact.newContent, [], language, language);
-
- warmDiffFile(diffFile, resolvedTheme);
-
- return {
- kind: "rich",
- diffFiles: [
- {
- meta: {
- id: artifact.id,
- patch: "",
- oldPath: artifact.filename ?? null,
- newPath: artifact.filename ?? null,
- displayPath: artifact.filename ?? artifact.id,
- status: "modified",
- isBinary: false,
- },
- diffFile,
- },
- ],
- };
+ return { kind: "rich-patch", patchFiles };
} catch (error) {
return getFallbackState(
- artifact,
- "This before-and-after diff could not be rendered, so the rich diff view has been skipped.",
+ "This patch could not be rendered as a valid unified diff. Showing the raw patch instead.",
error,
);
}
}
+ if (artifact.oldContent !== undefined && artifact.newContent !== undefined) {
+ const fileName = artifact.filename ?? artifact.id;
+ return {
+ kind: "rich-contents",
+ fileName,
+ language: toPierreLanguage(detectCodeLanguage(fileName, artifact.language)),
+ };
+ }
+
return getFallbackState(
- artifact,
"This diff artifact does not include a valid patch payload to render.",
);
- }, [artifact, diffLibrary, diffLibraryError, parsedPatchBundle, resolvedTheme]);
+ }, [artifact]);
- useEffect(() => {
- setIsReady(false);
- setActiveFileId(renderedDiff.kind === "rich" ? renderedDiff.diffFiles[0]?.meta.id ?? null : null);
- }, [renderedDiff]);
-
- useEffect(() => {
- let cancelled = false;
-
- if (renderedDiff.kind !== "rich" || !diffFilesHaveRenderableFile(renderedDiff.diffFiles)) {
- setStylesReady(true);
- return;
+ const patchFileTree = useMemo(() => {
+ if (renderedDiff.kind !== "rich-patch" || renderedDiff.patchFiles.length <= 1) {
+ return null;
}
- let released = false;
- const releaseStylesheet = () => {
- if (released) {
- return;
- }
- released = true;
- releaseDiffViewStylesheet();
- };
-
- retainDiffViewStylesheet();
- setStylesReady(false);
- loadDiffViewStylesheet()
- .then(() => {
- if (!cancelled) {
- setStylesReady(true);
- }
- })
- .catch((error) => {
- console.warn("Failed to load diff stylesheet", error);
- releaseStylesheet();
- if (!cancelled) {
- setStylesReady(true);
+ const labels = getPatchFileLabels(renderedDiff.patchFiles);
+ const fileIdByPath = new Map();
+ const paths: string[] = [];
+ for (const file of renderedDiff.patchFiles) {
+ const label = labels.get(file.id) ?? file.displayPath;
+ fileIdByPath.set(label, file.id);
+ paths.push(label);
+ }
+ const selectedId =
+ renderedDiff.patchFiles.find((file) => file.id === activeFileId)?.id ??
+ renderedDiff.patchFiles[0]?.id;
+ const selectedPath = selectedId ? labels.get(selectedId) : undefined;
+
+ return { fileIdByPath, paths, selectedPath };
+ }, [renderedDiff, activeFileId]);
+
+ const diffOptions = useMemo(
+ () => ({
+ ...getDiffOptions(mode, resolvedTheme),
+ onPostRender: (_node, _instance, phase) => {
+ if (phase !== "unmount") {
+ reportReady();
}
- });
-
- return () => {
- cancelled = true;
- releaseStylesheet();
- };
- }, [renderedDiff]);
+ },
+ }),
+ [mode, resolvedTheme, reportReady],
+ );
useEffect(() => {
- if (!mounted || !stylesReady || renderedDiff.kind !== "rich" || renderedDiff.diffFiles.length === 0) {
- return;
+ if (
+ renderedDiff.kind === "rich-patch" &&
+ renderedDiff.patchFiles.every((file) => file.isBinary)
+ ) {
+ reportReady();
}
-
- const animationFrame = window.requestAnimationFrame(() => {
- setIsReady(true);
- onReadyRef.current?.();
- });
-
- return () => {
- window.cancelAnimationFrame(animationFrame);
- };
- }, [mounted, renderedDiff, stylesReady]);
+ }, [renderedDiff, reportReady]);
if (renderedDiff.kind === "fallback") {
return ;
}
- if (renderedDiff.kind === "loading") {
- return ;
- }
-
- const { diffFiles } = renderedDiff;
- const RichDiffView = diffLibrary?.DiffView;
- const richDiffMode = diffLibrary ? getDiffLibraryMode(mode, diffLibrary) : null;
-
const handleFileSelect = (fileId: string) => {
setActiveFileId(fileId);
const section = document.getElementById(`patch-file-${fileId}`);
@@ -622,16 +359,11 @@ function DiffRendererContent({ artifact, onReady }: DiffRendererProps) {
data-mobile-layout={isNarrowScreen ? "true" : "false"}
>
-
- review-style diff
- syntax highlighted
-
{isNarrowScreen ? (
-
-
Unified is the phone default
+
setMode(mode === "split" ? "unified" : "split")}
aria-pressed={mode === "split"}
>
@@ -640,19 +372,21 @@ function DiffRendererContent({ artifact, onReady }: DiffRendererProps) {
) : (
-
+
setMode("unified")}
+ aria-pressed={mode === "unified"}
>
Unified
setMode("split")}
+ aria-pressed={mode === "split"}
>
Split
@@ -661,50 +395,68 @@ function DiffRendererContent({ artifact, onReady }: DiffRendererProps) {
)}
- {mounted ? (
-
-
- {diffFiles.map(({ meta }) => (
- handleFileSelect(meta.id)}
- >
- {meta.status}
- {meta.displayPath}
-
- ))}
-
+ {renderedDiff.kind === "rich-contents" ? (
+
+
+
+
+
+
modified
+
{renderedDiff.fileName}
+
+
+
+
+
+
+ ) : (
+
+ {patchFileTree ? (
+
{
+ const fileId = patchFileTree.fileIdByPath.get(path);
+ if (fileId) {
+ handleFileSelect(fileId);
+ }
+ }}
+ />
+ ) : null}
- {diffFiles.map(({ meta, diffFile }) => (
-
- ) : null}
+ )}
);
@@ -714,18 +466,19 @@ function DiffRendererContent({ artifact, onReady }: DiffRendererProps) {
* Renders diff artifacts as review-style unified/split views in the artifact stage.
* Uses `artifact` diff payload details and optional `onReady` callback when the active diff UI is mount-ready.
* Prefers parsed git patches, supports old/new content diffs, and falls back to raw patch output on parse/runtime errors.
+ * Rendering is delegated to @pierre/diffs (Shiki-based, shadow DOM) with a theme-aware document surface.
*/
export function DiffRenderer({ artifact, onReady }: DiffRendererProps) {
- // resetKey hashes patch/content (FNV-1a + length) as a deliberate bound to avoid embedding huge
- // patches into a React key on every render; a hash collision could fail to clear a stuck error
- // boundary, which is accepted as the cost of not concatenating large payloads into the key.
+ // Remount the renderer and its error boundary when the artifact contents change. The bounded
+ // key avoids retaining a full decoded payload in React's child identity while giving every
+ // meaningful diff input a fresh renderer lifecycle.
const resetKey = useMemo(
() =>
[
artifact.id,
- hashResetValue(artifact.patch),
- hashResetValue(artifact.oldContent),
- hashResetValue(artifact.newContent),
+ getContentKey(artifact.patch),
+ getContentKey(artifact.oldContent),
+ getContentKey(artifact.newContent),
artifact.filename ?? "",
artifact.language ?? "",
artifact.view ?? "",
@@ -742,7 +495,7 @@ export function DiffRenderer({ artifact, onReady }: DiffRendererProps) {
);
return (
-
+
);
diff --git a/src/components/renderers/diff-view-stylesheet.ts b/src/components/renderers/diff-view-stylesheet.ts
deleted file mode 100644
index 97f85d4..0000000
--- a/src/components/renderers/diff-view-stylesheet.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { withBasePath } from "@/lib/site/base-path";
-
-// Ref-counted loader for the @git-diff-view vendor stylesheet. The diff renderer is the only consumer
-// and mounts at most a few instances, so a module-level refcount keeps the heavy stylesheet injected
-// exactly while a rich diff is on screen and removes it once the last instance unmounts.
-
-const DIFF_VIEW_STYLESHEET_ID = "agent-render-diff-view-styles";
-const diffViewStylesheetHrefs = [
- withBasePath("/vendor/diff-view-pure.css.br"),
- withBasePath("/vendor/diff-view-pure.css"),
-];
-
-let diffViewStylesheetPromise: Promise | null = null;
-let diffViewStylesheetRefCount = 0;
-
-function loadStylesheetHref(href: string) {
- return new Promise((resolve, reject) => {
- const link = document.createElement("link");
-
- const cleanup = () => {
- link.removeEventListener("load", handleLoad);
- link.removeEventListener("error", handleError);
- };
- const handleLoad = () => {
- link.dataset.loaded = "true";
- cleanup();
- resolve();
- };
- const handleError = () => {
- cleanup();
- link.remove();
- reject(new Error(`Diff view stylesheet failed to load: ${href}`));
- };
-
- link.id = DIFF_VIEW_STYLESHEET_ID;
- link.rel = "stylesheet";
- link.href = href;
- link.addEventListener("load", handleLoad);
- link.addEventListener("error", handleError);
- document.head.appendChild(link);
- });
-}
-
-/** Inject the diff-view stylesheet (preferring the precompressed variant), de-duplicating in flight. */
-export function loadDiffViewStylesheet() {
- if (typeof document === "undefined") {
- return Promise.resolve();
- }
-
- const existingLink = document.getElementById(DIFF_VIEW_STYLESHEET_ID) as HTMLLinkElement | null;
-
- if (existingLink?.dataset.loaded === "true" || existingLink?.sheet) {
- if (existingLink) {
- existingLink.dataset.loaded = "true";
- }
- return Promise.resolve();
- }
-
- if (diffViewStylesheetPromise && !existingLink) {
- diffViewStylesheetPromise = null;
- }
-
- if (diffViewStylesheetPromise) {
- return diffViewStylesheetPromise;
- }
-
- existingLink?.remove();
-
- diffViewStylesheetPromise = (async () => {
- let lastError: unknown;
-
- for (const href of diffViewStylesheetHrefs) {
- try {
- await loadStylesheetHref(href);
- return;
- } catch (error) {
- lastError = error;
- }
- }
-
- throw lastError instanceof Error ? lastError : new Error("Diff view stylesheet failed to load.");
- })().catch((error) => {
- diffViewStylesheetPromise = null;
- throw error;
- });
-
- return diffViewStylesheetPromise;
-}
-
-/** Mark one diff instance as using the stylesheet. */
-export function retainDiffViewStylesheet() {
- diffViewStylesheetRefCount += 1;
-}
-
-/** Release one diff instance; removes the stylesheet once the last consumer unmounts. */
-export function releaseDiffViewStylesheet() {
- diffViewStylesheetRefCount = Math.max(0, diffViewStylesheetRefCount - 1);
- if (diffViewStylesheetRefCount > 0) {
- return;
- }
-
- document.getElementById(DIFF_VIEW_STYLESHEET_ID)?.remove();
- diffViewStylesheetPromise = null;
-}
diff --git a/src/components/renderers/json-renderer.tsx b/src/components/renderers/json-renderer.tsx
index d1ae14f..aa1aed2 100644
--- a/src/components/renderers/json-renderer.tsx
+++ b/src/components/renderers/json-renderer.tsx
@@ -1,5 +1,6 @@
"use client";
+import dynamic from "next/dynamic";
import { Component, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Braces, ChevronRight, ListTree } from "lucide-react";
import type { JsonArtifact } from "@/lib/payload/schema";
@@ -16,6 +17,37 @@ type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string
// (200k char) payload can nest thousands deep in only a few KB, which crashes the reconciler with
// a RangeError; 200 is far beyond any human-readable JSON. Change only by maintainer decision.
const MAX_JSON_TREE_DEPTH = 200;
+const MAX_JSON_TREE_NODES = 5_000;
+
+function isJsonTreeWithinBudget(value: JsonValue): boolean {
+ const pending: JsonValue[] = [value];
+ let count = 0;
+ while (pending.length > 0) {
+ const current = pending.pop();
+ count += 1;
+ if (count > MAX_JSON_TREE_NODES) {
+ return false;
+ }
+ if (current && typeof current === "object") {
+ const children = Array.isArray(current) ? current : Object.values(current);
+ if (count + pending.length + children.length > MAX_JSON_TREE_NODES) {
+ return false;
+ }
+ for (const child of children) {
+ pending.push(child);
+ }
+ }
+ }
+ return true;
+}
+
+const RawCodeRenderer = dynamic(
+ () =>
+ import("@/components/renderers/code-renderer").then(
+ (module) => module.CodeRenderer,
+ ),
+ { ssr: false },
+);
function JsonNode({ label, value, level = 0 }: { label?: string; value: JsonValue; level?: number }) {
if (value === null || typeof value !== "object") {
@@ -33,8 +65,8 @@ function JsonNode({ label, value, level = 0 }: { label?: string; value: JsonValu
{label ? {label} : null}
{Array.isArray(value)
- ? `Array(${value.length}) — max depth reached`
- : `Object(${Object.keys(value).length}) — max depth reached`}
+ ? `Array(${value.length}): max depth reached`
+ : `Object(${Object.keys(value).length}): max depth reached`}
);
@@ -76,11 +108,22 @@ function JsonNode({ label, value, level = 0 }: { label?: string; value: JsonValu
);
}
-function JsonRawSource({ content }: { content: string }) {
+function JsonRawSource({ artifact, onReady }: { artifact: JsonArtifact; onReady?: () => void }) {
return (
-
- {content}
-
+
+
+
);
}
@@ -107,16 +150,20 @@ class JsonTreeBoundary extends Component<{ fallback: ReactNode; children: ReactN
}
/**
- * Shows JSON artifacts with a toggle between structured tree and native raw source views.
+ * Shows JSON artifacts with a toggle between structured tree and syntax-highlighted raw source views.
* Receives `artifact` and optional `onReady`, including readiness updates across parse and view-mode changes.
- * Falls back to a native raw source block with an error notice when JSON parsing fails.
+ * Falls back to highlighted raw source when parsing fails or the tree exceeds its render budget.
*/
export function JsonRenderer({ artifact, onReady }: JsonRendererProps) {
const onReadyRef = useRef(onReady);
const [view, setView] = useState<"tree" | "raw">("tree");
+ // Keyed on content, not the artifact object: a re-decoded equal artifact is a
+ // new object identity but the same rendered raw document.
+ const [rawReadyContent, setRawReadyContent] = useState
(null);
const parsed = useMemo(() => {
try {
- return { ok: true as const, json: JSON.parse(artifact.content) as JsonValue };
+ const json = JSON.parse(artifact.content) as JsonValue;
+ return { ok: true as const, json, treeWithinBudget: isJsonTreeWithinBudget(json) };
} catch (error) {
return { ok: false as const, message: error instanceof Error ? error.message : "Invalid JSON payload." };
}
@@ -126,42 +173,82 @@ export function JsonRenderer({ artifact, onReady }: JsonRendererProps) {
onReadyRef.current = onReady;
}, [onReady]);
+ // Only the tree view reports ready here; in the raw view (and the invalid-JSON
+ // fallback) readiness belongs to the deferred code surface, which fires its own
+ // onReady once the highlighted document has actually mounted.
useEffect(() => {
+ if (parsed.ok && parsed.treeWithinBudget && view === "tree") {
+ onReadyRef.current?.();
+ }
+ }, [artifact.id, parsed, view]);
+
+ const handleRawReady = () => {
+ setRawReadyContent(artifact.content);
onReadyRef.current?.();
- }, [artifact.id, parsed.ok, view]);
+ };
+ const isReady =
+ parsed.ok && parsed.treeWithinBudget && view === "tree"
+ ? true
+ : rawReadyContent === artifact.content;
- if (!parsed.ok) {
+ if (!parsed.ok || !parsed.treeWithinBudget) {
return (
-
-
{parsed.message}
-
+
+
+ {parsed.ok
+ ? "This JSON has too many values for the interactive tree. Showing the raw source instead."
+ : parsed.message}
+
+
);
}
return (
-
+
-
-
setView("tree")}>
+
+ setView("tree")}
+ aria-pressed={view === "tree"}
+ >
Tree
- setView("raw")}>
+ {
+ if (view !== "raw") {
+ setRawReadyContent(null);
+ setView("raw");
+ }
+ }}
+ aria-pressed={view === "raw"}
+ >
Raw
- read-only
{view === "tree" ? (
-
}>
+
}>
) : (
-
+
)}
);
diff --git a/src/components/renderers/markdown-renderer.tsx b/src/components/renderers/markdown-renderer.tsx
index 82019cb..dd7a45f 100644
--- a/src/components/renderers/markdown-renderer.tsx
+++ b/src/components/renderers/markdown-renderer.tsx
@@ -150,7 +150,7 @@ const markdownSchema = {
/**
* Displays markdown artifacts in the primary viewer stage using sanitized GFM output.
* Consumes `artifact` content and optional `onReady`, which fires after embedded fenced code blocks report ready.
- * Reuses the CodeMirror renderer for code fences and keeps raw HTML disabled for safer rendering.
+ * Reuses the Pierre-backed code renderer for code fences and keeps raw HTML disabled for safer rendering.
*/
export function MarkdownRenderer({ artifact, onReady }: MarkdownRendererProps) {
const heading = artifact.title ?? artifact.filename ?? artifact.id;
@@ -233,7 +233,6 @@ export function MarkdownRenderer({ artifact, onReady }: MarkdownRendererProps) {
mermaid
- diagram
{language}
- premium fence
= embeddedBlockCount ? "true" : "false"}>
- Markdown artifact
{heading}
{artifact.filename ? {artifact.filename}
: null}
diff --git a/src/components/renderers/mermaid-block.tsx b/src/components/renderers/mermaid-block.tsx
index 62462f6..875c329 100644
--- a/src/components/renderers/mermaid-block.tsx
+++ b/src/components/renderers/mermaid-block.tsx
@@ -52,6 +52,9 @@ export function MermaidBlock({ code, onReady }: MermaidBlockProps) {
if (cancelled || !containerRef.current) return;
containerRef.current.innerHTML = svg;
+ const diagram = containerRef.current.querySelector("svg");
+ diagram?.setAttribute("role", "img");
+ diagram?.setAttribute("aria-label", "Mermaid diagram");
setError(null);
} catch (err) {
if (cancelled) return;
diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx
index c518223..02d2805 100644
--- a/src/components/theme-toggle.tsx
+++ b/src/components/theme-toggle.tsx
@@ -29,13 +29,13 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
type="button"
onClick={() => mounted && setTheme(isDark ? "light" : "dark")}
className={cn(
- "mono-pill shell-pill min-w-[8.5rem] justify-center transition-colors duration-150",
+ "shell-key theme-key",
className,
)}
aria-label={mounted ? `Switch to ${isDark ? "light" : "dark"} theme` : "Theme toggle loading"}
>
{mounted && isDark ? : }
- {mounted ? (isDark ? "Light mode" : "Dark mode") : "Theme"}
+ {mounted ? (isDark ? "Light" : "Dark") : "Theme"}
);
}
diff --git a/src/components/viewer-shell.tsx b/src/components/viewer-shell.tsx
index 199e96d..20cb80c 100644
--- a/src/components/viewer-shell.tsx
+++ b/src/components/viewer-shell.tsx
@@ -4,13 +4,14 @@ import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import {
+ MAX_DECODED_PAYLOAD_LENGTH,
MAX_FRAGMENT_LENGTH,
- artifactKinds,
type ArtifactPayload,
type ParsedPayload,
type PayloadEnvelope,
} from "@/lib/payload/schema";
import { getHashPreview } from "@/components/viewer/hash-preview";
+import { getContentKey } from "@/lib/content-key";
import { numberFormatter } from "@/lib/format";
import { withBasePath } from "@/lib/site/base-path";
@@ -23,11 +24,6 @@ const iconImageStyle: CSSProperties = {
backgroundRepeat: "no-repeat",
backgroundSize: "contain",
};
-const heroAnimationStyle: CSSProperties = { animationDelay: "80ms" };
-const bentoAnimationStyle: CSSProperties = { animationDelay: "120ms" };
-const sampleAnimationStyle: CSSProperties = { animationDelay: "180ms" };
-const inspectorAnimationStyle: CSSProperties = { animationDelay: "220ms" };
-const initializeAnimationStyle: CSSProperties = { animationDelay: "260ms" };
// Intentionally a local copy of getVisibleFragmentLength (src/lib/payload/fragment.ts).
// Importing that helper statically would pull the fragment/codec module (lz-string, fflate)
@@ -42,38 +38,16 @@ function getVisibleHashLength(hash: string): number {
}
}
-const ecosystemLinks = [
- {
- href: "https://github.com/baanish/agent-render",
- kicker: "Source",
- title: "GitHub",
- description: "Source code, issues, releases, and self-hosting notes.",
- },
- {
- href: "https://github.com/baanish/agent-render/blob/main/docs/payload-format.md",
- kicker: "Protocol",
- title: "Payload format docs",
- description: "Fragment key, codecs, envelope fields, and size limits.",
- },
- {
- href: "https://github.com/baanish/agent-render/blob/main/docs/architecture.md#security-posture",
- kicker: "Safety",
- title: "Security page",
- description: "The current security posture and zero-retention boundaries.",
- },
- {
- href: "https://openclaw.ai",
- kicker: "Ecosystem",
- title: "OpenClaw",
- description: "The agent ecosystem this viewer was built to support.",
- },
-] as const;
+function getRendererReadyKey(artifact: ArtifactPayload | null): string {
+ if (!artifact) {
+ return "";
+ }
+ return `${artifact.id}:${artifact.kind}:${getContentKey(JSON.stringify(artifact))}`;
+}
-const emptyStateSteps = [
- "Pick a sample fragment below.",
- "The payload decodes client-side from the URL hash.",
- "The renderer displays the artifact without contacting a server.",
-] as const;
+const githubPath = "https://github.com/baanish/agent-render";
+const payloadDocsPath = `${githubPath}/blob/main/docs/payload-format.md`;
+const openClawPath = "https://openclaw.ai";
const ThemeToggle = dynamic(
() =>
@@ -83,7 +57,7 @@ const ThemeToggle = dynamic(
loading: () => (
Theme
@@ -163,30 +137,6 @@ function getEmptyParsedPayload(): ParsedPayload {
};
}
-function getStatusTone(parsed: ParsedPayload) {
- if (parsed.ok) {
- return {
- label: "Decoded",
- color: "var(--success)",
- message: "Fragment decoded successfully.",
- };
- }
-
- if (parsed.code === "empty") {
- return {
- label: "Empty",
- color: "var(--accent-secondary)",
- message: parsed.message,
- };
- }
-
- return {
- label: "Error",
- color: "var(--danger)",
- message: parsed.message,
- };
-}
-
/**
* Render the main viewer shell for decoding and displaying artifact fragments from the URL hash.
*
@@ -198,8 +148,7 @@ function getStatusTone(parsed: ParsedPayload) {
export function ViewerShell() {
const [hash, setHash] = useState("");
const [activeArtifactId, setActiveArtifactId] = useState(null);
- const [rendererReady, setRendererReady] = useState(true);
- const rendererReadyKeyRef = useRef("");
+ const [readyRendererKey, setReadyRendererKey] = useState("");
const artifactSelectionRequestRef = useRef(0);
/** True when the current hash originated from a server-injected payload (self-hosted UUID mode). */
const injectedPayloadRef = useRef(false);
@@ -278,7 +227,10 @@ export function ViewerShell() {
() => (envelope ? getArtifactById(envelope, activeArtifactId) : null),
[activeArtifactId, envelope],
);
- const rendererReadyKey = activeArtifact ? `${hash}:${activeArtifact.id}` : "";
+ const rendererReadyKey = useMemo(
+ () => getRendererReadyKey(activeArtifact),
+ [activeArtifact],
+ );
useEffect(() => {
setActiveArtifactId(parsed.ok ? parsed.envelope.activeArtifactId ?? null : null);
@@ -289,8 +241,6 @@ export function ViewerShell() {
document.title = title ? `${title} — agent-render` : "agent-render";
}, [envelope, activeArtifact]);
- const budgetRatio = Math.min(fragmentLength / MAX_FRAGMENT_LENGTH, 1);
- const statusTone = getStatusTone(parsed);
const viewerState =
activeArtifact && envelope
? "artifact"
@@ -300,24 +250,10 @@ export function ViewerShell() {
? "empty"
: "error";
- useEffect(() => {
- rendererReadyKeyRef.current = rendererReadyKey;
-
- if (!rendererReadyKey) {
- setRendererReady(true);
- return;
- }
-
- // Reset only when the hash/artifact-id key changes. A later decode that only
- // replaces artifact contents (same id, new fragment) must not clear a ready
- // signal the remounted renderer already reported for that key.
- setRendererReady(false);
- }, [rendererReadyKey]);
+ const rendererReady = !rendererReadyKey || readyRendererKey === rendererReadyKey;
const markRendererReady = useCallback((readyKey: string) => {
- if (rendererReadyKeyRef.current === readyKey) {
- setRendererReady(true);
- }
+ setReadyRendererKey(readyKey);
}, []);
const setFragmentHash = useCallback((nextHash: string) => {
@@ -380,37 +316,37 @@ export function ViewerShell() {
data-active-artifact-id={activeArtifact?.id ?? "none"}
data-renderer-ready={rendererReady ? "true" : "false"}
>
-
+
-
+
{activeArtifact && envelope ? (
) : (
- {/* ── Editorial hero ── */}
-
- Artifact viewer
-
- Zero-retention artifact viewer for AI outputs.
-
-
- Artifact content lives in the URL fragment, so in static mode
- the static host does not receive artifact content on the page
- request.
-
-
- Fragment links can still appear in browser history, screenshots,
- copied messages, extensions, and other places you share or run
- your browser.
-
-
- static export
- 5 renderers
- zero retention
-
-
-
- {/* ── Bento feature grid ── */}
-
-
-
-
Static boundary
-
- The browser decodes markdown, code, diffs, CSV, and JSON
- locally from the fragment after the shell loads.
-
-
- {ecosystemLinks.map((link) => (
-
- {link.kicker}
- {link.title}
-
- {link.description}
-
-
- ))}
-
-
Try it
-
- Load a sample below
-
-
- Click any sample to populate the viewer from the URL hash.
-
-
-
+ {hash && !parsed.ok && parsed.code !== "empty" ? (
+
+
+
+
Invalid fragment
+
{parsed.message}
+
+
+
+ HASH
+ {getHashPreview(hash)}
+
+
+ ) : null}
- {/* ── Link creator ── */}
-
+
+
+
+
- {/* ── Samples + Inspector — full-bleed sections ── */}
-
+
+
-
-
+
-
Fragment inspector
-
- Current URL state
-
-
-
- {statusTone.label}
-
-
-
- {statusTone.message}
-
-
-
-
-
Fragment budget
-
- {numberFormatter.format(fragmentLength)} /{" "}
- {numberFormatter.format(MAX_FRAGMENT_LENGTH)}
-
-
-
-
-
Codec
-
- {parsed.ok ? parsed.envelope.codec : "plain"}
-
-
-
-
Artifacts
-
- {parsed.ok
- ? numberFormatter.format(parsed.envelope.artifacts.length)
- : "0"}
-
+
TRANSPORT
+
URL fragment only
-
-
Hash preview
-
- {getHashPreview(hash)}
-
+
+
FRAGMENT
+ {numberFormatter.format(MAX_FRAGMENT_LENGTH)} chars max
-
-
-
- {/* ── Initialize section ── */}
-
-
-
Viewer shell
-
- Initialize your Artifact
-
-
- Select a fragment above to render it here. Payloads stay off
- the host request path, but links still need care.
-
+
DECODED
+
{numberFormatter.format(MAX_DECODED_PAYLOAD_LENGTH)} chars max
-
-
- {artifactKinds.map((kind) => (
-
- {kind}
-
- ))}
+
+
FORMATS
+ MD · CODE · DIFF · CSV · JSON
-
+
-
-
-
Getting started
-
- Pick a sample or paste your own content above.
-
-
- {parsed.ok
- ? "Fragment decoded — select an artifact to render it."
- : "No fragment in the URL yet."}
-
-
- {emptyStateSteps.map((step, index) => (
-
-
Step {index + 1}
-
- {step}
-
-
- ))}
-
-
-
Hosting
-
- Single static route. Works on any static host.
-
-
+
+
+ NOTE
+ Artifact content lives in the URL fragment, so the static host does not receive artifact content on the initial page request.
+
+
+ WARN
+ Fragment links can still appear in browser history, screenshots, copied messages, extensions, and other places the browser exposes.
+
)}
diff --git a/src/components/viewer/artifact-body-editor.tsx b/src/components/viewer/artifact-body-editor.tsx
new file mode 100644
index 0000000..b0d2e1c
--- /dev/null
+++ b/src/components/viewer/artifact-body-editor.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { useLayoutEffect, useMemo, useRef, type Ref } from "react";
+import { useResolvedTheme } from "@/components/theme/use-theme-controller";
+import {
+ CodeView,
+ EditProvider,
+ Editor,
+ type CodeViewHandle,
+ type CodeViewItem,
+} from "@/lib/diff/pierre-edit";
+
+export type ArtifactBodyDocument = {
+ id: string;
+ name: string;
+ contents: string;
+};
+
+type ArtifactBodyEditorProps = {
+ documents: readonly ArtifactBodyDocument[];
+ onDocumentChange: (id: string, contents: string) => void;
+ codeViewRef?: Ref
>;
+};
+
+/**
+ * Editable document surface for the artifact editor: each document mounts as a CodeView file
+ * item in edit mode, so editing happens on the same syntax-highlighted Pierre surface as the
+ * viewer instead of a plain textarea. The edit runtime (`@pierre/diffs/edit`) only loads with
+ * this module, which the artifact editor imports dynamically.
+ *
+ * Callers remount this component (via `key`) when the edited artifact switches; document changes
+ * flow out through `onDocumentChange` so the owning draft stays the source of truth.
+ */
+export function ArtifactBodyEditor({
+ documents,
+ onDocumentChange,
+ codeViewRef,
+}: ArtifactBodyEditorProps) {
+ const onChangeRef = useRef(onDocumentChange);
+ useLayoutEffect(() => {
+ onChangeRef.current = onDocumentChange;
+ }, [onDocumentChange]);
+ const initialItems = useMemo(
+ () =>
+ documents.map((doc) => ({
+ id: doc.id,
+ type: "file",
+ file: { name: doc.name, contents: doc.contents, cacheKey: doc.id },
+ version: 0,
+ edit: true,
+ })),
+ [documents],
+ );
+ const resolvedTheme = useResolvedTheme();
+ const codeViewOptions = useMemo(
+ () => ({ theme: "agent-render", themeType: resolvedTheme }),
+ [resolvedTheme],
+ );
+
+ return (
+ new Editor(options)}>
+ {
+ onChangeRef.current(item.id, file.contents);
+ }}
+ />
+
+ );
+}
diff --git a/src/components/viewer/artifact-editor.tsx b/src/components/viewer/artifact-editor.tsx
index c9bdb16..9d7c907 100644
--- a/src/components/viewer/artifact-editor.tsx
+++ b/src/components/viewer/artifact-editor.tsx
@@ -1,9 +1,15 @@
"use client";
-import { useEffect, useRef, useState } from "react";
-import { ArrowUpRight, Check, Copy, ExternalLink, Link2 } from "lucide-react";
+import dynamic from "next/dynamic";
+import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
+import { Link2 } from "lucide-react";
import { copyTextToClipboard } from "@/lib/copy-text";
-import { numberFormatter } from "@/lib/format";
+import { CODE_LANGUAGE_CHOICES } from "@/lib/code/language";
+import { CodecPicker, GeneratedLinkResult } from "@/components/generated-link";
+import { getPatchFileLabels, parseRenderablePatchFiles, type ParsedPatchFile } from "@/lib/diff/git-patch";
+import { getUniqueLabels } from "@/lib/unique-labels";
+import type { CodeViewHandle, Editor } from "@/lib/diff/pierre-edit";
+import type { ArtifactBodyDocument } from "@/components/viewer/artifact-body-editor";
import {
applyArtifactEditDraft,
createArtifactEditDraft,
@@ -12,9 +18,6 @@ import {
type GeneratedArtifactLink,
} from "@/lib/payload/link-creator";
import {
- codecPickerLabel,
- codecs,
- isDeprecatedEmitCodec,
type ArtifactKind,
type ArtifactPayload,
type PayloadEnvelope,
@@ -22,12 +25,45 @@ import {
import { withBasePath } from "@/lib/site/base-path";
import { cn } from "@/lib/utils";
+// The Trees runtime stays behind its own chunk; the rail only renders when there is more than
+// one thing to navigate, so single-row editing flows do not load it.
+const FileTreeNav = dynamic(
+ () => import("@/components/file-tree-nav").then((module) => module.FileTreeNav),
+ { ssr: false },
+);
+
+// The Pierre edit surface (CodeView + EditProvider + Editor) is heavy and only needed while
+// editing, so it loads behind its own dynamic boundary inside the already-deferred editor chunk.
+const ArtifactBodyEditor = dynamic(
+ () =>
+ import("@/components/viewer/artifact-body-editor").then(
+ (module) => module.ArtifactBodyEditor,
+ ),
+ { ssr: false },
+);
+
type ArtifactEditorProps = {
artifact: ArtifactPayload;
envelope: PayloadEnvelope;
onPreviewHash: (hash: string) => void;
};
+const EMPTY_PATCH_FILES: ParsedPatchFile[] = [];
+
+// Snapshots a draft into the documents the Pierre edit surface mounts. Pair diffs become two
+// documents with the conventional `a/`/`b/` prefixes so file headers read like a git patch and
+// the extension still drives language inference.
+function buildBodyDocuments(draft: ArtifactEditDraft): ArtifactBodyDocument[] {
+ const name = draft.filename.trim() || "content";
+ if (draft.kind === "diff" && draft.diffSource === "pair") {
+ return [
+ { id: "old", name: `a/${name}`, contents: draft.oldContent ?? "" },
+ { id: "new", name: `b/${name}`, contents: draft.newContent ?? "" },
+ ];
+ }
+ return [{ id: "content", name, contents: draft.content }];
+}
+
const fieldHints: Record = {
markdown: "Edit the markdown, then generate a new shareable link.",
code: "Edit the snippet and keep the language hint when it helps.",
@@ -36,8 +72,6 @@ const fieldHints: Record = {
json: "Edit the JSON, then generate a new shareable link.",
};
-const codecOptions = ["auto", ...codecs] as const;
-
function getShareBaseUrl() {
if (typeof window === "undefined") {
return undefined;
@@ -60,22 +94,34 @@ function getBodyFieldLabel(kind: ArtifactKind) {
return kind === "diff" ? "Patch" : "Content";
}
+function getArtifactTreeLabel(artifact: ArtifactPayload) {
+ return artifact.filename?.trim() || artifact.title?.trim() || artifact.id;
+}
+
/**
* In-viewer editor for the currently open artifact.
*
* Starts from the decoded artifact, lets the user correct title/body fields, and generates a new
* fragment link without writing anything back to a server. Preview replaces the current hash so the
- * edited artifact renders immediately.
+ * edited artifact renders immediately. The body edits on a Pierre `CodeView`/`EditProvider` surface
+ * (`artifact-body-editor.tsx`); when the envelope has more than one navigable entry a tree rail lets
+ * the edit target switch in place without losing per-artifact drafts.
*/
export function ArtifactEditor({
artifact,
envelope,
onPreviewHash,
}: ArtifactEditorProps) {
- const [{ draft, version: draftVersion }, setDraftState] = useState(() => ({
- draft: createArtifactEditDraft(artifact),
+ const [editingArtifactId, setEditingArtifactId] = useState(artifact.id);
+ const [draftState, setDraftState] = useState(() => ({
+ drafts: new Map(),
version: 0,
}));
+ const editingArtifact =
+ envelope.artifacts.find((entry) => entry.id === editingArtifactId) ?? artifact;
+ const draft =
+ draftState.drafts.get(editingArtifactId) ?? createArtifactEditDraft(editingArtifact);
+ const draftVersion = draftState.version;
const [generatedLink, setGeneratedLink] =
useState(null);
const [generatedVersion, setGeneratedVersion] = useState(-1);
@@ -95,6 +141,129 @@ export function ArtifactEditor({
Boolean(generatedLink) && draftVersion !== generatedVersion;
const usesPairDiff = draft.kind === "diff" && draft.diffSource === "pair";
const contentFieldLabel = getBodyFieldLabel(draft.kind);
+ const bodyEditorRef = useRef | null>(null);
+ // The patch re-parses on the deferred copy so a keystroke paints before the
+ // tree rebuilds; a stale rail while typing beats an input stall.
+ const deferredDraftContent = useDeferredValue(draft.content);
+ const patchFiles = useMemo(() => {
+ if (draft.kind !== "diff" || draft.diffSource !== "patch") {
+ return EMPTY_PATCH_FILES;
+ }
+
+ try {
+ return parseRenderablePatchFiles(deferredDraftContent);
+ } catch {
+ // The patch mid-edit may be malformed; the tree hides until it parses again.
+ return EMPTY_PATCH_FILES;
+ }
+ }, [draft.kind, draft.diffSource, deferredDraftContent]);
+ const patchFileLabels = useMemo(() => getPatchFileLabels(patchFiles), [patchFiles]);
+ const patchFilePaths = useMemo(
+ () => patchFiles.map((file) => patchFileLabels.get(file.id) ?? file.displayPath),
+ [patchFiles, patchFileLabels],
+ );
+ const patchFileByPath = useMemo(
+ () => new Map(patchFiles.map((file) => [patchFileLabels.get(file.id) ?? file.displayPath, file])),
+ [patchFiles, patchFileLabels],
+ );
+
+
+ // The picker keeps an opened artifact's out-of-list language selectable instead of
+ // silently clearing it, since payloads can carry any language hint.
+ const languageChoices = useMemo(() => {
+ if (!draft.language || CODE_LANGUAGE_CHOICES.some((choice) => choice.value === draft.language)) {
+ return CODE_LANGUAGE_CHOICES;
+ }
+ return [...CODE_LANGUAGE_CHOICES, { value: draft.language, label: draft.language }];
+ }, [draft.language]);
+
+ const artifactLabels = useMemo(
+ () =>
+ getUniqueLabels(
+ envelope.artifacts.map((entry) => ({
+ id: entry.id,
+ base: getArtifactTreeLabel(entry),
+ })),
+ // Patch file paths share the tree namespace with artifact labels; reserve them so a
+ // filename-shaped artifact label can never shadow a patch row under handleTreeSelect.
+ patchFiles.length > 1 ? new Set(patchFilePaths) : undefined,
+ ),
+ [envelope.artifacts, patchFiles.length, patchFilePaths],
+ );
+ const artifactIdByLabel = useMemo(
+ () => new Map(Array.from(artifactLabels, ([id, label]) => [label, id])),
+ [artifactLabels],
+ );
+ // The rail lists every artifact in the envelope; a multi-file patch being edited also lists its
+ // files so tree selection can move the patch caret without leaving the editor. A single row
+ // (one artifact, nothing nested) is just noise, so the rail hides then.
+ const treePaths = useMemo(
+ () => [...artifactLabels.values(), ...(patchFiles.length > 1 ? patchFilePaths : [])],
+ [artifactLabels, patchFiles.length, patchFilePaths],
+ );
+ const showTreeRail = treePaths.length > 1;
+ const selectedTreePath = artifactLabels.get(editingArtifactId);
+
+ const handlePatchFileSelect = (path: string) => {
+ const file = patchFileByPath.get(path);
+ const codeView = bodyEditorRef.current;
+ if (!file || !codeView) {
+ return;
+ }
+
+ const lineNumber = file.startLine;
+
+ codeView.scrollTo({ type: "line", id: "content", lineNumber, align: "center" });
+ // CodeView exposes the editor as the narrow DiffsEditor interface, but this surface creates
+ // Pierre's concrete Editor. Use its selection and focus APIs instead of walking shadow DOM.
+ const editor = codeView.getEditor("content") as Editor | undefined;
+ if (!editor) {
+ return;
+ }
+ const position = { line: lineNumber - 1, character: 0 };
+ editor.setSelections([{ start: position, end: position, direction: "none" }]);
+ // The tree row takes focus when its click finishes, so restore editor focus on the next task.
+ window.setTimeout(() => {
+ editor.focus({ preventScroll: true, lineNumber, character: 0 });
+ }, 0);
+ };
+
+ const handleTreeSelect = (path: string) => {
+ // Patch rows only exist in the rail when the patch has more than one file; the
+ // lookup is gated the same way so a hidden single-file row cannot shadow an
+ // artifact label that shares its path.
+ if (patchFiles.length > 1 && patchFileByPath.has(path)) {
+ handlePatchFileSelect(path);
+ return;
+ }
+
+ const targetId = artifactIdByLabel.get(path);
+ if (!targetId || targetId === editingArtifactId) {
+ return;
+ }
+
+ const target = envelope.artifacts.find((entry) => entry.id === targetId);
+ if (!target) {
+ return;
+ }
+
+ generationRequestRef.current += 1;
+ copyTokenRef.current += 1;
+ markdownCopyTokenRef.current += 1;
+ setEditingArtifactId(targetId);
+ setIsGenerating(false);
+ setCopyState("idle");
+ setMarkdownLinkCopyState("idle");
+ setError(null);
+ // A link generated for the previous artifact does not describe this one; drop it
+ // so Copy/Preview cannot hand out the wrong link while the other draft is open.
+ setGeneratedLink(null);
+ setGeneratedVersion(-1);
+ };
+
+ const handleBodyDocumentChange = (id: string, contents: string) => {
+ updateDraft(id === "old" ? "oldContent" : id === "new" ? "newContent" : "content", contents);
+ };
useEffect(() => {
copyTokenRef.current += 1;
@@ -104,22 +273,40 @@ export function ArtifactEditor({
setError(null);
}, [draftVersion]);
+ // CodeView treats item.version as the controlled-update boundary. Bump it when a filename
+ // changes, and publish the current draft contents with the rename so the controlled item
+ // cannot restore the snapshot from before the user started typing.
+ useEffect(() => {
+ const codeView = bodyEditorRef.current;
+ if (!codeView) {
+ return;
+ }
+ for (const document of buildBodyDocuments(draft)) {
+ const item = codeView.getItem(document.id);
+ if (item?.type === "file" && item.file.name !== document.name) {
+ codeView.updateItem({
+ ...item,
+ version: (item.version ?? 0) + 1,
+ file: { ...item.file, name: document.name, contents: document.contents },
+ });
+ }
+ }
+ }, [draft]);
+
const updateDraft = (
field: K,
value: ArtifactEditDraft[K],
) => {
setDraftState((current) => {
- if (Object.is(current.draft[field], value)) {
+ const base =
+ current.drafts.get(editingArtifactId) ?? createArtifactEditDraft(editingArtifact);
+ if (Object.is(base[field], value)) {
return current;
}
- return {
- draft: {
- ...current.draft,
- [field]: value,
- },
- version: current.version + 1,
- };
+ const drafts = new Map(current.drafts);
+ drafts.set(editingArtifactId, { ...base, [field]: value });
+ return { drafts, version: current.version + 1 };
});
};
@@ -129,8 +316,26 @@ export function ArtifactEditor({
setIsGenerating(true);
try {
+ // Apply every edited artifact, then the active one last so the generated
+ // link opens on the artifact currently on screen.
+ let nextEnvelope = envelope;
+ for (const [artifactId, editedDraft] of draftState.drafts) {
+ if (artifactId !== editingArtifactId) {
+ try {
+ nextEnvelope = applyArtifactEditDraft(nextEnvelope, editedDraft);
+ } catch (applyError) {
+ const source = envelope.artifacts.find((entry) => entry.id === artifactId);
+ const label = source ? getArtifactTreeLabel(source) : artifactId;
+ throw new Error(
+ `${label}: ${applyError instanceof Error ? applyError.message : String(applyError)}`,
+ );
+ }
+ }
+ }
+ nextEnvelope = applyArtifactEditDraft(nextEnvelope, draft);
+
const nextGeneratedLink = await createGeneratedEnvelopeLinkAsync(
- applyArtifactEditDraft(envelope, draft),
+ nextEnvelope,
getShareBaseUrl(),
draft.codec,
);
@@ -175,16 +380,15 @@ export function ArtifactEditor({
}
const requestToken = ++copyTokenRef.current;
- const expectedHash = generatedLink.hash;
try {
await copyTextToClipboard(generatedLink.url);
- if (copyTokenRef.current !== requestToken || generatedLink.hash !== expectedHash) {
+ if (copyTokenRef.current !== requestToken) {
return;
}
setCopyState("copied");
} catch {
- if (copyTokenRef.current !== requestToken || generatedLink.hash !== expectedHash) {
+ if (copyTokenRef.current !== requestToken) {
return;
}
setCopyState("failed");
@@ -197,22 +401,15 @@ export function ArtifactEditor({
}
const requestToken = ++markdownCopyTokenRef.current;
- const expectedHash = generatedLink.hash;
try {
await copyTextToClipboard(generatedLink.markdownLink);
- if (
- markdownCopyTokenRef.current !== requestToken ||
- generatedLink.hash !== expectedHash
- ) {
+ if (markdownCopyTokenRef.current !== requestToken) {
return;
}
setMarkdownLinkCopyState("copied");
} catch {
- if (
- markdownCopyTokenRef.current !== requestToken ||
- generatedLink.hash !== expectedHash
- ) {
+ if (markdownCopyTokenRef.current !== requestToken) {
return;
}
setMarkdownLinkCopyState("failed");
@@ -235,18 +432,28 @@ export function ArtifactEditor({
return (
-
- Editing creates a new shareable link. The current URL stays put until
- you preview or copy the new one.
-
-
-
{
- event.preventDefault();
- void handleGenerate();
- }}
+
+ {showTreeRail ? (
+
+ ) : null}
+
{
+ event.preventDefault();
+ void handleGenerate();
+ }}
+ >
Title
Language
- updateDraft("language", event.target.value)}
- placeholder="tsx"
className="creator-input"
- />
+ >
+ {languageChoices.map((choice) => (
+
+ {choice.label}
+
+ ))}
+
) : null}
@@ -300,232 +512,66 @@ export function ArtifactEditor({
) : null}
- {usesPairDiff ? (
- <>
-
-
- Old content
-
-
- updateDraft("oldContent", event.target.value)
- }
- className="creator-textarea"
- rows={8}
- data-testid="artifact-editor-old-content"
- />
-
-
-
- New content
-
-
- updateDraft("newContent", event.target.value)
- }
- className="creator-textarea"
- rows={8}
- data-testid="artifact-editor-new-content"
- />
-
- >
- ) : (
-
-
- {contentFieldLabel}
-
- {fieldHints[draft.kind]}
-
+
+
+
+ {usesPairDiff ? "Old and new content" : contentFieldLabel}
- updateDraft("content", event.target.value)}
- className="creator-textarea"
- rows={14}
- autoFocus
- data-testid="artifact-editor-content"
+
+ {usesPairDiff
+ ? "Edit the old and new content, then generate a new shareable link."
+ : fieldHints[draft.kind]}
+
+
+
+
{isGenerating ? "Generating…" : "Generate new link"}
-
- Compression
- {codecOptions.map((option) => (
- updateDraft("codec", option)}
- >
- {codecPickerLabel(option)}
-
- ))}
-
+
updateDraft("codec", option)}
+ />
-
+
+
{generatedLink ? (
-
-
-
-
New link
-
- Ready to share
-
-
-
{generatedLink.artifact.kind}
-
-
-
-
-
-
-
-
-
Codec
-
{generatedLink.codec}
-
-
-
Fragment size
-
- {numberFormatter.format(generatedLink.fragmentLength)} chars
-
-
-
-
- {generatedLink.discordMarkdownLinkWarning ? (
-
- {generatedLink.discordMarkdownLinkWarning}
-
- ) : null}
-
-
-
{
- void handleCopy();
- }}
- >
- {copyState === "copied" ? (
-
- ) : (
-
- )}
- {copyState === "copied"
- ? "Copied"
- : copyState === "failed"
- ? "Copy failed"
- : "Copy link"}
-
-
{
- void handleCopyMarkdownLink();
- }}
- >
- {markdownLinkCopyState === "copied" ? (
-
- ) : (
-
- )}
- {markdownLinkCopyState === "copied"
- ? "Copied"
- : markdownLinkCopyState === "failed"
- ? "Copy failed"
- : "Copy markdown link"}
-
-
-
- Preview here
-
-
{
- if (isGeneratedLinkStale) {
- event.preventDefault();
- }
- }}
- >
-
- Open in new tab
-
-
-
- {isGeneratedLinkStale ? (
-
- Draft changed since last generation.
-
- ) : null}
-
+ {
+ void handleCopy();
+ }}
+ onCopyMarkdownLink={() => {
+ void handleCopyMarkdownLink();
+ }}
+ onPreview={handlePreview}
+ />
) : null}
{error ? (
diff --git a/src/components/viewer/artifact-selector.tsx b/src/components/viewer/artifact-selector.tsx
index 4582468..da07d4f 100644
--- a/src/components/viewer/artifact-selector.tsx
+++ b/src/components/viewer/artifact-selector.tsx
@@ -30,6 +30,9 @@ export function ArtifactSelector({
const Icon = kindIcons[artifact.kind];
const heading = getHeading(artifact);
const supportingLabel = getSupportingLabel(artifact);
+ const showSupportingLabel = Boolean(
+ artifact.filename && artifact.filename !== heading,
+ );
const isCurrent = artifact.id === activeArtifactId;
return (
@@ -46,10 +49,11 @@ export function ArtifactSelector({
{heading}
-
- {artifact.kind}
- {supportingLabel}
-
+ {showSupportingLabel ? (
+
+ {supportingLabel}
+
+ ) : null}
);
diff --git a/src/components/viewer/artifact-stage.tsx b/src/components/viewer/artifact-stage.tsx
index 6378582..abda67a 100644
--- a/src/components/viewer/artifact-stage.tsx
+++ b/src/components/viewer/artifact-stage.tsx
@@ -1,8 +1,7 @@
"use client";
import dynamic from "next/dynamic";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import type { CSSProperties } from "react";
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Check, Code, Copy, Download, Eye, Link2, Pencil, Printer, X } from "lucide-react";
import { copyTextToClipboard } from "@/lib/copy-text";
import { numberFormatter } from "@/lib/format";
@@ -32,18 +31,8 @@ type ArtifactStageProps = {
onPreviewHash: (hash: string) => void;
onRendererReady: (readyKey: string) => void;
rendererReadyKey: string;
- statusTone: {
- color: string;
- label: string;
- message: string;
- };
};
-const toolbarAnimationStyle: CSSProperties = { animationDelay: "80ms" };
-const selectorAnimationStyle: CSSProperties = { animationDelay: "100ms" };
-const contentAnimationStyle: CSSProperties = { animationDelay: "140ms" };
-const metadataAnimationStyle: CSSProperties = { animationDelay: "200ms" };
-
const MarkdownRenderer = dynamic(
() =>
import("@/components/renderers/markdown-renderer").then(
@@ -104,16 +93,6 @@ function getArtifactBody(artifact: ArtifactPayload): string {
return artifact.content;
}
-function getArtifactSubtitle(artifact: ArtifactPayload): string {
- if (artifact.kind === "markdown") return "Markdown";
- if (artifact.kind === "code") return artifact.language ?? "Code";
- if (artifact.kind === "diff")
- return artifact.view ? `${artifact.view} diff` : "Diff";
- if (artifact.kind === "json") return "JSON";
- if (artifact.kind === "csv") return "CSV";
- return (artifact as ArtifactPayload).kind;
-}
-
function getArtifactHeading(artifact: ArtifactPayload): string {
return artifact.title ?? artifact.filename ?? artifact.id;
}
@@ -126,21 +105,21 @@ function getArtifactSupportingLabel(artifact: ArtifactPayload, heading = getArti
function getArtifactDetailRows(artifact: ArtifactPayload, bodyLength: number) {
const rows = [
- { label: "Kind", value: artifact.kind },
- { label: "Artifact", value: artifact.id },
- { label: "File", value: artifact.filename ?? "Not provided" },
+ { label: "Format", value: artifact.kind },
+ { label: "Ident", value: artifact.id },
+ { label: "File", value: artifact.filename ?? "-" },
{
- label: "Size",
+ label: "Body",
value: `${numberFormatter.format(bodyLength)} chars`,
},
];
if (artifact.kind === "code") {
- rows.push({ label: "Language", value: artifact.language ?? "Auto later" });
+ rows.push({ label: "Language", value: artifact.language ?? "auto" });
}
if (artifact.kind === "diff") {
- rows.push({ label: "View", value: artifact.view ?? "Unified later" });
+ rows.push({ label: "View", value: artifact.view ?? "unified" });
}
return rows;
@@ -149,7 +128,7 @@ function getArtifactDetailRows(artifact: ArtifactPayload, bodyLength: number) {
function getPreviewText(content: string): string {
return (
content.trim().slice(0, 960) ||
- "Artifact contents will appear here once a renderer is attached."
+ "Artifact contents will appear here."
);
}
@@ -164,33 +143,41 @@ function getDownloadFilename(artifact: ArtifactPayload): string {
/**
- * Renders the 'Raw' view of markdown/CSV as un-highlighted plain text — by design.
+ * Renders the 'Raw' view of markdown/CSV on the same Pierre `File` surface as code.
*
- * Deliberate decision: the raw path renders a plain instead of routing through
- * CodeRenderer (CodeMirror). This keeps the raw view free of the CodeMirror bundle, so
- * toggling to Raw never pulls that heavy chunk into the page. Consequence: raw output is
- * intentionally not syntax-highlighted. Changing this back to a highlighted view would
- * re-introduce the CodeMirror dependency on the raw path and must be an owner decision.
- *
- * React escapes the text child, so rendering untrusted artifact content here is XSS-safe.
+ * Owner decision: raw source viewing uses the highlighted file surface for every
+ * text body rather than a bare , since the highlighting chunk is already part
+ * of the viewer contract. The artifact is synthesized as a `code` payload so the
+ * existing CodeRenderer handles it, with the language hint pointing at the source
+ * format (`markdown` fences stay legible; `csv` degrades to `text`).
*/
-function RawArtifactSource({
- content,
+function RawArtifactView({
+ artifact,
+ language,
onReady,
testId,
}: {
- content: string;
+ artifact: ArtifactPayload;
+ language: string;
onReady: () => void;
testId: string;
}) {
- useEffect(() => {
- onReady();
- }, [onReady]);
+ const rawArtifact = useMemo(
+ () => ({
+ id: artifact.id,
+ kind: "code",
+ title: artifact.title,
+ filename: artifact.filename,
+ content: getArtifactBody(artifact),
+ language,
+ }),
+ [artifact, language],
+ );
return (
-
- {content}
-
+
+
+
);
}
@@ -198,8 +185,7 @@ function RawArtifactSource({
* Renders the artifact-first viewer branch after the shell has decoded a valid fragment.
*
* Keeps toolbar actions, artifact switching, metadata, edit-and-reshare, and heavy renderer
- * wrappers out of the empty-state shell chunk while preserving the same viewer behavior for
- * decoded payloads.
+ * wrappers out of the empty-state shell chunk while preserving decoded artifact behavior.
*/
export function ArtifactStage({
activeArtifact,
@@ -210,7 +196,6 @@ export function ArtifactStage({
onPreviewHash,
onRendererReady,
rendererReadyKey,
- statusTone,
}: ArtifactStageProps) {
const [artifactCopyState, setArtifactCopyState] = useState<
"idle" | "copied" | "failed"
@@ -224,11 +209,7 @@ export function ArtifactStage({
const activeArtifactRef = useRef(activeArtifact);
const activeArtifactBody = useMemo(() => getArtifactBody(activeArtifact), [activeArtifact]);
const activeArtifactHeading = useMemo(() => getArtifactHeading(activeArtifact), [activeArtifact]);
- const activeArtifactSubtitle = useMemo(() => getArtifactSubtitle(activeArtifact), [activeArtifact]);
- const activeArtifactSupportingLabel = useMemo(
- () => getArtifactSupportingLabel(activeArtifact, activeArtifactHeading),
- [activeArtifact, activeArtifactHeading],
- );
+ const activeArtifactFilename = activeArtifact.filename?.trim() || null;
const artifactDetailRows = useMemo(
() => getArtifactDetailRows(activeArtifact, activeArtifactBody.length),
[activeArtifact, activeArtifactBody.length],
@@ -248,8 +229,10 @@ export function ArtifactStage({
activeArtifact.kind === "json" ? activeArtifact : null;
const hasRawToggle = Boolean(markdownArtifact || csvArtifact);
- activeArtifactRef.current = activeArtifact;
- activeArtifactBodyRef.current = activeArtifactBody;
+ useLayoutEffect(() => {
+ activeArtifactRef.current = activeArtifact;
+ activeArtifactBodyRef.current = activeArtifactBody;
+ }, [activeArtifact, activeArtifactBody]);
const markActiveRendererReady = useCallback(() => {
onRendererReady(rendererReadyKey);
@@ -428,23 +411,11 @@ export function ArtifactStage({
return (
-
+
-
- {statusTone.label}
-
-
- {activeArtifactSupportingLabel}
-
-
- {numberFormatter.format(fragmentLength)} chars
-
+ {activeArtifactFilename ? (
+
{activeArtifactFilename}
+ ) : null}
{isEditing ? (
@@ -462,7 +433,7 @@ export function ArtifactStage({
type="button"
className={cn(
"artifact-action",
- artifactCopyState === "copied" && "is-primary",
+ artifactCopyState === "copied" && "is-confirmed",
)}
onClick={handleArtifactCopy}
>
@@ -481,7 +452,7 @@ export function ArtifactStage({
type="button"
className={cn(
"artifact-action",
- markdownLinkCopyState === "copied" && "is-primary",
+ markdownLinkCopyState === "copied" && "is-confirmed",
)}
onClick={handleCopyMarkdownLink}
>
@@ -512,7 +483,7 @@ export function ArtifactStage({
type="button"
className={cn(
"artifact-action",
- viewMode === "rendered" && "is-primary",
+ viewMode === "rendered" && "is-depressed",
)}
onClick={() => setViewMode("rendered")}
>
@@ -523,7 +494,7 @@ export function ArtifactStage({
type="button"
className={cn(
"artifact-action",
- viewMode === "raw" && "is-primary",
+ viewMode === "raw" && "is-depressed",
)}
onClick={() => setViewMode("raw")}
>
@@ -542,7 +513,7 @@ export function ArtifactStage({
@@ -555,19 +526,15 @@ export function ArtifactStage({
{markdownLinkShareInfo?.discordViewerNotice ? (
{markdownLinkShareInfo.discordViewerNotice}
) : null}
{envelope.artifacts.length > 1 ? (
-
+
) : null}
-
-
-
- {isEditing ? `Edit ${activeArtifactSubtitle}` : activeArtifactSubtitle}
-
-
+
+
+
+ {artifactDetailRows.map((row) => (
+
+
{row.label}
+
{row.value}
+
+ ))}
+
+
+
+
+
+
{activeArtifactHeading}
-
+
{isEditing ? (
{markdownArtifact && viewMode === "raw" ? (
-
) : markdownArtifact ? (
) : codeArtifact ? (
-
+
) : diffArtifact ? (
-
+
) : csvArtifact && viewMode === "raw" ? (
-
) : csvArtifact ? (
-
+
) : jsonArtifact ? (
-
+
) : (
{getPreviewText(activeArtifactBody)}
)}
@@ -642,32 +644,12 @@ export function ArtifactStage({
-
-
- {artifactDetailRows.map((row) => (
-
-
{row.label}
-
{row.value}
-
- ))}
-
-
+
diff --git a/src/components/viewer/fragment-details-disclosure.tsx b/src/components/viewer/fragment-details-disclosure.tsx
index 10b823d..0e6a844 100644
--- a/src/components/viewer/fragment-details-disclosure.tsx
+++ b/src/components/viewer/fragment-details-disclosure.tsx
@@ -1,6 +1,4 @@
type FragmentDetailsDisclosureProps = {
- statusLabel: string;
- statusMessage: string;
fragmentLength: string;
maxLength: string;
codec: string;
@@ -9,50 +7,38 @@ type FragmentDetailsDisclosureProps = {
/**
* Shows protocol diagnostics for the current fragment payload in a collapsible viewer panel.
- * Receives status, codec, length budget, and hash preview props from the shell-level decode state.
- * Stays read-only and provides quick visibility into transport/fallback conditions.
+ * Receives codec, length budget, and hash preview props from the shell-level decode state.
+ * Stays read-only and provides quick visibility into transport details.
*/
export function FragmentDetailsDisclosure({
- statusLabel,
- statusMessage,
fragmentLength,
maxLength,
codec,
hashPreview,
}: FragmentDetailsDisclosureProps) {
return (
-
+
-
- Fragment details
-
- Codec, budget, and hash preview
-
-
+ Fragment details
-
{statusMessage}
-
-
-
Status
-
{statusLabel}
+
+
+
Budget
+ {fragmentLength} / {maxLength}
-
-
Budget
-
{fragmentLength} / {maxLength}
+
+
Codec
+ {codec}
-
-
Codec
-
{codec}
+
+
Transport
+ Fragment only
-
-
Transport
-
Fragment only
-
-
+
-
Hash preview
-
+ Hash
+
{hashPreview}
diff --git a/src/lib/code/language.ts b/src/lib/code/language.ts
index f894bd9..b089925 100644
--- a/src/lib/code/language.ts
+++ b/src/lib/code/language.ts
@@ -1,19 +1,33 @@
-import type { Extension } from "@codemirror/state";
+import type { SupportedLanguages } from "@pierre/diffs";
+import { bundledLanguages } from "shiki";
-const languageSupportCache = new Map
>();
-
-function getLanguageSupportCacheKey(language: string): string {
- switch (language) {
- case "javascript":
- return "js";
- case "py":
- return "python";
- case "yml":
- return "yaml";
- default:
- return language;
- }
-}
+/**
+ * Options for the language pickers in the link creator and artifact editor.
+ * `auto` stores an empty language hint so detection falls back to the filename.
+ * Values are `detectCodeLanguage` keys; `toPierreLanguage` resolves them to
+ * Shiki grammars and degrades unknown ones to `text`.
+ */
+export const CODE_LANGUAGE_CHOICES: readonly { value: string; label: string }[] = [
+ { value: "", label: "auto" },
+ { value: "tsx", label: "tsx" },
+ { value: "ts", label: "ts" },
+ { value: "jsx", label: "jsx" },
+ { value: "js", label: "js" },
+ { value: "python", label: "python" },
+ { value: "json", label: "json" },
+ { value: "html", label: "html" },
+ { value: "css", label: "css" },
+ { value: "markdown", label: "markdown" },
+ { value: "yaml", label: "yaml" },
+ { value: "shell", label: "shell" },
+ { value: "rust", label: "rust" },
+ { value: "go", label: "go" },
+ { value: "java", label: "java" },
+ { value: "c", label: "c" },
+ { value: "cpp", label: "cpp" },
+ { value: "sql", label: "sql" },
+ { value: "text", label: "text" },
+];
/**
* Determines the code language token used by the viewer.
@@ -51,80 +65,17 @@ export function detectCodeLanguage(filename?: string, explicit?: string) {
}
/**
- * Lazily loads CodeMirror language support for a normalized language key.
- *
- * Returns an extension for supported languages and aliases (for example `js`/`javascript`,
- * `python`/`py`, `yaml`/`yml`) using dynamic imports to keep base bundles small.
- *
- * @param language - Normalized language token from detection or artifact metadata.
- * @returns A CodeMirror extension for supported languages, or `null` when unsupported.
- *
- * Failure/fallback: unsupported language keys return `null` so callers can render plain text.
+ * Maps a `detectCodeLanguage` key to the Shiki grammar id Pierre's `File`/`CodeView`
+ * surfaces understand. `bundledLanguages` carries Shiki's alias keys too, so `ts`, `js`,
+ * `py`, `yml`, `shell`, and friends resolve. Anything outside the registry (for example a
+ * codec name like `plain` leaking into `language`) falls back to `text`, because Pierre's
+ * `resolveLanguage` throws on unknown ids instead of degrading.
*/
-async function loadLanguageSupportUncached(language: string): Promise {
- switch (language) {
- case "tsx": {
- const { javascript } = await import("@codemirror/lang-javascript");
- return javascript({ jsx: true, typescript: true });
- }
- case "ts": {
- const { javascript } = await import("@codemirror/lang-javascript");
- return javascript({ typescript: true });
- }
- case "jsx": {
- const { javascript } = await import("@codemirror/lang-javascript");
- return javascript({ jsx: true });
- }
- case "js": {
- const { javascript } = await import("@codemirror/lang-javascript");
- return javascript();
- }
- case "json": {
- const { json } = await import("@codemirror/lang-json");
- return json();
- }
- case "css": {
- const { css } = await import("@codemirror/lang-css");
- return css();
- }
- case "html": {
- const { html } = await import("@codemirror/lang-html");
- return html();
- }
- case "python": {
- const { python } = await import("@codemirror/lang-python");
- return python();
- }
- case "markdown": {
- const { markdown } = await import("@codemirror/lang-markdown");
- return markdown();
- }
- case "yaml": {
- const { yaml } = await import("@codemirror/lang-yaml");
- return yaml();
- }
- default:
- return null;
+export function toPierreLanguage(language: string): SupportedLanguages {
+ if (language === "text" || language === "ansi") {
+ return language;
}
-}
-
-/**
- * Lazily loads and caches CodeMirror language support for a normalized language key.
- *
- * Returns an extension for supported languages and aliases; unsupported language keys resolve
- * to `null` so callers can render plain text.
- */
-export async function loadLanguageSupport(language: string): Promise {
- const cacheKey = getLanguageSupportCacheKey(language);
- const cached = languageSupportCache.get(cacheKey);
- if (cached) {
- return cached;
- }
-
- const supportPromise = loadLanguageSupportUncached(cacheKey).catch((error) => {
- languageSupportCache.delete(cacheKey);
- throw error;
- });
- languageSupportCache.set(cacheKey, supportPromise);
- return supportPromise;
+ return Object.prototype.hasOwnProperty.call(bundledLanguages, language)
+ ? (language as SupportedLanguages)
+ : "text";
}
diff --git a/src/lib/content-key.ts b/src/lib/content-key.ts
new file mode 100644
index 0000000..69ddb51
--- /dev/null
+++ b/src/lib/content-key.ts
@@ -0,0 +1,18 @@
+/**
+ * Bounded FNV-1a identity key for a payload string. Renderer remount keys use
+ * this so artifact changes get a fresh lifecycle without React keys retaining
+ * full decoded contents.
+ */
+export function getContentKey(value: string | undefined): string {
+ if (value === undefined) {
+ return "u";
+ }
+
+ let hash = 2166136261;
+ for (let index = 0; index < value.length; index += 1) {
+ hash ^= value.charCodeAt(index);
+ hash = Math.imul(hash, 16777619);
+ }
+
+ return `${value.length}:${(hash >>> 0).toString(36)}`;
+}
diff --git a/src/lib/diff/git-patch.ts b/src/lib/diff/git-patch.ts
index 1675a5c..ccc24b4 100644
--- a/src/lib/diff/git-patch.ts
+++ b/src/lib/diff/git-patch.ts
@@ -1,196 +1,196 @@
-export type PatchFileStatus = "added" | "deleted" | "modified" | "renamed" | "copied" | "binary";
+import {
+ parsePatchFiles,
+ type FileDiffMetadata,
+} from "@pierre/diffs";
+import { getUniqueLabels } from "@/lib/unique-labels";
+
+export type PatchFileStatus =
+ | "added"
+ | "deleted"
+ | "modified"
+ | "renamed"
+ | "binary";
+/**
+ * One renderable file section in a patch, adapted from Pierre's
+ * `FileDiffMetadata` with the labels the shell UI needs.
+ */
export type ParsedPatchFile = {
id: string;
- patch: string;
+ meta: FileDiffMetadata | null;
oldPath: string | null;
newPath: string | null;
displayPath: string;
status: PatchFileStatus;
isBinary: boolean;
+ /** One-based line where the file's section starts in the normalized patch. */
+ startLine: number;
};
-const UNIFIED_HUNK_HEADER_RE = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@(?: .*)?$/;
-const DIFF_SECTION_HEADER_RE = /^diff --git .*$/gm;
-
-function stripDiffPrefix(filePath: string | null): string | null {
- if (!filePath || filePath === "/dev/null") {
- return null;
- }
-
- const stripped = filePath.replace(/^[ab]\//, "");
- // A path that reduces to empty (e.g. a bare "a/") is not a usable path; return null so the
- // `displayPath`/`id` fallback chain (newPath ?? oldPath ?? `file-N`) applies instead of
- // producing an empty label and a degenerate "-N" id.
- return stripped === "" ? null : stripped;
-}
-
-function normalizePatch(patch: string): string {
- return patch.replace(/\r\n/g, "\n").trim();
-}
-
-function getFirstLine(value: string): string {
- const newlineIndex = value.indexOf("\n");
- return newlineIndex === -1 ? value : value.slice(0, newlineIndex);
-}
-
-function scanLines(value: string, visitLine: (line: string) => void): void {
- let lineStart = 0;
-
- while (lineStart <= value.length) {
- const lineEnd = value.indexOf("\n", lineStart);
- if (lineEnd === -1) {
- visitLine(value.slice(lineStart));
- return;
+// Pierre's parser routes leading text (commit messages, preambles) into
+// `patchMetadata`; a traditional `---`/`+++`/`@@` file diff stranded there is
+// still a real file, so it gets reparsed recursively.
+const TRADITIONAL_FILE_RE = /^--- \S[^\n]*\n\+\+\+ \S/m;
+
+// Binary sections carry no hunks, so Pierre cannot distinguish them from a
+// mode-only change; the marker lines are the only reliable signal.
+const BINARY_MARKER_RE = /^(?:GIT binary patch|Binary files .+ and .+ differ)$/m;
+const SECTION_START_RE = /^diff --git /;
+const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
+
+function collectFileDiffs(patch: string, into: FileDiffMetadata[]): void {
+ for (const parsed of parsePatchFiles(patch, undefined, true)) {
+ const metadata = parsed.patchMetadata;
+ if (metadata && TRADITIONAL_FILE_RE.test(metadata)) {
+ collectFileDiffs(metadata, into);
}
-
- visitLine(value.slice(lineStart, lineEnd));
- lineStart = lineEnd + 1;
+ into.push(...parsed.files);
}
}
-function parsePatchSections(patch: string): ParsedPatchFile[] {
- const normalized = normalizePatch(patch);
- if (!normalized) {
- return [];
- }
-
- DIFF_SECTION_HEADER_RE.lastIndex = 0;
-
- const files: ParsedPatchFile[] = [];
- let previousStart = -1;
- let sectionIndex = 0;
- let match: RegExpExecArray | null;
- while ((match = DIFF_SECTION_HEADER_RE.exec(normalized)) !== null) {
- if (previousStart !== -1) {
- files.push(parsePatchSection(normalized.slice(previousStart, match.index).trim(), sectionIndex));
- sectionIndex += 1;
- } else if (match.index > 0) {
- const preamble = normalized.slice(0, match.index).trim();
- if (preamble) {
- files.push(parsePatchSection(preamble, sectionIndex));
- sectionIndex += 1;
+// Maps file index to the raw line range of its section, used for editor
+// scroll targets and binary-marker detection. A `---`/`+++`/`@@` triple is a
+// traditional file header, but only outside a hunk body: inside one, a removed
+// `-- x` line reads `--- x` and an added `++ y` line reads `+++ y`, so the
+// scanner tracks the declared hunk counts before trusting a triple.
+function findSectionRanges(lines: string[]): { start: number; end: number }[] {
+ const starts: number[] = [];
+ // A git section's own `--- a/`/`+++ b/` pair is that file's header, consumed
+ // once via gitHeaderSeen; any later triple is a standalone traditional file
+ // following the git content.
+ let insideGitSection = false;
+ let gitHeaderSeen = false;
+ let hunkOld = 0;
+ let hunkNew = 0;
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index] ?? "";
+ if (SECTION_START_RE.test(line)) {
+ insideGitSection = true;
+ gitHeaderSeen = false;
+ hunkOld = 0;
+ hunkNew = 0;
+ starts.push(index);
+ continue;
+ }
+ const hunk = HUNK_HEADER_RE.exec(line);
+ if (hunk) {
+ hunkOld = hunk[2] ? Number(hunk[2]) : 1;
+ hunkNew = hunk[4] ? Number(hunk[4]) : 1;
+ continue;
+ }
+ if (hunkOld > 0 || hunkNew > 0) {
+ if (line.startsWith("-")) {
+ hunkOld -= 1;
+ } else if (line.startsWith("+")) {
+ hunkNew -= 1;
+ } else if (line.startsWith(" ") || line === "") {
+ hunkOld -= 1;
+ hunkNew -= 1;
}
+ continue;
+ }
+ const isTraditionalHeader =
+ line.startsWith("--- ") &&
+ (lines[index + 1] ?? "").startsWith("+++ ") &&
+ HUNK_HEADER_RE.test(lines[index + 2] ?? "");
+ if (!isTraditionalHeader) {
+ continue;
+ }
+ if (insideGitSection && !gitHeaderSeen && /^--- (?:a\/|"a\/|\/dev\/null)/.test(line)) {
+ gitHeaderSeen = true;
+ } else {
+ insideGitSection = false;
+ starts.push(index);
}
- previousStart = match.index;
}
+ return starts.map((start, index) => ({
+ start,
+ end: starts[index + 1] ?? lines.length,
+ }));
+}
- if (previousStart === -1) {
- return [parsePatchSection(normalized, 0)];
+// Pierre strips the `a/`/`b/` prefix on `diff --git` paths but keeps it on
+// traditional `---`/`+++` names, so only traditional names need the strip.
+function normalizePatchPath(path: string | undefined, isTraditional: boolean): string | null {
+ if (!path || path === "/dev/null") {
+ return null;
}
-
- files.push(parsePatchSection(normalized.slice(previousStart).trim(), sectionIndex));
- return files;
+ return isTraditional ? path.replace(/^[ab]\//, "") : path;
}
-function parsePatchSection(section: string, index: number): ParsedPatchFile {
- let oldPath: string | null = null;
- let newPath: string | null = null;
- let renameFrom: string | null = null;
- let renameTo: string | null = null;
- let status: PatchFileStatus = "modified";
- let isBinary = false;
-
- const headerMatch = /^diff --git a\/(.+) b\/(.+)$/.exec(getFirstLine(section));
- if (headerMatch) {
- oldPath = headerMatch[1] ?? null;
- newPath = headerMatch[2] ?? null;
+function getPatchFileStatus(meta: FileDiffMetadata): PatchFileStatus {
+ switch (meta.type) {
+ case "new":
+ return "added";
+ case "deleted":
+ return "deleted";
+ case "rename-pure":
+ case "rename-changed":
+ return "renamed";
+ default:
+ return "modified";
}
+}
- scanLines(section, (line) => {
- if (line.startsWith("new file mode ")) {
- status = "added";
- return;
- }
-
- if (line.startsWith("deleted file mode ")) {
- status = "deleted";
- return;
- }
-
- if (line.startsWith("rename from ")) {
- renameFrom = line.slice("rename from ".length).trim();
- status = "renamed";
- return;
- }
-
- if (line.startsWith("rename to ")) {
- renameTo = line.slice("rename to ".length).trim();
- status = "renamed";
- return;
- }
-
- if (line.startsWith("copy from ")) {
- oldPath = line.slice("copy from ".length).trim();
- status = "copied";
- return;
- }
-
- if (line.startsWith("copy to ")) {
- newPath = line.slice("copy to ".length).trim();
- status = "copied";
- return;
- }
-
- if (line.startsWith("--- ")) {
- oldPath = stripDiffPrefix(line.slice(4).trim()) ?? oldPath;
- return;
- }
-
- if (line.startsWith("+++ ")) {
- newPath = stripDiffPrefix(line.slice(4).trim()) ?? newPath;
- return;
- }
-
- if (line.startsWith("@@") && !UNIFIED_HUNK_HEADER_RE.test(line)) {
- throw new Error(`Invalid hunk header: ${line}`);
- }
-
- if (line.startsWith("Binary files ") || line === "GIT binary patch") {
- if (line.startsWith("Binary files ")) {
- const binaryMatch = /^Binary files (.+) and (.+) differ$/.exec(line);
- if (binaryMatch) {
- oldPath = stripDiffPrefix(binaryMatch[1]?.trim() ?? null);
- newPath = stripDiffPrefix(binaryMatch[2]?.trim() ?? null);
- }
- }
- isBinary = true;
- status = "binary";
- }
+/**
+ * Parses a unified patch into the file sections the viewer renders. Throws on
+ * malformed hunk bodies so callers can fall back to the raw view.
+ */
+export function parseRenderablePatchFiles(patch: string): ParsedPatchFile[] {
+ const normalized = patch.replace(/\r\n/g, "\n");
+ const lines = normalized.split("\n");
+ const metas: FileDiffMetadata[] = [];
+ collectFileDiffs(normalized, metas);
+ const ranges = findSectionRanges(lines);
+
+ const files = metas.map((meta, index) => {
+ const range = index < ranges.length ? ranges[index] : undefined;
+ const isTraditional = range
+ ? !SECTION_START_RE.test(lines[range.start] ?? "")
+ : false;
+ const oldPath = normalizePatchPath(meta.prevName, isTraditional);
+ const newPath = normalizePatchPath(meta.name, isTraditional);
+ const isBinary = range
+ ? lines.slice(range.start, range.end).some((line) => BINARY_MARKER_RE.test(line))
+ : false;
+
+ return {
+ id: `${newPath ?? oldPath ?? `file-${index + 1}`}-${index}`,
+ meta,
+ oldPath,
+ newPath,
+ displayPath: newPath ?? oldPath ?? `file-${index + 1}`,
+ status: isBinary ? "binary" : getPatchFileStatus(meta),
+ isBinary,
+ startLine: range ? range.start + 1 : 1,
+ };
});
- oldPath = stripDiffPrefix(renameFrom ?? oldPath);
- newPath = stripDiffPrefix(renameTo ?? newPath);
-
- const displayPath = newPath ?? oldPath ?? `file-${index + 1}`;
+ if (files.length === 0 && BINARY_MARKER_RE.test(normalized)) {
+ const markerLine = lines.findIndex((line) => BINARY_MARKER_RE.test(line));
+ files.push({
+ id: "binary-0",
+ meta: null,
+ oldPath: null,
+ newPath: null,
+ displayPath: "binary patch",
+ status: "binary",
+ isBinary: true,
+ startLine: markerLine + 1,
+ });
+ }
- return {
- id: `${displayPath}-${index}`,
- patch: `${section.trimEnd()}\n`,
- oldPath,
- newPath,
- displayPath,
- status,
- isBinary,
- };
+ return files;
}
/**
- * Parses a git patch bundle into per-file patch entries.
- *
- * Expects a unified git patch string and splits multi-file input on each `diff --git` header;
- * if no such headers are present, the full input is treated as a single section.
- * Detects rename/copy metadata and binary markers (`Binary files ... differ` / `GIT binary patch`),
- * normalizes paths by removing `a/` and `b/` prefixes, and sets `status`/`isBinary` accordingly.
- * Output IDs are deterministic `${displayPath}-${index}` values so multiple sections with the same
- * path remain distinct.
- *
- * @param patch - Unified git patch text that may include one or many file sections.
- * @returns Parsed file-level patch records ready for diff rendering.
- *
- * Failure/fallback: empty or whitespace-only input returns an empty array; malformed hunk
- * headers throw so callers can stay on the lightweight raw fallback path.
+ * Builds unique display labels for patch files; repeated paths get ` (n)`
+ * suffixes so tree rows and section anchors stay distinct.
*/
-export function parseGitPatchBundle(patch: string): ParsedPatchFile[] {
- return parsePatchSections(patch);
+export function getPatchFileLabels(
+ files: readonly ParsedPatchFile[],
+): Map {
+ return getUniqueLabels(
+ files.map((file) => ({ id: file.id, base: file.displayPath })),
+ );
}
diff --git a/src/lib/diff/pierre-edit.ts b/src/lib/diff/pierre-edit.ts
new file mode 100644
index 0000000..96a926f
--- /dev/null
+++ b/src/lib/diff/pierre-edit.ts
@@ -0,0 +1,15 @@
+"use client";
+
+// Registers the shared "agent-render" Shiki theme for every Pierre surface.
+import "./pierre-theme";
+
+// Same import-seam reasoning as pierre-react.ts, but for the editing surface:
+// CodeView/EditProvider plus the Editor runtime stay in the deferred
+// artifact-body-editor chunk so the diff viewer never pays for edit machinery.
+export {
+ CodeView,
+ EditProvider,
+ type CodeViewHandle,
+ type CodeViewItem,
+} from "@pierre/diffs/react";
+export { Editor } from "@pierre/diffs/edit";
diff --git a/src/lib/diff/pierre-react.ts b/src/lib/diff/pierre-react.ts
new file mode 100644
index 0000000..38739bc
--- /dev/null
+++ b/src/lib/diff/pierre-react.ts
@@ -0,0 +1,23 @@
+"use client";
+
+// Registers the shared "agent-render" Shiki theme for every Pierre surface.
+import "./pierre-theme";
+
+/**
+ * Re-exports pierre react primitives so the deferred diff-renderer chunk stays
+ * stable across Next webpack dev/prod graphs (a direct deep import from the
+ * dynamic chunk produced "__webpack_modules__[moduleId] is not a function"
+ * chunk-id drift in a prior attempt). Also the single seam unit tests mock.
+ */
+export {
+ FileDiff,
+ MultiFileDiff,
+ File,
+ type FileDiffProps,
+ type FileOptions,
+} from "@pierre/diffs/react";
+export {
+ parsePatchFiles,
+ setLanguageOverride,
+ type FileDiffMetadata,
+} from "@pierre/diffs";
diff --git a/src/lib/diff/pierre-theme.ts b/src/lib/diff/pierre-theme.ts
new file mode 100644
index 0000000..a30dfa4
--- /dev/null
+++ b/src/lib/diff/pierre-theme.ts
@@ -0,0 +1,24 @@
+"use client";
+
+import { registerCustomCSSVariableTheme } from "@pierre/diffs";
+
+// Shiki theme driven by --diffs-token-* custom properties, which inherit
+// through the shadow boundary from .diff-renderer-frame (globals.css) and tie
+// diff syntax to the app's own rainbow palette instead of a foreign theme.
+// The values here are baked-in fallbacks for contexts without the app CSS.
+registerCustomCSSVariableTheme("agent-render", {
+ foreground: "#e6dfcf",
+ background: "#1c1915",
+ "token-keyword": "#f08d5e",
+ "token-function": "#9eb3ff",
+ "token-string": "#80c193",
+ "token-string-expression": "#80c193",
+ "token-constant": "#efb360",
+ "token-parameter": "#69d1dd",
+ "token-link": "#69d1dd",
+ "token-comment": "#8b8271",
+ "token-punctuation": "#b0a794",
+ "token-inserted": "#9ccfae",
+ "token-deleted": "#d96a5c",
+ "token-changed": "#efb360",
+});
diff --git a/src/lib/payload/examples.ts b/src/lib/payload/examples.ts
index 47e9f40..a097163 100644
--- a/src/lib/payload/examples.ts
+++ b/src/lib/payload/examples.ts
@@ -102,7 +102,7 @@ export const sampleEnvelopes: PayloadEnvelope[] = [
title: "v1 to v3 migration",
filename: "fragment.ts",
patch:
- "diff --git a/src/lib/payload/fragment.ts b/src/lib/payload/fragment.ts\nindex aaa1111..bbb2222 100644\n--- a/src/lib/payload/fragment.ts\n+++ b/src/lib/payload/fragment.ts\n@@ -1,18 +1,42 @@\n-import { deflateSync, inflateSync } from \"fflate\";\n-import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from \"lz-string\";\n+import { deflateSync, inflateSync } from \"fflate\";\n+import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from \"lz-string\";\n+import { arxCompress, arxDecompress } from \"./arx-codec\";\n+import { loadArxDictionary } from \"./arx-dictionary\";\n \n-const CODEC_PRIORITY = [\"deflate\", \"lz\", \"plain\"] as const;\n+const SYNC_CODECS = [\"deflate\", \"lz\", \"plain\"] as const;\n+const ASYNC_CODECS = [\"arx5\", \"arx2\", \"arx\", \"deflate\", \"lz\", \"plain\"] as const;\n \n export function encodeEnvelope(envelope: PayloadEnvelope): string {\n- const json = JSON.stringify(envelope);\n- const candidates = CODEC_PRIORITY.map((codec) => ({\n+ return encodeShortest(envelope, SYNC_CODECS);\n+}\n+\n+export async function encodeEnvelopeAsync(envelope: PayloadEnvelope): Promise {\n+ return encodeShortest(envelope, ASYNC_CODECS);\n+}\n+\n+function encodeShortest(envelope: PayloadEnvelope, codecs: readonly string[]): string {\n+ const json = JSON.stringify(envelope);\n+ const candidates = codecs.map((codec) => ({\n codec,\n- fragment: encodeWith(json, codec),\n+ fragment: encodeWith(json, codec),\n }));\n candidates.sort((a, b) => a.fragment.length - b.fragment.length);\n return candidates[0].fragment;\n }\n \n-export function decodeFragment(raw: string): PayloadEnvelope {\n+export async function decodeFragmentAsync(raw: string): Promise {\n+ const match = raw.match(/^agent-render=v1\\.(\\w+)\\.(.+)$/);\n+ if (!match) throw new Error(\"Invalid fragment format\");\n+ const [, codec, payload] = match;\n+\n+ if (codec.startsWith(\"arx\")) {\n+ const [dictVersion, ...rest] = payload.split(\".\");\n+ const dict = await loadArxDictionary(dictVersion);\n+ const json = await arxDecompress(rest.join(\".\"), dict);\n+ return JSON.parse(json);\n+ }\n+\n+ return decodeFragment(raw);\n+}\n+\n+export function decodeFragment(raw: string): PayloadEnvelope {\n const match = raw.match(/^agent-render=v1\\.(\\w+)\\.(.+)$/);\n if (!match) throw new Error(\"Invalid fragment format\");\n const [, codec, payload] = match;\n",
+ "diff --git a/src/lib/payload/fragment.ts b/src/lib/payload/fragment.ts\nindex aaa1111..bbb2222 100644\n--- a/src/lib/payload/fragment.ts\n+++ b/src/lib/payload/fragment.ts\n@@ -1,19 +1,45 @@\n-import { deflateSync, inflateSync } from \"fflate\";\n-import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from \"lz-string\";\n+import { deflateSync, inflateSync } from \"fflate\";\n+import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from \"lz-string\";\n+import { arxCompress, arxDecompress } from \"./arx-codec\";\n+import { loadArxDictionary } from \"./arx-dictionary\";\n \n-const CODEC_PRIORITY = [\"deflate\", \"lz\", \"plain\"] as const;\n+const SYNC_CODECS = [\"deflate\", \"lz\", \"plain\"] as const;\n+const ASYNC_CODECS = [\"arx5\", \"arx2\", \"arx\", \"deflate\", \"lz\", \"plain\"] as const;\n \n export function encodeEnvelope(envelope: PayloadEnvelope): string {\n- const json = JSON.stringify(envelope);\n- const candidates = CODEC_PRIORITY.map((codec) => ({\n+ return encodeShortest(envelope, SYNC_CODECS);\n+}\n+\n+export async function encodeEnvelopeAsync(envelope: PayloadEnvelope): Promise {\n+ return encodeShortest(envelope, ASYNC_CODECS);\n+}\n+\n+function encodeShortest(envelope: PayloadEnvelope, codecs: readonly string[]): string {\n+ const json = JSON.stringify(envelope);\n+ const candidates = codecs.map((codec) => ({\n codec,\n- fragment: encodeWith(json, codec),\n+ fragment: encodeWith(json, codec),\n }));\n candidates.sort((a, b) => a.fragment.length - b.fragment.length);\n return candidates[0].fragment;\n }\n \n-export function decodeFragment(raw: string): PayloadEnvelope {\n+export async function decodeFragmentAsync(raw: string): Promise {\n+ const match = raw.match(/^agent-render=v1\\.(\\w+)\\.(.+)$/);\n+ if (!match) throw new Error(\"Invalid fragment format\");\n+ const [, codec, payload] = match;\n+\n+ if (codec.startsWith(\"arx\")) {\n+ const [dictVersion, ...rest] = payload.split(\".\");\n+ const dict = await loadArxDictionary(dictVersion);\n+ const json = await arxDecompress(rest.join(\".\"), dict);\n+ return JSON.parse(json);\n+ }\n+\n+ return decodeFragment(raw);\n+}\n+\n+export function decodeFragment(raw: string): PayloadEnvelope {\n const match = raw.match(/^agent-render=v1\\.(\\w+)\\.(.+)$/);\n if (!match) throw new Error(\"Invalid fragment format\");\n const [, codec, payload] = match;\n",
view: "unified",
},
{
diff --git a/src/lib/unique-labels.ts b/src/lib/unique-labels.ts
new file mode 100644
index 0000000..6c6acc0
--- /dev/null
+++ b/src/lib/unique-labels.ts
@@ -0,0 +1,25 @@
+/**
+ * Builds unique labels for a list of entries that can share the same base text.
+ * Collisions gain a ` (n)` suffix, and `reserved` labels cannot be claimed.
+ */
+export function getUniqueLabels(
+ entries: readonly { id: string; base: string }[],
+ reserved?: ReadonlySet,
+): Map {
+ const used = new Set(reserved ?? []);
+ const labels = new Map();
+
+ for (const entry of entries) {
+ const base = entry.base.trim() || "untitled";
+ let label = base;
+ let suffix = 2;
+ while (used.has(label)) {
+ label = `${base} (${suffix})`;
+ suffix += 1;
+ }
+ used.add(label);
+ labels.set(entry.id, label);
+ }
+
+ return labels;
+}
diff --git a/tests/build-budgets.test.ts b/tests/build-budgets.test.ts
index d272d94..89001ad 100644
--- a/tests/build-budgets.test.ts
+++ b/tests/build-budgets.test.ts
@@ -13,9 +13,11 @@ describe("build budget policy", () => {
expect(table).toEqual([
{ name: "homepage route JS", maxBytes: 115 * 1024 },
- { name: "code renderer deferred JS", maxBytes: 100 * 1024 },
+ { name: "code renderer deferred JS", maxBytes: 190 * 1024 },
{ name: "markdown renderer deferred JS", maxBytes: 52 * 1024 },
- { name: "rich diff library deferred JS", maxBytes: 340 * 1024 },
+ { name: "rich diff library deferred JS", maxBytes: 195 * 1024 },
+ { name: "patch file tree deferred JS", maxBytes: 80 * 1024 },
+ { name: "artifact body editor deferred JS", maxBytes: 240 * 1024 },
]);
});
});
diff --git a/tests/code-language.test.ts b/tests/code-language.test.ts
index 9a64190..8a72878 100644
--- a/tests/code-language.test.ts
+++ b/tests/code-language.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { detectCodeLanguage, loadLanguageSupport } from "@/lib/code/language";
+import { detectCodeLanguage, toPierreLanguage } from "@/lib/code/language";
describe("code language detection", () => {
it("prefers explicit language hints", () => {
@@ -12,14 +12,17 @@ describe("code language detection", () => {
expect(detectCodeLanguage("README.md")).toBe("markdown");
});
- it("returns null support for unknown languages", async () => {
- await expect(loadLanguageSupport("unknown-language")).resolves.toBeNull();
+ it("passes through languages and aliases Shiki resolves", () => {
+ expect(toPierreLanguage("tsx")).toBe("tsx");
+ expect(toPierreLanguage("py")).toBe("py");
+ expect(toPierreLanguage("shell")).toBe("shell");
+ expect(toPierreLanguage("text")).toBe("text");
});
- it("shares cached support across language aliases", async () => {
- const pySupport = await loadLanguageSupport("py");
- const pythonSupport = await loadLanguageSupport("python");
-
- expect(pySupport).toBe(pythonSupport);
+ it("falls back to text for languages Pierre cannot resolve", () => {
+ // `resolveLanguage` throws on unknown ids; `plain` is a codec name that can
+ // reach `language` through metadata, not a Shiki grammar.
+ expect(toPierreLanguage("plain")).toBe("text");
+ expect(toPierreLanguage("not-a-real-grammar")).toBe("text");
});
});
diff --git a/tests/components/artifact-editor.test.tsx b/tests/components/artifact-editor.test.tsx
index aa70b48..2de46e4 100644
--- a/tests/components/artifact-editor.test.tsx
+++ b/tests/components/artifact-editor.test.tsx
@@ -1,10 +1,10 @@
-import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ArtifactEditor } from "@/components/viewer/artifact-editor";
import { buildMarkdownLinkShareInfo } from "@/lib/markdown-link";
import type { GeneratedArtifactLink } from "@/lib/payload/link-creator";
-import type { MarkdownArtifact, PayloadEnvelope } from "@/lib/payload/schema";
+import type { DiffArtifact, MarkdownArtifact, PayloadEnvelope } from "@/lib/payload/schema";
const generationMock = vi.hoisted(() => ({
createGeneratedEnvelopeLinkAsync: vi.fn(),
@@ -22,6 +22,74 @@ vi.mock("@/lib/payload/link-creator", async () => {
};
});
+vi.mock("@/components/file-tree-nav", () => ({
+ FileTreeNav: ({
+ paths,
+ onSelectPath,
+ }: {
+ paths: readonly string[];
+ onSelectPath: (path: string) => void;
+ }) => (
+
+ {paths.map((path) => (
+ onSelectPath(path)}>
+ {path}
+
+ ))}
+
+ ),
+}));
+
+const bodyEditorMock = vi.hoisted(() => ({
+ scrollTo: vi.fn(),
+ setSelections: vi.fn(),
+ focus: vi.fn(),
+ getItem: vi.fn(),
+ updateItem: vi.fn(),
+}));
+
+vi.mock("@/components/viewer/artifact-body-editor", () => ({
+ ArtifactBodyEditor: ({
+ documents,
+ onDocumentChange,
+ codeViewRef,
+ }: {
+ documents: readonly { id: string; name: string; contents: string }[];
+ onDocumentChange: (id: string, contents: string) => void;
+ codeViewRef?: { current: unknown };
+ }) => {
+ if (codeViewRef) {
+ codeViewRef.current = {
+ scrollTo: bodyEditorMock.scrollTo,
+ getItem: bodyEditorMock.getItem,
+ updateItem: bodyEditorMock.updateItem,
+ getEditor: () => ({
+ setSelections: bodyEditorMock.setSelections,
+ focus: bodyEditorMock.focus,
+ }),
+ };
+ }
+ return (
+
+ {documents.map((doc) => (
+ onDocumentChange(doc.id, event.target.value)}
+ />
+ ))}
+
+ );
+ },
+}));
+
const markdownArtifact: MarkdownArtifact = {
id: "notes",
kind: "markdown",
@@ -65,6 +133,11 @@ function createGeneratedLink(content: string): GeneratedArtifactLink {
afterEach(() => {
cleanup();
generationMock.createGeneratedEnvelopeLinkAsync.mockReset();
+ bodyEditorMock.scrollTo.mockReset();
+ bodyEditorMock.setSelections.mockReset();
+ bodyEditorMock.focus.mockReset();
+ bodyEditorMock.getItem.mockReset();
+ bodyEditorMock.updateItem.mockReset();
vi.restoreAllMocks();
});
@@ -84,7 +157,7 @@ describe("ArtifactEditor", () => {
/>,
);
- const content = screen.getByTestId("artifact-editor-content");
+ const content = await screen.findByTestId("artifact-editor-content");
expect(content).toHaveValue("# Hello\n\nOriginal notes.");
await user.clear(content);
@@ -98,6 +171,48 @@ describe("ArtifactEditor", () => {
expect(onPreviewHash).toHaveBeenCalledWith("#pcorrected");
});
+ it("updates the mounted Pierre item when the filename changes without restoring stale content", async () => {
+ const user = userEvent.setup();
+ bodyEditorMock.getItem.mockReturnValue({
+ id: "content",
+ type: "file",
+ version: 4,
+ file: {
+ name: "notes.md",
+ contents: "# Hello\n\nOriginal notes.",
+ cacheKey: "content",
+ },
+ edit: true,
+ });
+
+ render(
+ ,
+ );
+
+ const content = await screen.findByTestId("artifact-editor-content");
+ await user.clear(content);
+ await user.type(content, "# Current editor text");
+ await user.clear(screen.getByRole("textbox", { name: "Filename" }));
+ await user.type(screen.getByRole("textbox", { name: "Filename" }), "renamed.md");
+
+ await waitFor(() =>
+ expect(bodyEditorMock.updateItem).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ id: "content",
+ version: 5,
+ file: expect.objectContaining({
+ name: "renamed.md",
+ contents: "# Current editor text",
+ }),
+ }),
+ ),
+ );
+ });
+
it("disables reshare actions after the draft changes", async () => {
const user = userEvent.setup();
const onPreviewHash = vi.fn();
@@ -145,4 +260,191 @@ describe("ArtifactEditor", () => {
expect(await screen.findByRole("alert")).toHaveTextContent(/8,192 character limit/i);
expect(screen.queryByTestId("artifact-editor-result")).not.toBeInTheDocument();
});
+
+ const multiFilePatch = `diff --git a/src/hello.ts b/src/hello.ts
+index 1111111..2222222 100644
+--- a/src/hello.ts
++++ b/src/hello.ts
+@@ -1 +1 @@
+-export const hello = "old";
++export const hello = "new";
+diff --git a/src/second.ts b/src/second.ts
+index 3333333..4444444 100644
+--- a/src/second.ts
++++ b/src/second.ts
+@@ -1 +1 @@
+-export const second = "old";
++export const second = "new";
+`;
+
+ const diffArtifact: DiffArtifact = {
+ id: "diff-artifact",
+ kind: "diff",
+ title: "release.patch",
+ filename: "release.patch",
+ patch: multiFilePatch,
+ };
+
+ const diffEnvelope: PayloadEnvelope = {
+ v: 1,
+ codec: "plain",
+ title: "release.patch",
+ activeArtifactId: "diff-artifact",
+ artifacts: [diffArtifact],
+ };
+
+ it("hides the tree rail for a single-file single-artifact patch", async () => {
+ const singleFileArtifact: DiffArtifact = {
+ ...diffArtifact,
+ patch: multiFilePatch.slice(0, multiFilePatch.indexOf("diff --git a/src/second.ts")),
+ };
+ const singleFileEnvelope: PayloadEnvelope = {
+ ...diffEnvelope,
+ artifacts: [singleFileArtifact],
+ };
+
+ render(
+ ,
+ );
+
+ await screen.findByTestId("mock-body-editor");
+ expect(screen.queryByTestId("mock-patch-file-tree")).not.toBeInTheDocument();
+ });
+
+ it("adds patch files to the tree and scrolls the editor on selection", async () => {
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ const tree = await screen.findByTestId("mock-patch-file-tree");
+ expect(tree).toHaveTextContent("release.patch");
+ expect(tree).toHaveTextContent("src/hello.ts");
+ expect(tree).toHaveTextContent("src/second.ts");
+
+ await user.click(screen.getByRole("button", { name: "src/second.ts" }));
+
+ const expectedLine = multiFilePatch
+ .slice(0, multiFilePatch.indexOf("diff --git a/src/second.ts"))
+ .split("\n").length;
+ expect(bodyEditorMock.scrollTo).toHaveBeenCalledWith({
+ type: "line",
+ id: "content",
+ lineNumber: expectedLine,
+ align: "center",
+ });
+ expect(bodyEditorMock.setSelections).toHaveBeenCalledWith([
+ {
+ start: { line: expectedLine - 1, character: 0 },
+ end: { line: expectedLine - 1, character: 0 },
+ direction: "none",
+ },
+ ]);
+ });
+
+ it("switches the edit target through the tree without losing drafts", async () => {
+ const user = userEvent.setup();
+ const bundleEnvelope: PayloadEnvelope = {
+ v: 1,
+ codec: "plain",
+ title: "Release bundle",
+ activeArtifactId: "notes",
+ artifacts: [markdownArtifact, diffArtifact],
+ };
+
+ render(
+ ,
+ );
+
+ const tree = await screen.findByTestId("mock-patch-file-tree");
+ expect(tree).toHaveTextContent("notes.md");
+ expect(tree).toHaveTextContent("release.patch");
+
+ const content = screen.getByTestId("artifact-editor-content");
+ await user.clear(content);
+ await user.type(content, "# Edited notes");
+
+ await user.click(screen.getByRole("button", { name: "release.patch" }));
+ expect(screen.getByTestId("artifact-editor-content")).toHaveValue(
+ multiFilePatch,
+ );
+ expect(screen.getByLabelText("Diff view")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "notes.md" }));
+ expect(screen.getByTestId("artifact-editor-content")).toHaveValue(
+ "# Edited notes",
+ );
+ });
+
+ it("discards a generation that finishes after the edit target changes", async () => {
+ const user = userEvent.setup();
+ const bundleEnvelope: PayloadEnvelope = {
+ v: 1,
+ codec: "plain",
+ activeArtifactId: "notes",
+ artifacts: [markdownArtifact, diffArtifact],
+ };
+ let resolveGeneration!: (link: GeneratedArtifactLink) => void;
+ generationMock.createGeneratedEnvelopeLinkAsync.mockReturnValue(
+ new Promise((resolve) => {
+ resolveGeneration = resolve;
+ }),
+ );
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Generate new link" }));
+ await waitFor(() => expect(generationMock.createGeneratedEnvelopeLinkAsync).toHaveBeenCalledTimes(1));
+ await user.click(screen.getByRole("button", { name: "release.patch" }));
+ await act(async () => {
+ resolveGeneration(createGeneratedLink("# stale"));
+ });
+
+ expect(screen.queryByTestId("artifact-editor-result")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Generate new link" })).toBeEnabled();
+ });
+
+ it("keeps the pair editor for old/new content diffs", () => {
+ const pairArtifact: DiffArtifact = {
+ ...diffArtifact,
+ patch: undefined,
+ oldContent: "old\n",
+ newContent: "new\n",
+ };
+ const pairEnvelope: PayloadEnvelope = {
+ ...diffEnvelope,
+ artifacts: [pairArtifact],
+ };
+
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("artifact-editor-old-content")).toBeInTheDocument();
+ expect(screen.getByTestId("artifact-editor-new-content")).toBeInTheDocument();
+ expect(screen.queryByTestId("artifact-editor-content")).not.toBeInTheDocument();
+ });
});
diff --git a/tests/components/artifact-stage-raw.test.tsx b/tests/components/artifact-stage-raw.test.tsx
index fe3604e..e28bfb7 100644
--- a/tests/components/artifact-stage-raw.test.tsx
+++ b/tests/components/artifact-stage-raw.test.tsx
@@ -7,6 +7,43 @@ import type {
PayloadEnvelope,
} from "@/lib/payload/schema";
+vi.mock("@/components/renderers/code-renderer", () => ({
+ CodeRenderer: ({
+ artifact,
+ }: {
+ artifact: { content: string; language?: string; filename?: string };
+ }) => (
+
+ {artifact.content}
+
+ ),
+}));
+
+vi.mock("@/components/viewer/artifact-body-editor", () => ({
+ ArtifactBodyEditor: ({
+ documents,
+ onDocumentChange,
+ }: {
+ documents: readonly { id: string; contents: string }[];
+ onDocumentChange: (id: string, contents: string) => void;
+ }) => (
+
+ {documents.map((doc) => (
+ onDocumentChange(doc.id, event.target.value)}
+ />
+ ))}
+
+ ),
+}));
+
vi.mock("next/dynamic", async () => {
const React = await vi.importActual("react");
@@ -35,12 +72,6 @@ vi.mock("next/dynamic", async () => {
};
});
-const statusTone = {
- color: "#000000",
- label: "Ready",
- message: "Decoded fragment.",
-};
-
function renderStage(activeArtifact: ArtifactPayload) {
const envelope: PayloadEnvelope = {
v: 1,
@@ -59,7 +90,6 @@ function renderStage(activeArtifact: ArtifactPayload) {
onPreviewHash={vi.fn()}
onRendererReady={vi.fn()}
rendererReadyKey="ready"
- statusTone={statusTone}
/>,
);
}
@@ -69,7 +99,7 @@ afterEach(() => {
});
describe("ArtifactStage raw view", () => {
- it("renders markdown raw mode as un-highlighted plain text without mounting CodeMirror", async () => {
+ it("renders markdown raw mode on the highlighted file surface with a markdown hint", async () => {
renderStage({
id: "markdown-artifact",
kind: "markdown",
@@ -79,13 +109,14 @@ describe("ArtifactStage raw view", () => {
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
- expect(screen.getByTestId("renderer-markdown-raw")).toHaveTextContent(
- "raw markdown body",
- );
- expect(document.querySelector(".cm-editor")).not.toBeInTheDocument();
+ const rawView = screen.getByTestId("renderer-markdown-raw");
+ expect(rawView).toHaveTextContent("raw markdown body");
+ const surface = await screen.findByTestId("renderer-code");
+ expect(rawView).toContainElement(surface);
+ expect(surface).toHaveAttribute("data-language", "markdown");
});
- it("renders csv raw mode as un-highlighted plain text without mounting CodeMirror", async () => {
+ it("renders csv raw mode on the highlighted file surface with a csv hint", async () => {
renderStage({
id: "csv-artifact",
kind: "csv",
@@ -95,10 +126,11 @@ describe("ArtifactStage raw view", () => {
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
- expect(screen.getByTestId("renderer-csv-raw")).toHaveTextContent(
- "name,status",
- );
- expect(document.querySelector(".cm-editor")).not.toBeInTheDocument();
+ const rawView = screen.getByTestId("renderer-csv-raw");
+ expect(rawView).toHaveTextContent("name,status");
+ const surface = await screen.findByTestId("renderer-code");
+ expect(rawView).toContainElement(surface);
+ expect(surface).toHaveAttribute("data-language", "csv");
});
it("opens the in-viewer editor with the current artifact body", async () => {
@@ -112,7 +144,7 @@ describe("ArtifactStage raw view", () => {
await userEvent.click(screen.getByRole("button", { name: "Edit" }));
expect(await screen.findByTestId("artifact-editor")).toBeInTheDocument();
- expect(screen.getByTestId("artifact-editor-content")).toHaveValue(
+ expect(await screen.findByTestId("artifact-editor-content")).toHaveValue(
"# Heading\n\nraw markdown body",
);
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
diff --git a/tests/components/code-renderer.test.tsx b/tests/components/code-renderer.test.tsx
index fff6c04..113ff5a 100644
--- a/tests/components/code-renderer.test.tsx
+++ b/tests/components/code-renderer.test.tsx
@@ -4,64 +4,41 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { CodeRenderer } from "@/components/renderers/code-renderer";
import type { CodeArtifact } from "@/lib/payload/schema";
-type MockEditorStateConfig = {
- doc?: string;
- extensions?: unknown[];
+type MockFileProps = {
+ file: { name: string; contents: string; lang?: string };
+ options?: {
+ overflow?: "scroll" | "wrap";
+ disableFileHeader?: boolean;
+ onPostRender?: (node: HTMLElement, instance: unknown, phase: string) => void;
+ };
};
const codeRendererMock = vi.hoisted(() => ({
- editorStates: [] as MockEditorStateConfig[],
- pendingLanguageLoads: [] as Array<{
- language: string;
- resolve: (extension: unknown) => void;
- }>,
- rainbowPlugin: { kind: "rainbow-brackets" },
-}));
-
-vi.mock("@codemirror/view", () => ({
- EditorView: class MockEditorView {
- static theme() {
- return {};
- }
- static lineWrapping = {};
- static editable = { of: () => ({}) };
- constructor({ state }: { state: MockEditorStateConfig }) {
- codeRendererMock.editorStates.push(state);
- }
- destroy() {}
- },
- highlightActiveLine: () => ({}),
- lineNumbers: () => ({}),
- ViewPlugin: { fromClass: () => codeRendererMock.rainbowPlugin },
- Decoration: { mark: () => ({}), none: { kind: "no-decorations" } },
+ renders: [] as MockFileProps[],
}));
-vi.mock("@codemirror/state", () => ({
- EditorState: {
- create: (config: MockEditorStateConfig) => config,
- readOnly: { of: () => ({}) },
- },
- RangeSetBuilder: class {},
-}));
-
-vi.mock("@codemirror/language", () => ({
- bracketMatching: () => ({}),
- defaultHighlightStyle: {},
- syntaxTree: () => ({ iterate: () => {} }),
- syntaxHighlighting: () => ({}),
-}));
+vi.mock("@/lib/diff/pierre-react", async () => {
+ const React = await vi.importActual("react");
-vi.mock("@replit/codemirror-indentation-markers", () => ({
- indentationMarkers: () => ({}),
-}));
+ return {
+ File: (props: MockFileProps) => {
+ codeRendererMock.renders.push(props);
+ const onPostRender = props.options?.onPostRender;
+ React.useEffect(() => {
+ onPostRender?.(document.createElement("div"), {}, "mount");
+ }, [onPostRender]);
+ return React.createElement(
+ "pre",
+ { "data-testid": "mock-pierre-file" },
+ props.file.contents,
+ );
+ },
+ };
+});
vi.mock("@/lib/code/language", () => ({
detectCodeLanguage: (_filename?: string, language?: string) => language || "text",
- loadLanguageSupport: vi.fn((language: string) => {
- return new Promise((resolve: (extension: unknown) => void) => {
- codeRendererMock.pendingLanguageLoads.push({ language, resolve });
- });
- }),
+ toPierreLanguage: (language: string) => language,
}));
/** Shared controllable matchMedia for tests that need resize / change events. */
@@ -118,6 +95,10 @@ function createArtifact(overrides: Partial = {}): CodeArtifact {
};
}
+function lastFileProps() {
+ return codeRendererMock.renders.at(-1);
+}
+
const originalMatchMedia = window.matchMedia;
afterAll(() => {
@@ -135,8 +116,7 @@ afterEach(() => {
configurable: true,
value: originalMatchMedia,
});
- codeRendererMock.editorStates.length = 0;
- codeRendererMock.pendingLanguageLoads.length = 0;
+ codeRendererMock.renders.length = 0;
vi.restoreAllMocks();
});
@@ -149,9 +129,10 @@ describe("CodeRenderer", () => {
await waitFor(() => {
expect(screen.getByRole("button", { name: /enable wrap/i })).toBeVisible();
});
+ expect(lastFileProps()?.options?.overflow).toBe("scroll");
});
- it("toggling wrap changes the button label", async () => {
+ it("toggling wrap changes the button label and Pierre overflow", async () => {
createControllableMatchMedia(false);
render( );
@@ -159,6 +140,9 @@ describe("CodeRenderer", () => {
await userEvent.click(btn);
expect(screen.getByRole("button", { name: /disable wrap/i })).toBeVisible();
+ await waitFor(() => {
+ expect(lastFileProps()?.options?.overflow).toBe("wrap");
+ });
});
it("enables wrap when the viewport crosses to narrow without a prior manual toggle", async () => {
@@ -183,6 +167,9 @@ describe("CodeRenderer", () => {
await waitFor(() => {
expect(screen.getByRole("button", { name: /disable wrap/i })).toBeVisible();
});
+ await waitFor(() => {
+ expect(lastFileProps()?.options?.overflow).toBe("wrap");
+ });
});
it("disables wrap when the viewport crosses to wide without a prior manual toggle", async () => {
@@ -218,75 +205,52 @@ describe("CodeRenderer", () => {
});
describe("compact mode", () => {
- it("does not render a toolbar in compact mode", () => {
+ it("hides the wrap toolbar and file header", async () => {
createControllableMatchMedia(false);
render( );
expect(screen.queryByRole("button", { name: /wrap/i })).not.toBeInTheDocument();
- });
- });
-
- describe("language loading", () => {
- it("does not rebuild the editor when only the ready callback changes", async () => {
- createControllableMatchMedia(false);
- const artifact = createArtifact({ language: "text" });
- const { rerender } = render( );
-
await waitFor(() => {
- expect(codeRendererMock.editorStates.length).toBeGreaterThan(0);
+ expect(lastFileProps()?.options?.disableFileHeader).toBe(true);
+ expect(lastFileProps()?.options?.overflow).toBe("scroll");
});
- const editorStateCount = codeRendererMock.editorStates.length;
-
- rerender( );
- await act(async () => {});
-
- expect(codeRendererMock.editorStates).toHaveLength(editorStateCount);
});
+ });
- it("does not reuse a previous language extension while the next language loads", async () => {
+ describe("pierre file surface", () => {
+ it("passes the artifact through to the File item and reports ready on mount", async () => {
createControllableMatchMedia(false);
- const tsExtension = { language: "ts" };
- const { rerender } = render( );
+ const onReady = vi.fn();
+ render( );
await waitFor(() => {
- expect(codeRendererMock.pendingLanguageLoads.map((load) => load.language)).toContain("ts");
+ expect(screen.getByTestId("renderer-code")).toHaveAttribute(
+ "data-renderer-ready",
+ "true",
+ );
});
- await act(async () => {
- codeRendererMock.pendingLanguageLoads[0].resolve(tsExtension);
+ expect(onReady).toHaveBeenCalled();
+ expect(lastFileProps()?.file).toMatchObject({
+ name: "hello.ts",
+ contents: 'export const hello = "world";',
+ lang: "text",
});
- await waitFor(() => {
- expect(codeRendererMock.editorStates.some((state) => state.extensions?.includes(tsExtension))).toBe(true);
- });
-
- codeRendererMock.editorStates.length = 0;
- rerender( );
-
- await waitFor(() => {
- expect(codeRendererMock.pendingLanguageLoads.map((load) => load.language)).toContain("json");
- expect(codeRendererMock.editorStates.length).toBeGreaterThan(0);
- });
- expect(codeRendererMock.editorStates.at(-1)?.extensions).not.toContain(tsExtension);
});
- });
- describe("rainbow bracket plugin", () => {
- it("skips the bracket decoration plugin when content has no bracket tokens", async () => {
+ it("does not report readiness again when only the callback identity changes", async () => {
createControllableMatchMedia(false);
- render( );
+ const artifact = createArtifact({ language: "text" });
+ const firstReady = vi.fn();
+ const secondReady = vi.fn();
+ const { rerender } = render( );
- await waitFor(() => {
- expect(codeRendererMock.editorStates.length).toBeGreaterThan(0);
- });
- expect(codeRendererMock.editorStates.at(-1)?.extensions).not.toContain(codeRendererMock.rainbowPlugin);
- });
+ await waitFor(() => expect(firstReady).toHaveBeenCalledTimes(1));
- it("keeps the bracket decoration plugin when content contains brackets", async () => {
- createControllableMatchMedia(false);
- render( );
+ rerender( );
+ await act(async () => {});
- await waitFor(() => {
- expect(codeRendererMock.editorStates.at(-1)?.extensions).toContain(codeRendererMock.rainbowPlugin);
- });
+ expect(secondReady).not.toHaveBeenCalled();
+ expect(firstReady).toHaveBeenCalledTimes(1);
});
});
});
diff --git a/tests/components/diff-renderer.test.tsx b/tests/components/diff-renderer.test.tsx
index 6ba3f19..24e1e35 100644
--- a/tests/components/diff-renderer.test.tsx
+++ b/tests/components/diff-renderer.test.tsx
@@ -1,17 +1,52 @@
import React from "react";
-import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
-import { DiffFile } from "@git-diff-view/react";
import { DiffRenderer } from "@/components/renderers/diff-renderer";
import type { DiffArtifact } from "@/lib/payload/schema";
-vi.mock("@git-diff-view/react", async () => {
- const actual = await vi.importActual("@git-diff-view/react");
+const fileDiffMock = vi.fn();
+const multiFileDiffMock = vi.fn();
+const fileTreeMock = vi.hoisted(() => ({
+ options: [] as Array<{
+ initialSelectedPaths?: readonly string[];
+ onSelectionChange?: (selectedPaths: readonly string[]) => void;
+ paths: readonly string[];
+ }>,
+}));
+
+vi.mock("@pierre/trees/react", () => ({
+ FileTree: ({ model }: { model: { paths: readonly string[] } }) => (
+ {model.paths.join("|")}
+ ),
+ useFileTree: (options: {
+ initialSelectedPaths?: readonly string[];
+ onSelectionChange?: (selectedPaths: readonly string[]) => void;
+ paths: readonly string[];
+ }) => {
+ fileTreeMock.options.push(options);
+ return {
+ model: {
+ ...options,
+ getSelectedPaths: () => options.initialSelectedPaths ?? [],
+ getItem: () => ({ deselect: vi.fn(), select: vi.fn() }),
+ },
+ };
+ },
+}));
+vi.mock("@/lib/diff/pierre-react", async (importOriginal) => {
+ const actual = await importOriginal();
return {
...actual,
- DiffView: () => Rich diff view
,
+ FileDiff: (props: { fileDiff: { name: string; hunks: unknown[] } }) => {
+ fileDiffMock(props);
+ return Rich patch diff
;
+ },
+ MultiFileDiff: (props: { oldFile: { contents: string }; newFile: { contents: string } }) => {
+ multiFileDiffMock(props);
+ return Rich contents diff
;
+ },
};
});
@@ -24,6 +59,16 @@ index 1111111..2222222 100644
+export const hello = "new";
`;
+const multiFilePatch = `${validPatch}
+diff --git a/src/second.ts b/src/second.ts
+index 3333333..4444444 100644
+--- a/src/second.ts
++++ b/src/second.ts
+@@ -1 +1 @@
+-export const second = "old";
++export const second = "new";
+`;
+
const malformedPatch = `diff --git a/src/hello.ts b/src/hello.ts
index 1111111..2222222 100644
--- a/src/hello.ts
@@ -67,6 +112,7 @@ function createArtifact(overrides: Partial = {}): DiffArtifact {
}
const originalMatchMedia = window.matchMedia;
+const originalClipboard = navigator.clipboard;
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
@@ -93,7 +139,13 @@ afterAll(() => {
afterEach(() => {
cleanup();
- document.getElementById("agent-render-diff-view-styles")?.remove();
+ fileDiffMock.mockClear();
+ multiFileDiffMock.mockClear();
+ fileTreeMock.options.length = 0;
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: originalClipboard,
+ });
vi.restoreAllMocks();
});
@@ -105,18 +157,74 @@ describe("DiffRenderer", () => {
expect(screen.getByTestId("renderer-diff")).toHaveAttribute("data-diff-state", "rich");
});
expect(screen.queryByText(/could not be rendered as a valid unified diff/i)).not.toBeInTheDocument();
- expect(screen.getByRole("button", { name: /src\/hello\.ts/i })).toBeVisible();
- expect(screen.getByTestId("mock-rich-diff-view")).toBeVisible();
+ expect(screen.queryByTestId("mock-file-tree")).not.toBeInTheDocument();
+ expect(screen.getByTestId("mock-patch-diff")).toBeVisible();
+ expect(fileDiffMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fileDiff: expect.objectContaining({ name: "src/hello.ts" }),
+ }),
+ );
});
- it("loads the diff-view stylesheet only from the deferred public asset", async () => {
- render( );
+ it("reports readiness from Pierre's completed post-render callback without a timer", async () => {
+ const onReady = vi.fn();
+ render( );
+
+ await screen.findByTestId("mock-patch-diff");
+ expect(screen.getByTestId("renderer-diff")).toHaveAttribute("data-renderer-ready", "false");
+
+ const props = fileDiffMock.mock.calls.at(-1)?.[0] as {
+ options?: {
+ onPostRender?: (
+ node: HTMLElement,
+ instance: unknown,
+ phase: "mount" | "update" | "unmount",
+ ) => void;
+ };
+ };
+ act(() => {
+ props.options?.onPostRender?.(document.createElement("div"), {}, "mount");
+ });
+
+ expect(screen.getByTestId("renderer-diff")).toHaveAttribute("data-renderer-ready", "true");
+ expect(onReady).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses a path-aware tree for multi-file patches", async () => {
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mock-file-tree")).toHaveTextContent("src/hello.ts|src/second.ts");
+ });
+ expect(fileTreeMock.options.at(-1)).toEqual(
+ expect.objectContaining({
+ initialSelectedPaths: ["src/hello.ts"],
+ paths: ["src/hello.ts", "src/second.ts"],
+ }),
+ );
+ });
+
+ it("renders before-and-after content diffs through the contents path", async () => {
+ render(
+ ,
+ );
await waitFor(() => {
- const stylesheet = document.getElementById("agent-render-diff-view-styles");
- expect(stylesheet).toBeInstanceOf(HTMLLinkElement);
- expect((stylesheet as HTMLLinkElement).href).toContain("/vendor/diff-view-pure.css.br");
+ expect(screen.getByTestId("renderer-diff")).toHaveAttribute("data-diff-state", "rich");
});
+ expect(screen.getByTestId("mock-multi-file-diff")).toBeVisible();
+ expect(multiFileDiffMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ oldFile: expect.objectContaining({ contents: expect.stringContaining("old") }),
+ newFile: expect.objectContaining({ contents: expect.stringContaining("new") }),
+ }),
+ );
});
it("falls back to the raw patch when the diff parser rejects malformed hunks", async () => {
@@ -155,25 +263,10 @@ describe("DiffRenderer", () => {
expect(secondReady).not.toHaveBeenCalled();
});
- it("removes the rich diff stylesheet when switching to the fallback path", async () => {
- const { rerender } = render( );
-
- await waitFor(() => {
- expect(document.getElementById("agent-render-diff-view-styles")).toBeInstanceOf(HTMLLinkElement);
- });
-
- rerender( );
-
- await waitFor(() => {
- expect(screen.getByTestId("renderer-diff")).toHaveAttribute("data-diff-state", "fallback");
- });
- expect(document.getElementById("agent-render-diff-view-styles")).not.toBeInTheDocument();
- });
-
- it("falls back to the raw patch when the diff library throws during parsing", async () => {
+ it("falls back to the raw patch when the rich diff component throws at render", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
- vi.spyOn(DiffFile.prototype, "init").mockImplementation(() => {
- throw new Error("Invalid hunk header format");
+ fileDiffMock.mockImplementation(() => {
+ throw new Error("shadow root exploded");
});
try {
@@ -183,33 +276,36 @@ describe("DiffRenderer", () => {
expect(screen.getByTestId("renderer-diff")).toHaveAttribute("data-diff-state", "fallback");
});
expect(screen.getByText(/could not be rendered as a valid unified diff/i)).toBeVisible();
- expect(screen.getByText(/parser detail: Invalid hunk header format/i)).toBeVisible();
+ expect(screen.getByText(/parser detail: shadow root exploded/i)).toBeVisible();
expect(screen.getByTestId("renderer-diff-fallback-raw")).toHaveTextContent('export const hello = "new";');
} finally {
consoleError.mockRestore();
}
});
- it("skips diff parsing for binary patches and keeps the rich renderer shell", async () => {
- const initSpy = vi.spyOn(DiffFile.prototype, "init");
-
- render( );
+ it("skips diff rendering for binary patches and keeps the rich renderer shell", async () => {
+ const onReady = vi.fn();
+ render(
+ ,
+ );
const renderer = await screen.findByTestId("renderer-diff");
expect(renderer).toHaveAttribute("data-diff-state", "rich");
- expect(initSpy).not.toHaveBeenCalled();
+ expect(fileDiffMock).not.toHaveBeenCalled();
expect(screen.getByText(/binary patch preview is not expanded/i)).toBeVisible();
expect(screen.queryByText(/could not be rendered as a valid unified diff/i)).not.toBeInTheDocument();
+ await waitFor(() => expect(onReady).toHaveBeenCalledTimes(1));
});
it("keeps the rich/binary path for a CRLF binary patch instead of the raw fallback", async () => {
- const initSpy = vi.spyOn(DiffFile.prototype, "init");
-
render( );
const renderer = await screen.findByTestId("renderer-diff");
expect(renderer).toHaveAttribute("data-diff-state", "rich");
- expect(initSpy).not.toHaveBeenCalled();
+ expect(fileDiffMock).not.toHaveBeenCalled();
expect(screen.getByText(/binary patch preview is not expanded/i)).toBeVisible();
expect(screen.queryByTestId("renderer-diff-fallback-raw")).not.toBeInTheDocument();
expect(screen.queryByText(/not a valid unified diff/i)).not.toBeInTheDocument();
@@ -217,10 +313,9 @@ describe("DiffRenderer", () => {
it("copies the raw patch from the fallback view", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
- Object.assign(navigator, {
- clipboard: {
- writeText,
- },
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
});
render( );
@@ -238,10 +333,9 @@ describe("DiffRenderer", () => {
configurable: true,
value: execCommand,
});
- Object.assign(navigator, {
- clipboard: {
- writeText: vi.fn().mockRejectedValue(new Error("denied")),
- },
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText: vi.fn().mockRejectedValue(new Error("denied")) },
});
try {
diff --git a/tests/components/fragment-details-disclosure.test.tsx b/tests/components/fragment-details-disclosure.test.tsx
index de28524..7d0d17b 100644
--- a/tests/components/fragment-details-disclosure.test.tsx
+++ b/tests/components/fragment-details-disclosure.test.tsx
@@ -6,22 +6,24 @@ import { FragmentDetailsDisclosure } from "@/components/viewer/fragment-details-
import { MAX_FRAGMENT_LENGTH } from "@/lib/payload/schema";
describe("FragmentDetailsDisclosure", () => {
- it("reveals metadata when expanded", async () => {
+ it("opens fragment metadata by default and keeps the disclosure toggle", async () => {
render(
,
);
- const summary = screen.getByText(/Codec, budget, and hash preview/i);
- await userEvent.click(summary);
+ const disclosure = screen.getByTestId("fragment-disclosure");
+ const summary = screen.getByText("Fragment details");
- expect(screen.getByText("Decoded")).toBeVisible();
+ expect(disclosure).toHaveAttribute("open");
+ await userEvent.click(summary);
+ expect(disclosure).not.toHaveAttribute("open");
+ await userEvent.click(summary);
+ expect(disclosure).toHaveAttribute("open");
expect(screen.getByText("lz")).toBeVisible();
expect(screen.getByText(/#agent-render=v1.lz.abc/i)).toBeVisible();
});
diff --git a/tests/components/json-renderer.test.tsx b/tests/components/json-renderer.test.tsx
index c8d901d..55cb83b 100644
--- a/tests/components/json-renderer.test.tsx
+++ b/tests/components/json-renderer.test.tsx
@@ -1,9 +1,28 @@
-import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { JsonRenderer } from "@/components/renderers/json-renderer";
import type { JsonArtifact } from "@/lib/payload/schema";
+const pierreFileMock = vi.hoisted(() => ({
+ options: null as null | {
+ onPostRender?: (node: HTMLElement, instance: unknown, phase: "mount" | "update" | "unmount") => void;
+ },
+}));
+
+// The raw view mounts the Pierre-backed CodeRenderer; stub the Pierre surface so the test
+// asserts wiring (content + lang) rather than shadow-DOM rendering under jsdom.
+vi.mock("@/lib/diff/pierre-react", async () => {
+ const React = await vi.importActual("react");
+
+ return {
+ File: ({ file, options }: { file: { contents: string }; options: typeof pierreFileMock.options }) => {
+ pierreFileMock.options = options;
+ return React.createElement("pre", { "data-testid": "mock-pierre-file" }, file.contents);
+ },
+ };
+});
+
function createArtifact(overrides: Partial = {}): JsonArtifact {
return {
id: "json-artifact",
@@ -17,6 +36,7 @@ function createArtifact(overrides: Partial = {}): JsonArtifact {
afterEach(() => {
cleanup();
+ pierreFileMock.options = null;
});
describe("JsonRenderer", () => {
@@ -44,13 +64,26 @@ describe("JsonRenderer", () => {
expect(secondReady).not.toHaveBeenCalled();
});
- it("switches to a native raw source view without mounting CodeMirror", async () => {
+ it("switches to a syntax-highlighted raw source view", async () => {
render( );
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
- expect(screen.getByTestId("renderer-json-raw")).toHaveTextContent('"name": "agent-render"');
- expect(document.querySelector(".cm-editor")).not.toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId("renderer-json-raw")).toHaveTextContent('"name": "agent-render"');
+ expect(
+ screen.getByTestId("renderer-json-raw").querySelector("[data-testid='mock-pierre-file']"),
+ ).toBeInTheDocument();
+ });
+ expect(screen.getByTestId("renderer-json")).toHaveAttribute("data-renderer-ready", "false");
+
+ act(() => {
+ pierreFileMock.options?.onPostRender?.(document.createElement("div"), {}, "mount");
+ });
+ expect(screen.getByTestId("renderer-json")).toHaveAttribute("data-renderer-ready", "true");
+
+ await userEvent.click(screen.getByRole("button", { name: "Raw" }));
+ expect(screen.getByTestId("renderer-json")).toHaveAttribute("data-renderer-ready", "true");
});
it("renders array nodes with numeric child labels", () => {
@@ -63,11 +96,45 @@ describe("JsonRenderer", () => {
expect(screen.getByText("beta")).toBeVisible();
});
- it("shows invalid JSON as raw source with the parse error", () => {
+ it("shows invalid JSON as highlighted raw source with the parse error", async () => {
render( );
expect(screen.getByText(/expected property name/i)).toBeVisible();
- expect(screen.getByTestId("renderer-json-raw")).toHaveTextContent("{ nope");
+ await waitFor(() => {
+ expect(screen.getByTestId("renderer-json-raw")).toHaveTextContent("{ nope");
+ expect(
+ screen.getByTestId("renderer-json-raw").querySelector("[data-testid='mock-pierre-file']"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("keeps raw readiness when a re-decoded artifact object carries the same content", async () => {
+ const { rerender } = render( );
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("renderer-json-raw").querySelector("[data-testid='mock-pierre-file']"),
+ ).toBeInTheDocument();
+ });
+ act(() => {
+ pierreFileMock.options?.onPostRender?.(document.createElement("div"), {}, "mount");
+ });
+ expect(screen.getByTestId("renderer-json")).toHaveAttribute("data-renderer-ready", "true");
+
+ // The shell keeps this renderer mounted across a hash change that decodes to an equal
+ // artifact; the raw surface does not post-render again, so readiness must survive.
+ rerender( );
+ expect(screen.getByTestId("renderer-json")).toHaveAttribute("data-renderer-ready", "true");
+ });
+
+ it("falls back to raw source before a wide JSON value can flood the DOM", async () => {
+ const content = JSON.stringify(Array.from({ length: 5_001 }, (_, index) => index));
+
+ render( );
+
+ expect(screen.getByRole("status")).toHaveTextContent(/too many values/i);
+ await waitFor(() => expect(screen.getByTestId("renderer-json-raw")).toHaveTextContent("5000"));
+ expect(document.querySelectorAll(".json-leaf-row")).toHaveLength(0);
});
it("renders deeply nested JSON without overflowing the render stack", () => {
diff --git a/tests/components/link-creator.test.tsx b/tests/components/link-creator.test.tsx
index c773746..9fb70d6 100644
--- a/tests/components/link-creator.test.tsx
+++ b/tests/components/link-creator.test.tsx
@@ -24,7 +24,7 @@ vi.mock("@/lib/payload/link-creator", () => ({
}));
function createGeneratedLink(title: string): GeneratedArtifactLink {
- const url = `https://agent-render.test/#agent-render=v1.plain.${title}`;
+ const url = `https://agent-render.test/#p${title}`;
const shareInfo = buildMarkdownLinkShareInfo(title, url);
return {
@@ -44,7 +44,7 @@ function createGeneratedLink(title: string): GeneratedArtifactLink {
artifacts: [],
},
fragmentLength: 64,
- hash: `#agent-render=v1.plain.${title}`,
+ hash: `#p${title}`,
url,
markdownUrl: url,
markdownLink: shareInfo.markdownLink,
diff --git a/tests/components/markdown-renderer.test.tsx b/tests/components/markdown-renderer.test.tsx
index 182ccbc..5a1b28c 100644
--- a/tests/components/markdown-renderer.test.tsx
+++ b/tests/components/markdown-renderer.test.tsx
@@ -55,6 +55,17 @@ vi.mock("@/components/renderers/code-renderer", async () => {
};
});
+// Backstop for the real CodeRenderer if a deferred import ever resolves past the mock above:
+// Pierre's File needs ResizeObserver, which jsdom does not provide.
+vi.mock("@/lib/diff/pierre-react", async () => {
+ const React = await vi.importActual("react");
+
+ return {
+ File: ({ file }: { file: { contents: string; name: string } }) =>
+ React.createElement("pre", { "data-testid": "mock-pierre-file" }, file.contents),
+ };
+});
+
afterEach(() => {
cleanup();
});
diff --git a/tests/components/mermaid-block.test.tsx b/tests/components/mermaid-block.test.tsx
index 6c3fc93..77cd7fd 100644
--- a/tests/components/mermaid-block.test.tsx
+++ b/tests/components/mermaid-block.test.tsx
@@ -1,4 +1,4 @@
-import { act, cleanup, render, waitFor } from "@testing-library/react";
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MermaidBlock } from "@/components/renderers/mermaid-block";
@@ -25,6 +25,7 @@ describe("MermaidBlock", () => {
await waitFor(() => {
expect(mermaidMock.render).toHaveBeenCalledTimes(1);
});
+ expect(screen.getByRole("img", { name: "Mermaid diagram" })).toBeInTheDocument();
rerender( );
await act(async () => {});
diff --git a/tests/components/viewer-shell-artifact-select.test.tsx b/tests/components/viewer-shell-artifact-select.test.tsx
index 83d312b..dcc5c23 100644
--- a/tests/components/viewer-shell-artifact-select.test.tsx
+++ b/tests/components/viewer-shell-artifact-select.test.tsx
@@ -64,15 +64,18 @@ vi.mock("@/components/viewer/artifact-stage", async () => {
activeArtifact,
envelope,
onArtifactSelect,
+ rendererReadyKey,
}: {
activeArtifact: PayloadEnvelope["artifacts"][number];
envelope: PayloadEnvelope;
onArtifactSelect: (artifactId: string) => void;
+ rendererReadyKey: string;
}) =>
React.createElement(
"section",
{
"data-active-id": activeArtifact.id,
+ "data-renderer-key": rendererReadyKey,
"data-testid": "mock-artifact-stage",
},
envelope.artifacts.map((artifact) =>
@@ -163,11 +166,18 @@ describe("ViewerShell artifact selection", () => {
});
await waitFor(() => expect(fragmentMock.encodes).toHaveLength(2));
expect(fragmentMock.encodes[1].activeArtifactId).toBe("three");
+ const optimisticRendererKey = screen
+ .getByTestId("mock-artifact-stage")
+ .getAttribute("data-renderer-key");
await act(async () => {
fragmentMock.encodes[1].resolve("agent-render=v1.plain.three");
});
await waitFor(() => expect(window.location.hash).toBe("#agent-render=v1.plain.three"));
+ expect(screen.getByTestId("mock-artifact-stage")).toHaveAttribute(
+ "data-renderer-key",
+ optimisticRendererKey,
+ );
await act(async () => {
fragmentMock.encodes[0].resolve("agent-render=v1.plain.two");
diff --git a/tests/components/viewer-shell.test.tsx b/tests/components/viewer-shell.test.tsx
index 97eedba..6b4a448 100644
--- a/tests/components/viewer-shell.test.tsx
+++ b/tests/components/viewer-shell.test.tsx
@@ -13,7 +13,13 @@ describe("ViewerShell homepage", () => {
await waitFor(() => expect(screen.getByTestId("viewer-shell")).toHaveAttribute("data-viewer-state", "empty"));
- expect(screen.getByRole("heading", { name: /zero-retention artifact viewer/i })).toBeVisible();
+ expect(
+ await screen.findByRole(
+ "heading",
+ { name: /create a link/i },
+ { timeout: 8000 },
+ ),
+ ).toBeVisible();
expect(screen.getByText(/artifact content lives in the URL fragment/i)).toBeVisible();
expect(screen.getByText(/the static host does not receive artifact content/i)).toBeVisible();
expect(screen.getByText(/browser history, screenshots, copied messages, extensions/i)).toBeVisible();
@@ -21,5 +27,6 @@ describe("ViewerShell homepage", () => {
expect(screen.getByRole("link", { name: /payload format docs/i })).toBeVisible();
expect(screen.getByRole("link", { name: /safety.*security page/i })).toBeVisible();
expect(screen.getByRole("link", { name: /openclaw/i })).toBeVisible();
- });
+ expect(screen.queryByText(/no database/i)).not.toBeInTheDocument();
+ }, 10000);
});
diff --git a/tests/diff-style-asset.test.ts b/tests/diff-style-asset.test.ts
deleted file mode 100644
index e14e111..0000000
--- a/tests/diff-style-asset.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { brotliDecompressSync } from "node:zlib";
-import { readFileSync } from "node:fs";
-import { resolve } from "node:path";
-import { describe, expect, it } from "vitest";
-
-describe("diff stylesheet asset", () => {
- it("keeps the deferred public stylesheet in sync with the diff-view package", () => {
- const packageStylesheet = readFileSync(
- resolve("node_modules/@git-diff-view/react/styles/diff-view-pure.css"),
- "utf-8",
- );
- const publicStylesheet = readFileSync(
- resolve("public/vendor/diff-view-pure.css"),
- "utf-8",
- );
-
- expect(publicStylesheet).toBe(packageStylesheet);
- });
-
- it("keeps the precompressed deferred stylesheet in sync with the plain asset", () => {
- const publicStylesheet = readFileSync(
- resolve("public/vendor/diff-view-pure.css"),
- "utf-8",
- );
- const compressedStylesheet = readFileSync(
- resolve("public/vendor/diff-view-pure.css.br"),
- );
-
- expect(brotliDecompressSync(compressedStylesheet).toString("utf-8")).toBe(publicStylesheet);
- });
-});
diff --git a/tests/e2e/arx4-determinism.spec.ts b/tests/e2e/arx4-determinism.spec.ts
index e063cd9..56482ef 100644
--- a/tests/e2e/arx4-determinism.spec.ts
+++ b/tests/e2e/arx4-determinism.spec.ts
@@ -98,7 +98,7 @@ async function fillCreatorDraft(page: Page, draft: LinkCreatorDraft) {
await page.getByLabel("Title").fill(draft.title);
await page.getByLabel("Filename").fill(draft.filename);
if (draft.kind === "code") {
- await page.getByRole("textbox", { name: "Language", exact: true }).fill(draft.language);
+ await page.getByRole("combobox", { name: "Language", exact: true }).selectOption(draft.language);
}
await page.getByRole("textbox", { name: /^Content\b/ }).fill(draft.content);
await page.getByRole("button", { name: codecPickerLabel(draft.codec ?? "auto"), exact: true }).click();
diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts
index e1d8a09..59ef8cc 100644
--- a/tests/e2e/helpers.ts
+++ b/tests/e2e/helpers.ts
@@ -1,7 +1,15 @@
import { expect, type Page } from "@playwright/test";
export async function goToHash(page: Page, hash = "") {
+ if (hash) {
+ // WebKit intermittently drops the fragment on same-document page.goto calls.
+ // Leave the current document first so this always exercises a fresh page load.
+ await page.goto("about:blank");
+ }
await page.goto(`.${hash}`);
+ if (hash) {
+ await stabilizePage(page);
+ }
}
export async function setTheme(page: Page, theme: "light" | "dark") {
@@ -33,7 +41,7 @@ export async function waitForRendererReady(page: Page, kind: "markdown" | "code"
const readinessSelectorByKind: Record = {
markdown: "[data-testid='renderer-markdown'][data-renderer-ready='true'] .markdown-article",
- code: "[data-testid='renderer-code'][data-renderer-ready='true'] .cm-editor",
+ code: "[data-testid='renderer-code'][data-renderer-ready='true'] diffs-container",
diff: "[data-testid='renderer-diff'][data-renderer-ready='true'] .patch-file-section",
csv: "[data-testid='renderer-csv'][data-renderer-ready='true'] table.csv-table tbody tr",
json: "[data-testid='renderer-json'][data-renderer-ready='true'] .json-tree-shell",
diff --git a/tests/e2e/viewer.spec.ts b/tests/e2e/viewer.spec.ts
index d5bf142..4a80785 100644
--- a/tests/e2e/viewer.spec.ts
+++ b/tests/e2e/viewer.spec.ts
@@ -17,7 +17,7 @@ test.beforeEach(async ({ page }) => {
test("renders the zero-retention homepage when no fragment is present", async ({ page }) => {
await waitForViewerState(page, "empty");
- await expect(page.getByRole("heading", { name: /zero-retention artifact viewer/i })).toBeVisible();
+ await expect(page.getByRole("heading", { name: /create a link/i })).toBeVisible();
await expect(page.getByText(/artifact content lives in the URL fragment/i)).toBeVisible();
await expect(page.getByText(/the static host does not receive artifact content/i)).toBeVisible();
await expect(page.getByText(/browser history, screenshots, copied messages, extensions/i)).toBeVisible();
@@ -58,7 +58,7 @@ test("creates, copies, and previews a generated homepage link", async ({ page })
await page.getByRole("button", { name: "code" }).click();
await page.getByLabel("Title").fill("Homepage snippet");
await page.getByLabel("Filename").fill("snippet.ts");
- await page.getByRole("textbox", { name: "Language", exact: true }).fill("ts");
+ await page.getByRole("combobox", { name: "Language", exact: true }).selectOption("ts");
await page.getByRole("textbox", { name: /^Content\b/ }).fill("export const value = 42;\n");
await page.getByRole("button", { name: "Generate link" }).click();
@@ -84,6 +84,8 @@ test("renders markdown payloads and triggers print", async ({ page }) => {
await waitForViewerState(page, "artifact");
await expect(page.locator("[data-active-kind='markdown']")).toBeVisible();
await expect(page.getByText("Sprint roadmap").first()).toBeVisible();
+ // Compact fences mount the Pierre file surface without wrapping.
+ await expect(page.locator(".markdown-code-frame .code-renderer-shell.is-compact diffs-container").first()).toBeVisible();
await page.evaluate(() => {
window.__printCalled = false;
@@ -103,10 +105,17 @@ test("edits an open markdown artifact and reshares it as a new link", async ({ p
await waitForRendererReady(page, "markdown");
await page.getByRole("button", { name: "Edit" }).click();
- const editor = page.getByTestId("artifact-editor-content");
+ // The edit surface is a Pierre CodeView contenteditable inside shadow DOM.
+ const editor = page
+ .getByTestId("artifact-editor-body")
+ .locator("[contenteditable='true']")
+ .first();
await expect(editor).toBeVisible();
- const current = await editor.inputValue();
- await editor.fill(`${current}\n\nEdited in the viewer.`);
+ await editor.click();
+ await page.keyboard.press("ControlOrMeta+A");
+ await editor.pressSequentially("# Maintainer kickoff\n\nEdited in the viewer.", {
+ delay: 30,
+ });
await page.getByRole("button", { name: "plain", exact: true }).click();
await page.getByRole("button", { name: "Generate new link" }).click();
@@ -126,8 +135,15 @@ test("edits an open code artifact and reshares it as a new link", async ({ page
await waitForRendererReady(page, "code");
await page.getByRole("button", { name: "Edit" }).click();
- await expect(page.getByTestId("artifact-editor-content")).toBeVisible();
- await page.getByTestId("artifact-editor-content").fill('export const value = "edited";\n');
+ const codeEditor = page
+ .getByTestId("artifact-editor-body")
+ .locator("[contenteditable='true']")
+ .first();
+ await expect(codeEditor).toBeVisible();
+ // Pierre re-renders the editable surface per change, so keystrokes need spacing to stay aligned.
+ await codeEditor.click();
+ await page.keyboard.press("ControlOrMeta+A");
+ await codeEditor.pressSequentially('export const value = "edited";', { delay: 30 });
await page.getByRole("button", { name: "plain", exact: true }).click();
await page.getByRole("button", { name: "Generate new link" }).click();
await expect(page.getByTestId("artifact-editor-result")).toBeVisible();
@@ -135,7 +151,7 @@ test("edits an open code artifact and reshares it as a new link", async ({ page
await waitForViewerState(page, "artifact");
await waitForRendererReady(page, "code");
- await expect(page.locator(".cm-editor").first()).toContainText('export const value = "edited"');
+ await expect(page.getByTestId("renderer-code")).toContainText('export const value = "edited"');
await expect.poll(() => page.evaluate(() => window.location.hash)).not.toBe(beforeHash);
});
@@ -168,7 +184,14 @@ test("keeps other bundle artifacts when resharing an edited one", async ({ page
await waitForRendererReady(page, "markdown");
await page.getByRole("button", { name: "Edit" }).click();
- await page.getByTestId("artifact-editor-content").fill("# Notes\n\nCorrected bundle notes.");
+ const bundleEditor = page
+ .getByTestId("artifact-editor-body")
+ .locator("[contenteditable='true']")
+ .first();
+ await expect(bundleEditor).toBeVisible();
+ await bundleEditor.click();
+ await page.keyboard.press("ControlOrMeta+A");
+ await bundleEditor.pressSequentially("# Notes\n\nCorrected bundle notes.", { delay: 30 });
await page.getByRole("button", { name: "plain", exact: true }).click();
await page.getByRole("button", { name: "Generate new link" }).click();
await expect(page.getByTestId("artifact-editor-result")).toBeVisible();
@@ -184,7 +207,7 @@ test("keeps other bundle artifacts when resharing an edited one", async ({ page
await expect(page.locator(".json-tree-shell")).toContainText("kept");
});
-test("renders markdown raw view without mounting CodeMirror", async ({ page }) => {
+test("renders markdown raw view on the highlighted file surface", async ({ page }) => {
const plainMarkdownEnvelope = {
v: 1,
codec: "plain",
@@ -206,15 +229,18 @@ test("renders markdown raw view without mounting CodeMirror", async ({ page }) =
await page.getByRole("button", { name: /^Raw$/ }).click();
- await expect(page.getByTestId("renderer-markdown-raw")).toContainText("No fenced code here.");
- await expect(page.locator(".cm-editor")).toHaveCount(0);
+ const rawView = page.getByTestId("renderer-markdown-raw");
+ await expect(rawView).toContainText("No fenced code here.");
+ await expect(rawView.locator("[data-testid='renderer-code'][data-renderer-ready='true']")).toHaveCount(1);
});
test("renders code payloads", async ({ page }) => {
await goToHash(page, getFragmentHash("Viewer bootstrap"));
await waitForViewerState(page, "artifact");
await expect(page.locator("[data-active-kind='code']")).toBeVisible();
- await expect(page.locator(".cm-editor").first()).toBeVisible();
+ await expect(
+ page.locator("[data-testid='renderer-code'] diffs-container").first(),
+ ).toBeVisible();
});
test("renders arx2 fragments through the viewer", async ({ page }) => {
@@ -242,27 +268,111 @@ test("renders multi-file diffs without mutating the payload hash", async ({ page
await waitForViewerState(page, "artifact");
const beforeHash = await page.evaluate(() => window.location.hash);
await expect(page.locator(".patch-file-section")).toHaveCount(2);
- await page.locator("button.patch-bundle-link").nth(1).click();
+ await expect
+ .poll(() =>
+ page
+ .locator(".patch-file-tree")
+ .evaluate((tree) => tree.shadowRoot?.querySelectorAll('button[data-type="item"][data-item-type="file"]').length ?? 0),
+ )
+ .toBe(2);
+ await page.locator(".patch-file-tree").evaluate((tree) => {
+ const items = tree.shadowRoot?.querySelectorAll('button[data-type="item"][data-item-type="file"]');
+ items?.item(items.length - 1).click();
+ });
+ await expect
+ .poll(() =>
+ page.locator(".patch-file-tree").evaluate((tree) => {
+ const items = tree.shadowRoot?.querySelectorAll('button[data-type="item"][data-item-type="file"]');
+ return items && items.length > 0
+ ? items.item(items.length - 1).hasAttribute("data-item-selected")
+ : false;
+ }),
+ )
+ .toBe(true);
await expect.poll(() => page.evaluate(() => window.location.hash)).toBe(beforeHash);
});
-test("loads the compressed diff stylesheet only after opening a diff artifact", async ({ page }) => {
- await waitForViewerState(page, "empty");
+test("shows the editable-files tree inside the editor and focuses the selected file", async ({ page }) => {
+ await goToHash(page, getFragmentHash("Phase 1 sample diff"));
+ await waitForViewerState(page, "artifact");
+ await waitForRendererReady(page, "diff");
+
+ await page.getByRole("button", { name: "Edit" }).click();
+ const editor = page.getByTestId("artifact-editor");
+ await expect(editor).toBeVisible();
+ const editorBody = page.getByTestId("artifact-editor-body");
+ await expect(editorBody).toBeVisible();
+ const editorTree = page.getByTestId("artifact-editor-frame").locator(".patch-file-tree");
+ await expect
+ .poll(() =>
+ editorTree.evaluate(
+ (tree) =>
+ tree.shadowRoot?.querySelector('button[data-type="item"][data-item-path="src/version.ts"]') instanceof
+ HTMLButtonElement,
+ ),
+ )
+ .toBe(true);
+
+ // Wait for the deferred Pierre editor chunk to finish mounting the editable element.
+ await expect
+ .poll(() =>
+ editorBody.evaluate((frame) => {
+ let found = false;
+ const visit = (root: ParentNode) => {
+ for (const el of Array.from(root.children)) {
+ if (el.getAttribute("contenteditable") === "true") {
+ found = true;
+ }
+ visit(el);
+ if (el.shadowRoot) {
+ visit(el.shadowRoot);
+ }
+ }
+ };
+ visit(frame);
+ return found;
+ }),
+ )
+ .toBe(true);
+
+ await editorTree.evaluate((tree) => {
+ tree.shadowRoot
+ ?.querySelector('button[data-type="item"][data-item-path="src/version.ts"]')
+ ?.click();
+ });
+ // The patch caret nav scrolls the CodeView (`.artifact-body-editor` is its scroll root) to
+ // the file's `diff --git` section and lands focus on the editable surface inside it.
await expect
- .poll(() => page.evaluate(() => Array.from(document.querySelectorAll('link[rel="stylesheet"]')).some((link) => link.getAttribute("href")?.includes("diff-view-pure.css"))))
- .toBe(false);
+ .poll(() =>
+ page.evaluate(
+ () => document.querySelector(".artifact-body-editor")?.scrollTop ?? 0,
+ ),
+ )
+ .toBeGreaterThan(0);
+ await expect
+ .poll(() =>
+ page.evaluate(() => {
+ const frame = document.querySelector('[data-testid="artifact-editor-body"]');
+ return frame?.contains(document.activeElement) ?? false;
+ }),
+ )
+ .toBe(true);
+});
+test("renders rich diffs in shadow DOM without an external stylesheet", async ({ page }) => {
await goToHash(page, getFragmentHash("Phase 1 sample diff"));
await waitForViewerState(page, "artifact");
await waitForRendererReady(page, "diff");
await expect(page.getByTestId("renderer-diff")).toHaveAttribute("data-diff-state", "rich");
+ await expect
+ .poll(() => page.evaluate(() => Array.from(document.querySelectorAll(".patch-file-section *")).some((element) => element.shadowRoot !== null)))
+ .toBe(true);
const stylesheetHrefs = await page.evaluate(() => Array.from(document.querySelectorAll('link[rel="stylesheet"]'), (link) => link.getAttribute("href") ?? ""));
- expect(stylesheetHrefs.some((href) => href.endsWith("/vendor/diff-view-pure.css.br"))).toBe(true);
- expect(stylesheetHrefs.some((href) => href.endsWith("/vendor/diff-view-pure.css"))).toBe(false);
+ expect(stylesheetHrefs.some((href) => href.includes("diff-view"))).toBe(false);
});
-test("keeps fallback diffs off the rich diff stylesheet path", async ({ page }) => {
+test("shows the raw patch fallback for invalid unified diffs", async ({ page }) => {
const fallbackDiffEnvelope = {
v: 1,
codec: "plain",
@@ -283,9 +393,6 @@ test("keeps fallback diffs off the rich diff stylesheet path", async ({ page })
await expect(page.getByTestId("renderer-diff")).toHaveAttribute("data-diff-state", "fallback");
await expect(page.locator('[data-testid="viewer-shell"][data-renderer-ready="true"]')).toBeVisible();
await expect(page.getByTestId("renderer-diff-fallback-raw")).toContainText("not a unified diff");
- await expect
- .poll(() => page.evaluate(() => Array.from(document.querySelectorAll('link[rel="stylesheet"]')).some((link) => link.getAttribute("href")?.includes("diff-view-pure.css"))))
- .toBe(false);
});
test.describe("mobile UX", () => {
@@ -297,12 +404,12 @@ test.describe("mobile UX", () => {
await waitForRendererReady(page, "diff");
const diffRenderer = page.getByTestId("renderer-diff");
- const patchNav = page.locator(".patch-bundle-nav");
+ const patchTree = page.locator(".patch-file-tree");
await expect(diffRenderer).toHaveAttribute("data-mobile-layout", "true");
await expect(diffRenderer).toHaveAttribute("data-diff-mode", "unified");
await expect(page.getByRole("button", { name: "Open split columns" })).toBeVisible();
await expect(page.getByRole("button", { name: /^Split$/ })).toHaveCount(0);
- await expect.poll(() => patchNav.evaluate((element) => window.getComputedStyle(element).flexDirection)).toBe("row");
+ await expect(patchTree).toBeVisible();
await page.getByRole("button", { name: "Open split columns" }).click();
await expect(diffRenderer).toHaveAttribute("data-diff-mode", "split");
@@ -390,15 +497,16 @@ test("renders compact CSV payloads without giant whitespace", async ({ page }) =
await expect(page.locator("table.csv-table")).toBeVisible();
});
-test("renders CSV raw view without mounting CodeMirror", async ({ page }) => {
+test("renders CSV raw view on the highlighted file surface", async ({ page }) => {
await goToHash(page, getFragmentHash("Data export preview"));
await waitForViewerState(page, "artifact");
await waitForRendererReady(page, "csv");
await page.getByRole("button", { name: /^Raw$/ }).click();
- await expect(page.getByTestId("renderer-csv-raw")).toContainText("artifact,kind,summary");
- await expect(page.locator(".cm-editor")).toHaveCount(0);
+ const rawView = page.getByTestId("renderer-csv-raw");
+ await expect(rawView).toContainText("artifact,kind,summary");
+ await expect(rawView.locator("[data-testid='renderer-code'][data-renderer-ready='true']")).toHaveCount(1);
});
test("renders JSON tree and raw views", async ({ page }) => {
@@ -409,7 +517,7 @@ test("renders JSON tree and raw views", async ({ page }) => {
await expect(page.locator(".json-tree-shell")).toBeVisible();
await page.getByRole("button", { name: "Raw" }).click();
await expect(page.getByTestId("renderer-json-raw")).toBeVisible();
- await expect(page.locator(".json-renderer-shell .cm-editor")).toHaveCount(0);
+ await expect(page.locator(".json-renderer-shell diffs-container")).toHaveCount(1);
});
test("switches artifacts within a bundle", async ({ page }) => {
@@ -428,7 +536,7 @@ test("header icon and name navigate to homepage", async ({ page }) => {
await page.getByRole("link", { name: "Go to homepage" }).click();
await waitForViewerState(page, "empty");
- await expect(page.getByRole("heading", { name: /zero-retention artifact viewer/i })).toBeVisible();
+ await expect(page.getByRole("heading", { name: /create a link/i })).toBeVisible();
});
test("theme switching works", async ({ page }) => {
diff --git a/tests/e2e/visual.spec.ts-snapshots/bundle-switcher-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/bundle-switcher-light-chromium.png
index c273363..0a5749e 100644
Binary files a/tests/e2e/visual.spec.ts-snapshots/bundle-switcher-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/bundle-switcher-light-chromium.png differ
diff --git a/tests/e2e/visual.spec.ts-snapshots/code-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/code-light-chromium.png
index 295a904..d5a1693 100644
Binary files a/tests/e2e/visual.spec.ts-snapshots/code-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/code-light-chromium.png differ
diff --git a/tests/e2e/visual.spec.ts-snapshots/csv-compact-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/csv-compact-light-chromium.png
index dabb9bd..58f90df 100644
Binary files a/tests/e2e/visual.spec.ts-snapshots/csv-compact-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/csv-compact-light-chromium.png differ
diff --git a/tests/e2e/visual.spec.ts-snapshots/diff-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/diff-light-chromium.png
index 5346db6..3a78e69 100644
Binary files a/tests/e2e/visual.spec.ts-snapshots/diff-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/diff-light-chromium.png differ
diff --git a/tests/e2e/visual.spec.ts-snapshots/empty-state-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/empty-state-light-chromium.png
index cb095fd..3a008dd 100644
Binary files a/tests/e2e/visual.spec.ts-snapshots/empty-state-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/empty-state-light-chromium.png differ
diff --git a/tests/e2e/visual.spec.ts-snapshots/json-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/json-light-chromium.png
index a66d228..6c71ee7 100644
Binary files a/tests/e2e/visual.spec.ts-snapshots/json-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/json-light-chromium.png differ
diff --git a/tests/e2e/visual.spec.ts-snapshots/markdown-dark-chromium.png b/tests/e2e/visual.spec.ts-snapshots/markdown-dark-chromium.png
index 7c5c276..6254bd6 100644
Binary files a/tests/e2e/visual.spec.ts-snapshots/markdown-dark-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/markdown-dark-chromium.png differ
diff --git a/tests/e2e/visual.spec.ts-snapshots/markdown-light-chromium.png b/tests/e2e/visual.spec.ts-snapshots/markdown-light-chromium.png
index cd2830b..a3d6156 100644
Binary files a/tests/e2e/visual.spec.ts-snapshots/markdown-light-chromium.png and b/tests/e2e/visual.spec.ts-snapshots/markdown-light-chromium.png differ
diff --git a/tests/git-patch.test.ts b/tests/git-patch.test.ts
index 7ace1b0..c20a33a 100644
--- a/tests/git-patch.test.ts
+++ b/tests/git-patch.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
-import { parseGitPatchBundle } from "@/lib/diff/git-patch";
+import {
+ getPatchFileLabels,
+ parseRenderablePatchFiles,
+} from "@/lib/diff/git-patch";
const multiFilePatch = `diff --git a/src/alpha.ts b/src/alpha.ts
index 1111111..2222222 100644
@@ -20,13 +23,13 @@ Binary files /dev/null and b/assets/logo.png differ
describe("git patch parsing", () => {
it("parses a multi-file patch into separate file entries", () => {
- const files = parseGitPatchBundle(multiFilePatch);
+ const files = parseRenderablePatchFiles(multiFilePatch);
expect(files).toHaveLength(3);
expect(files[0]).toMatchObject({
displayPath: "src/alpha.ts",
status: "modified",
- oldPath: "src/alpha.ts",
+ oldPath: null,
newPath: "src/alpha.ts",
});
expect(files[1]).toMatchObject({
@@ -38,53 +41,247 @@ describe("git patch parsing", () => {
expect(files[2]).toMatchObject({
displayPath: "assets/logo.png",
status: "binary",
- oldPath: null,
- newPath: "assets/logo.png",
isBinary: true,
});
});
- it("rejects malformed hunk headers before rich diff rendering", () => {
- expect(() =>
- parseGitPatchBundle(`diff --git a/src/alpha.ts b/src/alpha.ts
+ it("parses quoted paths and preserves repository paths that start with a/", () => {
+ const files = parseRenderablePatchFiles(`diff --git "a/my file.ts" "b/my file.ts"
+--- "a/my file.ts"
++++ "b/my file.ts"
+@@ -1 +1 @@
+-old
++new
+diff --git a/a/nested.ts b/a/nested.ts
+--- a/a/nested.ts
++++ b/a/nested.ts
+@@ -1 +1 @@
+-old
++new
+`);
+
+ expect(files.map((file) => file.displayPath)).toEqual(["my file.ts", "a/nested.ts"]);
+ });
+
+ it("keeps rename-only files whose unquoted paths contain spaces", () => {
+ const files = parseRenderablePatchFiles(`diff --git a/old name.txt b/new name.txt
+similarity index 100%
+rename from old name.txt
+rename to new name.txt
+diff --git a/normal.txt b/normal.txt
+index 111..222 100644
+--- a/normal.txt
++++ b/normal.txt
+@@ -1 +1 @@
+-old
++new
+`);
+
+ expect(files.map((file) => file.displayPath)).toEqual([
+ "new name.txt",
+ "normal.txt",
+ ]);
+ expect(files[0]?.status).toBe("renamed");
+ expect(files[0]?.oldPath).toBe("old name.txt");
+ });
+
+ it("keeps email preambles out of the file list", () => {
+ const files = parseRenderablePatchFiles(`From 123 Mon Sep 17 00:00:00 2001
+Subject: [PATCH] preserve preamble
+
+diff --git a/src/alpha.ts b/src/alpha.ts
--- a/src/alpha.ts
+++ b/src/alpha.ts
-@@ nope @@
+@@ -1 +1 @@
-export const alpha = 1;
+export const alpha = 2;
-`),
- ).toThrow(/invalid hunk header/i);
+`);
+
+ expect(files.map((file) => file.displayPath)).toEqual(["src/alpha.ts"]);
});
- it("keeps leading patch preambles as their own section", () => {
- const files = parseGitPatchBundle(`From 123 Mon Sep 17 00:00:00 2001
-Subject: [PATCH] preserve preamble
+ it("keeps a traditional unified diff before a git-style section", () => {
+ const files = parseRenderablePatchFiles(`Subject: [PATCH] mixed formats
+--- a/legacy.txt
++++ b/legacy.txt
+@@ -1 +1 @@
+-old
++new
diff --git a/src/alpha.ts b/src/alpha.ts
--- a/src/alpha.ts
+++ b/src/alpha.ts
@@ -1 +1 @@
--export const alpha = 1;
-+export const alpha = 2;
+-old
++new
`);
- expect(files).toHaveLength(2);
- expect(files[0]).toMatchObject({
- displayPath: "file-1",
- patch: expect.stringContaining("Subject: [PATCH] preserve preamble"),
- });
- expect(files[1]).toMatchObject({
- displayPath: "src/alpha.ts",
- });
+ expect(files.map((file) => file.displayPath)).toEqual(["legacy.txt", "src/alpha.ts"]);
+ expect(files[0]?.startLine).toBeLessThan(files[1]?.startLine ?? 0);
+ });
+
+ it("keeps prose-shaped hunk headers in a preamble from creating files", () => {
+ const files = parseRenderablePatchFiles(`From 123 Mon Sep 17 00:00:00 2001
+Subject: [PATCH] quoted review
+
+The review mentioned these separately:
+--- a/not-a-header.ts
+some prose
++++ b/not-a-header.ts
+@@ -1 +1 @@
+
+diff --git a/src/alpha.ts b/src/alpha.ts
+--- a/src/alpha.ts
++++ b/src/alpha.ts
+@@ -1 +1 @@
+-old
++new
+`);
+
+ expect(files.map((file) => file.displayPath)).toEqual(["src/alpha.ts"]);
});
- it("falls back to a file-N label when a rename target strips to an empty path", () => {
- // `rename to a/` reduces to "" after stripping the a/ prefix; the display label must not be
- // empty and the id must not be degenerate ("-0"). Fuzz regression (git-patch parser).
- const files = parseGitPatchBundle("rename to a/");
+ it("throws on malformed hunk counts so callers fall back to raw", () => {
+ expect(() =>
+ parseRenderablePatchFiles(`--- a/short.txt
++++ b/short.txt
+@@ -1,2 +1 @@
+-only one old line
++one new line
+`),
+ ).toThrow();
+
+ expect(() =>
+ parseRenderablePatchFiles(`--- a/long.txt
++++ b/long.txt
+@@ -1 +1 @@
+-old
++new
++undeclared
+`),
+ ).toThrow();
+ });
+
+ it("rejects a blank unprefixed line inside a hunk body", () => {
+ // The empty line between the two change lines must not count as context;
+ // without a leading space the hunk is malformed and the patch falls back.
+ expect(() =>
+ parseRenderablePatchFiles(`diff --git a/a.txt b/a.txt
+--- a/a.txt
++++ b/a.txt
+@@ -1,2 +1,2 @@
+-old
+
++new
+`),
+ ).toThrow();
+ });
+
+ it("accepts a format-patch signature trailer after the final hunk", () => {
+ const files = parseRenderablePatchFiles(`--- a/one.txt
++++ b/one.txt
+@@ -1 +1 @@
+-old
++new
+--
+2.34.1
+
+`);
+
+ expect(files.map((file) => file.newPath)).toEqual(["one.txt"]);
+ });
+
+ it("accepts a hunk whose final context line is a blank line", () => {
+ const files = parseRenderablePatchFiles(`diff --git a/note.txt b/note.txt
+index 111..222 100644
+--- a/note.txt
++++ b/note.txt
+@@ -1,2 +1,2 @@
+-before
++after
+
+`);
+
+ expect(files.map((file) => file.newPath)).toEqual(["note.txt"]);
+ });
+
+ it("marks binary file sections and synthesizes an entry for a bare binary patch", () => {
+ const files = parseRenderablePatchFiles("GIT binary patch\nliteral 4\nLc!NkF#\n\n");
expect(files).toHaveLength(1);
- expect(files[0]).toMatchObject({ displayPath: "file-1", id: "file-1-0" });
- expect(files[0].displayPath).not.toBe("");
+ expect(files[0]?.isBinary).toBe(true);
+ expect(files[0]?.status).toBe("binary");
+ });
+
+ it("does not expose plain text as a renderable patch file", () => {
+ expect(parseRenderablePatchFiles("plain review notes")).toEqual([]);
+ expect(parseRenderablePatchFiles("")).toEqual([]);
+ });
+
+ it("records one-based section start lines for editor navigation", () => {
+ const files = parseRenderablePatchFiles(`diff --git a/first.txt b/first.txt
+--- a/first.txt
++++ b/first.txt
+@@ -1 +1 @@
+-a
++b
+diff --git a/second.txt b/second.txt
+--- a/second.txt
++++ b/second.txt
+@@ -1 +1 @@
+-c
++d
+`);
+
+ expect(files[0]?.startLine).toBe(1);
+ expect(files[1]?.startLine).toBe(7);
+ });
+
+ it("does not let hunk content fake a traditional section start", () => {
+ // The removed `-- x` and added `++ y` lines inside the first hunk read as a
+ // `--- `/`+++ ` pair; without hunk tracking the second file's startLine
+ // would point at that fake triple instead of its real header on line 13.
+ const files = parseRenderablePatchFiles(`--- a/f1.txt
++++ b/f1.txt
+@@ -1,3 +1,4 @@
+ base
+ mid
+--- x
++++ y
++z
+@@ -10,2 +10,2 @@
+ f
+-g
++h
+--- a/f2.txt
++++ b/f2.txt
+@@ -1 +1 @@
+-old
++new
+`);
+
+ expect(files.map((file) => file.displayPath)).toEqual(["f1.txt", "f2.txt"]);
+ expect(files[0]?.startLine).toBe(1);
+ expect(files[1]?.startLine).toBe(13);
+ });
+
+ it("dedupes repeated paths into unique labels", () => {
+ const files = parseRenderablePatchFiles(`diff --git a/x.txt b/x.txt
+--- a/x.txt
++++ b/x.txt
+@@ -1 +1 @@
+-a
++b
+diff --git a/x.txt b/x.txt
+--- a/x.txt
++++ b/x.txt
+@@ -1 +1 @@
+-c
++d
+`);
+
+ const labels = getPatchFileLabels(files);
+ expect(labels.get(files[0]?.id ?? "")).toBe("x.txt");
+ expect(labels.get(files[1]?.id ?? "")).toBe("x.txt (2)");
});
});
diff --git a/tests/headers.test.ts b/tests/headers.test.ts
index 79590e9..a4a9278 100644
--- a/tests/headers.test.ts
+++ b/tests/headers.test.ts
@@ -31,9 +31,7 @@ describe("static security headers", () => {
expect(headers).toContain("/arx-dictionary.json.br");
expect(headers).toContain("/arx2-dictionary.json.br");
- expect(headers).toContain("/vendor/diff-view-pure.css.br");
expect(headers).toContain("Content-Type: application/json; charset=utf-8");
- expect(headers).toContain("Content-Type: text/css; charset=utf-8");
expect(headers).toContain("Content-Encoding: br");
expect(headers).toContain("Vary: Accept-Encoding");
});
diff --git a/tests/link-creator.test.ts b/tests/link-creator.test.ts
index 3cecd9c..44366a6 100644
--- a/tests/link-creator.test.ts
+++ b/tests/link-creator.test.ts
@@ -194,7 +194,6 @@ describe("link creator payloads", () => {
// The paste URL keeps the packed non-ASCII fragment; the markdown URL must carry a
// percent-escape-free ASCII fragment whose payload decodes identically.
const markdownFragment = generatedLink.markdownUrl.slice(generatedLink.markdownUrl.indexOf("#") + 1);
- // eslint-disable-next-line no-control-regex
expect(markdownFragment).toMatch(/^[\x21-\x7e]+$/);
expect(markdownFragment).not.toContain("%");
// The markdown link must beat the percent-encoded serialization of the packed URL,
diff --git a/tests/selfhosted/api-catalog.test.ts b/tests/selfhosted/api-catalog.test.ts
index 901454f..f5242ce 100644
--- a/tests/selfhosted/api-catalog.test.ts
+++ b/tests/selfhosted/api-catalog.test.ts
@@ -52,10 +52,6 @@ function createExportFixture(): { root: string; outDir: string } {
path.join(outDir, "arx2-dictionary.json.br"),
"brotli-compressed-json",
);
- writeFileSync(
- path.join(outDir, "vendor", "diff-view-pure.css.br"),
- "brotli-compressed-css",
- );
writeFileSync(
path.join(outDir, ".well-known", "api-catalog"),
readFileSync(path.resolve("public", ".well-known", "api-catalog")),
@@ -242,21 +238,6 @@ describe("RFC 9727 api-catalog", () => {
"Accept-Encoding",
);
- const compressedDiffStyleResponse = await fetch(
- `http://127.0.0.1:${port}/vendor/diff-view-pure.css.br`,
- { method: "HEAD" },
- );
- expect(compressedDiffStyleResponse.status).toBe(200);
- expect(compressedDiffStyleResponse.headers.get("content-type")).toBe(
- "text/css; charset=utf-8",
- );
- expect(compressedDiffStyleResponse.headers.get("content-encoding")).toBe(
- "br",
- );
- expect(compressedDiffStyleResponse.headers.get("vary")).toBe(
- "Accept-Encoding",
- );
-
const escapeResponse = await rawHttpGet(port, "/../secret.txt");
expect(escapeResponse.status).toBe(404);
expect(escapeResponse.body).not.toContain("outside-out");
@@ -371,20 +352,6 @@ describe("RFC 9727 api-catalog", () => {
"Accept-Encoding",
);
- const compressedDiffStyleResponse = await fetch(
- `http://127.0.0.1:${port}/vendor/diff-view-pure.css.br`,
- { method: "HEAD" },
- );
- expect(compressedDiffStyleResponse.status).toBe(200);
- expect(compressedDiffStyleResponse.headers.get("content-type")).toBe(
- "text/css; charset=utf-8",
- );
- expect(compressedDiffStyleResponse.headers.get("content-encoding")).toBe(
- "br",
- );
- expect(compressedDiffStyleResponse.headers.get("vary")).toBe(
- "Accept-Encoding",
- );
} finally {
await stopServer(child);
rmSync(fixture.root, { recursive: true, force: true });
diff --git a/tests/selfhosted/static-headers.test.ts b/tests/selfhosted/static-headers.test.ts
index 34a7fd1..c4b5993 100644
--- a/tests/selfhosted/static-headers.test.ts
+++ b/tests/selfhosted/static-headers.test.ts
@@ -51,7 +51,7 @@ function createExportFixture(): { root: string; outDir: string } {
"brotli-compressed-json",
);
writeFileSync(
- path.join(outDir, "vendor", "diff-view-pure.css.br"),
+ path.join(outDir, "vendor", "test-styles.css.br"),
"brotli-compressed-css",
);
return { root, outDir };
@@ -140,7 +140,7 @@ describe("selfhosted precompressed header contract", () => {
it("serves *.css.br with decompressed Content-Type and Brotli headers", async () => {
const response = await fetch(
- `http://127.0.0.1:${port}/vendor/diff-view-pure.css.br`,
+ `http://127.0.0.1:${port}/vendor/test-styles.css.br`,
{ method: "HEAD" },
);
expect(response.status).toBe(200);
diff --git a/tests/serve-export-headers.test.ts b/tests/serve-export-headers.test.ts
index 0bcba4c..338e8f0 100644
--- a/tests/serve-export-headers.test.ts
+++ b/tests/serve-export-headers.test.ts
@@ -51,7 +51,7 @@ function createExportFixture(): { root: string } {
"brotli-compressed-json",
);
writeFileSync(
- path.join(outDir, "vendor", "diff-view-pure.css.br"),
+ path.join(outDir, "vendor", "test-styles.css.br"),
"brotli-compressed-css",
);
return { root };
@@ -134,7 +134,7 @@ describe("serve-export precompressed header contract", () => {
it("serves *.css.br with decompressed Content-Type and Brotli headers", async () => {
const response = await fetch(
- `http://127.0.0.1:${port}/vendor/diff-view-pure.css.br`,
+ `http://127.0.0.1:${port}/vendor/test-styles.css.br`,
{ method: "HEAD" },
);
expect(response.status).toBe(200);