import { Accordion } from "./accordion" import { Button } from "./button" import { DropdownMenu } from "./dropdown-menu" import { RadioGroup } from "./radio-group" import { DiffChanges } from "./diff-changes" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { IconButton } from "./icon-button" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Tooltip } from "./tooltip" import { ScrollView } from "./scroll-view" import { FileSearchBar } from "./file-search" import type { FileSearchHandle } from "./file" import { buildSessionSearchHits, stepSessionSearchIndex, type SessionSearchHit } from "./session-review-search" import { useFileComponent } from "../context/file" import { useI18n } from "../context/i18n" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { createEffect, createMemo, createSignal, For, Match, Show, Switch, untrack, type JSX } from "solid-js" import { onCleanup } 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" import { mediaKindFromPath } from "../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge" import { createLineCommentController } from "./line-comment-annotations" const MAX_DIFF_CHANGED_LINES = 500 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 SessionReviewCommentUpdate = SessionReviewLineComment & { id: string } export type SessionReviewCommentDelete = { id: string file: string } export type SessionReviewCommentActions = { moreLabel: string editLabel: string deleteLabel: string saveLabel: string } export type SessionReviewFocus = { file: string; id: string } export interface SessionReviewProps { title?: JSX.Element empty?: JSX.Element split?: boolean diffStyle?: SessionReviewDiffStyle onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void onDiffRendered?: () => void onLineComment?: (comment: SessionReviewLineComment) => void onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void lineCommentActions?: SessionReviewCommentActions comments?: SessionReviewComment[] focusedComment?: SessionReviewFocus | null onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void focusedFile?: string 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 } function ReviewCommentMenu(props: { labels: SessionReviewCommentActions onEdit: VoidFunction onDelete: VoidFunction }) { return (
event.stopPropagation()} onClick={(event) => event.stopPropagation()}> {props.labels.editLabel} {props.labels.deleteLabel}
) } function diffId(file: string): string | undefined { const sum = checksum(file) if (!sum) return return `session-review-diff-${sum}` } type SessionReviewSelection = { file: string range: SelectedLineRange } export const SessionReview = (props: SessionReviewProps) => { let scroll: HTMLDivElement | undefined let searchInput: HTMLInputElement | undefined let focusToken = 0 let revealToken = 0 let highlightedFile: string | undefined const i18n = useI18n() const fileComponent = useFileComponent() const anchors = new Map() const searchHandles = new Map() const readyFiles = new Set() const [store, setStore] = createStore<{ open: string[]; force: Record }>({ open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file), force: {}, }) const [selection, setSelection] = createSignal(null) const [commenting, setCommenting] = createSignal(null) const [opened, setOpened] = createSignal(null) const [searchOpen, setSearchOpen] = createSignal(false) const [searchQuery, setSearchQuery] = createSignal("") const [searchActive, setSearchActive] = createSignal(0) const [searchPos, setSearchPos] = createSignal({ top: 8, right: 8 }) const open = () => props.open ?? store.open const files = createMemo(() => props.diffs.map((d) => d.file)) const diffs = createMemo(() => new Map(props.diffs.map((d) => [d.file, d] as const))) const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") const hasDiffs = () => files().length > 0 const searchValue = createMemo(() => searchQuery().trim()) const searchExpanded = createMemo(() => searchValue().length > 0) const handleChange = (open: string[]) => { props.onOpenChange?.(open) if (props.open !== undefined) return setStore("open", open) } const handleExpandOrCollapseAll = () => { const next = open().length > 0 ? [] : files() handleChange(next) } const clearViewerSearch = () => { for (const handle of searchHandles.values()) handle.clear() highlightedFile = undefined } const openFileLabel = () => i18n.t("ui.sessionReview.openFile") const selectionLabel = (range: SelectedLineRange) => { const start = Math.min(range.start, range.end) const end = Math.max(range.start, range.end) if (start === end) return i18n.t("ui.sessionReview.selection.line", { line: start }) return i18n.t("ui.sessionReview.selection.lines", { start, end }) } const focusSearch = () => { if (!hasDiffs()) return setSearchOpen(true) requestAnimationFrame(() => { searchInput?.focus() searchInput?.select() }) } const closeSearch = () => { revealToken++ setSearchOpen(false) setSearchQuery("") setSearchActive(0) clearViewerSearch() } const positionSearchBar = () => { if (typeof window === "undefined") return if (!scroll) return const rect = scroll.getBoundingClientRect() const title = parseFloat(getComputedStyle(scroll).getPropertyValue("--session-title-height")) const header = Number.isNaN(title) ? 0 : title setSearchPos({ top: Math.round(rect.top) + header - 4, right: Math.round(window.innerWidth - rect.right) + 8, }) } const searchHits = createMemo(() => buildSessionSearchHits({ query: searchQuery(), files: props.diffs.flatMap((diff) => { if (mediaKindFromPath(diff.file)) return [] return [ { file: diff.file, before: typeof diff.before === "string" ? diff.before : undefined, after: typeof diff.after === "string" ? diff.after : undefined, }, ] }), }), ) const waitForViewer = (file: string, token: number) => new Promise((resolve) => { let attempt = 0 const tick = () => { if (token !== revealToken) { resolve(undefined) return } const handle = searchHandles.get(file) if (handle && readyFiles.has(file)) { resolve(handle) return } if (attempt >= 180) { resolve(undefined) return } attempt++ requestAnimationFrame(tick) } tick() }) const waitForFrames = (count: number, token: number) => new Promise((resolve) => { const tick = (left: number) => { if (token !== revealToken) { resolve(false) return } if (left <= 0) { resolve(true) return } requestAnimationFrame(() => tick(left - 1)) } tick(count) }) const revealSearchHit = async (token: number, hit: SessionSearchHit, query: string) => { const diff = diffs().get(hit.file) if (!diff) return if (!open().includes(hit.file)) { handleChange([...open(), hit.file]) } if (!mediaKindFromPath(hit.file) && diff.additions + diff.deletions > MAX_DIFF_CHANGED_LINES) { setStore("force", hit.file, true) } const handle = await waitForViewer(hit.file, token) if (!handle || token !== revealToken) return if (searchValue() !== query) return if (!(await waitForFrames(2, token))) return if (highlightedFile && highlightedFile !== hit.file) { searchHandles.get(highlightedFile)?.clear() highlightedFile = undefined } anchors.get(hit.file)?.scrollIntoView({ block: "nearest" }) let done = false for (let i = 0; i < 4; i++) { if (token !== revealToken) return if (searchValue() !== query) return handle.setQuery(query) if (handle.reveal(hit)) { done = true break } const expanded = handle.expand(hit) handle.refresh() if (!(await waitForFrames(expanded ? 2 : 1, token))) return } if (!done) return if (!(await waitForFrames(1, token))) return handle.reveal(hit) highlightedFile = hit.file } const navigateSearch = (dir: 1 | -1) => { const total = searchHits().length if (total <= 0) return setSearchActive((value) => stepSessionSearchIndex(total, value, dir)) } const inReview = (node: unknown, path?: unknown[]) => { if (node === searchInput) return true if (path?.some((item) => item === scroll || item === searchInput)) return true if (path?.some((item) => item instanceof HTMLElement && item.dataset.component === "session-review")) { return true } if (!(node instanceof Node)) return false if (searchInput?.contains(node)) return true if (node instanceof HTMLElement && node.closest("[data-component='session-review']")) return true if (!scroll) return false return scroll.contains(node) } createEffect(() => { if (typeof window === "undefined") return const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return const mod = event.metaKey || event.ctrlKey if (!mod) return const key = event.key.toLowerCase() if (key !== "f" && key !== "g") return if (key === "f") { if (!hasDiffs()) return event.preventDefault() event.stopPropagation() focusSearch() return } const path = typeof event.composedPath === "function" ? event.composedPath() : undefined if (!inReview(event.target, path) && !inReview(document.activeElement, path)) return if (!searchOpen()) return event.preventDefault() event.stopPropagation() navigateSearch(event.shiftKey ? -1 : 1) } window.addEventListener("keydown", onKeyDown, { capture: true }) onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true })) }) createEffect(() => { diffStyle() searchExpanded() readyFiles.clear() }) createEffect(() => { if (!searchOpen()) return if (!scroll) return const root = scroll requestAnimationFrame(positionSearchBar) window.addEventListener("resize", positionSearchBar, { passive: true }) const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(positionSearchBar) observer?.observe(root) onCleanup(() => { window.removeEventListener("resize", positionSearchBar) observer?.disconnect() }) }) createEffect(() => { const total = searchHits().length if (total === 0) { if (searchActive() !== 0) setSearchActive(0) return } if (searchActive() >= total) setSearchActive(total - 1) }) createEffect(() => { diffStyle() const query = searchValue() const hits = searchHits() const token = ++revealToken if (!query || hits.length === 0) { clearViewerSearch() return } const hit = hits[Math.min(searchActive(), hits.length - 1)] if (!hit) return void revealSearchHit(token, hit, query) }) onCleanup(() => { revealToken++ clearViewerSearch() readyFiles.clear() searchHandles.clear() }) 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 return previewSelectedLines(contents, range) } createEffect(() => { const focus = props.focusedComment if (!focus) return untrack(() => { 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: cloneSelectedLineRange(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 wrapper = anchors.get(focus.file) const anchor = wrapper?.querySelector(`[data-comment-id="${focus.id}"]`) const ready = anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0" const target = ready ? anchor : wrapper if (!target) { if (attempt >= 120) 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 >= 120) return requestAnimationFrame(() => scrollTo(attempt + 1)) } requestAnimationFrame(() => scrollTo(0)) requestAnimationFrame(() => props.onFocusedCommentChange?.(null)) }) }) const handleReviewKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return const mod = event.metaKey || event.ctrlKey const key = event.key.toLowerCase() const target = event.target if (mod && key === "f") { event.preventDefault() event.stopPropagation() focusSearch() return } if (mod && key === "g") { if (!searchOpen()) return event.preventDefault() event.stopPropagation() navigateSearch(event.shiftKey ? -1 : 1) } } const handleSearchInputKeyDown = (event: KeyboardEvent) => { const mod = event.metaKey || event.ctrlKey const key = event.key.toLowerCase() if (mod && key === "g") { event.preventDefault() event.stopPropagation() navigateSearch(event.shiftKey ? -1 : 1) return } if (mod && key === "f") { event.preventDefault() event.stopPropagation() focusSearch() return } if (event.key === "Escape") { event.preventDefault() event.stopPropagation() closeSearch() return } if (event.key !== "Enter") return event.preventDefault() event.stopPropagation() navigateSearch(event.shiftKey ? -1 : 1) } return ( { scroll = el props.scrollRef?.(el) }} onScroll={props.onScroll as any} onKeyDown={handleReviewKeyDown} classList={{ ...(props.classList ?? {}), [props.classes?.root ?? ""]: !!props.classes?.root, [props.class ?? ""]: !!props.class, }} >
{props.title ?? 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}
(searchHits().length ? Math.min(searchActive(), searchHits().length - 1) : 0)} count={() => searchHits().length} setInput={(el) => { searchInput = el }} onInput={(value) => { setSearchQuery(value) setSearchActive(0) }} onKeyDown={(event) => handleSearchInputKeyDown(event)} onClose={closeSearch} onPrev={() => navigateSearch(-1)} onNext={() => navigateSearch(1)} />
{(file) => { let wrapper: HTMLDivElement | undefined const diff = createMemo(() => diffs().get(file)) const item = () => diff()! const expanded = createMemo(() => open().includes(file)) const force = () => !!store.force[file] const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file)) const commentedLines = createMemo(() => comments().map((c) => c.selection)) const beforeText = () => (typeof item().before === "string" ? item().before : "") const afterText = () => (typeof item().after === "string" ? item().after : "") const changedLines = () => item().additions + item().deletions const mediaKind = createMemo(() => mediaKindFromPath(file)) const tooLarge = createMemo(() => { if (!expanded()) return false if (force()) return false if (mediaKind()) return false return changedLines() > MAX_DIFF_CHANGED_LINES }) const isAdded = () => item().status === "added" || (beforeText().length === 0 && afterText().length > 0) const isDeleted = () => item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0) const selectedLines = createMemo(() => { const current = selection() if (!current || current.file !== file) return null return current.range }) const draftRange = createMemo(() => { const current = commenting() if (!current || current.file !== file) return null return current.range }) const commentsUi = createLineCommentController({ comments, label: i18n.t("ui.lineComment.submit"), draftKey: () => file, state: { opened: () => { const current = opened() if (!current || current.file !== file) return null return current.id }, setOpened: (id) => setOpened(id ? { file, id } : null), selected: selectedLines, setSelected: (range) => setSelection(range ? { file, range } : null), commenting: draftRange, setCommenting: (range) => setCommenting(range ? { file, range } : null), }, getSide: selectionSide, clearSelectionOnSelectionEndNull: false, onSubmit: ({ comment, selection }) => { props.onLineComment?.({ file, selection, comment, preview: selectionPreview(item(), selection), }) }, onUpdate: ({ id, comment, selection }) => { props.onLineCommentUpdate?.({ id, file, selection, comment, preview: selectionPreview(item(), selection), }) }, onDelete: (comment) => { props.onLineCommentDelete?.({ id: comment.id, file, }) }, editSubmitLabel: props.lineCommentActions?.saveLabel, renderCommentActions: props.lineCommentActions ? (comment, controls) => ( ) : undefined, }) onCleanup(() => { anchors.delete(file) readyFiles.delete(file) searchHandles.delete(file) if (highlightedFile === file) highlightedFile = undefined }) const handleLineSelected = (range: SelectedLineRange | null) => { if (!props.onLineComment) return commentsUi.onLineSelected(range) } const handleLineSelectionEnd = (range: SelectedLineRange | null) => { if (!props.onLineComment) return commentsUi.onLineSelectionEnd(range) } return (
{`\u202A${getDirectory(file)}\u202C`} {getFilename(file)}
{i18n.t("ui.sessionReview.change.added")}
{i18n.t("ui.sessionReview.change.removed")} {i18n.t("ui.sessionReview.change.modified")}
{ wrapper = el anchors.set(file, el) }} >
{i18n.t("ui.sessionReview.largeDiff.title")}
{i18n.t("ui.sessionReview.largeDiff.meta", { limit: MAX_DIFF_CHANGED_LINES.toLocaleString(), current: changedLines().toLocaleString(), })}
{ readyFiles.add(file) props.onDiffRendered?.() }} enableLineSelection={props.onLineComment != null} enableHoverUtility={props.onLineComment != null} onLineSelected={handleLineSelected} onLineSelectionEnd={handleLineSelectionEnd} onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd} annotations={commentsUi.annotations()} renderAnnotation={commentsUi.renderAnnotation} renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined} selectedLines={selectedLines()} commentedLines={commentedLines()} search={{ shortcuts: "disabled", showBar: false, disableVirtualization: searchExpanded(), register: (handle: FileSearchHandle | null) => { if (!handle) { searchHandles.delete(file) readyFiles.delete(file) if (highlightedFile === file) highlightedFile = undefined return } searchHandles.set(file, handle) }, }} before={{ name: file, contents: typeof item().before === "string" ? item().before : "", }} after={{ name: file, contents: typeof item().after === "string" ? item().after : "", }} media={{ mode: "auto", path: file, before: item().before, after: item().after, readFile: props.readFile, }} />
) }}
) }