opencode/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx
Aiden Cline 9e0d3976e1
chore: merge dev into v2 (#35591)
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
2026-07-06 16:05:29 -05:00

287 lines
10 KiB
TypeScript

import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import type { SelectedLineRange } from "@pierre/diffs"
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { mediaKindFromPath } from "../../pierre/media"
import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge"
import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { normalize, text, type ViewDiff } from "../../components/session-diff"
import type {
SessionReviewComment,
SessionReviewCommentActions,
SessionReviewCommentDelete,
SessionReviewCommentUpdate,
SessionReviewDiffStyle,
SessionReviewFocus,
SessionReviewLineComment,
} from "../../components/session-review"
import type { SessionReviewExpandMode } from "./session-review-v2"
import { createLineCommentControllerV2 } from "./line-comment-annotations-v2"
import { shouldVirtualizeReviewDiff } from "./session-review-file-preview-v2-virtualize"
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import "./session-review-v2.css"
type ReviewDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
export type SessionReviewFilePreviewV2Props = {
file: string
diff: ReviewDiff
diffStyle: SessionReviewDiffStyle
expandMode?: SessionReviewExpandMode
readFile?: (path: string) => Promise<FileContent | undefined>
onLineComment?: (comment: SessionReviewLineComment) => void
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
lineCommentActions?: SessionReviewCommentActions
comments?: SessionReviewComment[]
focusedComment?: SessionReviewFocus | null
onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
}
function statusLabel(status: ViewDiff["status"]) {
if (status === "added") return "A"
if (status === "deleted") return "D"
return "M"
}
function statusType(status: ViewDiff["status"]) {
if (status === "added") return "added"
if (status === "deleted") return "deleted"
return "modified"
}
function selectionSide(range: SelectedLineRange) {
return range.endSide ?? range.side ?? "additions"
}
function selectionPreview(diff: ViewDiff, range: SelectedLineRange) {
const side = selectionSide(range)
const contents = text(diff, side)
if (contents.length === 0) return undefined
return previewSelectedLines(contents, range)
}
function ReviewCommentMenuV2(props: {
labels: SessionReviewCommentActions
onEdit: VoidFunction
onDelete: VoidFunction
}) {
return (
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<MenuV2 gutter={4}>
<MenuV2.Trigger
as="button"
type="button"
data-slot="line-comment-v2-overflow"
aria-label={props.labels.moreLabel}
>
<LineCommentV2OverflowIcon />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={props.onEdit}>{props.labels.editLabel}</MenuV2.Item>
<MenuV2.Item onSelect={props.onDelete}>{props.labels.deleteLabel}</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
)
}
export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Props) {
const i18n = useI18n()
const fileComponent = useFileComponent()
let scrollRef: HTMLDivElement | undefined
let focusToken = 0
const [store, setStore] = createStore({
selection: null as SelectedLineRange | null,
commenting: null as SelectedLineRange | null,
opened: null as string | null,
})
const view = createMemo(() => ({
...normalize(props.diff),
preloaded: "preloaded" in props.diff ? props.diff.preloaded : undefined,
}))
const diffCanRender = createMemo(() => view().additions !== 0 || view().deletions !== 0)
const mediaKind = createMemo(() => mediaKindFromPath(props.file))
const comments = createMemo(() => (props.comments ?? []).filter((comment) => comment.file === props.file))
const commentedLines = createMemo(() => comments().map((comment) => comment.selection))
const lineCommentsEnabled = () => props.onLineComment != null
const commentsUi = createLineCommentControllerV2<SessionReviewComment>({
comments,
label: i18n.t("ui.lineComment.submit"),
draftKey: () => props.file,
state: {
opened: () => store.opened,
setOpened: (id) => setStore("opened", id),
selected: () => store.selection,
setSelected: (range) => setStore("selection", range),
commenting: () => store.commenting,
setCommenting: (range) => setStore("commenting", range),
},
getSide: selectionSide,
onSubmit: ({ comment, selection }) => {
props.onLineComment?.({
file: props.file,
selection,
comment,
preview: selectionPreview(view(), selection),
})
},
onUpdate: ({ id, comment, selection }) => {
props.onLineCommentUpdate?.({
id,
file: props.file,
selection,
comment,
preview: selectionPreview(view(), selection),
})
},
onDelete: (comment) => {
props.onLineCommentDelete?.({
id: comment.id,
file: props.file,
})
},
editSubmitLabel: props.lineCommentActions?.saveLabel,
renderCommentActions: props.lineCommentActions
? (comment, controls) => (
<ReviewCommentMenuV2 labels={props.lineCommentActions!} onEdit={controls.edit} onDelete={controls.remove} />
)
: undefined,
})
onCleanup(() => {
focusToken++
})
createEffect(() => {
const focus = props.focusedComment
if (!focus) return
if (focus.file !== props.file) {
// The focused file has no mounted preview (e.g. not in the current diff
// set); clear the focus anyway so it cannot hijack a later diff refresh.
// V1 clears unconditionally the same way.
untrack(() => {
const token = focusToken
requestAnimationFrame(() => {
if (token !== focusToken) return
props.onFocusedCommentChange?.(null)
})
})
return
}
untrack(() => {
setStore("opened", focus.id)
const comment = (props.comments ?? []).find((item) => item.file === focus.file && item.id === focus.id)
if (comment) setStore("selection", cloneSelectedLineRange(comment.selection))
// The diff renders asynchronously, so poll for the comment anchor before
// scrolling; clear the focus once handled so revisiting the file does not
// re-open a stale comment (mirrors the v1 review behavior).
focusToken++
const token = focusToken
const scrollTo = (attempt: number) => {
if (token !== focusToken) return
const anchor = scrollRef?.querySelector(`[data-comment-id="${focus.id}"]`)
if (anchor instanceof HTMLElement) {
anchor.scrollIntoView({ block: "center" })
return
}
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
}
requestAnimationFrame(() => scrollTo(0))
requestAnimationFrame(() => {
if (token !== focusToken) return
props.onFocusedCommentChange?.(null)
})
})
})
const expandUnchanged = () => props.expandMode === "expand"
const diffViewer = () => (
<Dynamic
component={fileComponent}
mode="diff"
fileDiff={view().fileDiff}
preloadedDiff={view().preloaded}
diffStyle={props.diffStyle}
expandUnchanged={expandUnchanged()}
virtualize={shouldVirtualizeReviewDiff({
additionLines: view().fileDiff.additionLines.length,
deletionLines: view().fileDiff.deletionLines.length,
})}
hunkSeparators={view().fileDiff.isPartial ? "simple" : "line-info-basic"}
enableLineSelection={lineCommentsEnabled()}
enableGutterUtility={lineCommentsEnabled()}
onLineSelected={(range: SelectedLineRange | null) => {
if (!lineCommentsEnabled()) return
commentsUi.onLineSelected(range)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!lineCommentsEnabled()) return
commentsUi.onLineSelectionEnd(range)
}}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderGutterUtility={lineCommentsEnabled() ? commentsUi.renderGutterUtility : undefined}
selectedLines={store.selection}
commentedLines={commentedLines()}
media={{
mode: "auto",
path: props.file,
deleted: view().status === "deleted",
readFile: view().status === "deleted" ? undefined : props.readFile,
}}
/>
)
return (
<>
<div data-slot="session-review-v2-file-header">
<div data-slot="session-review-v2-file-title">
<div data-slot="session-review-v2-file-status" data-type={statusType(view().status)}>
{statusLabel(view().status)}
</div>
<FileIcon node={{ path: props.file, type: "file" }} />
<span data-slot="session-review-v2-file-name">{getFilename(props.file)}</span>
<Show when={props.file.includes("/")}>
<span data-slot="session-review-v2-file-path">{getDirectory(props.file)}</span>
</Show>
</div>
<DiffChanges changes={view()} />
</div>
<div
ref={(el) => {
scrollRef = el
}}
data-slot="session-review-v2-diff-scroll"
>
<Show
when={diffCanRender() || mediaKind()}
fallback={
<div data-slot="session-review-v2-empty">
<span class="text-12-regular text-text-weak">{i18n.t("ui.fileMedia.binary.title")}</span>
</div>
}
>
{diffViewer()}
</Show>
</div>
</>
)
}