From 15facd8cfd5ed43fd503326d950df40e5ef81a3a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 25 Nov 2025 07:40:11 -0600 Subject: feat(share): SSR'd diffs --- packages/ui/src/components/diff.tsx | 218 ++++++++++++++++++------------------ 1 file changed, 106 insertions(+), 112 deletions(-) (limited to 'packages/ui/src/components/diff.tsx') diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 8743be290..af89b5c1e 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -1,13 +1,10 @@ -import { - type FileContents, - FileDiff, - type DiffLineAnnotation, - type HunkData, - FileDiffOptions, -} from "@pierre/precision-diffs" -import { ComponentProps, createEffect, splitProps } from "solid-js" +import { type FileContents, FileDiff, type DiffLineAnnotation, FileDiffOptions } from "@pierre/precision-diffs" +import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr" +import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js" +import { isServer } from "solid-js/web" export type DiffProps = FileDiffOptions & { + preloadedDiff?: PreloadMultiFileDiffResult before: FileContents after: FileContents annotations?: DiffLineAnnotation[] @@ -21,116 +18,69 @@ export type DiffProps = FileDiffOptions & { export function Diff(props: DiffProps) { let container!: HTMLDivElement + let fileDiffRef!: HTMLElement const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"]) - // const lineAnnotations: DiffLineAnnotation[] = [ - // { - // side: "additions", - // // The line number specified for an annotation is the visual line number - // // you see in the number column of a diff - // lineNumber: 16, - // metadata: { threadId: "68b329da9893e34099c7d8ad5cb9c940" }, - // }, - // ] + let fileDiffInstance: FileDiff | undefined + const cleanupFunctions: Array<() => void> = [] - // If you ever want to update the options for an instance, simple call - // 'setOptions' with the new options. Bear in mind, this does NOT merge - // existing properties, it's a full replace - // instance.setOptions({ - // ...instance.options, - // theme: "pierre-dark", - // themes: undefined, - // }) - // - - // When ready to render, simply call .render with old/new file, optional - // annotations and a container element to hold the diff createEffect(() => { - const instance = new FileDiff({ + // Create FileDiff instance and connect to existing server-rendered DOM. + // Don't call hydrate() - that would re-render content and cause duplication. + // Instead, just set the fileContainer reference to attach event handlers. + if (props.preloadedDiff) return + container.innerHTML = "" + if (!fileDiffInstance) { + fileDiffInstance = new FileDiff({ + theme: "OpenCode", + themeType: "system", + disableLineNumbers: false, + overflow: "wrap", + diffStyle: "unified", + diffIndicators: "bars", + disableBackground: false, + expansionLineCount: 20, + lineDiffType: "word-alt", + maxLineDiffLength: 1000, + maxLineLengthForHighlighting: 1000, + disableFileHeader: true, + // You can optionally pass a render function for rendering out line + // annotations. Just return the dom node to render + // renderAnnotation(annotation: DiffLineAnnotation): HTMLElement { + // // Despite the diff itself being rendered in the shadow dom, + // // annotations are inserted via the web components 'slots' api and you + // // can use all your normal normal css and styling for them + // const element = document.createElement("div") + // element.innerText = annotation.metadata.threadId + // return element + // }, + ...others, + ...(props.preloadedDiff ?? {}), + }) + } + fileDiffInstance?.render({ + oldFile: local.before, + newFile: local.after, + lineAnnotations: local.annotations, + containerWrapper: container, + }) + }) + + onMount(() => { + if (isServer) return + + fileDiffInstance = new FileDiff({ theme: "OpenCode", - // When using the 'themes' prop, 'themeType' allows you to force 'dark' - // or 'light' theme, or inherit from the OS ('system') theme. themeType: "system", - // Disable the line numbers for your diffs, generally not recommended disableLineNumbers: false, - // Whether code should 'wrap' with long lines or 'scroll'. overflow: "wrap", - // Normally you shouldn't need this prop, but if you don't provide a - // valid filename or your file doesn't have an extension you may want to - // override the automatic detection. You can specify that language here: - // https://shiki.style/languages - // lang?: SupportedLanguages; - // 'diffStyle' controls whether the diff is presented side by side or - // in a unified (single column) view diffStyle: "unified", - // Line decorators to help highlight changes. - // 'bars' (default): - // Shows some red-ish or green-ish (theme dependent) bars on the left - // edge of relevant lines - // - // 'classic': - // shows '+' characters on additions and '-' characters on deletions - // - // 'none': - // No special diff indicators are shown diffIndicators: "bars", - // By default green-ish or red-ish background are shown on added and - // deleted lines respectively. Disable that feature here disableBackground: false, - // Diffs are split up into hunks, this setting customizes what to show - // between each hunk. - // - // 'line-info' (default): - // Shows a bar that tells you how many lines are collapsed. If you are - // using the oldFile/newFile API then you can click those bars to - // expand the content between them - // - // 'metadata': - // Shows the content you'd see in a normal patch file, usually in some - // format like '@@ -60,6 +60,22 @@'. You cannot use these to expand - // hidden content - // - // 'simple': - // Just a subtle bar separator between each hunk - // hunkSeparators: "line-info", - hunkSeparators(hunkData: HunkData) { - const fragment = document.createDocumentFragment() - const numCol = document.createElement("div") - numCol.innerHTML = ` ` - numCol.dataset["slot"] = "diff-hunk-separator-line-number" - fragment.appendChild(numCol) - const contentCol = document.createElement("div") - contentCol.dataset["slot"] = "diff-hunk-separator-content" - const span = document.createElement("span") - span.dataset["slot"] = "diff-hunk-separator-content-span" - span.textContent = `${hunkData.lines} unmodified lines` - contentCol.appendChild(span) - fragment.appendChild(contentCol) - return fragment - }, - // On lines that have both additions and deletions, we can run a - // separate diff check to mark parts of the lines that change. - // 'none': - // Do not show these secondary highlights - // - // 'char': - // Show changes at a per character granularity - // - // 'word': - // Show changes but rounded up to word boundaries - // - // 'word-alt' (default): - // Similar to 'word', however we attempt to minimize single character - // gaps between highlighted changes + expansionLineCount: 20, lineDiffType: "word-alt", - // If lines exceed these character lengths then we won't perform the - // line lineDiffType check maxLineDiffLength: 1000, - // If any line in the diff exceeds this value then we won't attempt to - // syntax highlight the diff maxLineLengthForHighlighting: 1000, - // Enabling this property will hide the file header with file name and - // diff stats. disableFileHeader: true, // You can optionally pass a render function for rendering out line // annotations. Just return the dom node to render @@ -143,15 +93,39 @@ export function Diff(props: DiffProps) { // return element // }, ...others, + ...(props.preloadedDiff ?? {}), }) + // @ts-expect-error - fileContainer is private but needed for SSR hydration + fileDiffInstance.fileContainer = fileDiffRef - container.innerHTML = "" - instance.render({ - oldFile: local.before, - newFile: local.after, - lineAnnotations: local.annotations, - containerWrapper: container, - }) + // Hydrate annotation slots with interactive SolidJS components + // if (props.annotations.length > 0 && props.renderAnnotation != null) { + // for (const annotation of props.annotations) { + // const slotName = `annotation-${annotation.side}-${annotation.lineNumber}`; + // const slotElement = fileDiffRef.querySelector( + // `[slot="${slotName}"]` + // ) as HTMLElement; + // + // if (slotElement != null) { + // // Clear the static server-rendered content from the slot + // slotElement.innerHTML = ''; + // + // // Mount a fresh SolidJS component into this slot using render(). + // // This enables full SolidJS reactivity (signals, effects, etc.) + // const dispose = render( + // () => props.renderAnnotation!(annotation), + // slotElement + // ); + // cleanupFunctions.push(dispose); + // } + // } + // } + }) + + onCleanup(() => { + // Clean up FileDiff event handlers and dispose SolidJS components + fileDiffInstance?.cleanUp() + cleanupFunctions.forEach((dispose) => dispose()) }) return ( @@ -168,6 +142,26 @@ export function Diff(props: DiffProps) { "--pjs-min-number-column-width": "4ch", }} ref={container} - /> + > + + {/* Only render on server - client hydrates the existing content */} + {isServer && props.preloadedDiff && ( + <> + {/* Declarative Shadow DOM - browsers parse this and create a shadow root */} + + {/* Render static annotation slots on server. + Client will clear these and mount interactive components. */} + {/* */} + {/* {(annotation) => { */} + {/* const slotName = `annotation-${annotation.side}-${annotation.lineNumber}` */} + {/* return
{props.renderAnnotation?.(annotation)}
*/} + {/* }} */} + {/*
*/} + + )} +
+ ) } -- cgit v1.2.3