diff --git a/bun.lock b/bun.lock index 250801cb05..d6e7220db4 100644 --- a/bun.lock +++ b/bun.lock @@ -166,6 +166,7 @@ "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", + "@pierre/precision-diffs": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", @@ -436,7 +437,7 @@ "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/precision-diffs": "0.5.4", + "@pierre/precision-diffs": "0.5.5", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", @@ -1211,7 +1212,7 @@ "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="], - "@pierre/precision-diffs": ["@pierre/precision-diffs@0.5.4", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-sfKCxApl+FcqvlZ4EenZtPfmCMi0w8VZBpRsCtSpgpCczmp7YI2sS/sxWokT/ieCJjyFiynb5rHcnAB7GZbXMQ=="], + "@pierre/precision-diffs": ["@pierre/precision-diffs@0.5.5", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-mmDHEWWQ6fmXY5qRNHqodzOxHPwLqVNbbnO/MOpXteOTjd0nVIGy5IcaNwU2WSxhxQRwaUepKyx5+wwPcZLEmw=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], diff --git a/package.json b/package.json index ba583142b4..94b43d8332 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/precision-diffs": "0.5.4", + "@pierre/precision-diffs": "0.5.5", "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", "ai": "5.0.97", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 09348b4371..6f94a4b2c7 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -13,6 +13,7 @@ "dependencies": { "@opencode-ai/util": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@pierre/precision-diffs": "catalog:", "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", "@solidjs/meta": "catalog:", diff --git a/packages/enterprise/src/app.tsx b/packages/enterprise/src/app.tsx index 185b36a79a..28c2717756 100644 --- a/packages/enterprise/src/app.tsx +++ b/packages/enterprise/src/app.tsx @@ -3,21 +3,19 @@ import { FileRoutes } from "@solidjs/start/router" import { Fonts } from "@opencode-ai/ui/fonts" import { MetaProvider } from "@solidjs/meta" import { MarkedProvider } from "@opencode-ai/ui/context/marked" -import "./app.css" import { Suspense } from "solid-js" +import "./app.css" export default function App() { return ( ( - - - - - {props.children} - - - + + + + {props.children} + + )} > diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index f96e4d57ac..033d87fed0 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -2,8 +2,8 @@ import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } f import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionReview } from "@opencode-ai/ui/session-review" import { DataProvider } from "@opencode-ai/ui/context" -import { createAsync, query, RouteDefinition, useParams } from "@solidjs/router" -import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Suspense, Switch } from "solid-js" +import { createAsync, query, useParams } from "@solidjs/router" +import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js" import { Share } from "~/core/share" import { Logo, Mark } from "@opencode-ai/ui/logo" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -16,6 +16,7 @@ import { createStore } from "solid-js/store" import z from "zod" import NotFound from "../[...404]" import { Tabs } from "@opencode-ai/ui/tabs" +import { HunkData, preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr" const SessionDataMissingError = NamedError.create( "SessionDataMissingError", @@ -36,6 +37,9 @@ const getData = query(async (shareID) => { session_diff: { [sessionID: string]: FileDiff[] } + session_diff_preload: { + [sessionID: string]: PreloadMultiFileDiffResult[] + } session_status: { [sessionID: string]: SessionStatus } @@ -54,6 +58,9 @@ const getData = query(async (shareID) => { session_diff: { [share.sessionID]: [], }, + session_diff_preload: { + [share.sessionID]: [], + }, session_status: { [share.sessionID]: { type: "idle", @@ -70,6 +77,29 @@ const getData = query(async (shareID) => { break case "session_diff": result.session_diff[share.sessionID] = item.data + result.session_diff_preload[share.sessionID] = await Promise.all( + item.data.map(async (diff) => + preloadMultiFileDiff({ + oldFile: { name: diff.file, contents: diff.before }, + newFile: { name: diff.file, contents: diff.after }, + options: { + 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, + }, + // annotations, + }), + ), + ) break case "message": result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? [] @@ -141,7 +171,14 @@ export default function () { const provider = createMemo(() => activeMessage()?.model?.providerID) const modelID = createMemo(() => activeMessage()?.model?.modelID) const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) - const diffs = createMemo(() => data().session_diff[data().sessionID] ?? []) + const diffs = createMemo(() => { + const diffs = data().session_diff[data().sessionID] ?? [] + const preloaded = data().session_diff_preload[data().sessionID] ?? [] + return diffs.map((diff) => ({ + ...diff, + preloaded: preloaded.find((d) => d.newFile.name === diff.file), + })) + }) const title = () => (
diff --git a/packages/ui/src/components/diff-changes.css b/packages/ui/src/components/diff-changes.css index 010860d130..be3cca885d 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 860e3b1d1e..690667ea7c 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 8743be2900..af89b5c1e9 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 7a6be8a6ea..554de8022a 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 e5d5bdb88b..36dbf36a9e 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) => {
- + [] + } message: { [sessionID: string]: Message[] } diff --git a/packages/ui/src/custom-elements.d.ts b/packages/ui/src/custom-elements.d.ts new file mode 100644 index 0000000000..9d4c30dd39 --- /dev/null +++ b/packages/ui/src/custom-elements.d.ts @@ -0,0 +1,14 @@ +/** + * TypeScript declaration for the custom element. + * This tells TypeScript that is a valid JSX element in SolidJS. + * Required for using the precision-diffs web component in .tsx files. + */ +declare module 'solid-js' { + namespace JSX { + interface IntrinsicElements { + 'file-diff': HTMLAttributes; + } + } +} + +export {}; diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 935119af1e..66136d7246 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -76,6 +76,7 @@ .text-12-mono { font-family: var(--font-family-mono); + font-feature-settings: var(--font-feature-settings-mono); font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-regular); @@ -103,6 +104,7 @@ .text-14-mono { font-family: var(--font-family-mono); + font-feature-settings: var(--font-feature-settings-mono); font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-regular);