|
|
@@ -13,8 +13,7 @@ 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, For, Match, Show, Switch, untrack, type JSX } from "solid-js"
|
|
|
-import { onCleanup } from "solid-js"
|
|
|
+import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, 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"
|
|
|
@@ -26,6 +25,7 @@ import { createLineCommentController } from "./line-comment-annotations"
|
|
|
import type { LineCommentEditorProps } from "./line-comment"
|
|
|
|
|
|
const MAX_DIFF_CHANGED_LINES = 500
|
|
|
+const REVIEW_MOUNT_MARGIN = 300
|
|
|
|
|
|
export type SessionReviewDiffStyle = "unified" | "split"
|
|
|
|
|
|
@@ -69,7 +69,7 @@ export interface SessionReviewProps {
|
|
|
split?: boolean
|
|
|
diffStyle?: SessionReviewDiffStyle
|
|
|
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
|
|
- onDiffRendered?: () => void
|
|
|
+ onDiffRendered?: VoidFunction
|
|
|
onLineComment?: (comment: SessionReviewLineComment) => void
|
|
|
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
|
|
|
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
|
|
|
@@ -137,11 +137,14 @@ type SessionReviewSelection = {
|
|
|
export const SessionReview = (props: SessionReviewProps) => {
|
|
|
let scroll: HTMLDivElement | undefined
|
|
|
let focusToken = 0
|
|
|
+ let frame: number | undefined
|
|
|
const i18n = useI18n()
|
|
|
const fileComponent = useFileComponent()
|
|
|
const anchors = new Map<string, HTMLElement>()
|
|
|
+ const nodes = new Map<string, HTMLDivElement>()
|
|
|
const [store, setStore] = createStore({
|
|
|
open: [] as string[],
|
|
|
+ visible: {} as Record<string, boolean>,
|
|
|
force: {} as Record<string, boolean>,
|
|
|
selection: null as SessionReviewSelection | null,
|
|
|
commenting: null as SessionReviewSelection | null,
|
|
|
@@ -154,13 +157,84 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
const open = () => props.open ?? store.open
|
|
|
const files = createMemo(() => props.diffs.map((diff) => diff.file))
|
|
|
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
|
|
|
+ const grouped = createMemo(() => {
|
|
|
+ const next = new Map<string, SessionReviewComment[]>()
|
|
|
+ for (const comment of props.comments ?? []) {
|
|
|
+ const list = next.get(comment.file)
|
|
|
+ if (list) {
|
|
|
+ list.push(comment)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ next.set(comment.file, [comment])
|
|
|
+ }
|
|
|
+ return next
|
|
|
+ })
|
|
|
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
|
|
const hasDiffs = () => files().length > 0
|
|
|
|
|
|
- const handleChange = (open: string[]) => {
|
|
|
- props.onOpenChange?.(open)
|
|
|
- if (props.open !== undefined) return
|
|
|
- setStore("open", open)
|
|
|
+ const syncVisible = () => {
|
|
|
+ frame = undefined
|
|
|
+ if (!scroll) return
|
|
|
+
|
|
|
+ const root = scroll.getBoundingClientRect()
|
|
|
+ const top = root.top - REVIEW_MOUNT_MARGIN
|
|
|
+ const bottom = root.bottom + REVIEW_MOUNT_MARGIN
|
|
|
+ const openSet = new Set(open())
|
|
|
+ const next: Record<string, boolean> = {}
|
|
|
+
|
|
|
+ for (const [file, el] of nodes) {
|
|
|
+ if (!openSet.has(file)) continue
|
|
|
+ const rect = el.getBoundingClientRect()
|
|
|
+ if (rect.bottom < top || rect.top > bottom) continue
|
|
|
+ next[file] = true
|
|
|
+ }
|
|
|
+
|
|
|
+ const prev = untrack(() => store.visible)
|
|
|
+ const prevKeys = Object.keys(prev)
|
|
|
+ const nextKeys = Object.keys(next)
|
|
|
+ if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
|
|
|
+ setStore("visible", next)
|
|
|
+ }
|
|
|
+
|
|
|
+ const queue = () => {
|
|
|
+ if (frame !== undefined) return
|
|
|
+ frame = requestAnimationFrame(syncVisible)
|
|
|
+ }
|
|
|
+
|
|
|
+ const pinned = (file: string) =>
|
|
|
+ props.focusedComment?.file === file ||
|
|
|
+ props.focusedFile === file ||
|
|
|
+ selection()?.file === file ||
|
|
|
+ commenting()?.file === file ||
|
|
|
+ opened()?.file === file
|
|
|
+
|
|
|
+ const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
|
|
|
+ queue()
|
|
|
+ const next = props.onScroll
|
|
|
+ if (!next) return
|
|
|
+ if (Array.isArray(next)) {
|
|
|
+ const [fn, data] = next as [(data: unknown, event: Event) => void, unknown]
|
|
|
+ fn(data, event)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ ;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
|
|
|
+ }
|
|
|
+
|
|
|
+ onCleanup(() => {
|
|
|
+ if (frame === undefined) return
|
|
|
+ cancelAnimationFrame(frame)
|
|
|
+ })
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ props.open
|
|
|
+ files()
|
|
|
+ queue()
|
|
|
+ })
|
|
|
+
|
|
|
+ const handleChange = (next: string[]) => {
|
|
|
+ props.onOpenChange?.(next)
|
|
|
+ if (props.open === undefined) setStore("open", next)
|
|
|
+ queue()
|
|
|
}
|
|
|
|
|
|
const handleExpandOrCollapseAll = () => {
|
|
|
@@ -274,8 +348,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
viewportRef={(el) => {
|
|
|
scroll = el
|
|
|
props.scrollRef?.(el)
|
|
|
+ queue()
|
|
|
}}
|
|
|
- onScroll={props.onScroll as any}
|
|
|
+ onScroll={handleScroll}
|
|
|
classList={{
|
|
|
[props.classes?.root ?? ""]: !!props.classes?.root,
|
|
|
}}
|
|
|
@@ -291,9 +366,10 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
const item = createMemo(() => diffs().get(file)!)
|
|
|
|
|
|
const expanded = createMemo(() => open().includes(file))
|
|
|
+ const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
|
|
const force = () => !!store.force[file]
|
|
|
|
|
|
- const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
|
|
|
+ const comments = createMemo(() => grouped().get(file) ?? [])
|
|
|
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
|
|
|
|
|
const beforeText = () => (typeof item().before === "string" ? item().before : "")
|
|
|
@@ -381,6 +457,8 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
|
|
|
onCleanup(() => {
|
|
|
anchors.delete(file)
|
|
|
+ nodes.delete(file)
|
|
|
+ queue()
|
|
|
})
|
|
|
|
|
|
const handleLineSelected = (range: SelectedLineRange | null) => {
|
|
|
@@ -465,10 +543,19 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
ref={(el) => {
|
|
|
wrapper = el
|
|
|
anchors.set(file, el)
|
|
|
+ nodes.set(file, el)
|
|
|
+ queue()
|
|
|
}}
|
|
|
>
|
|
|
<Show when={expanded()}>
|
|
|
<Switch>
|
|
|
+ <Match when={!mounted() && !tooLarge()}>
|
|
|
+ <div
|
|
|
+ data-slot="session-review-diff-placeholder"
|
|
|
+ class="rounded-lg border border-border-weak-base bg-background-stronger/40"
|
|
|
+ style={{ height: "160px" }}
|
|
|
+ />
|
|
|
+ </Match>
|
|
|
<Match when={tooLarge()}>
|
|
|
<div data-slot="session-review-large-diff">
|
|
|
<div data-slot="session-review-large-diff-title">
|