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-changes.css | 2 + packages/ui/src/components/diff.css | 3 + packages/ui/src/components/diff.tsx | 218 +++++++++++++------------- packages/ui/src/components/session-review.css | 11 ++ packages/ui/src/components/session-review.tsx | 10 +- 5 files changed, 128 insertions(+), 116 deletions(-) (limited to 'packages/ui/src/components') diff --git a/packages/ui/src/components/diff-changes.css b/packages/ui/src/components/diff-changes.css index 010860d13..be3cca885 100644 --- a/packages/ui/src/components/diff-changes.css +++ b/packages/ui/src/components/diff-changes.css @@ -6,6 +6,7 @@ [data-slot="diff-changes-additions"] { font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-regular); @@ -17,6 +18,7 @@ [data-slot="diff-changes-deletions"] { font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-regular); diff --git a/packages/ui/src/components/diff.css b/packages/ui/src/components/diff.css index 860e3b1d1..690667ea7 100644 --- a/packages/ui/src/components/diff.css +++ b/packages/ui/src/components/diff.css @@ -22,6 +22,9 @@ width: var(--pjs-column-content-width); left: var(--pjs-column-number-width); padding-left: 8px; + user-select: none; + cursor: default; + text-align: left; [data-slot="diff-hunk-separator-content-span"] { mix-blend-mode: var(--text-mix-blend-mode); 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)}
*/} + {/* }} */} + {/*
*/} + + )} +
+ ) } diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index 7a6be8a6e..554de8022 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -50,6 +50,17 @@ background-color: var(--background-stronger) !important; } + [data-slot="accordion-item"] { + [data-slot="accordion-content"] { + display: none; + } + &[data-expanded] { + [data-slot="accordion-content"] { + display: block; + } + } + } + [data-slot="session-review-trigger-content"] { display: flex; align-items: center; diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index e5d5bdb88..36dbf36a9 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -9,13 +9,14 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" import { For, Match, Show, Switch, type JSX, splitProps } from "solid-js" import { createStore } from "solid-js/store" import { type FileDiff } from "@opencode-ai/sdk" +import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr" export interface SessionReviewProps { split?: boolean class?: string classList?: Record actions?: JSX.Element - diffs: FileDiff[] + diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult })[] } export const SessionReview = (props: SessionReviewProps) => { @@ -38,7 +39,7 @@ export const SessionReview = (props: SessionReviewProps) => { } } - const [split, rest] = splitProps(props, ["class", "classList"]) + const [split] = splitProps(props, ["class", "classList"]) return (
{ {(diff) => ( - +
@@ -83,8 +84,9 @@ export const SessionReview = (props: SessionReviewProps) => {
- +