import { Accordion } from "./accordion" import { Button } from "./button" import { RadioGroup } from "./radio-group" import { DiffChanges } from "./diff-changes" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { StickyAccordionHeader } from "./sticky-accordion-header" import { useDiffComponent } from "../context/diff" import { useI18n } from "../context/i18n" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" export type SessionReviewDiffStyle = "unified" | "split" export type SessionReviewComment = { id: string file: string selection: SelectedLineRange comment: string } export type SessionReviewLineComment = { file: string selection: SelectedLineRange comment: string preview?: string } export type SessionReviewFocus = { file: string; id: string } export interface SessionReviewProps { split?: boolean diffStyle?: SessionReviewDiffStyle onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void onDiffRendered?: () => void onLineComment?: (comment: SessionReviewLineComment) => void comments?: SessionReviewComment[] focusedComment?: SessionReviewFocus | null onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void open?: string[] onOpenChange?: (open: string[]) => void scrollRef?: (el: HTMLDivElement) => void onScroll?: JSX.EventHandlerUnion class?: string classList?: Record classes?: { root?: string; header?: string; container?: string } actions?: JSX.Element diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult })[] onViewFile?: (file: string) => void readFile?: (path: string) => Promise } const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"]) const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"]) function normalizeMimeType(type: string | undefined): string | undefined { if (!type) return const mime = type.split(";", 1)[0]?.trim().toLowerCase() if (!mime) return if (mime === "audio/x-aac") return "audio/aac" if (mime === "audio/x-m4a") return "audio/mp4" return mime } function getExtension(file: string): string { const idx = file.lastIndexOf(".") if (idx === -1) return "" return file.slice(idx + 1).toLowerCase() } function isImageFile(file: string): boolean { return imageExtensions.has(getExtension(file)) } function isAudioFile(file: string): boolean { return audioExtensions.has(getExtension(file)) } function dataUrl(content: FileContent | undefined): string | undefined { if (!content) return if (content.encoding !== "base64") return const mime = normalizeMimeType(content.mimeType) if (!mime) return if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return return `data:${mime};base64,${content.content}` } function dataUrlFromValue(value: unknown): string | undefined { if (typeof value === "string") { if (value.startsWith("data:image/")) return value if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;") if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;") if (value.startsWith("data:audio/")) return value return } if (!value || typeof value !== "object") return const content = (value as { content?: unknown }).content const encoding = (value as { encoding?: unknown }).encoding const mimeType = (value as { mimeType?: unknown }).mimeType if (typeof content !== "string") return if (encoding !== "base64") return if (typeof mimeType !== "string") return const mime = normalizeMimeType(mimeType) if (!mime) return if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return return `data:${mime};base64,${content}` } type SessionReviewSelection = { file: string range: SelectedLineRange } function findSide(element: HTMLElement): "additions" | "deletions" { const code = element.closest("[data-code]") if (!(code instanceof HTMLElement)) return "additions" if (code.hasAttribute("data-deletions")) return "deletions" return "additions" } function findMarker(root: ShadowRoot, range: SelectedLineRange) { const line = Math.max(range.start, range.end) const side = range.endSide ?? range.side const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( (node): node is HTMLElement => node instanceof HTMLElement, ) if (nodes.length === 0) return if (!side) return nodes[0] const match = nodes.find((node) => findSide(node) === side) return match ?? nodes[0] } function markerTop(wrapper: HTMLElement, marker: HTMLElement) { const wrapperRect = wrapper.getBoundingClientRect() const rect = marker.getBoundingClientRect() return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) } export const SessionReview = (props: SessionReviewProps) => { let scroll: HTMLDivElement | undefined let focusToken = 0 const i18n = useI18n() const diffComponent = useDiffComponent() const anchors = new Map() const [store, setStore] = createStore({ open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file), }) const [selection, setSelection] = createSignal(null) const [commenting, setCommenting] = createSignal(null) const [opened, setOpened] = createSignal(null) const open = () => props.open ?? store.open const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") const handleChange = (open: string[]) => { props.onOpenChange?.(open) if (props.open !== undefined) return setStore("open", open) } const handleExpandOrCollapseAll = () => { const next = open().length > 0 ? [] : props.diffs.map((d) => d.file) handleChange(next) } const selectionLabel = (range: SelectedLineRange) => { const start = Math.min(range.start, range.end) const end = Math.max(range.start, range.end) if (start === end) return `line ${start}` return `lines ${start}-${end}` } const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions" const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => { const side = selectionSide(range) const contents = side === "deletions" ? diff.before : diff.after if (typeof contents !== "string" || contents.length === 0) return undefined const start = Math.max(1, Math.min(range.start, range.end)) const end = Math.max(range.start, range.end) const lines = contents.split("\n").slice(start - 1, end) if (lines.length === 0) return undefined return lines.slice(0, 2).join("\n") } createEffect(() => { const focus = props.focusedComment if (!focus) return focusToken++ const token = focusToken setOpened(focus) const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id) if (comment) setSelection({ file: comment.file, range: comment.selection }) const current = open() if (!current.includes(focus.file)) { handleChange([...current, focus.file]) } const scrollTo = (attempt: number) => { if (token !== focusToken) return const root = scroll if (!root) return const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`) const ready = anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0" const target = ready ? anchor : anchors.get(focus.file) if (!target) { if (attempt >= 24) return requestAnimationFrame(() => scrollTo(attempt + 1)) return } const rootRect = root.getBoundingClientRect() const targetRect = target.getBoundingClientRect() const offset = targetRect.top - rootRect.top const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2 root.scrollTop = Math.max(0, next) if (ready) return if (attempt >= 24) return requestAnimationFrame(() => scrollTo(attempt + 1)) } requestAnimationFrame(() => scrollTo(0)) requestAnimationFrame(() => props.onFocusedCommentChange?.(null)) }) return (
{ scroll = el props.scrollRef?.(el) }} onScroll={props.onScroll} classList={{ ...(props.classList ?? {}), [props.classes?.root ?? ""]: !!props.classes?.root, [props.class ?? ""]: !!props.class, }} >
{i18n.t("ui.sessionReview.title")}
style} label={(style) => i18n.t(style === "unified" ? "ui.sessionReview.diffStyle.unified" : "ui.sessionReview.diffStyle.split") } onSelect={(style) => style && props.onDiffStyleChange?.(style)} /> {props.actions}
{(diff) => { let wrapper: HTMLDivElement | undefined let textarea: HTMLTextAreaElement | undefined const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file)) const commentedLines = createMemo(() => comments().map((c) => c.selection)) const beforeText = () => (typeof diff.before === "string" ? diff.before : "") const afterText = () => (typeof diff.after === "string" ? diff.after : "") const isAdded = () => beforeText().length === 0 && afterText().length > 0 const isDeleted = () => afterText().length === 0 && beforeText().length > 0 const isImage = () => isImageFile(diff.file) const isAudio = () => isAudioFile(diff.file) const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before) const [imageSrc, setImageSrc] = createSignal(diffImageSrc) const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle") const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before) const [audioSrc, setAudioSrc] = createSignal(diffAudioSrc) const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle") const [audioMime, setAudioMime] = createSignal(undefined) const selectedLines = createMemo(() => { const current = selection() if (!current || current.file !== diff.file) return null return current.range }) const draftRange = createMemo(() => { const current = commenting() if (!current || current.file !== diff.file) return null return current.range }) const [draft, setDraft] = createSignal("") const [positions, setPositions] = createSignal>({}) const [draftTop, setDraftTop] = createSignal(undefined) const getRoot = () => { const el = wrapper if (!el) return const host = el.querySelector("diffs-container") if (!(host instanceof HTMLElement)) return return host.shadowRoot ?? undefined } const updateAnchors = () => { const el = wrapper if (!el) return const root = getRoot() if (!root) return const next: Record = {} for (const item of comments()) { const marker = findMarker(root, item.selection) if (!marker) continue next[item.id] = markerTop(el, marker) } setPositions(next) const range = draftRange() if (!range) { setDraftTop(undefined) return } const marker = findMarker(root, range) if (!marker) { setDraftTop(undefined) return } setDraftTop(markerTop(el, marker)) } const scheduleAnchors = () => { requestAnimationFrame(updateAnchors) } createEffect(() => { comments() scheduleAnchors() }) createEffect(() => { const range = draftRange() if (!range) return setDraft("") scheduleAnchors() requestAnimationFrame(() => textarea?.focus()) }) createEffect(() => { if (!open().includes(diff.file)) return if (!isImage()) return if (imageSrc()) return if (imageStatus() !== "idle") return const reader = props.readFile if (!reader) return setImageStatus("loading") reader(diff.file) .then((result) => { const src = dataUrl(result) if (!src) { setImageStatus("error") return } setImageSrc(src) setImageStatus("idle") }) .catch(() => { setImageStatus("error") }) }) createEffect(() => { if (!open().includes(diff.file)) return if (!isAudio()) return if (audioSrc()) return if (audioStatus() !== "idle") return const reader = props.readFile if (!reader) return setAudioStatus("loading") reader(diff.file) .then((result) => { const src = dataUrl(result) if (!src) { setAudioStatus("error") return } setAudioMime(normalizeMimeType(result?.mimeType)) setAudioSrc(src) setAudioStatus("idle") }) .catch(() => { setAudioStatus("error") }) }) const handleLineSelected = (range: SelectedLineRange | null) => { if (!props.onLineComment) return if (!range) { setSelection(null) return } setSelection({ file: diff.file, range }) } const handleLineSelectionEnd = (range: SelectedLineRange | null) => { if (!props.onLineComment) return if (!range) { setCommenting(null) return } setSelection({ file: diff.file, range }) setCommenting({ file: diff.file, range }) } const openComment = (comment: SessionReviewComment) => { setOpened({ file: comment.file, id: comment.id }) setSelection({ file: comment.file, range: comment.selection }) } const isCommentOpen = (comment: SessionReviewComment) => { const current = opened() if (!current) return false return current.file === comment.file && current.id === comment.id } return (
{`\u202A${getDirectory(diff.file)}\u202C`} {getFilename(diff.file)}
Added Removed
{ wrapper = el anchors.set(diff.file, el) scheduleAnchors() }} > { props.onDiffRendered?.() scheduleAnchors() }} enableLineSelection={props.onLineComment != null} onLineSelected={handleLineSelected} onLineSelectionEnd={handleLineSelectionEnd} selectedLines={selectedLines()} commentedLines={commentedLines()} before={{ name: diff.file!, contents: typeof diff.before === "string" ? diff.before : "", }} after={{ name: diff.file!, contents: typeof diff.after === "string" ? diff.after : "", }} /> {(comment) => (
{getFilename(comment.file)}:{selectionLabel(comment.selection)}
{comment.comment}
)}
{(range) => (
Commenting on {getFilename(diff.file)}:{selectionLabel(range())}